商品成本:统一占位行规则并排除种菜前缀 #522

Merged
chenxi merged 1 commits from fix/chenxi-product-cost-placeholder-rules into main 2026-10-24 20:09:27 +08:00
5 changed files with 476 additions and 52 deletions

View File

@ -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<string,mixed> $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<string,int|array<string,true>> */
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<array{source:string,sku:string,qty:float,amount:float,warehouse:string,o_id:string,parent_o_id:string,row_key:string,status:string,shop_i_id:string,shop_sku_id:string,outer_oi_id:string,properties_value:string,src_combine_sku_id:string}> $rows
* @return list<array{sku:string,qty:float,amount:float,warehouse:string,shop_i_id:string,shop_sku_id:string,properties_value:string}>
*/
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;

View File

@ -30,6 +30,7 @@
| [通用上游数据接入](通用上游数据接入.md) | 新来源发现、数据字典、固定只读查询、镜像登记和验收 |
| [上游业务镜像清单](上游业务镜像清单.md) | 当前来源、表/视图、镜像表和分阶段迁移状态 |
| [电商财务扩展开发说明](ecommerce-finance-development.md) | 订单、结算、成本、资金和利润分析的业务模型与扩展边界 |
| [商品成本占位行规则清单](商品成本占位行规则清单.md) | 商品成本同步的取数顺序、占位行去重、价格兜底和月度验收口径 |
| [主体维度经营报表需求说明](主体维度经营报表需求说明.md) | 主体维度经营报表的字段、口径、来源和验收契约 |
上游数据库按只读黑盒处理。正式页面优先读取共享业务镜像;尚未迁移的旧页面才使用兼容快照。不要在网页中接受任意 SQL,也不要把真实业务数据或连接凭据写进文档、测试和截图。

View File

@ -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`。

View File

@ -105,7 +105,8 @@
<div class="product-cost-note">
口径:以「业绩明细」的账单行为基数(发生时间 / 店铺名称 / 线上单号),按「店铺 + 线上订单号」展开订单侧商品行,
只取收入金额有数据且不为 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 全局不取数(组合装里拆出的这些编码同样剔除)。
发货仓库默认排除「常州简尚家居有限公司」「简尚-苏州福利品仓」「简尚-自动发货仓」,

View File

@ -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<array<string,mixed>>,1:int,2:int,3:int,4:int} */
/** @return array{0:list<array<string,mixed>>,1:int,2:int,3:int,4:int,5:array<string,mixed>} */
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<string,mixed> */
@ -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);
}
}