,amount:float}>>|null */ private ?array $goodsProfitProfileScopeIndex = null; /** * 订单侧两个来源:淘系(企微客户订单)与聚水潭订单。 * 两边的仓库字段不同:淘系明细没有 send_warehouse,用订单上的 wms_co_id; * 聚水潭优先取明细行的 send_warehouse,为空时退回订单的 wms_co_id(与「订单原始数据」一致)。 */ private const SOURCES = [ [ 'key' => '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); if ($this->option('remap-warehouses-only')) { return $this->remapWarehouseShortNames($connection, $reportMonth, $platform); } // 账单侧取数:业绩明细里本月(可按平台限定)的账单行,一行 = 一个店铺的一个订单。 // 收入为 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::isChineseBundleParentSku($sku) ) { continue; } $warehouse = trim((string) $row->warehouse_name); if ($warehouse === '') { $warehouse = trim((string) ($row->warehouse_code ?? '')); // 订单上的 wms_co_id 为 0 表示没有仓库信息,不能当成仓库名。 if ($warehouse === '0') { $warehouse = ''; } } // 发货仓库过滤(在商品编码全局排除之后执行)。 if ($this->isExcludedWarehouse($warehouse, $sku)) { 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 ?? '')), 'online_order_no' => trim((string) ($billByCore[$core]->online_order_no ?? '')), '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); $historyBundleMappings = $this->historyBundleComponentProfiles( $mirror, $lines, $billByCore, $platformSkuMappings['afs'], ); $importedBundleMappings = $this->importedAfterSalesBundleProfiles($lines, $billByCore); $this->info(sprintf( '订单售后导入组件:候选 %d 个,可用拆分 %d 个,组件 %d 行', $importedBundleMappings['summary']['candidate_keys'], $importedBundleMappings['summary']['profile_keys'], $importedBundleMappings['summary']['component_rows'], )); $bundleProfiles = $historyBundleMappings['profiles'] + $importedBundleMappings['profiles']; $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) )); $goodsProfitSplits = $this->goodsProfitProductSplitProfiles( $lines, $billByCore, $shortNames, $prices, $fallbackPrices, $catalogPrices, ); $this->info(sprintf( '商品利润当前订单拆分:候选订单 %d 个,可用拆分 %d 组,组件 %d 行', $goodsProfitSplits['summary']['candidate_orders'], $goodsProfitSplits['summary']['profile_groups'], $goodsProfitSplits['summary']['component_rows'], )); // 91. 基础料号先按条码打印导入记录映射内部商品编码。 // 多个不同目标编码时保持原编码待核验,不能按相似度或时间随机选择。 $materialCodes91 = $this->materialCodes91($lines); $materialBarcodeMappings = app(ProductMaterialBarcodeMappingRepository::class) ->resolve($materialCodes91); $this->info(sprintf( '91. 基础料号:%d 个;唯一映射 %d 个;多候选 %d 个;未找到映射 %d 个;映射源返回 %d 条', count($materialCodes91), count($materialBarcodeMappings['unique']), count($materialBarcodeMappings['ambiguous']), count($materialBarcodeMappings['missing']), $materialBarcodeMappings['source_rows'], )); $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; $accessoryNumericSuffixExcludedRows = 0; $autoReplacedRows = 0; $afsReplacedRows = 0; $mappingAmbiguousRows = 0; $mappingMissingRows = 0; $materialMappedRows = 0; $materialMappingAmbiguousRows = 0; $materialMappingMissingRows = 0; $materialMappedCollapsedRows = 0; $materialMappedQuantity = 0.0; $materialMappedCost = 0.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, $bundleProfiles, $goodsProfitSplits['profiles'], ); $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; } } $materialMapped = $this->map91MaterialSkus($costLines, $materialBarcodeMappings); $costLines = $materialMapped['lines']; $materialMappedRows += $materialMapped['mapped_rows']; $materialMappingAmbiguousRows += $materialMapped['ambiguous_rows']; $materialMappingMissingRows += $materialMapped['missing_rows']; $materialMappedCollapsedRows += $materialMapped['collapsed_rows']; $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']; $filteredCostLines = []; foreach ($costLines as $line) { if (self::isAccessoryNumericSuffixSku((string) ($line['sku'] ?? ''))) { $accessoryNumericSuffixExcludedRows++; continue; } $filteredCostLines[] = $line; } $costLines = $filteredCostLines; 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[] = in_array( ($line['bundle_resolution_status'] ?? ''), ['amount_mismatch', 'history_amount_mismatch', 'imported_amount_mismatch'], true, ) ? '组合装组件金额不一致' : '组合装未找到可用组件'; } 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'] ?? [], 'original_material_code' => $line['original_material_code'] ?? null, 'normalized_material_code' => $line['normalized_material_code'] ?? null, 'material_mapping_status' => $line['material_mapping_status'] ?? null, 'material_mapping_candidates' => $line['material_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 ? (in_array( ($line['bundle_resolution_status'] ?? ''), ['amount_mismatch', 'history_amount_mismatch', 'imported_amount_mismatch'], true, ) ? '组合装组件金额与主行金额不一致,组件行暂不计成本' : '组合装未找到可计价组件') : (($line['material_mapping_status'] ?? null) === 'ambiguous' ? '91 基础料号在条码映射库对应多个商品编码,未自动替换' : (($line['material_mapping_status'] ?? null) === 'missing' ? '91 基础料号在条码映射库未找到商品编码' : ($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 ? (in_array( ($line['bundle_resolution_status'] ?? ''), ['amount_mismatch', 'history_amount_mismatch', 'imported_amount_mismatch'], true, ) ? '组合装组件金额不一致' : '组合装未找到可用组件') : $this->pendingCodeType( (string) $line['sku'], $line['material_mapping_status'] ?? null, ), ]; } else { $matchedPriceRows++; $quantityTotal += $quantity; $costTotal += (float) $cost; if (($line['material_mapping_status'] ?? null) === 'unique') { $materialMappedQuantity += $quantity; $materialMappedCost += (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( ' 排除配件数字后缀编码:%d 行(仅最终商品编码以「-单个数字」结尾)', $accessoryNumericSuffixExcludedRows, )); $this->info(sprintf( ' 平台数字 SKU 自动替换:%d 行(售后镜像补充:%d 行);多候选保留:%d 行;无可用映射保留:%d 行', $autoReplacedRows, $afsReplacedRows, $mappingAmbiguousRows, $mappingMissingRows, )); $this->info(sprintf( ' 91. 基础料号替换:%d 行(映射后合并 %d 行);多候选保留:%d 行;未找到映射保留:%d 行;已映射成本:%s 件 / %s 元', $materialMappedRows, $materialMappedCollapsedRows, $materialMappingAmbiguousRows, $materialMappingMissingRows, number_format($materialMappedQuantity, 2), number_format($materialMappedCost, 2), )); $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'], )); $this->info(sprintf( ' 历史多组件映射:稳定画像 %d / 候选 %d;自动拆分 %d 组,剔除父行 %d 行,生成组件 %d 行;历史金额不一致 %d 组', $historyBundleMappings['summary']['profile_keys'], $historyBundleMappings['summary']['candidate_keys'], $bundleRollup['history_auto_split_groups'], $bundleRollup['history_auto_split_parent_rows'], $bundleRollup['history_auto_split_component_rows'], $bundleRollup['history_profile_amount_mismatch_groups'], )); $this->info(sprintf( ' 订单售后导入拆分:可用画像 %d / 候选 %d;自动拆分 %d 组,剔除父行 %d 行,生成组件 %d 行;金额不一致 %d 组', $importedBundleMappings['summary']['profile_keys'], $importedBundleMappings['summary']['candidate_keys'], $bundleRollup['imported_after_sales_split_groups'], $bundleRollup['imported_after_sales_split_parent_rows'], $bundleRollup['imported_after_sales_split_component_rows'], $bundleRollup['imported_after_sales_amount_mismatch_groups'], )); $this->info(sprintf( ' 商品利润当前订单拆分:对平 %d 组,替换基础行 %d 行,生成组件 %d 行;金额不一致 %d 组,基础码不唯一 %d 组', $bundleRollup['goods_profit_split_groups'], $bundleRollup['goods_profit_split_parent_rows'], $bundleRollup['goods_profit_split_component_rows'], $bundleRollup['goods_profit_split_mismatch_groups'], $bundleRollup['goods_profit_split_ambiguous_parent_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, 'accessory_numeric_suffix_excluded_rows' => $accessoryNumericSuffixExcludedRows, 'excluded_rows' => $placeholderExcludedRows + $bundleParentExcludedRows + $plusParentExcludedRows + $variantParentExcludedRows + $chineseAliasReplacedRows + $plantingPrefixExcludedRows + $accessoryNumericSuffixExcludedRows + $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']), ], 'history_component_mapping' => [ 'candidate_keys' => $historyBundleMappings['summary']['candidate_keys'], 'stable_profiles' => $historyBundleMappings['summary']['profile_keys'], 'historical_groups' => $historyBundleMappings['summary']['historical_groups'], 'afs_keys' => $historyBundleMappings['summary']['afs_keys'], 'auto_split_groups' => $bundleRollup['history_auto_split_groups'], 'auto_split_parent_rows' => $bundleRollup['history_auto_split_parent_rows'], 'auto_split_component_rows' => $bundleRollup['history_auto_split_component_rows'], 'history_amount_mismatch_groups' => $bundleRollup['history_profile_amount_mismatch_groups'], 'component_codes' => array_keys($bundleRollup['history_auto_split_component_codes']), ], 'imported_after_sales_component_mapping' => [ 'candidate_keys' => $importedBundleMappings['summary']['candidate_keys'], 'available_profiles' => $importedBundleMappings['summary']['profile_keys'], 'source_component_rows' => $importedBundleMappings['summary']['component_rows'], 'auto_split_groups' => $bundleRollup['imported_after_sales_split_groups'], 'auto_split_parent_rows' => $bundleRollup['imported_after_sales_split_parent_rows'], 'auto_split_component_rows' => $bundleRollup['imported_after_sales_split_component_rows'], 'amount_mismatch_groups' => $bundleRollup['imported_after_sales_amount_mismatch_groups'], 'component_codes' => array_keys($bundleRollup['imported_after_sales_component_codes']), ], 'goods_profit_order_split' => [ 'candidate_orders' => $goodsProfitSplits['summary']['candidate_orders'], 'available_profiles' => $goodsProfitSplits['summary']['profile_groups'], 'source_component_rows' => $goodsProfitSplits['summary']['component_rows'], 'split_groups' => $bundleRollup['goods_profit_split_groups'], 'replaced_parent_rows' => $bundleRollup['goods_profit_split_parent_rows'], 'generated_component_rows' => $bundleRollup['goods_profit_split_component_rows'], 'amount_mismatch_groups' => $bundleRollup['goods_profit_split_mismatch_groups'], 'ambiguous_parent_groups' => $bundleRollup['goods_profit_split_ambiguous_parent_groups'], 'component_codes' => array_keys($bundleRollup['goods_profit_split_component_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, 'material_91_mapping' => [ 'candidate_codes' => $materialCodes91, 'unique_mapping_codes' => $materialBarcodeMappings['unique'], 'ambiguous_codes' => $materialBarcodeMappings['ambiguous'], 'missing_codes' => $materialBarcodeMappings['missing'], 'source_rows' => $materialBarcodeMappings['source_rows'], 'mapped_rows' => $materialMappedRows, 'mapped_collapsed_rows' => $materialMappedCollapsedRows, 'ambiguous_rows' => $materialMappingAmbiguousRows, 'missing_rows' => $materialMappingMissingRows, 'mapped_quantity' => round($materialMappedQuantity, 4), 'mapped_cost' => round($materialMappedCost, 2), ], '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 ($shopSkuId === '' || $shopItemId === '') { continue; } $isPlatformSku = preg_match('/^\d+$/', $sku) === 1; $isBundleParent = self::isBundleParentRow($line); if ($isPlatformSku) { $historyCandidates[$shopSkuId . "\0" . $shopItemId] = [ 'shop_sku_id' => $shopSkuId, 'shop_i_id' => $shopItemId, ]; } if ($shopName !== '' && ($isPlatformSku || $isBundleParent)) { $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, ]; } private static function historyBundleProfileKey( string $source, string $storeName, string $shopSkuId, string $shopItemId, string $parentSku, ): string { return trim($source) . "\0" . trim($storeName) . "\0" . trim($shopSkuId) . "\0" . trim($shopItemId) . "\0" . mb_strtoupper(trim($parentSku)); } /** @param array $row */ private static function historyBundleProfileKeyForRow(array $row, string $parentSku): ?string { $source = trim((string) ($row['source'] ?? '')); $storeName = trim((string) ($row['store_name'] ?? '')); $shopSkuId = trim((string) ($row['shop_sku_id'] ?? '')); $shopItemId = trim((string) ($row['shop_i_id'] ?? '')); if (in_array('', [$source, $storeName, $shopSkuId, $shopItemId], true)) { return null; } return self::historyBundleProfileKey( $source, $storeName, $shopSkuId, $shopItemId, $parentSku, ); } private static function goodsProfitSplitProfileKey( string $source, string $orderId, string $shopSkuId, string $shopItemId, ): string { return trim($source) . "\0" . trim($orderId) . "\0" . trim($shopSkuId) . "\0" . trim($shopItemId); } /** * 仅对可能缺价的订单组读取商品利润当前订单的正数量、正金额组件。 * * 候选组包括:平台数字码、同组存在多个 SKU,或当前 SKU 在原三级价格 * 来源中无价格。最终是否替换仍由 costLines() 按金额对账决定。 * * @param array>> $lines * @param array $billByCore * @param array{by_provider:array,by_warehouse:array} $shortNames * @param array $prices * @param array $fallbackPrices * @param array $catalogPrices * @return array{ * profiles:array>, * summary:array{candidate_orders:int,profile_groups:int,component_rows:int} * } */ private function goodsProfitProductSplitProfiles( array $lines, array $billByCore, array $shortNames, array $prices, array $fallbackPrices, array $catalogPrices, ): array { $candidateOrders = []; $groups = []; foreach ($lines as $core => $orderLines) { $bill = $billByCore[$core] ?? null; if ($bill === null) { continue; } $storeName = trim((string) ($bill->store_name ?? '')); $onlineOrderNo = trim((string) ($bill->online_order_no ?? $core)); foreach ($orderLines as $row) { $source = trim((string) ($row['source'] ?? '')); $orderId = trim((string) ($row['o_id'] ?? '')); $shopSkuId = trim((string) ($row['shop_sku_id'] ?? '')); $shopItemId = trim((string) ($row['shop_i_id'] ?? '')); $sku = mb_strtoupper(trim((string) ($row['sku'] ?? ''))); if ($source === '' || $orderId === '' || $shopSkuId === '' || $shopItemId === '' || $sku === '') { continue; } $profileKey = self::goodsProfitSplitProfileKey($source, $orderId, $shopSkuId, $shopItemId); $groups[$profileKey] ??= [ 'source' => $source, 'order_id' => $orderId, 'store_name' => $storeName, 'online_order_no' => $onlineOrderNo, 'shop_sku_id' => $shopSkuId, 'shop_item_id' => $shopItemId, 'codes' => [], 'quantity' => 0.0, 'amount' => 0.0, ]; $groups[$profileKey]['codes']['c:' . $sku] = true; $groups[$profileKey]['quantity'] += (float) ($row['qty'] ?? 0); $groups[$profileKey]['amount'] += (float) ($row['amount'] ?? 0); $short = $shortNames['by_provider'][(string) ($row['warehouse'] ?? '')] ?? ''; $weighted = $short === '' ? null : ($prices[$short . "\0" . $sku] ?? null); $fallback = $fallbackPrices[$sku] ?? null; $fallbackPrice = is_array($fallback) ? ($fallback['price'] ?? null) : $fallback; $catalog = $catalogPrices[$sku] ?? null; if ( preg_match('/^\d+$/', $sku) === 1 || ($weighted === null && $fallbackPrice === null && $catalog === null) ) { $candidateOrders['o:' . $onlineOrderNo] = true; } } } foreach ($groups as $group) { if (count($group['codes']) > 1) { $candidateOrders['o:' . $group['online_order_no']] = true; } } $onlineOrderNos = array_map( static fn (string $key): string => substr($key, 2), array_keys($candidateOrders), ); if ($onlineOrderNos === []) { return [ 'profiles' => [], 'summary' => [ 'candidate_orders' => 0, 'profile_groups' => 0, 'component_rows' => 0, ], ]; } $sourceRows = app(GoodsProfitAfsRepository::class) ->positiveComponentRowsForOrders($onlineOrderNos); $componentsByOrderKey = []; $componentsByOnlineOrder = []; foreach ($sourceRows as $sourceRow) { $key = $sourceRow['shop_name'] . "\0" . $sourceRow['online_order_no'] . "\0" . $sourceRow['shop_sku_id'] . "\0" . $sourceRow['shop_i_id']; $componentsByOrderKey[$key][] = $sourceRow; $orderKey = $sourceRow['shop_name'] . "\0" . $sourceRow['online_order_no']; $code = mb_strtoupper((string) $sourceRow['product_code']); $componentsByOnlineOrder[$orderKey][$code]['qty'] = ($componentsByOnlineOrder[$orderKey][$code]['qty'] ?? 0.0) + (float) $sourceRow['product_quantity']; $componentsByOnlineOrder[$orderKey][$code]['amount'] = round(($componentsByOnlineOrder[$orderKey][$code]['amount'] ?? 0.0) + (float) $sourceRow['product_amount'], 2); } $profiles = []; $componentRows = 0; foreach ($groups as $profileKey => $group) { $orderKey = $group['store_name'] . "\0" . $group['online_order_no'] . "\0" . $group['shop_sku_id'] . "\0" . $group['shop_item_id']; $components = []; foreach ($componentsByOrderKey[$orderKey] ?? [] as $component) { $code = mb_strtoupper(trim((string) ($component['product_code'] ?? ''))); $quantity = (float) ($component['product_quantity'] ?? 0); $amount = (float) ($component['product_amount'] ?? 0); if ( $code === '' || $quantity <= 0 || $amount <= 0 || self::isPlantingSku($code) || in_array($code, self::EXCLUDED_SKUS, true) ) { continue; } $components[$code]['qty'] = ($components[$code]['qty'] ?? 0.0) + $quantity; $components[$code]['amount'] = round( ($components[$code]['amount'] ?? 0.0) + $amount, 2, ); } if ($components === []) { $codes = array_map( static fn (string $key): string => substr($key, 2), array_keys($group['codes']), ); $positiveComponents = $componentsByOnlineOrder[ $group['store_name'] . "\0" . $group['online_order_no'] ] ?? []; if ( count($codes) === 1 && preg_match('/^\d+$/', $codes[0]) === 1 && abs((float) $group['amount']) <= 0.005 && (float) $group['quantity'] > 0 && count($positiveComponents) === 1 && abs((float) (reset($positiveComponents)['qty'] ?? 0) - (float) $group['quantity']) <= 0.0001 ) { $profiles[$profileKey] = [ 'components' => [], 'component_codes' => [], 'source' => 'goods_profit_zero_placeholder', 'remove_only' => true, ]; } continue; } ksort($components, SORT_STRING); $profiles[$profileKey] = [ 'components' => $components, 'component_codes' => array_keys($components), 'source' => 'goods_profit_current_order', ]; $componentRows += count($components); } return [ 'profiles' => $profiles, 'summary' => [ 'candidate_orders' => count($onlineOrderNos), 'profile_groups' => count($profiles), 'component_rows' => $componentRows, ], ]; } private static function importedAfterSalesBundleProfileKey( string $storeName, string $onlineOrderNo, string $parentSku, ): string { return "imported_after_sales" . "\0" . trim($storeName) . "\0" . trim($onlineOrderNo) . "\0" . mb_strtoupper(trim($parentSku)); } /** @param array $row */ private static function importedAfterSalesBundleProfileKeyForRow(array $row, string $parentSku): ?string { $storeName = trim((string) ($row['store_name'] ?? '')); $onlineOrderNo = trim((string) ($row['online_order_no'] ?? '')); if ($storeName === '' || $onlineOrderNo === '') { return null; } return self::importedAfterSalesBundleProfileKey($storeName, $onlineOrderNo, $parentSku); } /** * 从「订单售后费用明细」导入表读取已经拆好的组件。 * * 仅在当前订单镜像没有可用组件时,由组合装解析器使用。组件金额仍与订单 * 主行金额对账,商品单价继续走月加权 / 线上代发 / 商品资料成本价。 * * @param array>> $lines * @param array $billByCore * @return array{profiles:array>,summary:array} */ private function importedAfterSalesBundleProfiles(array $lines, array $billByCore): array { $candidates = []; foreach ($lines as $core => $orderLines) { $bill = $billByCore[$core] ?? null; if ($bill === null) { continue; } $storeName = trim((string) ($bill->store_name ?? '')); $onlineOrderNo = trim((string) ($bill->online_order_no ?? '')); if ($onlineOrderNo === '') { $onlineOrderNo = trim((string) $core); } if ($storeName === '' || $onlineOrderNo === '') { continue; } foreach ($orderLines as $row) { if (! self::isBundleParentRow($row)) { continue; } $parentSku = mb_strtoupper(trim((string) ($row['sku'] ?? ''))); if ($parentSku === '') { continue; } $key = self::importedAfterSalesBundleProfileKey($storeName, $onlineOrderNo, $parentSku); $candidates[$key] ??= [ 'store_name' => $storeName, 'online_order_no' => $onlineOrderNo, 'parent_sku' => $parentSku, 'parent_quantity' => 0.0, 'shop_product_codes' => [], 'shop_style_codes' => [], ]; $candidates[$key]['parent_quantity'] += (float) ($row['qty'] ?? 0); $shopProductCode = trim((string) ($row['shop_sku_id'] ?? '')); $shopStyleCode = trim((string) ($row['shop_i_id'] ?? '')); if ($shopProductCode !== '') { $candidates[$key]['shop_product_codes'][$shopProductCode] = true; } if ($shopStyleCode !== '') { $candidates[$key]['shop_style_codes'][$shopStyleCode] = true; } } } if ($candidates === []) { return [ 'profiles' => [], 'summary' => [ 'candidate_keys' => 0, 'profile_keys' => 0, 'component_rows' => 0, ], ]; } $onlineOrderNos = array_values(array_unique(array_column($candidates, 'online_order_no'))); $importedRows = app(AfterSalesCostRowRepository::class) ->componentRowsForOrders($onlineOrderNos); $rowsByOrder = []; foreach ($importedRows as $row) { $rowsByOrder[$row['shop_name'] . "\0" . $row['online_order_no']][] = $row; } $profiles = []; $componentRows = 0; foreach ($candidates as $key => $candidate) { $parentQuantity = (float) $candidate['parent_quantity']; if ($parentQuantity <= 0) { continue; } $components = []; $orderRows = $rowsByOrder[$candidate['store_name'] . "\0" . $candidate['online_order_no']] ?? []; foreach ($orderRows as $row) { $productCode = mb_strtoupper(trim((string) ($row['product_code'] ?? ''))); $quantity = (float) ($row['product_quantity'] ?? 0); if ($productCode === '' || $productCode === $candidate['parent_sku'] || $quantity <= 0) { continue; } $shopProductCode = trim((string) ($row['shop_product_code'] ?? '')); $shopStyleCode = trim((string) ($row['shop_style_code'] ?? '')); if ( ($shopProductCode !== '' && $candidate['shop_product_codes'] !== [] && ! isset($candidate['shop_product_codes'][$shopProductCode])) || ($shopStyleCode !== '' && $candidate['shop_style_codes'] !== [] && ! isset($candidate['shop_style_codes'][$shopStyleCode])) ) { continue; } $components[$productCode]['qty'] = ($components[$productCode]['qty'] ?? 0.0) + $quantity; $components[$productCode]['amount'] = ($components[$productCode]['amount'] ?? 0.0) + (float) ($row['product_amount'] ?? 0); } if ($components === []) { continue; } ksort($components, SORT_STRING); $normalized = []; foreach ($components as $productCode => $component) { $normalized[$productCode] = [ 'qty' => round($component['qty'] / $parentQuantity, 4), 'amount' => round($component['amount'] / $parentQuantity, 2), ]; } $profiles[$key] = [ 'components' => $normalized, 'component_codes' => array_keys($normalized), 'source' => 'imported_after_sales', ]; $componentRows += count($components); } return [ 'profiles' => $profiles, 'summary' => [ 'candidate_keys' => count($candidates), 'profile_keys' => count($profiles), 'component_rows' => $componentRows, ], ]; } /** * 对上游未拆分的「组合」父行,从历史订单与售后镜像构造稳定组件画像。 * 历史订单必须能按父行数量还原唯一组件集合和数量,售后镜像集合必须完全一致。 * * @param array>> $lines * @param array $billByCore * @return array{profiles:array>,summary:array} */ private function historyBundleComponentProfiles( $mirror, array $lines, array $billByCore, array $afsMappings, ): array { $candidates = []; foreach ($lines as $core => $orderLines) { $storeName = trim((string) ($billByCore[$core]->store_name ?? '')); if ($storeName === '') { continue; } $localComponentParents = []; foreach ($orderLines as $row) { $sourceParent = mb_strtoupper(trim((string) ($row['src_combine_sku_id'] ?? ''))); if ($sourceParent !== '') { $localComponentParents[$sourceParent] = true; } } foreach ($orderLines as $row) { if (! self::isBundleParentRow($row)) { continue; } $parentSku = mb_strtoupper(trim((string) ($row['sku'] ?? ''))); if (isset($localComponentParents[$parentSku])) { continue; } $key = self::historyBundleProfileKeyForRow($row, $parentSku); if ($key === null) { continue; } $candidates[$key] = [ 'key' => $key, 'source' => trim((string) $row['source']), 'store_name' => $storeName, 'shop_sku_id' => trim((string) $row['shop_sku_id']), 'shop_i_id' => trim((string) $row['shop_i_id']), 'parent_sku' => $parentSku, ]; } } if ($candidates === []) { return [ 'profiles' => [], 'summary' => [ 'candidate_keys' => 0, 'profile_keys' => 0, 'historical_groups' => 0, 'afs_keys' => 0, ], ]; } $parentCodesBySource = []; foreach ($candidates as $candidate) { $parentCodesBySource[$candidate['source']][$candidate['parent_sku']] = true; } $historyRows = []; foreach (self::SOURCES as $source) { $sourceKey = (string) $source['key']; $parentCodes = array_keys($parentCodesBySource[$sourceKey] ?? []); if ($parentCodes === []) { continue; } try { $itemRows = []; foreach ([ $mirror->table($source['items'])->whereIn('sku_id', $parentCodes) ->get(['Id', 'o_id', 'sku_id', 'qty', 'amount', 'outer_oi_id', 'shop_sku_id', 'shop_i_id', 'src_combine_sku_id']), $mirror->table($source['items'])->whereIn('src_combine_sku_id', $parentCodes) ->get(['Id', 'o_id', 'sku_id', 'qty', 'amount', 'outer_oi_id', 'shop_sku_id', 'shop_i_id', 'src_combine_sku_id']), ] as $rows) { foreach ($rows as $row) { $itemRows[(string) $row->Id] = $row; } } $orderIds = array_values(array_unique(array_map( static fn ($row): string => (string) $row->o_id, $itemRows, ))); $orders = $orderIds === [] ? [] : $mirror->table($source['orders']) ->whereIn('o_id', $orderIds) ->get(['o_id', 'shop_name', 'link_o_id']) ->keyBy('o_id') ->all(); foreach ($itemRows as $row) { $order = $orders[(string) $row->o_id] ?? null; $historyRows[] = [ 'source' => $sourceKey, 'store_name' => trim((string) ($order->shop_name ?? '')), 'o_id' => (string) ($row->o_id ?? ''), 'sku' => mb_strtoupper(trim((string) ($row->sku_id ?? ''))), 'qty' => (float) ($row->qty ?? 0), 'amount' => (float) ($row->amount ?? 0), 'outer_oi_id' => trim((string) ($row->outer_oi_id ?? '')), 'parent_o_id' => trim((string) ($order->link_o_id ?? '')), 'shop_sku_id' => trim((string) ($row->shop_sku_id ?? '')), 'shop_i_id' => trim((string) ($row->shop_i_id ?? '')), 'src_combine_sku_id' => mb_strtoupper(trim((string) ($row->src_combine_sku_id ?? ''))), ]; } } catch (Throwable $exception) { $this->warn('历史组合组件读取失败(' . $source['items'] . '):' . $exception->getMessage()); } } $rowsByCandidate = []; $candidateByBase = []; foreach ($candidates as $key => $candidate) { $base = $candidate['source'] . "\0" . $candidate['store_name'] . "\0" . $candidate['shop_sku_id'] . "\0" . $candidate['shop_i_id']; $candidateByBase[$base][$candidate['parent_sku']] = $key; } foreach ($historyRows as $row) { $base = $row['source'] . "\0" . $row['store_name'] . "\0" . $row['shop_sku_id'] . "\0" . $row['shop_i_id']; foreach (array_unique([$row['sku'], $row['src_combine_sku_id']]) as $parentSku) { if ($parentSku === '' || ! isset($candidateByBase[$base][$parentSku])) { continue; } $rowsByCandidate[$candidateByBase[$base][$parentSku]][] = $row; } } $afsProfiles = $afsMappings; $profiles = []; $historicalGroupCount = 0; foreach ($candidates as $key => $candidate) { $candidateRows = $rowsByCandidate[$key] ?? []; $parentIds = []; foreach ($candidateRows as $row) { if ($row['parent_o_id'] !== '' && $row['parent_o_id'] !== '0') { $parentIds[$row['parent_o_id']] = true; } } $groups = []; foreach ($candidateRows as $row) { if (isset($parentIds[$row['o_id']]) && $row['sku'] !== $candidate['parent_sku']) { continue; } if ($row['outer_oi_id'] !== '') { $groups[$row['outer_oi_id']][] = $row; } } $stableProfile = null; foreach ($groups as $groupRows) { $parentQuantity = 0.0; $components = []; foreach ($groupRows as $row) { if ($row['sku'] === $candidate['parent_sku']) { $parentQuantity += max($row['qty'], 0.0); continue; } if ($row['src_combine_sku_id'] !== $candidate['parent_sku'] || $row['qty'] <= 0) { continue; } $components[$row['sku']]['qty'] = ($components[$row['sku']]['qty'] ?? 0.0) + $row['qty']; $components[$row['sku']]['amount'] = ($components[$row['sku']]['amount'] ?? 0.0) + $row['amount']; } if ($parentQuantity <= 0 || count($components) < 2) { continue; } ksort($components, SORT_STRING); $normalized = []; foreach ($components as $code => $component) { $normalized[$code] = [ 'qty' => round($component['qty'] / $parentQuantity, 4), 'amount' => round($component['amount'] / $parentQuantity, 2), ]; } $historicalGroupCount++; if ($stableProfile === null) { $stableProfile = $normalized; continue; } if (array_keys($stableProfile) !== array_keys($normalized)) { $stableProfile = false; break; } foreach ($normalized as $code => $component) { if ( abs($stableProfile[$code]['qty'] - $component['qty']) > 0.0001 || abs($stableProfile[$code]['amount'] - $component['amount']) > 0.01 ) { $stableProfile = false; break 2; } } } if (! is_array($stableProfile) || $stableProfile === []) { continue; } $afsKey = self::afsPlatformSkuKey( $candidate['store_name'], $candidate['shop_sku_id'], $candidate['shop_i_id'], ); $mappedAfsCodes = $afsProfiles[$afsKey] ?? []; $afsCodes = array_is_list($mappedAfsCodes) ? array_values($mappedAfsCodes) : array_keys($mappedAfsCodes); sort($afsCodes, SORT_STRING); $historyCodes = array_keys($stableProfile); sort($historyCodes, SORT_STRING); if ($afsCodes === [] || $afsCodes !== $historyCodes) { continue; } $profiles[$key] = [ 'components' => $stableProfile, 'component_codes' => $historyCodes, ]; } return [ 'profiles' => $profiles, 'summary' => [ 'candidate_keys' => count($candidates), 'profile_keys' => count($profiles), 'historical_groups' => $historicalGroupCount, 'afs_keys' => count($afsProfiles), ], ]; } /** * 从订单侧原始行和可解析组合装中收集 91. 基础料号,供一次批量只读查询。 * * @param array>> $lines * @return list */ private function materialCodes91(array $lines): array { $codes = []; foreach ($lines as $orderLines) { foreach ($orderLines as $line) { $expanded = ! empty($line['force_cost_pending']) ? [[(string) $line['sku'], (float) $line['qty'], false]] : self::expandComboSku((string) $line['sku'], (float) $line['qty']); foreach ($expanded as [$code]) { $materialCode = ProductMaterialBarcodeMappingRepository::normalizeMaterialCode((string) $code); if ($materialCode !== null) { $codes[$materialCode] = true; } } } } $codes = array_keys($codes); sort($codes, SORT_STRING); return $codes; } /** * 只替换唯一映射;多候选或未找到时保留原编码。 * 替换后按「发货仓库 + 商品编码」合并,避免同一条码的不同尾缀重复写行。 * * @param list> $lines * @param array{unique:array,ambiguous:array>,missing:list,source_rows:int} $mappings * @return array{ * lines:list>, * mapped_rows:int, * ambiguous_rows:int, * missing_rows:int, * collapsed_rows:int * } */ private function map91MaterialSkus(array $lines, array $mappings): array { $mappedLines = []; $mappedRows = 0; $ambiguousRows = 0; $missingRows = 0; $collapsedRows = 0; foreach ($lines as $line) { $originalSku = mb_strtoupper(trim((string) $line['sku'])); $materialCode = ProductMaterialBarcodeMappingRepository::normalizeMaterialCode($originalSku); $line['material_mapping_status'] = null; $line['material_mapping_candidates'] = []; $line['original_material_code'] = null; $line['normalized_material_code'] = $materialCode; if ($materialCode !== null) { if (isset($mappings['unique'][$materialCode])) { $line['sku'] = $mappings['unique'][$materialCode]; $line['original_material_code'] = $originalSku; $line['material_mapping_status'] = 'unique'; $line['material_mapping_candidates'] = [$line['sku']]; $mappedRows++; } elseif (isset($mappings['ambiguous'][$materialCode])) { $line['original_material_code'] = $originalSku; $line['material_mapping_status'] = 'ambiguous'; $line['material_mapping_candidates'] = $mappings['ambiguous'][$materialCode]; $ambiguousRows++; } else { $line['original_material_code'] = $originalSku; $line['material_mapping_status'] = 'missing'; $missingRows++; } } $key = (string) ($line['warehouse'] ?? '') . "\0" . (string) $line['sku']; if (! isset($mappedLines[$key])) { $mappedLines[$key] = $line; continue; } $existing = $mappedLines[$key]; $collapsedRows++; $existing['qty'] += $line['qty']; $existing['amount'] += $line['amount']; if (($line['material_mapping_status'] ?? null) === 'unique') { $existing['material_mapping_status'] = 'unique'; $existing['material_mapping_candidates'] = $line['material_mapping_candidates']; $existing['original_material_code'] = $line['original_material_code']; $existing['normalized_material_code'] = $line['normalized_material_code']; } 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])) { $existing[$bundleField] = $line[$bundleField]; } } $mappedLines[$key] = $existing; } return [ 'lines' => array_values($mappedLines), 'mapped_rows' => $mappedRows, 'ambiguous_rows' => $ambiguousRows, 'missing_rows' => $missingRows, 'collapsed_rows' => $collapsedRows, ]; } /** * @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, 'history_profile_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, 'history_auto_split_groups' => 0, 'history_auto_split_parent_rows' => 0, 'history_auto_split_component_rows' => 0, 'imported_after_sales_split_groups' => 0, 'imported_after_sales_split_parent_rows' => 0, 'imported_after_sales_split_component_rows' => 0, 'imported_after_sales_amount_mismatch_groups' => 0, 'goods_profit_split_groups' => 0, 'goods_profit_split_parent_rows' => 0, 'goods_profit_split_component_rows' => 0, 'goods_profit_split_mismatch_groups' => 0, 'goods_profit_split_ambiguous_parent_groups' => 0, 'component_codes' => [], 'unreconciled_parent_codes' => [], 'history_auto_split_component_codes' => [], 'imported_after_sales_component_codes' => [], 'goods_profit_split_component_codes' => [], ], ]; } private function pendingCodeType(string $code, ?string $materialMappingStatus = null): string { if ($materialMappingStatus === 'ambiguous') { return '91 基础料号存在多个条码候选'; } if ($materialMappingStatus === 'missing') { return '91 基础料号未找到条码映射'; } $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 isChineseBundleParentSku(string $sku): bool { $sku = mb_strtoupper(trim($sku)); return $sku !== '' && str_contains($sku, '组合'); } /** @param array $row */ private static function isBundleParentRow(array $row): bool { if (trim((string) ($row['src_combine_sku_id'] ?? '')) !== '') { return false; } return self::isChineseBundleParentSku((string) ($row['sku'] ?? '')); } /** * 用当前订单商品利润明细中的正数量、正金额组件替换基础码。 * * 规则限定在同一 source + o_id + 店铺商品编码 + 店铺款式编码: * 1. 只有一个非组件基础码; * 2. 基础码金额与组件金额合计误差≤0.01; * 3. 只保留组件行,基础码不参与成本。 * * 多基础码或金额不一致时不做替换,继续交给原有逻辑处理。 * * @param array> $unique * @param array $stats * @param array> $profiles */ private function applyGoodsProfitProductSplits(array &$unique, array &$stats, array $profiles): void { if ($profiles === []) { return; } $nonLeafOrderIds = []; foreach ($unique as $row) { $parentOrderId = trim((string) ($row['parent_o_id'] ?? '')); if ($parentOrderId !== '' && $parentOrderId !== '0') { $nonLeafOrderIds[$parentOrderId] = true; } } // 分单订单可能把同一组合父行拆到多个末级子单,商品利润组件仍是整单口径。 // 只有所有子单的组件完全一致且父行金额合计对平时,才合并成一组组件。 $this->applyGoodsProfitSplitFamilies($unique, $stats, $profiles); $groups = []; foreach ($unique as $key => $row) { $source = trim((string) ($row['source'] ?? '')); $orderId = trim((string) ($row['o_id'] ?? '')); $shopSkuId = trim((string) ($row['shop_sku_id'] ?? '')); $shopItemId = trim((string) ($row['shop_i_id'] ?? '')); if ( in_array('', [$source, $orderId, $shopSkuId, $shopItemId], true) || isset($nonLeafOrderIds[$orderId]) ) { continue; } $profileKey = self::goodsProfitSplitProfileKey($source, $orderId, $shopSkuId, $shopItemId); if (isset($profiles[$profileKey])) { $groups[$profileKey][] = $key; } } foreach ($groups as $profileKey => $groupKeys) { if (! empty($profiles[$profileKey]['remove_only'])) { $parentRows = []; $parentQuantity = 0.0; $parentAmount = 0.0; foreach ($groupKeys as $key) { $sku = mb_strtoupper(trim((string) ($unique[$key]['sku'] ?? ''))); $parentRows[$sku][] = $key; $parentQuantity += (float) ($unique[$key]['qty'] ?? 0); $parentAmount += (float) ($unique[$key]['amount'] ?? 0); } if ( count($parentRows) === 1 && $parentQuantity > 0 && abs($parentAmount) <= 0.005 ) { foreach ($groupKeys as $key) { unset($unique[$key]); } $stats['bundle_stats']['goods_profit_split_groups']++; $stats['bundle_stats']['goods_profit_split_parent_rows'] += count($groupKeys); } else { $stats['bundle_stats']['goods_profit_split_mismatch_groups']++; } continue; } $components = $profiles[$profileKey]['components'] ?? []; if (! is_array($components) || $components === []) { continue; } $componentCodes = array_fill_keys(array_keys($components), true); $parentRows = []; $activeBundleParentRows = []; foreach ($groupKeys as $key) { if (! isset($unique[$key])) { continue; } $sku = mb_strtoupper(trim((string) ($unique[$key]['sku'] ?? ''))); if ($sku === '' || isset($componentCodes[$sku])) { continue; } $parentRows[$sku][] = $key; $quantity = (float) ($unique[$key]['qty'] ?? 0); $amount = (float) ($unique[$key]['amount'] ?? 0); if ( self::isBundleParentRow($unique[$key]) && ($quantity > 0 || abs($amount) > 0.005) ) { $activeBundleParentRows[$sku][] = $key; } } // 订单镜像可能同时残留上一版零数量父行。存在唯一有效组合父行时, // 只按该父行与商品利润原表组件对账;零数量旧父行随同组一起剔除。 if (count($activeBundleParentRows) === 1) { $parentRows = $activeBundleParentRows; } if (count($parentRows) !== 1) { $stats['bundle_stats']['goods_profit_split_ambiguous_parent_groups']++; continue; } $parentSku = (string) array_key_first($parentRows); $parentKeys = array_values($parentRows[$parentSku]); $parentAmount = 0.0; foreach ($parentKeys as $parentKey) { $parentAmount += (float) ($unique[$parentKey]['amount'] ?? 0); } $componentAmount = 0.0; $validComponents = []; foreach ($components as $componentCode => $component) { $code = mb_strtoupper(trim((string) $componentCode)); $quantity = (float) ($component['qty'] ?? 0); $amount = (float) ($component['amount'] ?? 0); if ($code === '' || $quantity <= 0 || $amount <= 0) { continue; } $validComponents[$code] = ['qty' => $quantity, 'amount' => $amount]; $componentAmount += $amount; } if ( $validComponents === [] || $parentAmount <= 0 || abs(round($componentAmount - $parentAmount, 2)) > 0.01 ) { $stats['bundle_stats']['goods_profit_split_mismatch_groups']++; continue; } $firstParent = $unique[$parentKeys[0]]; $resolvedWarehouse = ''; foreach ($groupKeys as $key) { $warehouse = trim((string) ($unique[$key]['warehouse'] ?? '')); if ($warehouse !== '') { $resolvedWarehouse = $warehouse; break; } } foreach ($groupKeys as $key) { unset($unique[$key]); } $this->addGoodsProfitSplitComponents( $unique, $firstParent, $parentSku, $validComponents, $resolvedWarehouse, ); $stats['bundle_stats']['goods_profit_split_groups']++; $stats['bundle_stats']['goods_profit_split_parent_rows'] += count($parentKeys); $stats['bundle_stats']['goods_profit_split_component_rows'] += count($validComponents); foreach (array_keys($validComponents) as $code) { $stats['bundle_stats']['goods_profit_split_component_codes'][$code] = true; } } // 极少数订单的活动父行使用了新平台 SKU,而商品利润组件仍挂在旧平台 SKU 下。 // 同一订单、同一平台款式下仅有一个组件集合金额与活动父行对平时,允许跨 SKU 替换。 $this->applyGoodsProfitCrossSkuSplits($unique, $stats, $profiles); } /** * 合并分单末级父行后应用商品利润组件。 * * @param array> $unique * @param array $stats * @param array> $profiles */ private function applyGoodsProfitSplitFamilies(array &$unique, array &$stats, array $profiles): void { $families = []; foreach ($unique as $key => $row) { $parentOrderId = trim((string) ($row['parent_o_id'] ?? '')); if ( $parentOrderId === '' || $parentOrderId === '0' || ! self::isBundleParentRow($row) ) { continue; } $quantity = (float) ($row['qty'] ?? 0); $amount = (float) ($row['amount'] ?? 0); if ($quantity <= 0 && abs($amount) <= 0.005) { continue; } $source = trim((string) ($row['source'] ?? '')); $shopSkuId = trim((string) ($row['shop_sku_id'] ?? '')); $shopItemId = trim((string) ($row['shop_i_id'] ?? '')); $parentSku = mb_strtoupper(trim((string) ($row['sku'] ?? ''))); $orderId = trim((string) ($row['o_id'] ?? '')); if ( in_array('', [$source, $orderId, $shopSkuId, $shopItemId, $parentSku], true) ) { continue; } $familyKey = $source . "\0" . $parentOrderId . "\0" . $shopSkuId . "\0" . $shopItemId; $families[$familyKey]['first_parent_key'] = $families[$familyKey]['first_parent_key'] ?? $key; $families[$familyKey]['parent_keys'][] = $key; $families[$familyKey]['parent_skus'][$parentSku] = true; $families[$familyKey]['profile_keys'][ self::goodsProfitSplitProfileKey($source, $orderId, $shopSkuId, $shopItemId) ] = true; } foreach ($families as $family) { if (count($family['parent_keys']) < 2 || count($family['parent_skus']) !== 1) { continue; } $componentSets = []; foreach (array_keys($family['profile_keys']) as $profileKey) { $components = $profiles[$profileKey]['components'] ?? null; if (! is_array($components) || $components === []) { continue 2; } $componentSets[] = $components; } if ($componentSets === []) { continue; } $components = reset($componentSets); foreach ($componentSets as $componentSet) { if ($componentSet !== $components) { continue 2; } } $validComponents = self::validGoodsProfitComponents($components); $componentAmount = (float) array_sum(array_column($validComponents, 'amount')); $parentAmount = 0.0; foreach ($family['parent_keys'] as $parentKey) { if (isset($unique[$parentKey])) { $parentAmount += (float) ($unique[$parentKey]['amount'] ?? 0); } } if ( $validComponents === [] || $parentAmount <= 0 || abs(round($componentAmount - $parentAmount, 2)) > 0.01 ) { continue; } $firstParent = $unique[$family['first_parent_key']]; $familyRootOrderId = trim((string) ($firstParent['parent_o_id'] ?? '')); $resolvedWarehouse = ''; foreach ($unique as $row) { $rowRootOrderId = trim((string) ($row['parent_o_id'] ?? '')); if ($rowRootOrderId === '' || $rowRootOrderId === '0') { $rowRootOrderId = trim((string) ($row['o_id'] ?? '')); } if ( trim((string) ($row['source'] ?? '')) !== trim((string) ($firstParent['source'] ?? '')) || $rowRootOrderId !== $familyRootOrderId || trim((string) ($row['shop_sku_id'] ?? '')) !== trim((string) ($firstParent['shop_sku_id'] ?? '')) || trim((string) ($row['shop_i_id'] ?? '')) !== trim((string) ($firstParent['shop_i_id'] ?? '')) ) { continue; } if (trim((string) ($row['warehouse'] ?? '')) !== '') { $resolvedWarehouse = trim((string) $row['warehouse']); break; } } $componentCodes = array_fill_keys(array_keys($validComponents), true); $parentRows = 0; foreach ($unique as $key => $row) { $rowRootOrderId = trim((string) ($row['parent_o_id'] ?? '')); if ($rowRootOrderId === '' || $rowRootOrderId === '0') { $rowRootOrderId = trim((string) ($row['o_id'] ?? '')); } if ( trim((string) ($row['source'] ?? '')) !== trim((string) ($firstParent['source'] ?? '')) || $rowRootOrderId !== $familyRootOrderId || trim((string) ($row['shop_sku_id'] ?? '')) !== trim((string) ($firstParent['shop_sku_id'] ?? '')) || trim((string) ($row['shop_i_id'] ?? '')) !== trim((string) ($firstParent['shop_i_id'] ?? '')) ) { continue; } $sku = mb_strtoupper(trim((string) ($row['sku'] ?? ''))); if (! self::isBundleParentRow($row) && ! isset($componentCodes[$sku])) { continue; } if (self::isBundleParentRow($row)) { $parentRows++; } unset($unique[$key]); } $parentSku = (string) array_key_first($family['parent_skus']); $this->addGoodsProfitSplitComponents( $unique, $firstParent, $parentSku, $validComponents, $resolvedWarehouse, ); $stats['bundle_stats']['goods_profit_split_groups']++; $stats['bundle_stats']['goods_profit_split_parent_rows'] += $parentRows; $stats['bundle_stats']['goods_profit_split_component_rows'] += count($validComponents); foreach (array_keys($validComponents) as $code) { $stats['bundle_stats']['goods_profit_split_component_codes'][$code] = true; } } } /** * 活动父行平台 SKU 与商品利润组件平台 SKU 不一致时,只在组件集合唯一时替换。 * * @param array> $unique * @param array $stats * @param array> $profiles */ private function applyGoodsProfitCrossSkuSplits(array &$unique, array &$stats, array $profiles): void { $profilesByScope = $this->buildGoodsProfitProfileScopeIndex($profiles); $parentsByScope = []; foreach ($unique as $key => $row) { $source = trim((string) ($row['source'] ?? '')); $orderId = trim((string) ($row['o_id'] ?? '')); $shopSkuId = trim((string) ($row['shop_sku_id'] ?? '')); $shopItemId = trim((string) ($row['shop_i_id'] ?? '')); $sku = mb_strtoupper(trim((string) ($row['sku'] ?? ''))); if ( in_array('', [$source, $orderId, $shopSkuId, $shopItemId, $sku], true) || ! self::isBundleParentRow($row) || (float) ($row['qty'] ?? 0) <= 0 || (float) ($row['amount'] ?? 0) <= 0 || isset($profiles[self::goodsProfitSplitProfileKey($source, $orderId, $shopSkuId, $shopItemId)]) ) { continue; } $scopeKey = $source . "\0" . $orderId . "\0" . $shopItemId; $parentsByScope[$scopeKey]['keys'][] = $key; $parentsByScope[$scopeKey]['skus'][$sku] = true; $parentsByScope[$scopeKey]['amount'] = ($parentsByScope[$scopeKey]['amount'] ?? 0.0) + (float) ($row['amount'] ?? 0); $parentsByScope[$scopeKey]['shop_sku_id'] = $shopSkuId; } foreach ($parentsByScope as $scopeKey => $parentGroup) { if (count($parentGroup['skus']) !== 1 || (float) ($parentGroup['amount'] ?? 0) <= 0) { continue; } $candidates = []; foreach ($profilesByScope[$scopeKey] ?? [] as $candidate) { if ( $candidate['shop_sku_id'] !== $parentGroup['shop_sku_id'] && abs(round((float) $candidate['amount'] - (float) $parentGroup['amount'], 2)) <= 0.01 ) { $candidates[] = $candidate; } } if (count($candidates) !== 1) { continue; } $candidate = $candidates[0]; $componentCodes = array_fill_keys(array_keys($candidate['components']), true); [$source, $orderId, $shopItemId] = explode("\0", $scopeKey); $linkedRows = false; $resolvedWarehouse = ''; foreach ($unique as $row) { if ( trim((string) ($row['source'] ?? '')) !== $source || trim((string) ($row['o_id'] ?? '')) !== $orderId || trim((string) ($row['shop_sku_id'] ?? '')) !== $candidate['shop_sku_id'] || trim((string) ($row['shop_i_id'] ?? '')) !== $shopItemId ) { continue; } if (trim((string) ($row['warehouse'] ?? '')) !== '') { $resolvedWarehouse = trim((string) $row['warehouse']); } $linkedParent = mb_strtoupper(trim((string) ($row['src_combine_sku_id'] ?? ''))); if ( self::isBundleParentRow($row) || self::isChineseBundleParentSku($linkedParent) ) { $linkedRows = true; } } if (! $linkedRows) { continue; } $firstParent = $unique[$parentGroup['keys'][0]]; foreach ($unique as $key => $row) { if ( trim((string) ($row['source'] ?? '')) !== $source || trim((string) ($row['o_id'] ?? '')) !== $orderId || trim((string) ($row['shop_sku_id'] ?? '')) !== $candidate['shop_sku_id'] || trim((string) ($row['shop_i_id'] ?? '')) !== $shopItemId ) { continue; } $sku = mb_strtoupper(trim((string) ($row['sku'] ?? ''))); $linkedParent = mb_strtoupper(trim((string) ($row['src_combine_sku_id'] ?? ''))); if ( self::isBundleParentRow($row) || isset($componentCodes[$sku]) || self::isChineseBundleParentSku($linkedParent) ) { unset($unique[$key]); } } foreach ($parentGroup['keys'] as $parentKey) { unset($unique[$parentKey]); } $parentSku = (string) array_key_first($parentGroup['skus']); $this->addGoodsProfitSplitComponents( $unique, $firstParent, $parentSku, $candidate['components'], $resolvedWarehouse, ); $stats['bundle_stats']['goods_profit_split_groups']++; $stats['bundle_stats']['goods_profit_split_parent_rows'] += count($parentGroup['keys']); $stats['bundle_stats']['goods_profit_split_component_rows'] += count($candidate['components']); foreach (array_keys($candidate['components']) as $code) { $stats['bundle_stats']['goods_profit_split_component_codes'][$code] = true; } } } /** * 商品利润画像全场一致,跨 SKU 候选索引只构建一次,避免每个账单重复扫描。 * * @param array> $profiles * @return array,amount:float}>> */ private function buildGoodsProfitProfileScopeIndex(array $profiles): array { if ($this->goodsProfitProfileScopeIndex !== null) { return $this->goodsProfitProfileScopeIndex; } $index = []; foreach ($profiles as $profileKey => $profile) { $parts = explode("\0", $profileKey); if (count($parts) !== 4 || ! is_array($profile['components'] ?? null)) { continue; } $validComponents = self::validGoodsProfitComponents($profile['components']); if ($validComponents === []) { continue; } $scopeKey = $parts[0] . "\0" . $parts[1] . "\0" . $parts[3]; $index[$scopeKey][] = [ 'shop_sku_id' => $parts[2], 'components' => $validComponents, 'amount' => round((float) array_sum(array_column($validComponents, 'amount')), 2), ]; } return $this->goodsProfitProfileScopeIndex = $index; } /** * @param array $components * @return array */ private static function validGoodsProfitComponents(array $components): array { $valid = []; foreach ($components as $componentCode => $component) { $code = mb_strtoupper(trim((string) $componentCode)); $quantity = (float) ($component['qty'] ?? 0); $amount = (float) ($component['amount'] ?? 0); if ($code === '' || $quantity <= 0 || $amount <= 0) { continue; } $valid[$code] = [ 'qty' => round($quantity, 4), 'amount' => round($amount, 2), ]; } ksort($valid, SORT_STRING); return $valid; } /** * @param array> $unique * @param array $firstParent * @param array $components */ private function addGoodsProfitSplitComponents( array &$unique, array $firstParent, string $parentSku, array $components, string $resolvedWarehouse, ): void { foreach ($components as $code => $component) { $rowKey = (string) ($firstParent['row_key'] ?? $parentSku) . ':goods-profit:' . sha1($parentSku . "\0" . $code); $unique[$rowKey] = array_merge($firstParent, [ 'sku' => $code, 'sku_type' => 'normal', 'base_price' => 0.0, 'item_name' => $code, 'qty' => round((float) $component['qty'], 4), 'amount' => round((float) $component['amount'], 2), 'warehouse' => $resolvedWarehouse, 'parent_o_id' => '', 'row_key' => $rowKey, 'src_combine_sku_id' => '', 'properties_value' => '', 'bundle_component_source' => 'goods_profit_current_order', 'keep_after_leaf_filter' => true, ]); } } /** @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, array $bundleProfiles = []): 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; } } $profileUsed = false; $profileSource = null; $importedProfileKey = self::importedAfterSalesBundleProfileKeyForRow($firstParent, $parentSku); $importedProfile = $importedProfileKey === null ? null : ($bundleProfiles[$importedProfileKey] ?? null); $historyProfileKey = self::historyBundleProfileKeyForRow($firstParent, $parentSku); $historyProfile = $historyProfileKey === null ? null : ($bundleProfiles[$historyProfileKey] ?? null); $profile = is_array($historyProfile) ? $historyProfile : (is_array($importedProfile) ? $importedProfile : null); $profileSource = is_array($historyProfile) ? 'history_profile' : (is_array($importedProfile) ? 'imported_after_sales' : null); if ($children === [] && $parentQuantity > 0 && is_array($profile)) { $children = []; foreach (($profile['components'] ?? []) as $componentCode => $component) { $childKey = $profileSource . ':' . md5($firstParent['row_key'] . "\0" . $parentSku . "\0" . $componentCode); $children[$childKey] = array_merge($firstParent, [ 'sku' => mb_strtoupper((string) $componentCode), 'sku_type' => 'normal', 'base_price' => 0.0, 'item_name' => (string) $componentCode, 'qty' => round((float) ($component['qty'] ?? 0) * $parentQuantity, 4), 'amount' => round((float) ($component['amount'] ?? 0) * $parentQuantity, 2), 'parent_o_id' => $parentOrderId, 'row_key' => (string) $firstParent['row_key'] . ':' . $profileSource . ':' . $componentCode, 'src_combine_sku_id' => $parentSku, 'properties_value' => '', 'bundle_component_source' => $profileSource, ]); } $profileUsed = $children !== []; $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; } } $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; if ($profileUsed && $profileSource === 'imported_after_sales') { $bundleStats['imported_after_sales_split_groups']++; $bundleStats['imported_after_sales_split_parent_rows'] += count($parentKeys); $bundleStats['imported_after_sales_split_component_rows'] += count($children); } elseif ($profileUsed && $profileSource === 'history_profile') { $bundleStats['history_auto_split_groups']++; $bundleStats['history_auto_split_parent_rows'] += count($parentKeys); $bundleStats['history_auto_split_component_rows'] += count($children); } foreach (array_keys($componentCodes) as $componentCode) { $bundleStats['component_codes'][$componentCode] = true; if ($profileUsed && $profileSource === 'imported_after_sales') { $bundleStats['imported_after_sales_component_codes'][$componentCode] = true; } elseif ($profileUsed && $profileSource === 'history_profile') { $bundleStats['history_auto_split_component_codes'][$componentCode] = true; } } unset($bundleStats); if ($profileUsed) { foreach ($children as $childKey => $child) { $unique[$childKey] = $child; } } 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 ($profileUsed && $profileSource === 'imported_after_sales') { $bundleStats['imported_after_sales_amount_mismatch_groups']++; $bundleStats['mismatch_amount'] += abs($difference); } elseif ($profileUsed && $profileSource === 'history_profile') { $bundleStats['history_profile_amount_mismatch_groups']++; $bundleStats['mismatch_amount'] += abs($difference); } elseif ($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' => $profileUsed ? ($profileSource === 'imported_after_sales' ? 'imported_amount_mismatch' : 'history_amount_mismatch') : ($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 $bundleProfiles = [], array $goodsProfitSplits = [], ): 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->applyGoodsProfitProductSplits($unique, $stats, $goodsProfitSplits); // 4) 组合装主行先与拆分组件做金额对账;主行不计成本, // 只有组件金额能与主行金额对平时才剔除主行并保留组件。 $this->resolveBundleParents($unique, $stats, $bundleProfiles); // 非中文「组合」编码继续沿用原有 src_combine_sku_id 父子去重逻辑, // 本次金额回溯不改变 ZH-/MKH- 等结构化组合的既有口径。 $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) { $bundleSku = mb_strtoupper(trim((string) $row['sku'])); if ( self::isChineseBundleParentSku($bundleSku) || ! isset($bundleParents[$row['source'] . "\0" . $row['o_id'] . "\0" . $bundleSku]) ) { continue; } $stats['bundle_parent_excluded_rows']++; $stats['bundle_parent_codes'][$bundleSku] = true; $stats['bundle_parent_code_rows'][$bundleSku] = ($stats['bundle_parent_code_rows'][$bundleSku] ?? 0) + 1; unset($unique[$key]); } } // 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 => ! empty($row['keep_after_leaf_filter']) || ! 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]; } /** * 只刷新商品成本行的仓库简称。 * 仅更新当前映射表能命中的仓库,未映射行保留原值;数量、单价、成本等字段不动。 */ private function remapWarehouseShortNames(string $connection, string $reportMonth, string $platform): int { $mapping = $this->warehouseShortNames()['by_provider']; $base = DB::connection($connection)->table(self::TARGET_TABLE) ->where('report_month', $reportMonth); if ($platform !== '') { $base->where('platform', $platform); } $warehouses = (clone $base) ->whereNotNull('warehouse') ->where('warehouse', '!=', '') ->distinct() ->pluck('warehouse'); $updatedRows = 0; $matchedWarehouses = 0; $unmatchedWarehouses = 0; foreach ($warehouses as $warehouseValue) { $warehouse = trim((string) $warehouseValue); $short = trim((string) ($mapping[$warehouse] ?? '')); if ($short === '') { $unmatchedWarehouses++; continue; } $matchedWarehouses++; $updatedRows += (clone $base) ->where('warehouse', (string) $warehouseValue) ->where(static function ($query) use ($short): void { $query->whereNull('warehouse_short_name') ->orWhere('warehouse_short_name', '!=', $short); }) ->update(['warehouse_short_name' => $short]); } $this->info(sprintf( '仓库简称定向重映射完成:匹配仓库 %d 个,未映射仓库 %d 个,更新成本行 %d 行;其他字段未修改。', $matchedWarehouses, $unmatchedWarehouses, $updatedRows, )); return self::SUCCESS; } /** * 配件- 开头的仓库整仓不核算成本。 * 其余黑名单保留原有 DF- 例外,规则顺序与历史口径一致。 */ private function isExcludedWarehouse(string $warehouse, string $sku): bool { $warehouse = trim($warehouse); foreach (self::EXCLUDED_WAREHOUSE_PREFIXES as $prefix) { if (str_starts_with($warehouse, $prefix)) { return true; } } if (! in_array($warehouse, self::EXCLUDED_WAREHOUSES, true)) { return false; } return ! ( $warehouse === '常州简尚家居有限公司' && str_starts_with(mb_strtoupper(trim($sku)), 'DF-') ); } /** * 映射和组合展开后的正式内部编码若以「-单个数字」结尾,视为配件编码,不核算成本。 * 91. 基础料号及结构化组合沿用各自既有规则,不在此提前排除。 */ private static function isAccessoryNumericSuffixSku(string $sku): bool { $sku = mb_strtoupper(trim($sku)); if ($sku === '' || str_starts_with($sku, '91.') || str_contains($sku, '组合')) { return false; } foreach (self::COMBO_PREFIXES as $prefix) { if (str_starts_with($sku, $prefix)) { return false; } } return preg_match('/-[0-9]$/', $sku) === 1; } /** * 商品月加权平均单价:按「仓库简称 + 商品编码」取加权平均价。 * * @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); } }