'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.sku_type as sku_type, i.base_price as base_price, " . " i.name as item_name, 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.outer_oi_id as outer_oi_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 && ! self::isBundleParentSku($sku, (string) ($row->sku_type ?? '')) ) { 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; } } $linePlatform = (string) ($billByCore[$core]->platform ?? ''); $lineStoreName = (string) ($billByCore[$core]->store_name ?? ''); $lines[$core][] = [ 'source' => (string) $row->source_key, 'platform' => $linePlatform, 'store_name' => $lineStoreName, // 月加权平均单价表里的商品编码统一大写,订单侧可能小写,统一成大写再比。 'sku' => mb_strtoupper($sku), 'sku_type' => mb_strtolower(trim((string) ($row->sku_type ?? ''))), 'base_price' => (float) ($row->base_price ?? 0), 'item_name' => trim((string) ($row->item_name ?? '')), '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 ?? '')), 'outer_oi_id' => trim((string) ($row->outer_oi_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)); $platformSkuMappings = $this->platformSkuMapper($mirror, $lines, $billByCore); $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; $matchedPriceRows = 0; $placeholderExcludedRows = 0; $bundleParentExcludedRows = 0; $plusParentExcludedRows = 0; $variantParentExcludedRows = 0; $chineseAliasReplacedRows = 0; $plantingPrefixExcludedRows = 0; $autoReplacedRows = 0; $afsReplacedRows = 0; $mappingAmbiguousRows = 0; $mappingMissingRows = 0; $pendingCostRows = []; $placeholderCodeSets = [ 'chinese_alias_placeholder_codes' => [], 'chinese_alias_formal_codes' => [], 'generic_variant_parent_codes' => [], 'bundle_parent_codes' => [], 'plus_parent_codes' => [], 'planting_prefix_codes' => [], ]; $placeholderCodeRows = [ 'chinese_alias_placeholder_code_rows' => [], 'generic_variant_parent_code_rows' => [], 'bundle_parent_code_rows' => [], 'plus_parent_code_rows' => [], 'planting_prefix_code_rows' => [], ]; $bundleRollup = self::initialPlaceholderStats()['bundle_stats']; foreach ($billByCore as $core => $bill) { // 展示单号与业绩明细完全一致;$core 只用于订单侧清洗后匹配。 $displayOrderNo = (string) ($bill->online_order_no ?? ''); if ($displayOrderNo === '') { $displayOrderNo = $core; } $placeholderStats = []; $costLines = $this->costLines($lines[$core] ?? [], $placeholderStats); $placeholderExcludedRows += $placeholderStats['platform_placeholder_excluded_rows']; $bundleParentExcludedRows += $placeholderStats['bundle_parent_excluded_rows']; $plusParentExcludedRows += $placeholderStats['plus_parent_excluded_rows']; $variantParentExcludedRows += $placeholderStats['generic_variant_parent_excluded_rows']; $chineseAliasReplacedRows += $placeholderStats['chinese_alias_replaced_rows']; $plantingPrefixExcludedRows += $placeholderStats['planting_prefix_excluded_rows']; foreach ($placeholderCodeSets as $codeSetName => $_) { foreach (array_keys($placeholderStats[$codeSetName] ?? []) as $code) { $placeholderCodeSets[$codeSetName][$code] = true; } } foreach ($placeholderCodeRows as $codeRowsName => $_) { foreach (($placeholderStats[$codeRowsName] ?? []) as $code => $rowCount) { $placeholderCodeRows[$codeRowsName][$code] = ($placeholderCodeRows[$codeRowsName][$code] ?? 0) + (int) $rowCount; } } foreach (($placeholderStats['bundle_stats'] ?? []) as $bundleMetric => $bundleValue) { if (is_array($bundleValue)) { foreach ($bundleValue as $code => $_) { $bundleRollup[$bundleMetric][$code] = true; } } else { $bundleRollup[$bundleMetric] += $bundleValue; } } $mapped = $this->mapPlatformSkus( $costLines, $platformSkuMappings['history'], $platformSkuMappings['afs'], (string) $bill->store_name, ); $costLines = $mapped['lines']; $autoReplacedRows += $mapped['auto_replaced_rows']; $afsReplacedRows += $mapped['afs_replaced_rows']; $mappingAmbiguousRows += $mapped['ambiguous_rows']; $mappingMissingRows += $mapped['missing_rows']; foreach ($costLines as $line) { $short = $shortNames['by_provider'][$line['warehouse']] ?? ''; $forceBundlePending = ! empty($line['force_cost_pending']); $price = ! $forceBundlePending && $short !== '' ? ($prices[$short . "\0" . $line['sku']] ?? null) : null; $priceSource = $price === null ? null : self::PRICE_SOURCE_WEIGHTED; // 月加权平均单价按「仓库简称 + 商品编码」匹配不到时,退回线上代发价格(按商品编码)。 if (! $forceBundlePending && $price === null) { $fallback = $fallbackPrices[$line['sku']] ?? null; if ($fallback !== null) { $price = $fallback['price']; $priceSource = $fallback['source']; $fallbackPriceRows++; } } // 线上代发价格也没有时,最后用普通商品资料的商品成本价兜底。 if (! $forceBundlePending && $price === null) { $catalogPrice = $catalogPrices[$line['sku']] ?? null; if ($catalogPrice !== null) { $price = $catalogPrice; $priceSource = self::PRICE_SOURCE_CATALOG; $catalogPriceRows++; } } $priced = $this->calculateLineCost((float) $line['qty'], $price); $quantity = $priced['quantity']; $unitPrice = $priced['unit_price']; $cost = $priced['cost']; $remarks = []; if ($forceBundlePending) { $remarks[] = ($line['bundle_resolution_status'] ?? '') === 'amount_mismatch' ? '组合装组件金额不一致' : '组合装未找到可用组件'; } if ($short === '') { $remarks[] = self::REMARK_NO_WAREHOUSE; $missingWarehouseRows++; } if ($unitPrice === null) { $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'] ?? [], 'bundle_resolution_status' => $line['bundle_resolution_status'] ?? null, 'bundle_component_rows' => (int) ($line['bundle_component_rows'] ?? 0), 'bundle_component_amount' => $line['bundle_component_amount'] ?? null, 'bundle_expected_amount' => $line['bundle_expected_amount'] ?? null, 'bundle_amount_difference' => $line['bundle_amount_difference'] ?? null, 'bundle_component_codes' => $line['bundle_component_codes'] ?? [], 'product_quantity' => $quantity, 'reason' => $forceBundlePending ? (($line['bundle_resolution_status'] ?? '') === 'amount_mismatch' ? '组合装组件金额与主行金额不一致,组件行暂不计成本' : '组合装未找到可计价组件') : ($short === '' ? '未匹配仓库简称' : (in_array(($line['platform_mapping_status'] ?? null), ['ambiguous', 'ambiguous_afs'], true) ? '平台 shop_sku_id 在历史订单或售后镜像中对应多个内部商品编码' : (($line['platform_mapping_status'] ?? null) === 'missing' ? '平台 shop_sku_id 未找到历史内部商品编码' : '仓库简称 + 商品编码在月加权、线上代发、普通商品资料中均无有效单价'))), 'code_type' => $forceBundlePending ? (($line['bundle_resolution_status'] ?? '') === 'amount_mismatch' ? '组合装组件金额不一致' : '组合装未找到可用组件') : $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 行', $missingWarehouseRows, $fallbackPriceRows, $catalogPriceRows, $missingPriceRows, )); $pendingCostQuantity = array_sum(array_column($pendingCostRows, 'product_quantity')); $this->info(sprintf( ' 正常匹配单价:%d 行;排除平台占位行:%d 行;排除组合装主行:%d 行;排除加号组合父行:%d 行;排除父子前缀占位行:%d 行;中文别名替换:%d 行;排除种菜前缀:%d 行;待核验成本:%d 行 / %s 件', $matchedPriceRows, $placeholderExcludedRows, $bundleParentExcludedRows, $plusParentExcludedRows, $variantParentExcludedRows, $chineseAliasReplacedRows, $plantingPrefixExcludedRows, $missingPriceRows, number_format((float) $pendingCostQuantity, 2), )); $this->info(sprintf( ' 平台数字 SKU 自动替换:%d 行(售后镜像补充:%d 行);多候选保留:%d 行;无可用映射保留:%d 行', $autoReplacedRows, $afsReplacedRows, $mappingAmbiguousRows, $mappingMissingRows, )); $this->info(sprintf( ' 组合装回溯:对平 %d 组,剔除主行 %d 行,保留组件 %d 行(识别明确指向 %d 行、同规格兼容 %d 行);缺组件 %d 组,金额不一致 %d 组,暂不计组件 %d 行,忽略零值空占位 %d 组', $bundleRollup['reconciled_groups'], $bundleParentExcludedRows, $bundleRollup['component_rows'] - $bundleRollup['held_component_rows'], $bundleRollup['explicit_component_rows'], $bundleRollup['inferred_component_rows'], $bundleRollup['missing_component_groups'], $bundleRollup['amount_mismatch_groups'], $bundleRollup['held_component_rows'], $bundleRollup['zero_placeholder_ignored_groups'], )); $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, 'plus_parent_excluded_rows' => $plusParentExcludedRows, 'variant_parent_excluded_rows' => $variantParentExcludedRows, 'chinese_alias_replaced_rows' => $chineseAliasReplacedRows, 'planting_prefix_excluded_rows' => $plantingPrefixExcludedRows, 'excluded_rows' => $placeholderExcludedRows + $bundleParentExcludedRows + $plusParentExcludedRows + $variantParentExcludedRows + $chineseAliasReplacedRows + $plantingPrefixExcludedRows + $bundleRollup['held_component_rows'], 'placeholder_rule_codes' => array_map( static function (array $codes): array { $codes = array_keys($codes); sort($codes); return $codes; }, $placeholderCodeSets, ), 'placeholder_rule_code_rows' => $placeholderCodeRows, 'bundle_backfill' => [ 'reconciled_groups' => $bundleRollup['reconciled_groups'], 'missing_component_groups' => $bundleRollup['missing_component_groups'], 'amount_mismatch_groups' => $bundleRollup['amount_mismatch_groups'], 'zero_placeholder_ignored_groups' => $bundleRollup['zero_placeholder_ignored_groups'], 'component_rows' => $bundleRollup['component_rows'], 'explicit_component_rows' => $bundleRollup['explicit_component_rows'], 'inferred_component_rows' => $bundleRollup['inferred_component_rows'], 'held_component_rows' => $bundleRollup['held_component_rows'], 'reconciled_amount' => round((float) $bundleRollup['reconciled_amount'], 2), 'mismatch_amount' => round((float) $bundleRollup['mismatch_amount'], 2), 'component_codes' => array_keys($bundleRollup['component_codes']), 'unreconciled_parent_codes' => array_keys($bundleRollup['unreconciled_parent_codes']), ], 'auto_replaced_platform_sku_rows' => $autoReplacedRows, 'afs_fallback_replaced_platform_sku_rows' => $afsReplacedRows, 'platform_sku_mapping_ambiguous_rows' => $mappingAmbiguousRows, 'platform_sku_mapping_missing_rows' => $mappingMissingRows, 'pending_cost_rows' => $missingPriceRows, 'pending_cost_quantity' => round((float) $pendingCostQuantity, 4), '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 * @param array $billByCore * @return array{ * history:StoreReceiptPlatformSkuMapper, * afs:array> * } */ private function platformSkuMapper($mirror, array $lines, array $billByCore): array { $historyCandidates = []; $afsCandidates = []; foreach ($lines as $core => $orderLines) { $shopName = trim((string) ($billByCore[$core]->store_name ?? '')); 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 === '' ) { continue; } $historyCandidates[$shopSkuId . "\0" . $shopItemId] = [ 'shop_sku_id' => $shopSkuId, 'shop_i_id' => $shopItemId, ]; if ($shopName !== '') { $afsKey = self::afsPlatformSkuKey($shopName, $shopSkuId, $shopItemId); $afsCandidates[$afsKey] = [ 'shop_name' => $shopName, 'shop_sku_id' => $shopSkuId, 'shop_i_id' => $shopItemId, ]; } } } $historyRows = []; if ($historyCandidates !== []) { $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', ); try { foreach (array_chunk(array_values($historyCandidates), 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 历史映射读取失败,将尝试订单售后镜像:' . $exception->getMessage()); } finally { $mirror->statement('DROP TEMPORARY TABLE IF EXISTS tmp_platform_sku_keys'); } } $afsMappings = []; if ($afsCandidates !== []) { try { $rows = app(GoodsProfitAfsRepository::class) ->platformSkuMappings(array_values($afsCandidates)); foreach ($rows as $row) { $productCode = mb_strtoupper(trim((string) ($row['product_code'] ?? ''))); if ($productCode === '') { continue; } $key = self::afsPlatformSkuKey( (string) ($row['shop_name'] ?? ''), (string) ($row['shop_sku_id'] ?? ''), (string) ($row['shop_i_id'] ?? ''), ); $afsMappings[$key][$productCode] = true; } } catch (Throwable $exception) { $this->warn('订单售后镜像平台 SKU 映射读取失败,相关数字 SKU 保持待核验:' . $exception->getMessage()); } } foreach ($afsMappings as &$codes) { $codes = array_keys($codes); sort($codes); } unset($codes); $this->info(sprintf( '平台数字 SKU 候选:%d 个;历史内部商品映射:%d 条;售后镜像映射键:%d 个', count($historyCandidates), count($historyRows), count($afsMappings), )); return [ 'history' => new StoreReceiptPlatformSkuMapper($historyRows), 'afs' => $afsMappings, ]; } /** * @param list> $lines * @return array{ * lines:list>, * auto_replaced_rows:int, * afs_replaced_rows:int, * ambiguous_rows:int, * missing_rows:int * } * @param array> $afsMappings */ private function mapPlatformSkus( array $lines, StoreReceiptPlatformSkuMapper $mapper, array $afsMappings = [], string $storeName = '', ): 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); $mappingStatus = $resolution['status']; $mappingCandidates = $resolution['candidates']; $internalSku = $resolution['internal_sku']; if ($mappingStatus === 'missing') { $afsCandidates = $afsMappings[self::afsPlatformSkuKey($storeName, $shopSkuId, $shopItemId)] ?? []; if (count($afsCandidates) === 1) { $mappingStatus = 'unique_afs'; $mappingCandidates = $afsCandidates; $internalSku = $afsCandidates[0]; } elseif (count($afsCandidates) > 1) { $mappingStatus = 'ambiguous_afs'; $mappingCandidates = $afsCandidates; $internalSku = null; } } $line['platform_mapping_status'] = $mappingStatus; $line['platform_mapping_candidates'] = $mappingCandidates; $line['original_platform_sku'] = $sku; if ( in_array($mappingStatus, ['unique', 'unique_afs'], true) && $internalSku !== null ) { $line['sku'] = mb_strtoupper($internalSku); $line['auto_mapped_platform_sku'] = true; $line['platform_mapping_source'] = $mappingStatus === 'unique_afs' ? 'afs' : 'history'; } } $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'] = $line['platform_mapping_status']; $mappedLines[$key]['platform_mapping_candidates'] = $line['platform_mapping_candidates']; $mappedLines[$key]['platform_mapping_source'] = $line['platform_mapping_source'] ?? null; } foreach ([ 'force_cost_pending', 'bundle_resolution_status', 'bundle_component_rows', 'bundle_component_amount', 'bundle_expected_amount', 'bundle_amount_difference', 'bundle_component_codes', ] as $bundleField) { if (! empty($line[$bundleField])) { $mappedLines[$key][$bundleField] = $line[$bundleField]; } } } $autoReplacedRows = 0; $afsReplacedRows = 0; $ambiguousRows = 0; $missingRows = 0; foreach ($mappedLines as $line) { if (! empty($line['auto_mapped_platform_sku'])) { $autoReplacedRows++; if (($line['platform_mapping_source'] ?? null) === 'afs') { $afsReplacedRows++; } } elseif (in_array(($line['platform_mapping_status'] ?? null), ['ambiguous', 'ambiguous_afs'], true)) { $ambiguousRows++; } elseif (($line['platform_mapping_status'] ?? null) === 'missing') { $missingRows++; } } return [ 'lines' => array_values($mappedLines), 'auto_replaced_rows' => $autoReplacedRows, 'afs_replaced_rows' => $afsReplacedRows, 'ambiguous_rows' => $ambiguousRows, 'missing_rows' => $missingRows, ]; } /** * @return array{quantity:float,unit_price:?float,cost:?float} */ private function calculateLineCost(float $quantity, ?float $unitPrice): array { $quantity = round($quantity, 4); $unitPrice = $unitPrice === null ? null : round($unitPrice, 2); return [ 'quantity' => $quantity, 'unit_price' => $unitPrice, 'cost' => $unitPrice === null ? null : round($quantity * $unitPrice, 2), ]; } /** @param array $row */ private function plusCombinationGroupKey(array $row): ?string { $outerItemId = trim((string) ($row['outer_oi_id'] ?? '')); $shopSkuId = trim((string) ($row['shop_sku_id'] ?? '')); if ($outerItemId === '' || $shopSkuId === '') { return null; } return (string) $row['source'] . "\0" . (string) $row['o_id'] . "\0" . $outerItemId . "\0" . $shopSkuId; } private static function afsPlatformSkuKey( string $shopName, string $shopSkuId, string $shopItemId, ): string { return trim($shopName) . "\0" . trim($shopSkuId) . "\0" . trim($shopItemId); } /** * 非数字父行先限定到同一订单、平台商品和发货仓,再比较可用的平台规格维度。 * * @param array $row */ private function variantParentGroupKey(array $row): ?string { $shopItemId = trim((string) ($row['shop_i_id'] ?? '')); $warehouse = trim((string) ($row['warehouse'] ?? '')); if ($shopItemId === '' || $warehouse === '') { return null; } return (string) $row['source'] . "\0" . (string) $row['o_id'] . "\0" . $shopItemId . "\0" . $warehouse; } /** * 「正式编码 + 非」等白名单后缀只能在完整业务键一致时替换, * 不能仅按字符包含关系处理。 * * @param array $row */ private function chineseAliasGroupKey(array $row): ?string { $parts = [ trim((string) ($row['source'] ?? '')), trim((string) ($row['platform'] ?? '')), trim((string) ($row['store_name'] ?? '')), trim((string) ($row['o_id'] ?? '')), trim((string) ($row['outer_oi_id'] ?? '')), trim((string) ($row['shop_sku_id'] ?? '')), trim((string) ($row['shop_i_id'] ?? '')), trim((string) ($row['warehouse'] ?? '')), ]; if (in_array('', $parts, true)) { return null; } return implode("\0", $parts); } private static function chineseAliasFormalSku(string $sku): ?string { $sku = mb_strtoupper(trim($sku)); foreach (self::PLACEHOLDER_SKU_SUFFIXES as $suffix) { $suffix = mb_strtoupper($suffix); if (! str_ends_with($sku, $suffix)) { continue; } $formalSku = trim(substr($sku, 0, strlen($sku) - strlen($suffix))); if ($formalSku !== '') { return $formalSku; } } return null; } private static function isPlantingSku(string $sku): bool { return str_starts_with(trim($sku), self::PLANTING_SKU_PREFIX); } private static function isBundlePlaceholderSku(string $sku): bool { $sku = mb_strtoupper(trim($sku)); foreach (self::BUNDLE_PLACEHOLDER_PREFIXES as $prefix) { if (str_starts_with($sku, mb_strtoupper($prefix))) { return true; } } foreach (self::COMBO_PREFIXES as $prefix) { if (str_starts_with($sku, $prefix)) { return true; } } return false; } private static function isGenericVariantCandidate(string $sku): bool { $sku = mb_strtoupper(trim($sku)); if ($sku === '' || preg_match('/^\d+$/', $sku) === 1) { return false; } if ( self::isPlantingSku($sku) || self::chineseAliasFormalSku($sku) !== null || self::isBundlePlaceholderSku($sku) || in_array($sku, self::READABLE_ACCESSORY_SKUS, true) || str_contains($sku, '+') ) { return false; } return true; } /** @return array> */ private static function initialPlaceholderStats(): array { return [ 'platform_placeholder_excluded_rows' => 0, 'bundle_parent_excluded_rows' => 0, 'plus_parent_excluded_rows' => 0, 'generic_variant_parent_excluded_rows' => 0, 'chinese_alias_replaced_rows' => 0, 'planting_prefix_excluded_rows' => 0, 'chinese_alias_placeholder_codes' => [], 'chinese_alias_formal_codes' => [], 'generic_variant_parent_codes' => [], 'bundle_parent_codes' => [], 'plus_parent_codes' => [], 'planting_prefix_codes' => [], 'chinese_alias_placeholder_code_rows' => [], 'generic_variant_parent_code_rows' => [], 'bundle_parent_code_rows' => [], 'plus_parent_code_rows' => [], 'planting_prefix_code_rows' => [], 'bundle_stats' => [ 'reconciled_groups' => 0, 'missing_component_groups' => 0, 'amount_mismatch_groups' => 0, 'zero_placeholder_ignored_groups' => 0, 'component_rows' => 0, 'explicit_component_rows' => 0, 'inferred_component_rows' => 0, 'held_component_rows' => 0, 'reconciled_amount' => 0.0, 'mismatch_amount' => 0.0, 'component_codes' => [], 'unreconciled_parent_codes' => [], ], ]; } 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 '内部商品编码缺少有效价格'; } private static function isBundleParentSku(string $sku, string $skuType = ''): bool { $sku = mb_strtoupper(trim($sku)); if ($sku === '') { return false; } if (in_array(mb_strtolower(trim($skuType)), ['combine', '组合'], true)) { return true; } if (str_contains($sku, '组合')) { return true; } foreach (self::COMBO_PREFIXES as $prefix) { if (str_starts_with($sku, $prefix)) { return true; } } return false; } /** @param array $row */ private static function isBundleParentRow(array $row): bool { if (trim((string) ($row['src_combine_sku_id'] ?? '')) !== '') { return false; } return self::isBundleParentSku( (string) ($row['sku'] ?? ''), (string) ($row['sku_type'] ?? ''), ); } /** @param array $row */ private function bundleContextKey(array $row): ?string { $parts = [ trim((string) ($row['source'] ?? '')), trim((string) ($row['o_id'] ?? '')), trim((string) ($row['outer_oi_id'] ?? '')), trim((string) ($row['shop_sku_id'] ?? '')), trim((string) ($row['shop_i_id'] ?? '')), ]; if (in_array('', $parts, true)) { return null; } return implode("\0", $parts); } /** * 先对组合装主行和组件做金额对账,再允许后续成本计算。 * 明确 src_combine_sku_id 指向父行的组件优先;同一订单、规格和仓库下 * 没有标记但金额可与主行对平的组件作为兼容路径。 * * @param array> $unique * @param array $stats */ private function resolveBundleParents(array &$unique, array &$stats): void { $parentGroups = []; foreach ($unique as $key => $row) { if (! self::isBundleParentRow($row)) { continue; } $context = $this->bundleContextKey($row); if ($context === null) { continue; } $parentSku = mb_strtoupper(trim((string) $row['sku'])); $parentGroups[$context][$parentSku][$key] = $row; } $heldComponents = []; foreach ($parentGroups as $context => $groups) { foreach ($groups as $parentSku => $parentRows) { $parentKeys = array_fill_keys(array_keys($parentRows), true); $firstParent = reset($parentRows); $parentSource = (string) ($firstParent['source'] ?? ''); $parentOrderId = (string) ($firstParent['o_id'] ?? ''); $explicitChildren = []; $inferredChildren = []; foreach ($unique as $childKey => $child) { if (isset($parentKeys[$childKey])) { continue; } if ( (string) ($child['source'] ?? '') !== $parentSource || (string) ($child['o_id'] ?? '') !== $parentOrderId ) { continue; } $childSku = mb_strtoupper(trim((string) ($child['sku'] ?? ''))); $sourceParent = mb_strtoupper(trim((string) ($child['src_combine_sku_id'] ?? ''))); if ($sourceParent === $parentSku) { $explicitChildren[$childKey] = $child; continue; } if ( self::isBundleParentRow($child) || $this->bundleContextKey($child) !== $context || ( abs((float) ($child['qty'] ?? 0)) <= 0.0001 && abs((float) ($child['amount'] ?? 0)) <= 0.005 ) ) { continue; } $inferredChildren[$childKey] = $child; } $children = $explicitChildren + $inferredChildren; $componentAmount = 0.0; $maxComponentQty = 0.0; $componentCodes = []; foreach ($children as $child) { $componentAmount += (float) ($child['amount'] ?? 0); $maxComponentQty = max($maxComponentQty, (float) ($child['qty'] ?? 0)); $componentCodes[mb_strtoupper(trim((string) ($child['sku'] ?? '')))] = true; } $parentAmount = 0.0; $parentQuantity = 0.0; $basePrices = []; foreach ($parentRows as $parent) { $parentAmount += (float) ($parent['amount'] ?? 0); $parentQuantity += (float) ($parent['qty'] ?? 0); $basePrice = max((float) ($parent['base_price'] ?? 0), 0.0); if ($basePrice > 0) { $basePrices[(string) round($basePrice, 4)] = $basePrice; } } $candidateTotals = []; if ($parentAmount > 0) { $candidateTotals[(string) round($parentAmount, 2)] = round($parentAmount, 2); } foreach ($basePrices as $basePrice) { if ($maxComponentQty > 0) { $candidateTotals[(string) round($basePrice * $maxComponentQty, 2)] = round($basePrice * $maxComponentQty, 2); } $candidateTotals[(string) round($basePrice, 2)] = round($basePrice, 2); } $expectedAmount = $candidateTotals === [] ? 0.0 : reset($candidateTotals); foreach ($candidateTotals as $candidate) { if ( abs((float) $candidate - $componentAmount) < abs((float) $expectedAmount - $componentAmount) ) { $expectedAmount = (float) $candidate; } } $difference = round($componentAmount - $expectedAmount, 2); $reconciled = $children !== [] && $candidateTotals !== [] && abs($difference) <= 0.01; $zeroPlaceholder = abs($parentAmount) <= 0.005 && abs($parentQuantity) <= 0.0001; // 数量、金额均为 0 的组合主行原本就不进入成本表。对平后仍按 // 正常组合规则只计组件;没有组件或无法对平时忽略该空占位, // 组件保持原有计价路径,避免凭空产生新的主行待核验。 if ($zeroPlaceholder && ($children === [] || ! $reconciled)) { $stats['bundle_stats']['zero_placeholder_ignored_groups']++; foreach (array_keys($parentKeys) as $parentKey) { unset($unique[$parentKey]); } continue; } if ($reconciled) { $stats['bundle_parent_excluded_rows'] += count($parentKeys); $stats['bundle_parent_codes'][$parentSku] = true; $stats['bundle_parent_code_rows'][$parentSku] = ($stats['bundle_parent_code_rows'][$parentSku] ?? 0) + count($parentKeys); $bundleStats = &$stats['bundle_stats']; $bundleStats['reconciled_groups']++; $bundleStats['component_rows'] += count($children); $bundleStats['explicit_component_rows'] += count($explicitChildren); $bundleStats['inferred_component_rows'] += count($inferredChildren); $bundleStats['reconciled_amount'] += $componentAmount; foreach (array_keys($componentCodes) as $componentCode) { $bundleStats['component_codes'][$componentCode] = true; } unset($bundleStats); foreach (array_keys($parentKeys) as $parentKey) { unset($unique[$parentKey]); } continue; } $bundleStats = &$stats['bundle_stats']; $bundleStats['component_rows'] += count($children); $bundleStats['explicit_component_rows'] += count($explicitChildren); $bundleStats['inferred_component_rows'] += count($inferredChildren); $bundleStats['unreconciled_parent_codes'][$parentSku] = true; if ($children === []) { $bundleStats['missing_component_groups']++; } else { $bundleStats['amount_mismatch_groups']++; $bundleStats['mismatch_amount'] += abs($difference); foreach (array_keys($children) as $childKey) { $heldComponents[$childKey] = true; } } foreach (array_keys($componentCodes) as $componentCode) { $bundleStats['component_codes'][$componentCode] = true; } unset($bundleStats); foreach (array_keys($parentKeys) as $parentKey) { $unique[$parentKey] = array_merge($unique[$parentKey], [ 'force_cost_pending' => true, 'bundle_resolution_status' => $children === [] ? 'missing_components' : 'amount_mismatch', 'bundle_component_rows' => count($children), 'bundle_component_amount' => round($componentAmount, 2), 'bundle_expected_amount' => round($expectedAmount, 2), 'bundle_amount_difference' => $difference, 'bundle_component_codes' => array_keys($componentCodes), ]); } } } $stats['bundle_stats']['held_component_rows'] += count($heldComponents); foreach (array_keys($heldComponents) as $childKey) { unset($unique[$childKey]); } } /** * 拆单取数:聚水潭拆单后,主单/中间汇总单(被其他行 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, ?array &$stats = null): array { $stats = self::initialPlaceholderStats(); 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) 种菜商品整行排除,不尝试把它替换成去掉「种菜」前缀后的正式编码。 foreach ($unique as $key => $row) { $sku = mb_strtoupper(trim((string) $row['sku'])); if (! self::isPlantingSku($sku)) { continue; } $stats['planting_prefix_excluded_rows']++; $stats['planting_prefix_codes'][$sku] = true; $stats['planting_prefix_code_rows'][$sku] = ($stats['planting_prefix_code_rows'][$sku] ?? 0) + 1; unset($unique[$key]); } // 3) 组合装主行先与拆分组件做金额对账;主行不计成本, // 只有组件金额能与主行金额对平时才剔除主行并保留组件。 $this->resolveBundleParents($unique, $stats); // 4) 上游有些平台组合会同时返回「+ 拼接父行」和多条组件行。 // 只有同一订单、outer_oi_id、shop_sku_id 下至少有两条组件,且组件金额 // 合计与父行一致时,父行才是可确认的汇总占位,才允许排除。 $plusParents = []; $plusComponents = []; foreach ($unique as $key => $row) { $groupKey = $this->plusCombinationGroupKey($row); if ($groupKey === null) { continue; } if (str_contains((string) $row['sku'], '+')) { $plusParents[$key] = $groupKey; continue; } $plusComponents[$groupKey]['rows'] = ($plusComponents[$groupKey]['rows'] ?? 0) + 1; $plusComponents[$groupKey]['amount'] = ($plusComponents[$groupKey]['amount'] ?? 0.0) + (float) $row['amount']; } foreach ($plusParents as $key => $groupKey) { $component = $plusComponents[$groupKey] ?? null; if ( $component !== null && $component['rows'] >= 2 && abs(round((float) $component['amount'], 2) - round((float) $unique[$key]['amount'], 2)) <= 0.01 ) { $stats['plus_parent_excluded_rows']++; $plusSku = mb_strtoupper(trim((string) $unique[$key]['sku'])); $stats['plus_parent_codes'][$plusSku] = true; $stats['plus_parent_code_rows'][$plusSku] = ($stats['plus_parent_code_rows'][$plusSku] ?? 0) + 1; unset($unique[$key]); } } // 5) 中文占位后缀只允许明确白名单(当前为「非」)。 // 必须同数据源、平台、店铺、子订单、shop_sku_id、shop_i_id、仓库, // 且占位行与正式编码行各自唯一、数量和金额完全一致,才保留正式编码。 $aliasGroups = []; foreach ($unique as $key => $row) { $groupKey = $this->chineseAliasGroupKey($row); if ($groupKey === null) { continue; } $sku = mb_strtoupper(trim((string) $row['sku'])); $aliasGroups[$groupKey][$sku][$key] = true; } foreach ($aliasGroups as $groupRows) { foreach ($groupRows as $placeholderSku => $placeholderKeys) { $formalSku = self::chineseAliasFormalSku((string) $placeholderSku); if ($formalSku === null) { continue; } $formalKeys = $groupRows[$formalSku] ?? []; if (count($placeholderKeys) !== 1 || count($formalKeys) !== 1) { continue; } $placeholderKey = array_key_first($placeholderKeys); $formalKey = array_key_first($formalKeys); $placeholder = $unique[$placeholderKey]; $formal = $unique[$formalKey]; if ( abs(round((float) $placeholder['qty'], 4) - round((float) $formal['qty'], 4)) > 0.0001 || abs(round((float) $placeholder['amount'], 2) - round((float) $formal['amount'], 2)) > 0.01 ) { continue; } $stats['chinese_alias_replaced_rows']++; $stats['chinese_alias_placeholder_codes'][(string) $placeholderSku] = true; $stats['chinese_alias_formal_codes'][$formalSku] = true; $stats['chinese_alias_placeholder_code_rows'][(string) $placeholderSku] = ($stats['chinese_alias_placeholder_code_rows'][(string) $placeholderSku] ?? 0) + 1; unset($unique[$placeholderKey]); } } // 6) 非纯数字父行:同一订单、平台商品、平台规格、子订单和发货仓内, // 如果只有一个严格扩展编码的子商品,且父子数量、金额都一致,则父行是可核对的别名占位。 // 只依赖编码前缀会误删正常的相似商品,因此任一项不一致都保留父行待核验。 $variantGroups = []; foreach ($unique as $key => $row) { $groupKey = $this->variantParentGroupKey($row); if ($groupKey !== null) { $variantGroups[$groupKey][$key] = $row; } } $variantParents = []; foreach ($variantGroups as $groupRows) { foreach ($groupRows as $parentKey => $parent) { $parentSku = mb_strtoupper(trim((string) $parent['sku'])); if (! self::isGenericVariantCandidate($parentSku)) { continue; } $parentShopSkuId = trim((string) ($parent['shop_sku_id'] ?? '')); $parentOuterItemId = trim((string) ($parent['outer_oi_id'] ?? '')); $children = []; foreach ($groupRows as $childKey => $child) { $childSku = mb_strtoupper(trim((string) $child['sku'])); $childShopSkuId = trim((string) ($child['shop_sku_id'] ?? '')); $childOuterItemId = trim((string) ($child['outer_oi_id'] ?? '')); if ($parentShopSkuId !== '' && $childShopSkuId !== '' && $parentShopSkuId !== $childShopSkuId) { continue; } if ($parentOuterItemId !== '' && $childOuterItemId !== '' && $parentOuterItemId !== $childOuterItemId) { continue; } if ( self::chineseAliasFormalSku($childSku) !== null || self::isBundlePlaceholderSku($childSku) ) { continue; } if ($childKey === $parentKey || $childSku === $parentSku || ! str_starts_with($childSku, $parentSku)) { continue; } $children[$childSku]['qty'] = ($children[$childSku]['qty'] ?? 0.0) + (float) $child['qty']; $children[$childSku]['amount'] = ($children[$childSku]['amount'] ?? 0.0) + (float) $child['amount']; } // 多子编码的父子关系可能来自真实组合,当前规则不自动猜测,继续待核验。 if (count($children) !== 1) { continue; } $child = reset($children); if ( abs(round((float) $child['qty'], 4) - round((float) $parent['qty'], 4)) <= 0.0001 && abs(round((float) $child['amount'], 2) - round((float) $parent['amount'], 2)) <= 0.01 ) { $variantParents[$parentKey] = true; } } } foreach (array_keys($variantParents) as $key) { $stats['generic_variant_parent_excluded_rows']++; $variantSku = mb_strtoupper(trim((string) $unique[$key]['sku'])); $stats['generic_variant_parent_codes'][$variantSku] = true; $stats['generic_variant_parent_code_rows'][$variantSku] = ($stats['generic_variant_parent_code_rows'][$variantSku] ?? 0) + 1; unset($unique[$key]); } // 7) 平台原始商品编码通常是一长串数字。同一订单、同一 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]) ) { $stats['platform_placeholder_excluded_rows']++; unset($unique[$key]); } } } // 8) 组合装条码(ZH-/MKH-条码-数量-条码-数量)按子订单拆成组件行: // 一个订单有几个发货仓库、商品编码、数量就分开列,组件数量 = 明细数量 × 组件倍数。 // 解析不了的组合条码(条码被截断等)保持原样,避免丢行。 $expanded = []; foreach ($unique as $row) { $expandedCodes = ! empty($row['force_cost_pending']) ? [[(string) $row['sku'], (float) $row['qty'], false]] : self::expandComboSku((string) $row['sku'], (float) $row['qty']); foreach ($expandedCodes 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 []; } // 9) 只保留叶子行: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; } } // 10) 组内只要出现带仓库的行,空仓库行就是汇总/占位行,直接丢弃。 $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']) !== '' ); } // 11) 排除已取消行;若带仓库行全部取消,上面已把空仓库汇总行丢掉,这里结果为空 → 整单不计。 $unique = array_filter( $unique, static fn (array $row): bool => strtoupper(trim((string) ($row['status'] ?? ''))) !== 'CANCELLED' ); // 12) 汇总到「发货仓库 + 商品编码」。 $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'] ?? ''), 'force_cost_pending' => ! empty($row['force_cost_pending']), 'bundle_resolution_status' => $row['bundle_resolution_status'] ?? null, 'bundle_component_rows' => (int) ($row['bundle_component_rows'] ?? 0), 'bundle_component_amount' => $row['bundle_component_amount'] ?? null, 'bundle_expected_amount' => $row['bundle_expected_amount'] ?? null, 'bundle_amount_difference' => $row['bundle_amount_difference'] ?? null, 'bundle_component_codes' => $row['bundle_component_codes'] ?? [], '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); } }