'qmcust', 'orders' => 'ops_jst_qmcust_orders', 'items' => 'ops_jst_qmcust_order_items', 'warehouse' => 'o.wms_co_id', ], [ 'key' => 'jst', '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 的补行没有对应的收款商品,不参与商品成本。 // 种菜金额非空,或新标签为「种菜报销 / 种菜未报销」的账单行整条排除。 $bills = $this->billRows($mirror, $reportMonth, $platform); $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 '{$source['key']}' as source_key, " . "o.so_id as so_id, " . $source['warehouse'] . " as warehouse_code, " . " COALESCE(wh.name, '') as warehouse_name, " . " o.o_id as o_id, o.link_o_id as parent_o_id, " . " CONCAT(o.o_id, '-', i.oi_id) as row_key, " . " i.sku_id as sku_id, i.qty as qty, i.amount as amount, o.status as status, " . " i.shop_i_id as shop_i_id, i.shop_sku_id as shop_sku_id, " . " i.properties_value as properties_value, i.src_combine_sku_id as src_combine_sku_id 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 = ''; } } // 发货仓库过滤(在商品编码全局排除之后执行): // 默认排除常州简尚家居有限公司 / 简尚-苏州福利品仓 / 简尚-自动发货仓; // 例外:常州简尚家居有限公司里「DF-」开头的商品行保留。 if (in_array($warehouse, self::EXCLUDED_WAREHOUSES, true)) { $isDfException = $warehouse === '常州简尚家居有限公司' && str_starts_with(mb_strtoupper($sku), 'DF-'); if (! $isDfException) { continue; } } $lines[$core][] = [ 'source' => (string) $row->source_key, // 月加权平均单价表里的商品编码统一大写,订单侧可能小写,统一成大写再比。 'sku' => mb_strtoupper($sku), 'qty' => $qty, 'amount' => $amount, 'warehouse' => $warehouse, // 拆单层级:o_id 与上级 link_o_id,用于只保留末级发货行(叶子行)。 'o_id' => (string) $row->o_id, 'parent_o_id' => (string) ($row->parent_o_id ?? ''), 'status' => (string) ($row->status ?? ''), 'row_key' => (string) $row->row_key, 'shop_i_id' => trim((string) ($row->shop_i_id ?? '')), 'shop_sku_id' => trim((string) ($row->shop_sku_id ?? '')), 'properties_value' => trim((string) ($row->properties_value ?? '')), 'src_combine_sku_id' => mb_strtoupper(trim((string) ($row->src_combine_sku_id ?? ''))), ]; } } $this->info('订单侧命中核心单号:' . count($lines)); $platformSkuMapper = $this->platformSkuMapper($mirror, $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; $matchedPriceRows = 0; $placeholderExcludedRows = 0; $bundleParentExcludedRows = 0; $autoReplacedRows = 0; $mappingAmbiguousRows = 0; $mappingMissingRows = 0; $pendingCostRows = []; foreach ($billByCore as $core => $bill) { // 展示单号与业绩明细完全一致;$core 只用于订单侧清洗后匹配。 $displayOrderNo = (string) ($bill->online_order_no ?? ''); if ($displayOrderNo === '') { $displayOrderNo = $core; } $excludedPlaceholders = 0; $excludedBundleParents = 0; $costLines = $this->costLines( $lines[$core] ?? [], $excludedPlaceholders, $excludedBundleParents, ); $placeholderExcludedRows += $excludedPlaceholders; $bundleParentExcludedRows += $excludedBundleParents; $mapped = $this->mapPlatformSkus($costLines, $platformSkuMapper); $costLines = $mapped['lines']; $autoReplacedRows += $mapped['auto_replaced_rows']; $mappingAmbiguousRows += $mapped['ambiguous_rows']; $mappingMissingRows += $mapped['missing_rows']; foreach ($costLines 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++; $pendingCostRows[] = [ 'platform' => (string) $bill->platform, 'store_name' => (string) $bill->store_name, 'online_order_no' => $displayOrderNo, 'warehouse' => $line['warehouse'] !== '' ? $line['warehouse'] : null, 'warehouse_short_name' => $short !== '' ? $short : null, 'product_code' => $line['sku'], 'original_platform_sku' => $line['original_platform_sku'] ?? null, 'shop_sku_id' => $line['shop_sku_id'] ?? null, 'shop_i_id' => $line['shop_i_id'] ?? null, 'properties_value' => $line['properties_value'] ?? null, 'mapping_status' => $line['platform_mapping_status'] ?? null, 'mapping_candidates' => $line['platform_mapping_candidates'] ?? [], 'product_quantity' => $quantity, 'reason' => $short === '' ? '未匹配仓库简称' : (($line['platform_mapping_status'] ?? null) === 'ambiguous' ? '平台 shop_sku_id 对应多个历史内部商品编码' : (($line['platform_mapping_status'] ?? null) === 'missing' ? '平台 shop_sku_id 未找到历史内部商品编码' : '仓库简称 + 商品编码在月加权、线上代发、普通商品资料中均无有效单价')), 'code_type' => $this->pendingCodeType((string) $line['sku']), ]; } } else { $matchedPriceRows++; $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' => $displayOrderNo, '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 )); $pendingCostQuantity = array_sum(array_column($pendingCostRows, 'product_quantity')); $this->info(sprintf( ' 正常匹配单价:%d 行;排除平台占位行:%d 行;排除组合装主行:%d 行;待核验成本:%d 行 / %s 件', $matchedPriceRows, $placeholderExcludedRows, $bundleParentExcludedRows, $missingPriceRows, number_format((float) $pendingCostQuantity, 2), )); $this->info(sprintf( ' 平台数字 SKU 自动替换:%d 行;多候选保留:%d 行;无历史映射保留:%d 行', $autoReplacedRows, $mappingAmbiguousRows, $mappingMissingRows, )); $result = [ 'status' => 'success', 'report_month' => $reportMonth, 'platform' => $platform !== '' ? $platform : '全部', 'source_bill_rows' => $bills->count(), 'candidate_orders' => count($candidates), 'matched_orders' => count($lines), 'written_rows' => $imported, 'normal_matched_rows' => $matchedPriceRows, 'placeholder_excluded_rows' => $placeholderExcludedRows, 'bundle_parent_excluded_rows' => $bundleParentExcludedRows, 'excluded_rows' => $placeholderExcludedRows + $bundleParentExcludedRows, 'auto_replaced_platform_sku_rows' => $autoReplacedRows, 'platform_sku_mapping_ambiguous_rows' => $mappingAmbiguousRows, 'platform_sku_mapping_missing_rows' => $mappingMissingRows, 'pending_cost_rows' => $missingPriceRows, 'pending_cost_quantity' => round((float) $pendingCostQuantity, 4), 'unit_mismatch_rows' => $mismatchRows, 'missing_warehouse_rows' => $missingWarehouseRows, 'fallback_purchase_price_rows' => $fallbackPriceRows, 'fallback_catalog_price_rows' => $catalogPriceRows, 'matched_quantity' => round($quantityTotal, 4), 'matched_cost' => round($costTotal, 2), 'pending_cost' => $pendingCostRows, 'generated_at' => now()->toDateTimeString(), ]; $resultPath = trim((string) $this->option('result-json')); if ($resultPath !== '') { File::ensureDirectoryExists(dirname($resultPath)); File::put( $resultPath, json_encode( $result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT, ) . PHP_EOL, ); } return self::SUCCESS; } /** * @return \Illuminate\Support\Collection */ private function billRows($mirror, string $reportMonth, string $platform) { $query = $mirror->table(self::SOURCE_TABLE) ->where('report_month', $reportMonth) ->whereNotNull('income_amount') ->where('income_amount', '!=', 0) // planting_amount 是旧字段兼容:任何非空值都表示该订单命中了种菜单。 ->whereNull('planting_amount') // 新版业绩明细把两类种菜状态合并为标签;任一种菜标签都不参与商品成本。 ->whereNull('planting_label'); if ($platform !== '') { $query->where('platform', $platform); } return $query->get([ 'platform', 'store_name', 'order_core_no', 'online_order_no', 'occurred_date', ]); } /** * @param array>> $lines */ private function platformSkuMapper($mirror, array $lines): StoreReceiptPlatformSkuMapper { $candidates = []; foreach ($lines as $orderLines) { foreach ($orderLines as $line) { $sku = mb_strtoupper(trim((string) ($line['sku'] ?? ''))); $shopSkuId = trim((string) ($line['shop_sku_id'] ?? '')); $shopItemId = trim((string) ($line['shop_i_id'] ?? '')); if ( preg_match('/^\d+$/', $sku) === 1 && $shopSkuId !== '' && $shopItemId !== '' ) { $candidates[$shopSkuId . "\0" . $shopItemId] = [ 'shop_sku_id' => $shopSkuId, 'shop_i_id' => $shopItemId, ]; } } } if ($candidates === []) { return new StoreReceiptPlatformSkuMapper([]); } $mirror->statement('DROP TEMPORARY TABLE IF EXISTS tmp_platform_sku_keys'); $mirror->statement( 'CREATE TEMPORARY TABLE tmp_platform_sku_keys (' . 'shop_sku_id varchar(255) NOT NULL, ' . 'shop_i_id varchar(255) NOT NULL, ' . 'PRIMARY KEY (shop_sku_id, shop_i_id)' . ') ENGINE=InnoDB', ); $historyRows = []; try { foreach (array_chunk(array_values($candidates), 500) as $chunk) { $mirror->table('tmp_platform_sku_keys')->insertOrIgnore($chunk); } foreach (self::SOURCES as $source) { $rows = $mirror->select( "SELECT i.shop_sku_id, i.shop_i_id, i.sku_id, i.i_id, i.properties_value FROM {$source['items']} i STRAIGHT_JOIN tmp_platform_sku_keys k ON k.shop_sku_id = i.shop_sku_id AND k.shop_i_id = i.shop_i_id WHERE COALESCE(i.i_id, '') <> '' AND i.i_id <> i.shop_i_id", ); foreach ($rows as $row) { $historyRows[] = $row; } } } catch (Throwable $exception) { $this->warn('平台 SKU 历史映射读取失败,本批数字 SKU 保持待核验:' . $exception->getMessage()); } finally { $mirror->statement('DROP TEMPORARY TABLE IF EXISTS tmp_platform_sku_keys'); } $this->info(sprintf( '平台数字 SKU 候选:%d 个;历史内部商品映射记录:%d 条', count($candidates), count($historyRows), )); return new StoreReceiptPlatformSkuMapper($historyRows); } /** * @param list> $lines * @return array{ * lines:list>, * auto_replaced_rows:int, * ambiguous_rows:int, * missing_rows:int * } */ private function mapPlatformSkus( array $lines, StoreReceiptPlatformSkuMapper $mapper, ): array { $mappedLines = []; foreach ($lines as $line) { $sku = mb_strtoupper(trim((string) $line['sku'])); $shopSkuId = trim((string) ($line['shop_sku_id'] ?? '')); $shopItemId = trim((string) ($line['shop_i_id'] ?? '')); $line['platform_mapping_status'] = null; $line['platform_mapping_candidates'] = []; $line['original_platform_sku'] = null; if ( preg_match('/^\d+$/', $sku) === 1 && $shopSkuId !== '' && $shopItemId !== '' ) { $resolution = $mapper->resolve($shopSkuId, $shopItemId); $line['platform_mapping_status'] = $resolution['status']; $line['platform_mapping_candidates'] = $resolution['candidates']; $line['original_platform_sku'] = $sku; if ($resolution['status'] === 'unique' && $resolution['internal_sku'] !== null) { $line['sku'] = mb_strtoupper($resolution['internal_sku']); $line['auto_mapped_platform_sku'] = true; } } $key = $line['warehouse'] . "\0" . $line['sku']; if (! isset($mappedLines[$key])) { $mappedLines[$key] = $line; continue; } $mappedLines[$key]['qty'] += $line['qty']; $mappedLines[$key]['amount'] += $line['amount']; if (! empty($line['auto_mapped_platform_sku'])) { $mappedLines[$key]['auto_mapped_platform_sku'] = true; $mappedLines[$key]['original_platform_sku'] = $line['original_platform_sku']; $mappedLines[$key]['platform_mapping_status'] = 'unique'; $mappedLines[$key]['platform_mapping_candidates'] = $line['platform_mapping_candidates']; } } $autoReplacedRows = 0; $ambiguousRows = 0; $missingRows = 0; foreach ($mappedLines as $line) { if (! empty($line['auto_mapped_platform_sku'])) { $autoReplacedRows++; } elseif (($line['platform_mapping_status'] ?? null) === 'ambiguous') { $ambiguousRows++; } elseif (($line['platform_mapping_status'] ?? null) === 'missing') { $missingRows++; } } return [ 'lines' => array_values($mappedLines), 'auto_replaced_rows' => $autoReplacedRows, 'ambiguous_rows' => $ambiguousRows, 'missing_rows' => $missingRows, ]; } private function pendingCodeType(string $code): string { $code = mb_strtoupper(trim($code)); if (preg_match('/^\d+$/', $code) === 1) { return '平台数字编码未映射内部商品'; } if (str_starts_with($code, '运营组合套装')) { return '运营组合装未找到可计价组件'; } if (str_starts_with($code, 'MKH-') || str_starts_with($code, 'ZH-')) { return '组合装编码不完整或无法拆分'; } if (str_starts_with($code, 'DF-')) { return '配件编码缺少成本价'; } if (str_contains($code, '+')) { return '多个商品编码拼接未拆分'; } return '内部商品编码缺少有效价格'; } /** * 拆单取数:聚水潭拆单后,主单/中间汇总单(被其他行 link 到的 o_id)只是汇总记录, * 最末级订单行(没有任何下级)才是实际发货行。 * * 取数规则: * 1) 按「o_id + oi_id + 商品编码」去掉完全重复行; * 2) 只保留叶子行——凡是被同一订单其他行 link_o_id 引用过的 o_id 全部剔除, * 避免主单、中间汇总层与末级发货行重复计数; * 3) 订单内只要存在带仓库的行,就丢弃空仓库的汇总/占位行; * 如果带仓库的行全部是 Cancelled,则整单不参与商品成本; * 4) 排除 Cancelled 行,同一订单的多条末级行按「发货仓库 + 商品编码」汇总数量。 * * @param list $rows * @return list */ private function costLines( array $rows, ?int &$placeholderExcludedRows = null, ?int &$bundleParentExcludedRows = null, ): array { $placeholderExcludedRows = 0; $bundleParentExcludedRows = 0; if ($rows === []) { return []; } // 1) 先按订单明细主键去掉上游的完全重复行(同一 o_id + oi_id 只算一次)。 $unique = []; foreach ($rows as $row) { $key = $row['source'] . "\0" . $row['row_key'] . "\0" . $row['sku']; $unique[$key] = $row; } // 2) 组合装拆分后的组件会记录 src_combine_sku_id。源订单上的组合装主行 // 只是汇总占位,不能与组件同时计入成本。 $bundleParents = []; foreach ($unique as $row) { $parentSku = mb_strtoupper(trim((string) ($row['src_combine_sku_id'] ?? ''))); if ($parentSku !== '') { $bundleParents[$row['source'] . "\0" . $row['o_id'] . "\0" . $parentSku] = true; } } if ($bundleParents !== []) { foreach ($unique as $key => $row) { if (isset($bundleParents[$row['source'] . "\0" . $row['o_id'] . "\0" . $row['sku']])) { $bundleParentExcludedRows++; unset($unique[$key]); } } } // 3) 平台原始商品编码通常是一长串数字。同一订单、同一 shop_i_id // 如果已经有内部商品编码,则数字行只是平台占位,不参与成本。 $internalSiblings = []; foreach ($unique as $row) { $shopItemId = trim((string) ($row['shop_i_id'] ?? '')); if ($shopItemId !== '' && preg_match('/^\d+$/', (string) $row['sku']) !== 1) { $internalSiblings[$row['source'] . "\0" . $row['o_id'] . "\0" . $shopItemId] = true; } } if ($internalSiblings !== []) { foreach ($unique as $key => $row) { $shopItemId = trim((string) ($row['shop_i_id'] ?? '')); if ( $shopItemId !== '' && preg_match('/^\d+$/', (string) $row['sku']) === 1 && isset($internalSiblings[$row['source'] . "\0" . $row['o_id'] . "\0" . $shopItemId]) ) { $placeholderExcludedRows++; unset($unique[$key]); } } } // 4) 组合装条码(ZH-/MKH-条码-数量-条码-数量)按子订单拆成组件行: // 一个订单有几个发货仓库、商品编码、数量就分开列,组件数量 = 明细数量 × 组件倍数。 // 解析不了的组合条码(条码被截断等)保持原样,避免丢行。 $expanded = []; foreach ($unique as $row) { foreach (self::expandComboSku((string) $row['sku'], (float) $row['qty']) as [$code, $codeQty, $isComponent]) { $expanded[] = array_merge($row, [ 'sku' => mb_strtoupper($code), 'qty' => $codeQty, // 组合装明细金额对应整包,拆到组件后不再参与数量×单价校验。 'amount' => $isComponent ? 0.0 : (float) $row['amount'], ]); } } $unique = $expanded; if ($unique === []) { return []; } // 5) 只保留叶子行:o_id 没有被同组其他行的 link_o_id 引用的,才是实际发货行。 $parentIds = []; foreach ($unique as $row) { $parent = trim((string) ($row['parent_o_id'] ?? '')); if ($parent !== '' && $parent !== '0') { $parentIds[$parent] = true; } } if ($parentIds !== []) { $leaves = array_filter( $unique, static fn (array $row): bool => ! isset($parentIds[trim((string) $row['o_id'])]) ); // 兜底:理论上不会出现全部被过滤(除非数据自相矛盾),真有则保留原集合避免整单丢失。 if ($leaves !== []) { $unique = $leaves; } } // 6) 组内只要出现带仓库的行,空仓库行就是汇总/占位行,直接丢弃。 $hasWarehouse = false; foreach ($unique as $row) { if (trim((string) $row['warehouse']) !== '') { $hasWarehouse = true; break; } } if ($hasWarehouse) { $unique = array_filter( $unique, static fn (array $row): bool => trim((string) $row['warehouse']) !== '' ); } // 7) 排除已取消行;若带仓库行全部取消,上面已把空仓库汇总行丢掉,这里结果为空 → 整单不计。 $unique = array_filter( $unique, static fn (array $row): bool => strtoupper(trim((string) ($row['status'] ?? ''))) !== 'CANCELLED' ); // 8) 汇总到「发货仓库 + 商品编码」。 $aggregated = []; foreach ($unique as $row) { $isPlatformSku = preg_match('/^\d+$/', (string) $row['sku']) === 1; $key = $row['warehouse'] . "\0" . $row['sku'] . ($isPlatformSku ? "\0" . ($row['shop_sku_id'] ?? '') : ''); $aggregated[$key] = [ 'sku' => $row['sku'], 'warehouse' => $row['warehouse'], 'shop_i_id' => (string) ($row['shop_i_id'] ?? ''), 'shop_sku_id' => (string) ($row['shop_sku_id'] ?? ''), 'properties_value' => (string) ($row['properties_value'] ?? ''), 'qty' => ($aggregated[$key]['qty'] ?? 0.0) + $row['qty'], 'amount' => ($aggregated[$key]['amount'] ?? 0.0) + $row['amount'], ]; } return array_values($aggregated); } /** * 组合装条码拆组件:聚水潭的组合装 sku 形如 * ZH-<条码>-<数量>[-<条码>-<数量>...] * MKH-<条码>-<数量>[-<条码>-<数量>...] * 条码本身含横杠,所以按「纯数字段」识别数量,再按组件展开。 * * 返回 [条码, 数量, 是否组件];非组合装或解析失败时返回原条码 / 原数量。 * * @return list */ private static function expandComboSku(string $sku, float $quantity): array { $prefix = null; foreach (self::COMBO_PREFIXES as $candidate) { if (str_starts_with(mb_strtoupper($sku), $candidate)) { $prefix = $candidate; break; } } if ($prefix === null) { return [[$sku, $quantity, false]]; } $parts = explode('-', substr($sku, strlen($prefix))); $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); } }