'ops_jst_qmcust_orders', 'items' => 'ops_jst_qmcust_order_items', 'warehouse' => 'o.wms_co_id', ], [ 'orders' => 'ops_jst_orders', 'items' => 'ops_jst_order_item', 'warehouse' => "COALESCE(NULLIF(i.send_warehouse, ''), o.wms_co_id)", ], ]; /** * 账单页签 key → 业绩明细里的平台名:--platform 既接受页签 key(jingxi), * 也接受平台名(京喜账单),避免命令行和页面口径对不上。 */ private const PLATFORM_LABELS = [ 'taobao-alipay' => '淘系支付宝', 'taobao-wechat' => '淘系微信', 'pinduoduo' => '拼多多', 'dewu' => '得物', 'jd-wallet' => '京东网银钱包', 'jd-transactions' => '京东账单', 'jingxi' => '京喜账单', 'douyin' => '抖音', ]; public function handle(): int { if ((int) ini_get('memory_limit') > 0) { @ini_set('memory_limit', '2G'); } $month = trim((string) $this->argument('month')); if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) { throw new InvalidArgumentException('月份格式应为 YYYY-MM,例如 2026-08。'); } $reportMonth = $month . '-01'; $platformInput = trim((string) $this->option('platform')); $platform = self::PLATFORM_LABELS[$platformInput] ?? $platformInput; $chunkSize = max((int) $this->option('chunk'), 100); $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); $mirror = DB::connection($connection); // 账单侧取数:业绩明细里本月(可按平台限定)的账单行,一行 = 一个店铺的一个订单。 // 只取「收入金额有数据且不为 0」的账单行:收入为 0 的行是补出来的优惠/冲减/无收款账单行, // 没有对应的收款商品,不参与商品成本。 // 「种菜金额」有数据(>0)的账单行整条排除,不参与匹配。 $billQuery = $mirror->table(self::SOURCE_TABLE) ->where('report_month', $reportMonth) ->whereNotNull('income_amount') ->where('income_amount', '!=', 0) ->where(static function ($query): void { $query->whereNull('planting_amount') ->orWhere('planting_amount', '<=', 0); }); if ($platform !== '') { $billQuery->where('platform', $platform); } $bills = $billQuery->get([ 'platform', 'store_name', 'order_core_no', 'online_order_no', 'occurred_date', ]); $this->info(sprintf( '账期 %s%s:业绩明细账单行 %d 条', $month, $platform !== '' ? '(' . $platform . ')' : '', $bills->count() )); /** @var array $billByCore 核心单号 → 账单行 */ $billByCore = []; $candidates = []; foreach ($bills as $bill) { $core = self::cleanOrderNo((string) ($bill->order_core_no ?? '')); if ($core === '') { continue; } if (! isset($billByCore[$core])) { $billByCore[$core] = $bill; } $candidates[$core] = true; $raw = trim((string) ($bill->online_order_no ?? '')); if ($raw !== '') { $candidates[$raw] = true; } } if ($billByCore === []) { $this->warn('没有可匹配的账单单号,已清空本月数据。'); $this->pruneTarget($connection, $reportMonth, $platform); return self::SUCCESS; } // 订单侧数据量大(企微 500 万单 / 聚水潭 900 万行),先用临时表收敛候选单号, // 再走 so_id / o_id 索引取明细,避免逐批 IN 查询退化成全表扫描。 $mirror->statement('DROP TEMPORARY TABLE IF EXISTS tmp_cost_order_nos'); $mirror->statement('CREATE TEMPORARY TABLE tmp_cost_order_nos (no varchar(64) NOT NULL, PRIMARY KEY (no)) ENGINE=InnoDB'); foreach (array_chunk(array_keys($candidates), $chunkSize) as $batch) { $mirror->table('tmp_cost_order_nos')->insertOrIgnore( array_map(static fn (string $no): array => ['no' => $no], $batch) ); } $this->info('订单侧候选单号:' . count($candidates)); /** @var array> $lines */ $lines = []; $excludedSkuSql = "'" . implode("','", self::EXCLUDED_SKUS) . "'"; foreach (self::SOURCES as $source) { try { $rows = $mirror->select( "SELECT o.so_id as so_id, " . $source['warehouse'] . " as warehouse_code, " . " COALESCE(wh.name, '') as warehouse_name, " . " CASE WHEN o.link_o_id IS NULL OR o.link_o_id = 0 THEN 1 ELSE 0 END as is_main, " . " CONCAT(o.o_id, '-', i.oi_id) as row_key, " . " i.sku_id as sku_id, i.qty as qty, i.amount as amount FROM tmp_cost_order_nos t STRAIGHT_JOIN {$source['orders']} o ON o.so_id = t.no STRAIGHT_JOIN {$source['items']} i ON i.o_id = o.o_id LEFT JOIN ops_jst_warehouse_info wh ON wh.wms_co_id = o.wms_co_id WHERE COALESCE(i.sku_id, '') NOT LIKE 'X0000%' AND UPPER(TRIM(COALESCE(i.sku_id, ''))) NOT IN ({$excludedSkuSql})" ); } catch (Throwable $exception) { $this->error('订单明细查询失败(' . $source['items'] . '):' . $exception->getMessage()); return self::FAILURE; } foreach ($rows as $row) { $core = self::cleanOrderNo((string) $row->so_id); if ($core === '' || ! isset($billByCore[$core])) { continue; } $sku = trim((string) $row->sku_id); if ($sku === '') { continue; } $qty = (float) $row->qty; $amount = (float) $row->amount; // 数量与金额都为 0 的行是拆单/换货留下的占位行,不是真实发货商品。 if ($qty == 0.0 && $amount == 0.0) { continue; } $warehouse = trim((string) $row->warehouse_name); if ($warehouse === '') { $warehouse = trim((string) ($row->warehouse_code ?? '')); // 订单上的 wms_co_id 为 0 表示没有仓库信息,不能当成仓库名。 if ($warehouse === '0') { $warehouse = ''; } } // 发货仓库过滤(在 TCXJ/FU WU 之后执行): // 默认排除常州简尚家居有限公司 / 简尚-苏州福利品仓 / 简尚-自动发货仓; // 例外:常州简尚家居有限公司里「DF-」开头的商品行保留。 if (in_array($warehouse, self::EXCLUDED_WAREHOUSES, true)) { $isDfException = $warehouse === '常州简尚家居有限公司' && str_starts_with(mb_strtoupper($sku), 'DF-'); if (! $isDfException) { continue; } } // 组合装条码(ZH-条码-数量-条码-数量)按子订单拆成组件行: // 一个订单有几个发货仓库、商品编码、数量就分开列,组件数量 = 明细数量 × 组件倍数。 // 解析不了的组合条码(条码被截断等)保持原样,避免丢行。 foreach (self::expandComboSku($sku, $qty) as [$code, $codeQty, $isComponent]) { $lines[$core][] = [ // 月加权平均单价表里的商品编码统一大写,订单侧可能小写,统一成大写再比。 'sku' => mb_strtoupper($code), 'qty' => $codeQty, // 组合装明细金额对应整包,拆到组件后不再参与「数量×单价 vs 明细金额」校验。 'amount' => $isComponent ? 0.0 : $amount, 'warehouse' => $warehouse, // 拆单后子单(link_o_id 非空)才是实际发货行;主单只是原始下单记录。 'is_main' => (int) $row->is_main === 1, 'row_key' => (string) $row->row_key, ]; } } } $this->info('订单侧命中核心单号:' . count($lines)); $shortNames = $this->warehouseShortNames(); $prices = $this->weightedAverageUnitPrices($mirror, $reportMonth); // 月加权平均单价匹配不到时,用「线上代发价格」(采购单价 / 钉钉代发单价)兜底。 $fallbackPrices = $this->onlineDaifaUnitPrices($mirror); // 代发价格也没有时,再用普通商品资料里的商品成本价兜底。 $catalogPrices = $this->catalogCostPrices($mirror); $this->info(sprintf( '仓库映射条目:%d,月加权单价条目:%d,线上代发价格条目:%d,商品资料成本价条目:%d', count($shortNames['by_provider']), count($prices), count($fallbackPrices), count($catalogPrices) )); $this->pruneTarget($connection, $reportMonth, $platform); $target = DB::connection($connection)->table(self::TARGET_TABLE); $now = now(); $buffer = []; $imported = 0; $quantityTotal = 0.0; $costTotal = 0.0; $missingPriceRows = 0; $missingWarehouseRows = 0; $fallbackPriceRows = 0; $catalogPriceRows = 0; $mismatchRows = 0; foreach ($billByCore as $core => $bill) { foreach ($this->costLines($lines[$core] ?? []) as $line) { $short = $shortNames['by_provider'][$line['warehouse']] ?? ''; $price = $short !== '' ? ($prices[$short . "\0" . $line['sku']] ?? null) : null; $priceSource = $price === null ? null : self::PRICE_SOURCE_WEIGHTED; // 月加权平均单价按「仓库简称 + 商品编码」匹配不到时,退回线上代发价格(按商品编码)。 if ($price === null) { $fallback = $fallbackPrices[$line['sku']] ?? null; if ($fallback !== null) { $price = $fallback['price']; $priceSource = $fallback['source']; $fallbackPriceRows++; } } // 线上代发价格也没有时,最后用普通商品资料的商品成本价兜底。 if ($price === null) { $catalogPrice = $catalogPrices[$line['sku']] ?? null; if ($catalogPrice !== null) { $price = $catalogPrice; $priceSource = self::PRICE_SOURCE_CATALOG; $catalogPriceRows++; } } $quantity = round($line['qty'], 4); $unitPrice = $price === null ? null : round((float) $price, 2); $cost = $unitPrice === null ? null : round($quantity * $unitPrice, 2); // 数量单位与单价口径校验:该行明细单价 = 明细金额 ÷ 数量。 // 成本单价高于明细单价 3 倍以上说明两边单位不是一回事(定制配件行),成本留空。 $detailUnitPrice = $quantity > 0 ? (float) $line['amount'] / $quantity : 0.0; $unitMismatch = $unitPrice !== null && $detailUnitPrice > 0 && $unitPrice > $detailUnitPrice * self::UNIT_MISMATCH_FACTOR; if ($unitMismatch) { $cost = null; $unitPrice = null; $priceSource = null; } $remarks = []; if ($unitMismatch) { $remarks[] = self::REMARK_UNIT_MISMATCH; $mismatchRows++; } if ($short === '') { $remarks[] = self::REMARK_NO_WAREHOUSE; $missingWarehouseRows++; } if ($unitPrice === null) { if (! $unitMismatch) { $remarks[] = self::REMARK_NO_PRICE; $missingPriceRows++; } } else { $quantityTotal += $quantity; $costTotal += (float) $cost; } $buffer[] = [ 'report_month' => $reportMonth, 'row_key' => md5($reportMonth . '|' . $bill->platform . '|' . $bill->store_name . '|' . $core . '|' . $line['warehouse'] . '|' . $line['sku']), 'platform' => $bill->platform, 'occurred_date' => $bill->occurred_date, 'store_name' => $bill->store_name, 'online_order_no' => $core, 'warehouse' => $line['warehouse'] !== '' ? $line['warehouse'] : null, 'warehouse_short_name' => $short !== '' ? $short : null, 'product_code' => $line['sku'], 'product_quantity' => $quantity, 'unit_price' => $unitPrice, 'price_source' => $priceSource, 'product_cost' => $cost, 'remark' => $remarks === [] ? null : implode(' / ', $remarks), 'imported_at' => $now, ]; if (count($buffer) >= 500) { $target->insertOrIgnore($buffer); $imported += count($buffer); $buffer = []; } } } if ($buffer !== []) { $target->insertOrIgnore($buffer); $imported += count($buffer); } $this->info(sprintf('写入完成,共 %d 行。', $imported)); $this->info(sprintf(' 商品数量合计:%s', number_format($quantityTotal, 2))); $this->info(sprintf(' 商品成本合计:%s', number_format($costTotal, 2))); $this->info(sprintf( ' 未匹配仓库简称:%d 行;线上代发价格兜底:%d 行;商品资料成本价兜底:%d 行;仍未匹配商品单价:%d 行;数量与单价口径不一致(不计成本):%d 行', $missingWarehouseRows, $fallbackPriceRows, $catalogPriceRows, $missingPriceRows, $mismatchRows )); return self::SUCCESS; } /** * 拆单取数:聚水潭拆单后,主单(link_o_id 为空)保留的是原始下单行, * 子单(link_o_id 指向主单)才是实际发货行。一个订单只要出现子单,就只取子单, * 否则主单和子单会被各算一遍(主单的原始商品 + 子单的实际发货商品)。 * 没有子单的订单才用主单行。 * * 同一订单的多条子单(不同仓库 / 分批发货)按「发货仓库 + 商品编码」汇总,数量相加, * 这样「一个订单有几个发货仓库、商品编码、数量」会分开列。 * * @param list $rows * @return list */ private function costLines(array $rows): array { if ($rows === []) { return []; } // 1) 先按订单明细主键去掉上游的完全重复行(同一 o_id + oi_id 只算一次)。 $unique = []; foreach ($rows as $row) { $key = $row['row_key'] . "\0" . $row['sku']; $unique[$key] = $row; } // 2) 有子单就只取子单(实际发货行);整单没有子单才用主单行。 $hasSubOrder = false; foreach ($unique as $row) { if (! $row['is_main']) { $hasSubOrder = true; break; } } if ($hasSubOrder) { $unique = array_filter($unique, static fn (array $row): bool => ! $row['is_main']); } // 3) 汇总到「发货仓库 + 商品编码」。 $aggregated = []; foreach ($unique as $row) { $key = $row['warehouse'] . "\0" . $row['sku']; $aggregated[$key] = [ 'sku' => $row['sku'], 'warehouse' => $row['warehouse'], 'qty' => ($aggregated[$key]['qty'] ?? 0.0) + $row['qty'], 'amount' => ($aggregated[$key]['amount'] ?? 0.0) + $row['amount'], ]; } return array_values($aggregated); } /** * 组合装条码拆组件:聚水潭的组合装 sku 形如 * ZH-<条码>-<数量>[-<条码>-<数量>...] * 条码本身含横杠,所以按「纯数字段」识别数量,再按组件展开。 * * 返回 [条码, 数量, 是否组件];非组合装或解析失败时返回原条码 / 原数量。 * * @return list */ private static function expandComboSku(string $sku, float $quantity): array { if (! str_starts_with(mb_strtoupper($sku), 'ZH-')) { return [[$sku, $quantity, false]]; } $parts = explode('-', substr($sku, 3)); $components = []; $buffer = []; foreach ($parts as $part) { if ($part === '') { return [[$sku, $quantity, false]]; } if (preg_match('/^\d+(\.\d+)?$/', $part) === 1) { if ($buffer === []) { return [[$sku, $quantity, false]]; } $components[] = [implode('-', $buffer), (float) $part]; $buffer = []; continue; } $buffer[] = $part; } if ($buffer !== [] || $components === []) { return [[$sku, $quantity, false]]; } $expanded = []; foreach ($components as [$code, $multiplier]) { // 组合装里如果拆出套餐/服务占位编码,同样不取数。 if (in_array(mb_strtoupper(trim($code)), self::EXCLUDED_SKUS, true)) { continue; } $expanded[] = [$code, $quantity * $multiplier, true]; } return $expanded === [] ? [] : $expanded; } /** * 仓库映射表:发货仓库 → 仓库简称。 * * 映射表按「仓储方 + 仓库」登记,仓库名带「主仓」的那条代表该仓储方的正式出货仓, * 因此优先用它作为发货仓库对应的简称;同时保留「完整仓库名 → 简称」的精确匹配。 * * @return array{by_provider:array,by_warehouse:array} */ private function warehouseShortNames(): array { $byProvider = []; foreach (DB::table(self::WAREHOUSE_MAPPING_TABLE) ->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']) as $mapping) { $provider = trim((string) $mapping->warehouse_provider); $warehouse = trim((string) $mapping->warehouse); $short = trim((string) $mapping->warehouse_short_name); if ($short === '') { continue; } if ($provider === '') { continue; } // 只认「仓储方 + 仓库含主仓」的那条:发货仓库是仓储方级别的名称, // 次品仓 / 销退仓 / 客退仓这些行的简称(多为「-不看」)不能套到主仓发货上。 $isMain = str_contains($warehouse, '主仓'); if ($isMain) { $byProvider[$provider] = $short; continue; } // 映射表没有主仓行时,仅接受「仓库名 = 仓储方名」这种同名仓库(如重庆渝北仓), // 其余(次品仓、配件单仓等)不再兜底,避免把主仓订单误判成不看仓。 if ($warehouse === $provider && ! isset($byProvider[$provider])) { $byProvider[$provider] = $short; continue; } // 业务确认:常州简尚家居有限公司的发货仓对应映射表里的「配件单仓」行 // (仓储方=常州简尚家居有限公司 / 仓库=配件单仓 → 配件单仓-不看)。 if (str_contains($warehouse, '配件单仓') && ! isset($byProvider[$provider])) { $byProvider[$provider] = $short; } } return ['by_provider' => $byProvider]; } /** * 商品月加权平均单价:按「仓库简称 + 商品编码」取加权平均价。 * * @return array 键为「仓库简称\0商品编码」 */ private function weightedAverageUnitPrices($mirror, string $reportMonth): array { $prices = []; $query = $mirror->table(self::PRICE_TABLE) ->where('report_month', $reportMonth) ->whereNotNull('weighted_average_unit_price'); foreach ($query->cursor() as $row) { $prices[trim((string) $row->warehouse_short_name) . "\0" . mb_strtoupper(trim((string) $row->product_code))] = (float) $row->weighted_average_unit_price; } return $prices; } /** * 线上代发价格:月加权平均单价匹配不到时的兜底单价(按商品编码)。 * * 与「线上代发价格」页面同源,取两个来源: * 1. 采购单管理(product_purchase_price_mirror_rows):按商品编码取最新一次采购单价; * 2. 钉钉线上代发审批(ding_online_daifa_price_mirror):商品ID 关联商品目录拿到商品编码, * 单价 = 代发订单金额 ÷ 订单发货数量。 * * 同一商品编码两边都有时取采购单价(本身就是单价字段,口径更直接)。 * * @return array */ private function onlineDaifaUnitPrices($mirror): array { $prices = []; try { // 采购单管理:同一商品分多次采购时,后发生的记录覆盖先发生的,最终留下最新采购单价。 $purchaseRows = $mirror->table(self::PURCHASE_PRICE_TABLE) ->whereIn('document_status', ['Confirmed', 'Finished']) ->where('unit_price', '>', 0) ->orderByRaw('COALESCE(finished_at, document_created_at, purchase_updated_at, created_at)') ->get(['sku_id', 'unit_price']); foreach ($purchaseRows as $row) { $sku = mb_strtoupper(trim((string) $row->sku_id)); if ($sku !== '') { $prices[$sku] = [ 'price' => round((float) $row->unit_price, 2), 'source' => self::PRICE_SOURCE_PURCHASE, ]; } } } catch (Throwable $exception) { $this->warn('采购单价格读取失败(跳过该兜底来源):' . $exception->getMessage()); } try { // 钉钉线上代发:镜像只存商品ID(= 聚水潭 shop_i_id),需要经商品目录换成商品编码。 $daifaRows = $mirror->table(self::DING_DAIFA_TABLE) ->where('ship_qty', '>', 0) ->where('daifa_amount', '>', 0) ->orderBy('workflow_created_at') ->get(['product_id', 'ship_qty', 'daifa_amount']); $productIds = []; foreach ($daifaRows as $row) { $productId = trim((string) $row->product_id); // 商品ID 是聚水潭的数字ID:审批表里偶尔填了文字(如「详情参考表格」), // 混进 IN 条件会让 bigint 列做隐式类型转换、索引失效,这里只保留纯数字。 if ($productId !== '' && ctype_digit($productId)) { $productIds[$productId] = true; } } /** @var array $skuByProductId */ $skuByProductId = []; // 注意:shop_i_id 是 varchar,PHP 会把「纯数字字符串」的数组键转成 int, // 直接传进去会让 MariaDB 做隐式类型转换、索引失效(实测 2500 万行全表扫)。 // 这里显式转字符串再绑定。 foreach (array_chunk(array_map('strval', array_keys($productIds)), 500) as $batch) { foreach ($mirror->table('ops_jst_sku_map')->whereIn('shop_i_id', $batch) ->get(['shop_i_id', 'sku_id']) as $row) { $sku = mb_strtoupper(trim((string) $row->sku_id)); if ($sku !== '') { $skuByProductId[(string) $row->shop_i_id] = $sku; } } } foreach ($daifaRows as $row) { $sku = $skuByProductId[trim((string) $row->product_id)] ?? ''; $quantity = (float) $row->ship_qty; if ($sku === '' || $quantity <= 0 || isset($prices[$sku])) { continue; } $prices[$sku] = [ 'price' => round((float) $row->daifa_amount / $quantity, 2), 'source' => self::PRICE_SOURCE_DING, ]; } } catch (Throwable $exception) { $this->warn('线上代发价格读取失败(跳过该兜底来源):' . $exception->getMessage()); } return $prices; } /** * 普通商品资料(operations_replica.rpt.jst_sku → ops_jst_sku)里的商品成本价: * 只取普通商品(sku_type=normal)且成本价大于 0 的记录,按商品编码建索引。 * * @return array */ private function catalogCostPrices($mirror): array { $prices = []; try { foreach ($mirror->table('ops_jst_sku') ->where('sku_type', 'normal') ->where('cost_price', '>', 0) ->get(['sku_id', 'cost_price']) as $row) { $sku = mb_strtoupper(trim((string) $row->sku_id)); if ($sku !== '') { $prices[$sku] = round((float) $row->cost_price, 2); } } } catch (Throwable $exception) { $this->warn('普通商品资料成本价读取失败(跳过该兜底来源):' . $exception->getMessage()); } return $prices; } private function pruneTarget(string $connection, string $reportMonth, string $platform): void { $query = DB::connection($connection)->table(self::TARGET_TABLE)->where('report_month', $reportMonth); if ($platform !== '') { $query->where('platform', $platform); } $query->delete(); } /** * 订单号清洗沿用业绩明细的同一套口径(去日期前缀 / 空格 / 各类横杠,统一大写)。 */ private static function cleanOrderNo(string $value): string { return SyncStoreReceiptOrderMatches::cleanOrderNo($value); } }