diff --git a/app/Console/Commands/SyncStoreReceiptProductCost.php b/app/Console/Commands/SyncStoreReceiptProductCost.php index a2ff7fa7..f4768fd7 100644 --- a/app/Console/Commands/SyncStoreReceiptProductCost.php +++ b/app/Console/Commands/SyncStoreReceiptProductCost.php @@ -38,6 +38,26 @@ class SyncStoreReceiptProductCost extends Command /** 虚拟条码(信誉订单/虚拟订单)不是真实商品,不计入商品成本。 */ private const VIRTUAL_SKU_PREFIX = 'X0000'; + /** 种菜商品不参与商品成本;前缀命中时整行排除,不做正式编码替换。 */ + private const PLANTING_SKU_PREFIX = '种菜'; + + /** 仅这些明确的中文后缀可作为占位后缀,且必须能找到唯一正式兄弟行。 */ + private const PLACEHOLDER_SKU_SUFFIXES = ['非']; + + /** 组合装编码由组合装主行 / 组件规则处理,不参与中文别名清洗。 */ + private const BUNDLE_PLACEHOLDER_PREFIXES = [ + '运营组合套装', + 'MKH-运营组合套装', + '运营自行组合替换', + ]; + + /** 已有人工可读配件编码,保持原样,禁止自动去掉中文。 */ + private const READABLE_ACCESSORY_SKUS = [ + '原木色广岛', + '原木色舒美特实木面1把', + '胡桃色舒美特实木面1把', + ]; + /** 套餐 / 服务及指定配件占位编码:不是实际成本商品,商品成本全局不取数。 */ private const EXCLUDED_SKUS = [ 'TCXJ', @@ -245,8 +265,12 @@ class SyncStoreReceiptProductCost extends Command } } + $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), 'qty' => $qty, @@ -299,11 +323,28 @@ class SyncStoreReceiptProductCost extends Command $bundleParentExcludedRows = 0; $plusParentExcludedRows = 0; $variantParentExcludedRows = 0; + $chineseAliasReplacedRows = 0; + $plantingPrefixExcludedRows = 0; $autoReplacedRows = 0; $afsReplacedRows = 0; $mappingAmbiguousRows = 0; $mappingMissingRows = 0; $pendingCostRows = []; + $placeholderCodeSets = [ + 'chinese_alias_placeholder_codes' => [], + 'chinese_alias_formal_codes' => [], + 'generic_variant_parent_codes' => [], + 'bundle_parent_codes' => [], + 'plus_parent_codes' => [], + 'planting_prefix_codes' => [], + ]; + $placeholderCodeRows = [ + 'chinese_alias_placeholder_code_rows' => [], + 'generic_variant_parent_code_rows' => [], + 'bundle_parent_code_rows' => [], + 'plus_parent_code_rows' => [], + 'planting_prefix_code_rows' => [], + ]; foreach ($billByCore as $core => $bill) { // 展示单号与业绩明细完全一致;$core 只用于订单侧清洗后匹配。 @@ -311,21 +352,25 @@ class SyncStoreReceiptProductCost extends Command if ($displayOrderNo === '') { $displayOrderNo = $core; } - $excludedPlaceholders = 0; - $excludedBundleParents = 0; - $excludedPlusParents = 0; - $excludedVariantParents = 0; - $costLines = $this->costLines( - $lines[$core] ?? [], - $excludedPlaceholders, - $excludedBundleParents, - $excludedPlusParents, - $excludedVariantParents, - ); - $placeholderExcludedRows += $excludedPlaceholders; - $bundleParentExcludedRows += $excludedBundleParents; - $plusParentExcludedRows += $excludedPlusParents; - $variantParentExcludedRows += $excludedVariantParents; + $placeholderStats = []; + $costLines = $this->costLines($lines[$core] ?? [], $placeholderStats); + $placeholderExcludedRows += $placeholderStats['platform_placeholder_excluded_rows']; + $bundleParentExcludedRows += $placeholderStats['bundle_parent_excluded_rows']; + $plusParentExcludedRows += $placeholderStats['plus_parent_excluded_rows']; + $variantParentExcludedRows += $placeholderStats['generic_variant_parent_excluded_rows']; + $chineseAliasReplacedRows += $placeholderStats['chinese_alias_replaced_rows']; + $plantingPrefixExcludedRows += $placeholderStats['planting_prefix_excluded_rows']; + foreach ($placeholderCodeSets as $codeSetName => $_) { + foreach (array_keys($placeholderStats[$codeSetName] ?? []) as $code) { + $placeholderCodeSets[$codeSetName][$code] = true; + } + } + foreach ($placeholderCodeRows as $codeRowsName => $_) { + foreach (($placeholderStats[$codeRowsName] ?? []) as $code => $rowCount) { + $placeholderCodeRows[$codeRowsName][$code] = ($placeholderCodeRows[$codeRowsName][$code] ?? 0) + + (int) $rowCount; + } + } $mapped = $this->mapPlatformSkus( $costLines, $platformSkuMappings['history'], @@ -445,12 +490,14 @@ class SyncStoreReceiptProductCost extends Command )); $pendingCostQuantity = array_sum(array_column($pendingCostRows, 'product_quantity')); $this->info(sprintf( - ' 正常匹配单价:%d 行;排除平台占位行:%d 行;排除组合装主行:%d 行;排除加号组合父行:%d 行;排除变体父行:%d 行;待核验成本:%d 行 / %s 件', + ' 正常匹配单价:%d 行;排除平台占位行:%d 行;排除组合装主行:%d 行;排除加号组合父行:%d 行;排除父子前缀占位行:%d 行;中文别名替换:%d 行;排除种菜前缀:%d 行;待核验成本:%d 行 / %s 件', $matchedPriceRows, $placeholderExcludedRows, $bundleParentExcludedRows, $plusParentExcludedRows, $variantParentExcludedRows, + $chineseAliasReplacedRows, + $plantingPrefixExcludedRows, $missingPriceRows, number_format((float) $pendingCostQuantity, 2), )); @@ -475,8 +522,21 @@ class SyncStoreReceiptProductCost extends Command 'bundle_parent_excluded_rows' => $bundleParentExcludedRows, 'plus_parent_excluded_rows' => $plusParentExcludedRows, 'variant_parent_excluded_rows' => $variantParentExcludedRows, + 'chinese_alias_replaced_rows' => $chineseAliasReplacedRows, + 'planting_prefix_excluded_rows' => $plantingPrefixExcludedRows, 'excluded_rows' => $placeholderExcludedRows + $bundleParentExcludedRows - + $plusParentExcludedRows + $variantParentExcludedRows, + + $plusParentExcludedRows + $variantParentExcludedRows + + $chineseAliasReplacedRows + $plantingPrefixExcludedRows, + 'placeholder_rule_codes' => array_map( + static function (array $codes): array { + $codes = array_keys($codes); + sort($codes); + + return $codes; + }, + $placeholderCodeSets, + ), + 'placeholder_rule_code_rows' => $placeholderCodeRows, 'auto_replaced_platform_sku_rows' => $autoReplacedRows, 'afs_fallback_replaced_platform_sku_rows' => $afsReplacedRows, 'platform_sku_mapping_ambiguous_rows' => $mappingAmbiguousRows, @@ -807,6 +867,113 @@ class SyncStoreReceiptProductCost extends Command . "\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' => [], + ]; + } + private function pendingCodeType(string $code): string { $code = mb_strtoupper(trim($code)); @@ -844,18 +1011,9 @@ class SyncStoreReceiptProductCost extends Command * @param list $rows * @return list */ - private function costLines( - array $rows, - ?int &$placeholderExcludedRows = null, - ?int &$bundleParentExcludedRows = null, - ?int &$plusParentExcludedRows = null, - ?int &$variantParentExcludedRows = null, - ): array + private function costLines(array $rows, ?array &$stats = null): array { - $placeholderExcludedRows = 0; - $bundleParentExcludedRows = 0; - $plusParentExcludedRows = 0; - $variantParentExcludedRows = 0; + $stats = self::initialPlaceholderStats(); if ($rows === []) { return []; } @@ -867,7 +1025,19 @@ class SyncStoreReceiptProductCost extends Command $unique[$key] = $row; } - // 2) 上游有些平台组合会同时返回「+ 拼接父行」和多条组件行。 + // 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) 上游有些平台组合会同时返回「+ 拼接父行」和多条组件行。 // 只有同一订单、outer_oi_id、shop_sku_id 下至少有两条组件,且组件金额 // 合计与父行一致时,父行才是可确认的汇总占位,才允许排除。 $plusParents = []; @@ -892,12 +1062,57 @@ class SyncStoreReceiptProductCost extends Command && $component['rows'] >= 2 && abs(round((float) $component['amount'], 2) - round((float) $unique[$key]['amount'], 2)) <= 0.01 ) { - $plusParentExcludedRows++; + $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]); } } - // 3) 非纯数字父行:同一订单、平台商品、平台规格、子订单和发货仓内, + // 4) 中文占位后缀只允许明确白名单(当前为「非」)。 + // 必须同数据源、平台、店铺、子订单、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]); + } + } + + // 5) 非纯数字父行:同一订单、平台商品、平台规格、子订单和发货仓内, // 如果只有一个严格扩展编码的子商品,且父子数量、金额都一致,则父行是可核对的别名占位。 // 只依赖编码前缀会误删正常的相似商品,因此任一项不一致都保留父行待核验。 $variantGroups = []; @@ -911,7 +1126,7 @@ class SyncStoreReceiptProductCost extends Command foreach ($variantGroups as $groupRows) { foreach ($groupRows as $parentKey => $parent) { $parentSku = mb_strtoupper(trim((string) $parent['sku'])); - if ($parentSku === '' || preg_match('/^\d+$/', $parentSku) === 1) { + if (! self::isGenericVariantCandidate($parentSku)) { continue; } $parentShopSkuId = trim((string) ($parent['shop_sku_id'] ?? '')); @@ -928,6 +1143,12 @@ class SyncStoreReceiptProductCost extends Command 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; } @@ -949,11 +1170,15 @@ class SyncStoreReceiptProductCost extends Command } } foreach (array_keys($variantParents) as $key) { - $variantParentExcludedRows++; + $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]); } - // 4) 组合装拆分后的组件会记录 src_combine_sku_id。源订单上的组合装主行 + // 6) 组合装拆分后的组件会记录 src_combine_sku_id。源订单上的组合装主行 // 只是汇总占位,不能与组件同时计入成本。 $bundleParents = []; foreach ($unique as $row) { @@ -965,13 +1190,17 @@ class SyncStoreReceiptProductCost extends Command if ($bundleParents !== []) { foreach ($unique as $key => $row) { if (isset($bundleParents[$row['source'] . "\0" . $row['o_id'] . "\0" . $row['sku']])) { - $bundleParentExcludedRows++; + $stats['bundle_parent_excluded_rows']++; + $bundleSku = mb_strtoupper(trim((string) $row['sku'])); + $stats['bundle_parent_codes'][$bundleSku] = true; + $stats['bundle_parent_code_rows'][$bundleSku] + = ($stats['bundle_parent_code_rows'][$bundleSku] ?? 0) + 1; unset($unique[$key]); } } } - // 5) 平台原始商品编码通常是一长串数字。同一订单、同一 shop_i_id + // 7) 平台原始商品编码通常是一长串数字。同一订单、同一 shop_i_id // 如果已经有内部商品编码,则数字行只是平台占位,不参与成本。 $internalSiblings = []; foreach ($unique as $row) { @@ -988,13 +1217,13 @@ class SyncStoreReceiptProductCost extends Command && preg_match('/^\d+$/', (string) $row['sku']) === 1 && isset($internalSiblings[$row['source'] . "\0" . $row['o_id'] . "\0" . $shopItemId]) ) { - $placeholderExcludedRows++; + $stats['platform_placeholder_excluded_rows']++; unset($unique[$key]); } } } - // 6) 组合装条码(ZH-/MKH-条码-数量-条码-数量)按子订单拆成组件行: + // 8) 组合装条码(ZH-/MKH-条码-数量-条码-数量)按子订单拆成组件行: // 一个订单有几个发货仓库、商品编码、数量就分开列,组件数量 = 明细数量 × 组件倍数。 // 解析不了的组合条码(条码被截断等)保持原样,避免丢行。 $expanded = []; @@ -1013,7 +1242,7 @@ class SyncStoreReceiptProductCost extends Command return []; } - // 7) 只保留叶子行:o_id 没有被同组其他行的 link_o_id 引用的,才是实际发货行。 + // 9) 只保留叶子行:o_id 没有被同组其他行的 link_o_id 引用的,才是实际发货行。 $parentIds = []; foreach ($unique as $row) { $parent = trim((string) ($row['parent_o_id'] ?? '')); @@ -1032,7 +1261,7 @@ class SyncStoreReceiptProductCost extends Command } } - // 8) 组内只要出现带仓库的行,空仓库行就是汇总/占位行,直接丢弃。 + // 10) 组内只要出现带仓库的行,空仓库行就是汇总/占位行,直接丢弃。 $hasWarehouse = false; foreach ($unique as $row) { if (trim((string) $row['warehouse']) !== '') { @@ -1047,13 +1276,13 @@ class SyncStoreReceiptProductCost extends Command ); } - // 9) 排除已取消行;若带仓库行全部取消,上面已把空仓库汇总行丢掉,这里结果为空 → 整单不计。 + // 11) 排除已取消行;若带仓库行全部取消,上面已把空仓库汇总行丢掉,这里结果为空 → 整单不计。 $unique = array_filter( $unique, static fn (array $row): bool => strtoupper(trim((string) ($row['status'] ?? ''))) !== 'CANCELLED' ); - // 10) 汇总到「发货仓库 + 商品编码」。 + // 12) 汇总到「发货仓库 + 商品编码」。 $aggregated = []; foreach ($unique as $row) { $isPlatformSku = preg_match('/^\d+$/', (string) $row['sku']) === 1; diff --git a/docs/README.md b/docs/README.md index 8657fffb..ca383b45 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,6 +30,7 @@ | [通用上游数据接入](通用上游数据接入.md) | 新来源发现、数据字典、固定只读查询、镜像登记和验收 | | [上游业务镜像清单](上游业务镜像清单.md) | 当前来源、表/视图、镜像表和分阶段迁移状态 | | [电商财务扩展开发说明](ecommerce-finance-development.md) | 订单、结算、成本、资金和利润分析的业务模型与扩展边界 | +| [商品成本占位行规则清单](商品成本占位行规则清单.md) | 商品成本同步的取数顺序、占位行去重、价格兜底和月度验收口径 | | [主体维度经营报表需求说明](主体维度经营报表需求说明.md) | 主体维度经营报表的字段、口径、来源和验收契约 | 上游数据库按只读黑盒处理。正式页面优先读取共享业务镜像;尚未迁移的旧页面才使用兼容快照。不要在网页中接受任意 SQL,也不要把真实业务数据或连接凭据写进文档、测试和截图。 diff --git a/docs/商品成本占位行规则清单.md b/docs/商品成本占位行规则清单.md new file mode 100644 index 00000000..070decef --- /dev/null +++ b/docs/商品成本占位行规则清单.md @@ -0,0 +1,88 @@ +# 商品成本占位行规则清单 + +适用页面:财务经营报表 → 商品成本。 + +适用命令:`store-receipt:product-cost:sync YYYY-MM`。 + +本清单是商品成本同步的固定校验口径。每月重跑时先完成商品月加权平均单价,再执行商品成本同步;同一规则不得在单月任务中临时改变。 + +## 1. 取数与排除顺序 + +1. 业绩明细只取收入金额非空且不为 0 的账单行。 +2. 种菜金额非空,或种菜标签为「种菜报销 / 种菜未报销」的账单行整行排除。 +3. 商品编码为 `TCXJ`、`FUWU`、`AZF`、`DF-PJZY0001`、`JIAXIANGJIANCAIJIAJUZIZHUANG964A`、`YUNFEI`、`BCJ` 的订单行全局排除;组合装拆出的这些编码同样排除。 +4. 商品编码以「种菜」开头的订单行整行排除,不尝试去掉前缀后计价。 +5. 发货仓库为「常州简尚家居有限公司」「简尚-苏州福利品仓」「简尚-自动发货仓」时排除;仅「常州简尚家居有限公司」中以 `DF-` 开头的商品行例外保留。 +6. 完成上述过滤后,再执行占位行去重、组合装展开、叶子行和已取消订单处理。 + +## 2. 占位行处理规则 + +| 类型 | 识别条件 | 自动处理 | 无法唯一确认时 | +| --- | --- | --- | --- | +| 平台数字编码 | 同一订单、同一 `shop_i_id` 已有内部商品编码,当前编码为纯数字 | 数字平台占位行不参与成本 | 没有内部兄弟行时进入待核验 | +| 加号组合父行 | 同一订单、`outer_oi_id`、`shop_sku_id` 下至少两条组件,组件金额合计与父行一致 | 剔除带 `+` 的组合父行,只保留组件 | 保留父行待核验 | +| 中文后缀别名 | 占位编码以白名单后缀结尾,当前白名单仅「非」;必须同数据源、平台、店铺、订单、子订单、`shop_sku_id`、`shop_i_id`、仓库 | 正式编码与占位编码各唯一,且数量和金额完全一致时,保留正式编码、剔除占位行 | 任一关系不唯一或数量、金额不一致,两个编码都保留 | +| 父子前缀占位 | 同一订单内只有一个严格扩展编码的子商品,且平台商品、规格、子订单和仓库一致 | 数量、金额一致时剔除较短父行,保留明细编码,例如 `CTG022516` 类父行 | 多子编码或任一字段不一致时保留父行 | +| 组合装主行 | 组件行的 `src_combine_sku_id` 指向同一订单的源组合装编码 | 剔除源组合装主行,只计拆分后的组件 | 找不到组件时保留主行并进入待核验 | +| 结构化组合条码 | `ZH-`、`MKH-` 开头,并按「组件编码 + 数量」完整解析 | 按组件及倍数展开;一个订单有几个仓库、商品编码和数量就分几行 | 条码无法完整解析时保留原编码 | +| 种菜前缀 | 商品编码以「种菜」开头 | 整行排除 | 不进入替换或价格匹配 | +| 运营组合套装 | 商品编码以「运营组合套装」开头 | 不进入中文后缀或通用父子别名清洗,交给组合装组件规则 | 组件缺失时待核验 | +| MKH-运营组合套装 | 商品编码以 `MKH-运营组合套装` 开头 | 不进入中文后缀或通用父子别名清洗,交给组合装组件规则 | 组件缺失时待核验 | +| 人工可读配件 | `原木色广岛`、`原木色舒美特实木面1把`、`胡桃色舒美特实木面1把` | 保持原编码,按现有价格来源计价 | 无有效价格时待核验,禁止自动删中文 | +| 其他中文别名 | 不属于上述分类,且找不到唯一正式编码或组件关系 | 不改写编码 | 保留原行待核验 | + +中文后缀规则的严格匹配键为: + +```text +数据源 + 平台 + 店铺 + o_id + outer_oi_id + shop_sku_id ++ shop_i_id + 发货仓库 + 数量 + 金额 +``` + +占位编码和正式编码必须各自只有一条命中记录,正式编码还必须唯一存在。禁止按包含关系、相似度或随机候选自动替换。 + +## 3. 价格匹配 + +商品单价按以下顺序匹配: + +1. `仓库简称 + 商品编码` → 商品月加权平均单价; +2. `商品编码` → 线上代发价格,采购单价优先,其次钉钉代发单价; +3. `商品编码` → 普通商品资料成本价。 + +仍无有效单价时,商品成本保留为空,不按 0 计入正常成本,并计入待核验成本。 + +## 4. 每月验收输出 + +同步结果至少输出并核对: + +- 账单行、订单侧命中单号、成本写入行; +- 正常匹配单价行、待核验成本行 / 件数; +- 平台数字占位、加号父行、父子前缀、组合装主行、中文后缀别名、种菜前缀各自的剔除或替换行数; +- 中文别名占位编码和对应正式编码; +- 商品数量合计、已计价成本合计、缺失仓库行、缺失单价行; +- 本月与上一版结果的成本金额、待核验行和中文编码行数变化。 + +可随重跑结果写入 `--result-json` 的字段包括: + +```text +chinese_alias_replaced_rows +planting_prefix_excluded_rows +placeholder_rule_codes +placeholder_rule_code_rows +variant_parent_excluded_rows +bundle_parent_excluded_rows +plus_parent_excluded_rows +pending_cost_rows +pending_cost_quantity +``` + +## 5. 固定回归检查 + +- 「正式编码 + 非」同金额、同数量、同业务键时,结果只保留正式编码。 +- 金额或数量不一致时,占位编码和正式编码都必须保留。 +- 同组出现多条占位行或多个正式候选时,不自动替换。 +- 种菜前缀行不出现于成本结果,也不会被转成去掉前缀的正式编码。 +- 运营组合套装和 `MKH-运营组合套装` 只走组件规则,不进入中文别名清洗。 +- 人工可读配件编码保持原样。 +- 找不到唯一正式编码、仓库或组件关系的行继续进入待核验清单。 + +实现入口:`app/Console/Commands/SyncStoreReceiptProductCost.php`;对应单元测试:`tests/Unit/StoreReceiptProductCostLineTest.php`。 diff --git a/resources/views/business/pages/financial-operations/product-costs.blade.php b/resources/views/business/pages/financial-operations/product-costs.blade.php index f48b3102..a53eea4d 100644 --- a/resources/views/business/pages/financial-operations/product-costs.blade.php +++ b/resources/views/business/pages/financial-operations/product-costs.blade.php @@ -105,7 +105,8 @@
口径:以「业绩明细」的账单行为基数(发生时间 / 店铺名称 / 线上单号),按「店铺 + 线上订单号」展开订单侧商品行, - 只取收入金额有数据且不为 0 的账单行;种菜金额非空,或标签为「种菜报销 / 种菜未报销」的账单行整条排除、不参与匹配; + 只取收入金额有数据且不为 0 的账单行;种菜金额非空、标签为「种菜报销 / 种菜未报销」的账单行, + 以及商品编码以「种菜」开头的订单行整条排除、不参与匹配,也不自动替换成去前缀后的正式编码; 线上单号直接展示业绩明细中的原始格式;匹配订单时仍使用清洗后的核心单号。 一行 = 一个发货仓库的一个商品;发货仓库取订单的聚水潭仓库名称, 仓库简称按仓库映射表用「仓储方」匹配「仓库名含主仓」的那条取简称(没有主仓行时认同名仓库, @@ -122,6 +123,10 @@ 组件缺失或金额合计不一致时保留父行待核验。 非纯数字父编码在同一订单、shop_i_id 和发货仓内,仅有一个严格扩展编码的子商品;outer_oi_id、shop_sku_id 在父子两边都有值时还必须一致,且父子数量、金额均一致时才剔除父行。多子编码或任一项不一致时保留父行待核验。 + 「正式编码 + 非」等白名单占位后缀只在平台、店铺、子订单、平台 SKU、商品 ID、仓库、数量和金额全部一致, + 且正式编码与占位编码各唯一时自动保留正式编码;关系不唯一继续待核验。 + 运营组合套装、MKH-运营组合套装和「运营自行组合替换」不参与中文别名清洗,分别交给组合装组件规则或人工占位核验; + 「原木色广岛」「原木色舒美特实木面1把」「胡桃色舒美特实木面1把」保持原编码计价,禁止自动去掉中文。 套餐/服务、运费及指定配件、组合装占位编码 TCXJ、FUWU、AZF、DF-PJZY0001、 JIAXIANGJIANCAIJIAJUZIZHUANG964A、YUNFEI、BCJ 全局不取数(组合装里拆出的这些编码同样剔除)。 发货仓库默认排除「常州简尚家居有限公司」「简尚-苏州福利品仓」「简尚-自动发货仓」, diff --git a/tests/Unit/StoreReceiptProductCostLineTest.php b/tests/Unit/StoreReceiptProductCostLineTest.php index 27b888f7..067cbf82 100644 --- a/tests/Unit/StoreReceiptProductCostLineTest.php +++ b/tests/Unit/StoreReceiptProductCostLineTest.php @@ -108,6 +108,100 @@ class StoreReceiptProductCostLineTest extends TestCase $this->assertContains('TESTP001N1150V01', array_column($lines, 'sku')); } + public function test_ctg_parent_uses_the_same_generic_placeholder_rule(): void + { + [$lines, , , , $variantParents] = $this->costLines([ + $this->row('ctg-parent', 'CTG022516', '3767501840732782900', 1, 91), + $this->row('ctg-child', 'CTG022516N1150V01', '3767501840732782900', 1, 91), + ]); + + $this->assertSame(1, $variantParents); + $this->assertSame(['CTG022516N1150V01'], array_column($lines, 'sku')); + } + + public function test_strict_chinese_suffix_alias_keeps_the_formal_sku(): void + { + [$lines, , , , $variantParents, $stats] = $this->costLines([ + $this->row('alias-placeholder', 'ZSJ002020N1100V01非', '10034936120452', 1, 39), + $this->row('alias-formal', 'ZSJ002020N1100V01', '10034936120452', 1, 39), + ]); + + $this->assertSame(0, $variantParents); + $this->assertSame(1, $stats['chinese_alias_replaced_rows']); + $this->assertSame(['ZSJ002020N1100V01非'], array_keys($stats['chinese_alias_placeholder_codes'])); + $this->assertSame(['ZSJ002020N1100V01'], array_keys($stats['chinese_alias_formal_codes'])); + $this->assertSame(['ZSJ002020N1100V01'], array_column($lines, 'sku')); + } + + public function test_chinese_suffix_alias_is_not_removed_when_amounts_differ(): void + { + [$lines, , , , $variantParents, $stats] = $this->costLines([ + $this->row('alias-placeholder', 'ZSJ002020N1100V01非', '10034936120452', 1, 39), + $this->row('alias-formal', 'ZSJ002020N1100V01', '10034936120452', 1, 29), + ]); + + $this->assertSame(0, $variantParents); + $this->assertSame(0, $stats['chinese_alias_replaced_rows']); + $this->assertContains('ZSJ002020N1100V01非', array_column($lines, 'sku')); + $this->assertContains('ZSJ002020N1100V01', array_column($lines, 'sku')); + } + + public function test_chinese_suffix_alias_requires_a_unique_formal_relation(): void + { + [$lines, , , , , $stats] = $this->costLines([ + $this->row('alias-placeholder-a', 'ZSJ002020N1100V01非', '10034936120452', 1, 39), + $this->row('alias-placeholder-b', 'ZSJ002020N1100V01非', '10034936120452', 1, 39), + $this->row('alias-formal', 'ZSJ002020N1100V01', '10034936120452', 1, 39), + ]); + + $this->assertSame(0, $stats['chinese_alias_replaced_rows']); + $this->assertContains('ZSJ002020N1100V01非', array_column($lines, 'sku')); + $this->assertContains('ZSJ002020N1100V01', array_column($lines, 'sku')); + } + + public function test_bundle_named_parent_is_not_removed_by_generic_alias_rule(): void + { + [$lines, , , , $variantParents] = $this->costLines([ + $this->row('bundle-parent', '运营组合套装12994', '3767501840732782900', 1, 91), + $this->row('bundle-child', '运营组合套装12994-扩展', '3767501840732782900', 1, 91), + $this->row('mkh-bundle-parent', 'MKH-运营组合套装12994', '3767501840732782901', 1, 91), + $this->row('mkh-bundle-child', 'MKH-运营组合套装12994-扩展', '3767501840732782901', 1, 91), + ]); + + $this->assertSame(0, $variantParents); + $this->assertSame( + [ + '运营组合套装12994', + '运营组合套装12994-扩展', + 'MKH-运营组合套装12994', + 'MKH-运营组合套装12994-扩展', + ], + array_column($lines, 'sku'), + ); + } + + public function test_planting_prefix_rows_are_excluded_before_alias_matching(): void + { + [$lines, , , , , $stats] = $this->costLines([ + $this->row('planting', '种菜GBSFY-SJZ002802N1439V01', '3767501840732782900', 1, 215), + $this->row('formal', 'GBSFY-SJZ002802N1439V01', '3767501840732782900', 1, 215), + ]); + + $this->assertSame(1, $stats['planting_prefix_excluded_rows']); + $this->assertSame(['种菜GBSFY-SJZ002802N1439V01'], array_keys($stats['planting_prefix_codes'])); + $this->assertSame(['GBSFY-SJZ002802N1439V01'], array_column($lines, 'sku')); + } + + public function test_readable_chinese_accessory_code_is_kept_as_is(): void + { + [$lines, , , , , $stats] = $this->costLines([ + $this->row('accessory', '胡桃色舒美特实木面1把', '3767501840732782900', 1, 173), + ]); + + $this->assertSame(0, $stats['chinese_alias_replaced_rows']); + $this->assertSame(['胡桃色舒美特实木面1把'], array_column($lines, 'sku')); + } + public function test_unique_platform_sku_is_replaced_after_line_filtering(): void { [$lines] = $this->costLines([ @@ -251,19 +345,23 @@ class StoreReceiptProductCostLineTest extends TestCase $this->assertSame(552392.0, $result['cost']); } - /** @return array{0:list>,1:int,2:int,3:int,4:int} */ + /** @return array{0:list>,1:int,2:int,3:int,4:int,5:array} */ private function costLines(array $rows): array { $command = new SyncStoreReceiptProductCost(); $method = new ReflectionMethod($command, 'costLines'); $method->setAccessible(true); - $placeholders = 0; - $bundles = 0; - $plusParents = 0; - $variantParents = 0; - $lines = $method->invokeArgs($command, [$rows, &$placeholders, &$bundles, &$plusParents, &$variantParents]); + $stats = []; + $lines = $method->invokeArgs($command, [$rows, &$stats]); - return [$lines, $placeholders, $bundles, $plusParents, $variantParents]; + return [ + $lines, + $stats['platform_placeholder_excluded_rows'], + $stats['bundle_parent_excluded_rows'], + $stats['plus_parent_excluded_rows'], + $stats['generic_variant_parent_excluded_rows'], + $stats, + ]; } /** @return array */ @@ -274,9 +372,12 @@ class StoreReceiptProductCostLineTest extends TestCase float $quantity, float $amount, string $bundleParent = '', + array $overrides = [], ): array { - return [ + return array_replace([ 'source' => 'jst', + 'platform' => '京喜账单', + 'store_name' => '京喜-八方进宝工厂店', 'sku' => $sku, 'qty' => $quantity, 'amount' => $amount, @@ -290,6 +391,6 @@ class StoreReceiptProductCostLineTest extends TestCase 'outer_oi_id' => 'OUTER-TEST-1', 'properties_value' => '颜色分类:六格-90*30*75cm-木纹胡桃色?赠送后背板', 'src_combine_sku_id' => $bundleParent, - ]; + ], $overrides); } }