'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 的行是补出来的优惠/冲减/无收款账单行, // 没有对应的收款商品,不参与商品成本。 $billQuery = $mirror->table(self::SOURCE_TABLE) ->where('report_month', $reportMonth) ->whereNotNull('income_amount') ->where('income_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 = []; 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, " . " 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%'" ); } 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 = ''; } } $lines[$core][] = [ // 月加权平均单价表里的商品编码统一大写,订单侧可能小写,统一成大写再比。 'sku' => mb_strtoupper($sku), 'qty' => $qty, 'amount' => $amount, 'warehouse' => $warehouse, ]; } } $this->info('订单侧命中核心单号:' . count($lines)); $shortNames = $this->warehouseShortNames(); $prices = $this->weightedAverageUnitPrices($mirror, $reportMonth); // 月加权平均单价匹配不到时,用「线上代发价格」(采购单价 / 钉钉代发单价)兜底。 $fallbackPrices = $this->onlineDaifaUnitPrices($mirror); $this->info(sprintf( '仓库映射条目:%d,月加权单价条目:%d,线上代发价格条目:%d', count($shortNames['by_provider']), count($prices), count($fallbackPrices) )); $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; $mismatchRows = 0; foreach ($billByCore as $core => $bill) { foreach ($this->costLines($lines[$core] ?? []) as $line) { $short = $shortNames['by_warehouse'][$line['warehouse']] ?? $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++; } } $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 行', $missingWarehouseRows, $fallbackPriceRows, $missingPriceRows, $mismatchRows )); return self::SUCCESS; } /** * 拆单去重:聚水潭的拆单子单会把主单的商品行再列一遍,同一批商品只算一次; * 同一商品在同一订单里既有真实发货仓库行、又有无仓库的占位行时,丢掉占位行, * 这样「同一单分多仓发货」会按子单的实际仓库分行。 * * @param list $rows * @return list */ private function costLines(array $rows): array { if ($rows === []) { return []; } // 1) 完全相同的行(商品 + 数量 + 金额 + 仓库)只保留一条。 $unique = []; foreach ($rows as $row) { $key = $row['sku'] . "\0" . $row['qty'] . "\0" . $row['amount'] . "\0" . $row['warehouse']; $unique[$key] = $row; } // 2) 有真实仓库的同一(商品 + 数量 + 金额),优先于无仓库的占位行。 $withWarehouse = []; foreach ($unique as $row) { if ($row['warehouse'] !== '') { $withWarehouse[$row['sku'] . "\0" . $row['qty'] . "\0" . $row['amount']] = true; } } foreach ($unique as $key => $row) { if ($row['warehouse'] === '' && isset($withWarehouse[$row['sku'] . "\0" . $row['qty'] . "\0" . $row['amount']])) { unset($unique[$key]); } } // 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); } /** * 仓库映射表:发货仓库 → 仓库简称。 * * 映射表按「仓储方 + 仓库」登记,仓库名带「主仓」的那条代表该仓储方的正式出货仓, * 因此优先用它作为发货仓库对应的简称;同时保留「完整仓库名 → 简称」的精确匹配。 * * @return array{by_provider:array,by_warehouse:array} */ private function warehouseShortNames(): array { $byProvider = []; $byWarehouse = []; 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 ($warehouse !== '' && ! isset($byWarehouse[$warehouse])) { $byWarehouse[$warehouse] = $short; } if ($provider === '') { continue; } $isMain = str_contains($warehouse, '主仓'); if ($isMain || ! isset($byProvider[$provider])) { $byProvider[$provider] = $short; } } return ['by_provider' => $byProvider, 'by_warehouse' => $byWarehouse]; } /** * 商品月加权平均单价:按「仓库简称 + 商品编码」取加权平均价。 * * @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; } 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); } }