PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
← All changes | app/Modules/MCP/Tools/ReportTools.php +261 -20 1.5.3 → 1.6.5 View file →
@@ -91,9 +91,9 @@
91 91 ],
92 92 ],
93 93 ], ['date_basis' => ['type' => 'string'], 'mode' => ['type' => 'string'], 'page' => ['type' => 'object'], 'truncated' => ['type' => 'boolean']]);
94 94
95 - $subStatuses = ContextTools::ENUMS['subscription_statuses'];
95 + $subStatuses = ContextTools::enums()['subscription_statuses'];
96 96
97 97 $defs = [
98 98 'fluent-cart/get-sales-report' => [
99 99 'label' => __('Get Sales Report', 'fluent-cart'),
@@ -238,9 +238,9 @@
238 238 ],
239 239
240 240 'fluent-cart/query-orders' => [
241 241 'label' => __('Query Orders (flexible aggregate)', 'fluent-cart'),
242 - 'description' => __('Flexible order analytics: pick metrics, group by dimensions with filters, when a fixed report does not fit — e.g. revenue by payment_status, orders by month, or revenue by order_type (one-time payment vs new subscription vs renewal). product_id or variation_id limits to orders containing that product. One currency; window filters on created_at, echoed as meta.date_basis.', 'fluent-cart'),
242 + 'description' => __('Flexible order analytics: pick metrics, group by dimensions with filters, when a fixed report does not fit — e.g. revenue by payment_status, orders by month, revenue by order_type (one-time payment vs new subscription vs renewal), or revenue by country. product_id or variation_id limits to orders containing that product. This is the tool for revenue BY GEOGRAPHY: group by country/state, which read each order\'s own billing address, falling back to the buyer\'s primary billing address. One currency; window filters on created_at, echoed as meta.date_basis.', 'fluent-cart'),
243 243 'input_schema' => [
244 244 'type' => 'object',
245 245 'properties' => [
246 246 'metrics' => [
@@ -249,11 +249,12 @@
249 249 'items' => ['type' => 'string', 'enum' => ['order_count', 'gross_revenue', 'paid_revenue', 'refunded_amount', 'aov', 'unique_customers']],
250 250 ],
251 251 'dimensions' => [
252 252 'type' => 'array',
253 - 'description' => 'Group by these. Empty means a single total row. order_type splits sales by payment (one-time purchase), subscription (first subscription order) and renewal (recurring charge); combine with a time dimension for e.g. order_type x month.',
254 - 'items' => ['type' => 'string', 'enum' => ['day', 'week', 'month', 'status', 'payment_status', 'order_type']],
253 + 'description' => 'Group by these. Empty means a single total row. order_type splits sales by payment (one-time purchase), subscription (first subscription order) and renewal (recurring charge); combine with a time dimension for e.g. order_type x month. country/state come from each order\'s own billing address, falling back to the buyer\'s primary billing address when the order has no address row of its own (common for digital goods) — this is the right basis for revenue by geography. Orders neither source can place land in an explicit "unknown" bucket so the rows still sum to total revenue; see meta.geo_source.',
254 + 'items' => ['type' => 'string', 'enum' => ['day', 'week', 'month', 'status', 'payment_status', 'order_type', 'country', 'state']],
255 255 ],
256 + 'country' => ['type' => 'string', 'description' => 'ISO-2 code. Limit to orders billed to this country, resolved the same way as the country dimension (order address, then the buyer\'s primary billing address).'],
256 257 'product_id' => ['type' => 'integer', 'description' => 'Limit to orders CONTAINING this product. Order-level metrics (revenue, count) reflect the whole order, not just this product\'s lines — for per-product line revenue use query-products.'],
257 258 'variation_id' => ['type' => 'integer', 'description' => 'Limit to orders containing this variation.'],
258 259 'range' => $rangeProp,
259 260 'start_date' => ['type' => 'string'],
@@ -299,16 +300,16 @@
299 300 ],
300 301
301 302 'fluent-cart/query-customers' => [
302 303 'label' => __('Query Customers (flexible aggregate)', 'fluent-cart'),
303 - 'description' => __('Flexible customer analytics: pick metrics and group by country, state, status, or first/last purchase month, with optional filters. LTV is in store currency. Returns up to 200 rows.', 'fluent-cart'),
304 + 'description' => __('Flexible customer analytics: pick metrics and group by country, state, status, or first/last purchase month, with optional filters. LTV is in store currency. Returns up to 200 rows. country/state resolve from the customer\'s primary billing address, falling back to their profile location, then "unknown" — this is where a customer is REGISTERED, captured at their first purchase and not refreshed since. To attribute revenue to where each order was actually billed, group query-orders by country instead.', 'fluent-cart'),
304 305 'input_schema' => [
305 306 'type' => 'object',
306 307 'properties' => [
307 308 'metrics' => ['type' => 'array', 'description' => 'Defaults to customer_count and total_ltv.', 'items' => ['type' => 'string', 'enum' => ['customer_count', 'total_ltv', 'avg_ltv', 'avg_purchase_count', 'repeat_customers']]],
308 309 'dimensions' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['country', 'state', 'status', 'first_purchase_month', 'last_purchase_month']]],
309 - 'country' => ['type' => 'string'],
310 - 'status' => ['type' => 'string', 'enum' => ['active', 'archived']],
310 + 'country' => ['type' => 'string', 'description' => 'ISO-2 code, matched against the customer profile country (same caveat as the country dimension).'],
311 + 'status' => ['type' => 'string', 'enum' => ['active', 'archived'], 'description' => 'Not every customer has one set — customers with no status appear under the status dimension as "unknown" and are excluded by this filter. Omit it to count all customers.'],
311 312 'min_ltv' => ['type' => 'number', 'description' => 'Minimum LTV in store currency.'],
312 313 'min_purchase_count' => ['type' => 'integer'],
313 314 ],
314 315 ],
@@ -839,9 +840,9 @@
839 840 $currency = self::currency($params);
840 841 $range = self::resolveRange($params);
841 842 $mode = self::orderMode($params);
842 843 $metrics = self::pickList($params, 'metrics', ['order_count', 'gross_revenue', 'paid_revenue', 'refunded_amount', 'aov', 'unique_customers'], ['order_count', 'gross_revenue']);
843 - $dimensions = self::pickList($params, 'dimensions', ['day', 'week', 'month', 'status', 'payment_status', 'order_type'], []);
844 + $dimensions = self::pickList($params, 'dimensions', ['day', 'week', 'month', 'status', 'payment_status', 'order_type', 'country', 'state'], []);
844 845
845 846 $query = Order::query()
846 847 ->whereIn('payment_status', self::PAID)
847 848 ->where('currency', $currency)
@@ -853,13 +854,109 @@
853 854 // whereHas keeps the aggregate order-level (metrics still reflect the whole
854 855 // order); it never fans out rows the way a raw join would.
855 856 self::applyOrderItemFilter($query, $params);
856 857
858 + // Geography comes from the order's OWN billing address first, so each
859 + // order's revenue is attributed to where that order was actually billed —
860 + // not to wherever the customer is registered now. Joined only when asked
861 + // for, so every other dimension keeps its current cost.
862 + //
863 + // Grouped subqueries, not raw joins: neither fct_order_addresses nor
864 + // fct_customer_addresses has a UNIQUE constraint on its (parent, type)
865 + // pair — fct_order_addresses actually holds 208 duplicate billing groups on
866 + // the reference store — and a duplicate would fan out and double-count
867 + // that order's revenue in every SUM.
868 + //
869 + // Within a duplicate group the LATEST row wins (highest id), picked as one
870 + // whole row. Taking MAX(country) and MAX(state) independently would let the
871 + // two columns come from DIFFERENT rows and synthesize a place that does not
872 + // exist: order 259 on the reference store has billing rows (BD, BD-60) and
873 + // (AT, BD-60), so independent MAX()es resolve it to Bangladesh even though
874 + // its latest address is Austrian. (That order is payment_status=pending and
875 + // so outside self::PAID — it demonstrates the mechanism, not a revenue error
876 + // this report was making.) Five duplicate groups there disagree on country or
877 + // state. latestRowPick() does the one-row pick inside the GROUP BY.
878 + //
879 + // The customer's primary billing address is the SECOND source, and the
880 + // choice between the two is made PER ORDER, not per column — see
881 + // dimensionExpr(). Most orders here carry no address row of their own
882 + // (digital goods skip the billing step), so order-address-only attribution
883 + // left 976 of 4,905 paid orders — 19.9% of orders and 9.0% of revenue — in
884 + // the 'unknown' bucket, which makes "revenue by country" unusable for the
885 + // question it exists to answer. Falling back to the buyer's own primary
886 + // billing address recovers 878 of those 976 and cuts 'unknown' to 2.0%;
887 + // 98 orders are then genuinely unattributable. (Figures over all four
888 + // self::PAID statuses, which is what this method actually queries.)
889 + // geo_source in meta names the precedence so an agent never has to guess.
890 + $geoDims = array_values(array_intersect(['country', 'state'], $dimensions));
891 + $wantsGeo = $geoDims || !empty($params['country']);
892 + if ($wantsGeo) {
893 + // Columns aliased oaddr_*/caddr_* so raw select expressions stay
894 + // unambiguous without table prefixes (same note as queryCustomers).
895 + $addrSub = App::db()->table('fct_order_addresses')
896 + ->select('order_id')
897 + ->selectRaw(
898 + self::latestRowPick('country') . ' as oaddr_country, '
899 + . self::latestRowPick('state') . ' as oaddr_state'
900 + )
901 + ->where('type', 'billing')
902 + ->groupBy('order_id');
903 +
904 + $query->leftJoinSub($addrSub, 'fc_oaddr', 'fct_orders.id', '=', 'fc_oaddr.order_id');
905 +
906 + // LEFT JOIN on customer_id, so a guest order simply has no fallback and
907 + // still lands in 'unknown' rather than dropping: fct_orders.customer_id
908 + // is nullable and NULL never matches a join predicate. The customer_id > 0
909 + // guard covers the other shape of missing buyer — an order carrying 0
910 + // instead of NULL would otherwise join to an orphaned address row filed
911 + // under customer 0 and inherit a stranger's country.
912 + //
913 + // customer_id is aliased caddr_customer_id for the same reason the value
914 + // columns are aliased: fct_orders has a customer_id too, and exposing a
915 + // second one made the existing COUNT(DISTINCT customer_id) behind the
916 + // unique_customers metric ambiguous — a hard SQL error, not a wrong
917 + // number. Nothing joined here may share a name with an orders column.
918 + $custAddrSub = App::db()->table('fct_customer_addresses')
919 + ->selectRaw('customer_id as caddr_customer_id')
920 + ->selectRaw(
921 + self::latestRowPick('country') . ' as caddr_country, '
922 + . self::latestRowPick('state') . ' as caddr_state'
923 + )
924 + ->where('type', 'billing')
925 + ->where('is_primary', 1)
926 + ->where('customer_id', '>', 0)
927 + ->groupBy('customer_id');
928 +
929 + $query->leftJoinSub($custAddrSub, 'fc_caddr', 'fct_orders.customer_id', '=', 'fc_caddr.caddr_customer_id');
930 +
931 + if (!empty($params['country'])) {
932 + // Built from dimensionExpr() itself, so the filter and the country
933 + // dimension resolve a country identically BY CONSTRUCTION — including
934 + // the 'unknown' bucket, which a hand-written COALESCE here omitted,
935 + // making country=unknown return zero rows while the dimension
936 + // reported 98 such orders.
937 + $query->whereRaw(
938 + self::dimensionExpr('country') . ' = ?',
939 + [sanitize_text_field($params['country'])]
940 + );
941 + }
942 + }
943 +
857 944 $selects = [];
858 945 $groupCols = [];
946 + $groupRaws = [];
859 947 foreach ($dimensions as $dim) {
860 - $selects[] = self::dimensionExpr($dim) . ' as ' . $dim;
861 - $groupCols[] = $dim;
948 + $expr = self::dimensionExpr($dim);
949 + $selects[] = $expr . ' as ' . $dim;
950 + if (in_array($dim, ['country', 'state'], true)) {
951 + // Group by the EXPRESSION, not the alias: the alias collides with
952 + // the real fc_oaddr.country column, and MySQL resolves a GROUP BY
953 + // name to the column first — which would split NULL from '' and
954 + // scatter the unknowns across two rows instead of one bucket.
955 + $groupRaws[] = $expr;
956 + } else {
957 + $groupCols[] = $dim;
958 + }
862 959 }
863 960
864 961 $metricSql = [
865 962 'order_count' => 'COUNT(*) as order_count',
@@ -885,8 +982,11 @@
885 982 $query->selectRaw(implode(', ', $selects));
886 983 foreach ($groupCols as $g) {
887 984 $query->groupBy($g);
888 985 }
986 + foreach ($groupRaws as $g) {
987 + $query->groupByRaw($g);
988 + }
889 989
890 990 $sortDesc = !isset($params['sort_desc']) || !empty($params['sort_desc']);
891 991 $firstMetric = isset($metrics[0]) ? $metrics[0] : 'order_count';
892 992 if ($firstMetric === 'aov') {
@@ -902,9 +1002,9 @@
902 1002 }
903 1003 }
904 1004
905 1005 $paging = self::queryPaging($params);
906 - if ($groupCols) {
1006 + if ($groupCols || $groupRaws) {
907 1007 if ($timeDim !== null && !isset($params['sort_desc'])) {
908 1008 // A time series reads chronologically by default; ranking a
909 1009 // calendar by metric is rarely what's wanted. An explicit
910 1010 // sort_desc still overrides this.
@@ -916,8 +1016,12 @@
916 1016 // reshuffles equal-metric rows across pages.
917 1017 foreach ($groupCols as $g) {
918 1018 $query->orderBy($g, 'ASC');
919 1019 }
1020 + foreach ($groupRaws as $g) {
1021 + // Raw, for the same alias/column collision reason as the GROUP BY.
1022 + $query->orderByRaw($g . ' ASC');
1023 + }
920 1024 }
921 1025 // One extra row peeks past the page boundary → meta.page.has_more.
922 1026 $query->limit($paging['per_page'] + 1)->offset($paging['offset']);
923 1027
@@ -955,12 +1059,19 @@
955 1059 implode(', ', $metrics),
956 1060 $dimensions ? implode(', ', $dimensions) : __('total', 'fluent-cart')
957 1061 );
958 1062
1063 + $meta = ['currency' => $currency, 'date_basis' => 'created_at', 'mode' => $mode];
1064 + // Only when geography was actually asked for — an agent grouping by month
1065 + // should not have to read a note about addresses.
1066 + if ($wantsGeo) {
1067 + $meta['geo_source'] = 'the order\'s own billing address, falling back to the buyer\'s primary billing address, then "unknown" for orders neither source can place. Rows always sum to the period total, so the "unknown" bucket shows exactly how much revenue is unattributed.';
1068 + }
1069 +
959 1070 return MCPHelper::envelope(
960 1071 $summary,
961 1072 ['metrics' => $metrics, 'dimensions' => $dimensions, 'range' => self::rangeBlock($range, $currency), 'rows' => $out],
962 - array_merge(['currency' => $currency, 'date_basis' => 'created_at', 'mode' => $mode], self::pageMeta($paging, $fetched))
1073 + array_merge($meta, self::pageMeta($paging, $fetched))
963 1074 );
964 1075 }
965 1076
966 1077 // -----------------------------------------------------------------
@@ -1147,11 +1258,69 @@
1147 1258 $metrics = self::pickList($params, 'metrics', ['customer_count', 'total_ltv', 'avg_ltv', 'avg_purchase_count', 'repeat_customers'], ['customer_count', 'total_ltv']);
1148 1259 $dimensions = self::pickList($params, 'dimensions', ['country', 'state', 'status', 'first_purchase_month', 'last_purchase_month'], []);
1149 1260
1150 1261 $query = Customer::query();
1262 +
1263 + // Geography resolves through the customer's primary BILLING ADDRESS,
1264 + // falling back to the fct_customers profile columns.
1265 + //
1266 + // Neither source is complete on its own, which is the whole reason for the
1267 + // coalesce. Measured on the 2,320-customer reference store: 2,176 customers
1268 + // have both and the two NEVER disagree (zero rows where both are set and
1269 + // differ), 96 have only the profile column, 7 have only an address row, and
1270 + // 41 have neither. So reading either source alone silently drops customers
1271 + // the other could place — profile-only would lose 7, address-only would lose
1272 + // 96 — while the coalesce leaves just the 41 genuinely unplaceable.
1273 + //
1274 + // Billing address is ordered first because it is the value the buyer actually
1275 + // typed, whereas the profile column is written once when the customer row is
1276 + // created and never refreshed (and at checkout the seeded value can be a
1277 + // country the frontend guessed from the browser timezone). On this store that
1278 + // ordering changes no result; it is the correct precedence for the case where
1279 + // a profile snapshot has gone stale.
1280 + //
1281 + // LEFT JOIN, so a customer with no address row is still counted (on the
1282 + // profile value, or 'unknown'); an INNER JOIN would silently drop them
1283 + // and quietly shrink every total. The join rides the existing
1284 + // (customer_id, is_primary) index — measured at no material cost.
1285 + //
1286 + // Grouped subquery rather than a raw leftJoin: fct_customer_addresses has
1287 + // no UNIQUE constraint on (customer_id, type, is_primary), so a second
1288 + // primary billing row would fan the join out and double-count that
1289 + // customer in COUNT(*) and SUM(ltv). Same guard the source report applies
1290 + // to fct_order_operations above. MAX() is ONLY_FULL_GROUP_BY-safe and
1291 + // returns the row's own value in the normal one-row case.
1292 + // The subquery's columns are aliased addr_* on purpose: raw SQL fragments
1293 + // are not table-prefixed by the builder, so a bare `country` in a
1294 + // selectRaw would be ambiguous across the two tables and a qualified
1295 + // `fct_customers.country` would miss the wp_ prefix. Distinct names keep
1296 + // every raw expression unambiguous with no prefix handling at all.
1297 + // latestRowPick, not MAX() per column: with more than one primary billing row
1298 + // independent MAX()es can take country from one row and state from another and
1299 + // report a place that does not exist. See the note in queryOrders.
1300 + $addrSub = App::db()->table('fct_customer_addresses')
1301 + ->select('customer_id')
1302 + ->selectRaw(
1303 + self::latestRowPick('country') . ' as addr_country, '
1304 + . self::latestRowPick('state') . ' as addr_state'
1305 + )
1306 + ->where('type', 'billing')
1307 + ->where('is_primary', 1)
1308 + ->groupBy('customer_id');
1309 +
1310 + $query->leftJoinSub($addrSub, 'fc_addr', 'fct_customers.id', '=', 'fc_addr.customer_id');
1311 +
1151 1312 if (!empty($params['country'])) {
1152 - $query->where('country', sanitize_text_field($params['country']));
1313 + $country = sanitize_text_field($params['country']);
1314 + // Match on the resolved value, not the raw column, so the filter and
1315 + // the grouping can never disagree about which country a customer is in.
1316 + $query->whereRaw(
1317 + "COALESCE(NULLIF(fc_addr.addr_country, ''), NULLIF(country, '')) = ?",
1318 + [$country]
1319 + );
1153 1320 }
1321 + // Unambiguous without qualification: the joined subquery exposes only
1322 + // customer_id / addr_country / addr_state.
1154 1323 if (!empty($params['status'])) {
1155 1324 $query->where('status', sanitize_text_field($params['status']));
1156 1325 }
1157 1326 if (isset($params['min_ltv'])) {
@@ -1164,17 +1333,26 @@
1164 1333 $selects = [];
1165 1334 $groupCols = [];
1166 1335 foreach ($dimensions as $dim) {
1167 1336 if ($dim === 'country' || $dim === 'state') {
1168 - // Coalesce NULL and '' into a single 'unknown' bucket. Group by the
1169 - // expression (not the alias, which would resolve to the raw column
1170 - // and keep null/'' split).
1171 - $expr = "COALESCE(NULLIF($dim, ''), 'unknown')";
1337 + // Billing address first, profile column second, 'unknown' last.
1338 + // Coalesce NULL and '' into the single 'unknown' bucket. Group by
1339 + // the expression (not the alias, which would resolve to the raw
1340 + // column and keep null/'' split).
1341 + $expr = "COALESCE(NULLIF(fc_addr.addr_$dim, ''), NULLIF($dim, ''), 'unknown')";
1172 1342 $selects[] = "$expr as $dim";
1173 1343 $groupCols[] = $expr;
1174 1344 } elseif ($dim === 'status') {
1175 - $selects[] = $dim;
1176 - $groupCols[] = $dim;
1345 + // Same 'unknown' coalescing as country/state, for the same reason.
1346 + // fct_customers.status is nullable with no default and 77 of the
1347 + // 2,320 customers on the reference store have NULL or '' — so a bare
1348 + // GROUP BY status answered "how many customers per status" with two
1349 + // buckets the agent was never told about (null AND '', split apart),
1350 + // neither of them in the status enum. One labelled bucket keeps the
1351 + // rows summing to the customer total and makes the gap legible.
1352 + $expr = "COALESCE(NULLIF(status, ''), 'unknown')";
1353 + $selects[] = "$expr as status";
1354 + $groupCols[] = $expr;
1177 1355 } elseif ($dim === 'first_purchase_month') {
1178 1356 $selects[] = "DATE_FORMAT(first_purchase_date, '%Y-%m') as first_purchase_month";
1179 1357 $groupCols[] = "DATE_FORMAT(first_purchase_date, '%Y-%m')";
1180 1358 } elseif ($dim === 'last_purchase_month') {
@@ -1245,9 +1423,16 @@
1245 1423 count($out),
1246 1424 implode(', ', $metrics)
1247 1425 ),
1248 1426 ['metrics' => $metrics, 'dimensions' => $dimensions, 'rows' => $out],
1249 - array_merge(['currency' => MCPHelper::currencyCode(), 'note' => 'LTV is in store currency; customers are not currency-scoped.'], self::pageMeta($paging, $fetched))
1427 + array_merge(
1428 + [
1429 + 'currency' => MCPHelper::currencyCode(),
1430 + 'note' => 'LTV is in store currency; customers are not currency-scoped.',
1431 + 'country_source' => 'primary billing address, falling back to the customer profile location, then "unknown". Registration geography, captured at first purchase — for per-order billing geography use query-orders grouped by country.',
1432 + ],
1433 + self::pageMeta($paging, $fetched)
1434 + )
1250 1435 );
1251 1436 }
1252 1437
1253 1438 // -----------------------------------------------------------------
@@ -1391,8 +1576,39 @@
1391 1576 }
1392 1577 }
1393 1578 }
1394 1579
1580 + /**
1581 + * Aggregate expression returning $column from the HIGHEST-id row in the group.
1582 + *
1583 + * Address tables have no UNIQUE constraint on (parent, type), so a group can hold
1584 + * several rows and the newest is the one the buyer last entered. Two columns each
1585 + * aggregated with a bare MAX() can come from two different rows and describe a
1586 + * place that never existed (a real case: country from one row, state from another).
1587 + *
1588 + * Prefixing each value with its zero-padded id makes lexicographic MAX() agree
1589 + * with numeric id order, so every column built this way resolves to the SAME row;
1590 + * SUBSTRING then drops the prefix. The width is 20 because that is exactly the
1591 + * digit count of the largest BIGINT UNSIGNED (18446744073709551615) — no id can
1592 + * overflow the padding, so all prefixes are equal-length and compare numerically.
1593 + * Ids are unique within a group (id is the PK), so the prefix alone always decides
1594 + * the winner and the collation of the value suffix can never influence it.
1595 + *
1596 + * COALESCE(...,'') matters — CONCAT with NULL is NULL and MAX() skips NULLs, which
1597 + * would let a NULL column fall back to a different row and reintroduce the mixing
1598 + * this exists to prevent.
1599 + *
1600 + * Portable to MySQL 5.6+ (no window functions) and needs no nested join, unlike
1601 + * ORDER BY ... LIMIT 1 or ROW_NUMBER().
1602 + *
1603 + * @param string $column trusted column name — never interpolate caller input here
1604 + * @return string
1605 + */
1606 + private static function latestRowPick($column)
1607 + {
1608 + return "SUBSTRING(MAX(CONCAT(LPAD(id, 20, '0'), COALESCE($column, ''))), 21)";
1609 + }
1610 +
1395 1611 private static function dimensionExpr($dim)
1396 1612 {
1397 1613 if ($dim === 'day') {
1398 1614 return "DATE_FORMAT(created_at, '%Y-%m-%d')";
@@ -1408,8 +1624,33 @@
1408 1624 // fct_orders.type column; expose it under the order_type alias so the
1409 1625 // dimension name and response key read naturally and don't collide
1410 1626 // with the unrelated payment_type on line items.
1411 1627 return 'type';
1628 + }
1629 + if ($dim === 'country' || $dim === 'state') {
1630 + // ONE source per order, decided by whether the order has a billing
1631 + // address row at all (fc_oaddr.order_id IS NULL) — never per column.
1632 + //
1633 + // Coalescing each column independently mixes provenance inside a single
1634 + // order and invents places: order 16 on the reference store has its own
1635 + // billing row (BG, '') while its buyer's address says (BG, BG-22), so a
1636 + // per-column COALESCE reported that order's state as BG-22 — a value
1637 + // from a mutable customer record, for an order that carries its own
1638 + // address. 38 paid orders were affected. It is the same row-mixing
1639 + // latestRowPick() prevents inside a table, reappearing across tables.
1640 + //
1641 + // Consequence, deliberately: an order whose own address has a country
1642 + // but a blank state reports state 'unknown' rather than borrowing one.
1643 + // That is the honest answer — that order's address genuinely has no
1644 + // state — and it keeps historical attribution stable when a customer
1645 + // later edits their address.
1646 + //
1647 + // Orders neither source can place get the 'unknown' bucket rather than
1648 + // being dropped, so rows still sum to the period's total revenue and the
1649 + // size of the gap stays visible.
1650 + $pick = "CASE WHEN fc_oaddr.order_id IS NULL THEN fc_caddr.caddr_$dim ELSE fc_oaddr.oaddr_$dim END";
1651 +
1652 + return "COALESCE(NULLIF($pick, ''), 'unknown')";
1412 1653 }
1413 1654 return $dim;
1414 1655 }
1415 1656