商品成本:统一占位行规则并排除种菜前缀 #522
@ -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;
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
| [通用上游数据接入](通用上游数据接入.md) | 新来源发现、数据字典、固定只读查询、镜像登记和验收 |
|
||||
| [上游业务镜像清单](上游业务镜像清单.md) | 当前来源、表/视图、镜像表和分阶段迁移状态 |
|
||||
| [电商财务扩展开发说明](ecommerce-finance-development.md) | 订单、结算、成本、资金和利润分析的业务模型与扩展边界 |
|
||||
| [商品成本占位行规则清单](商品成本占位行规则清单.md) | 商品成本同步的取数顺序、占位行去重、价格兜底和月度验收口径 |
|
||||
| [主体维度经营报表需求说明](主体维度经营报表需求说明.md) | 主体维度经营报表的字段、口径、来源和验收契约 |
|
||||
|
||||
上游数据库按只读黑盒处理。正式页面优先读取共享业务镜像;尚未迁移的旧页面才使用兼容快照。不要在网页中接受任意 SQL,也不要把真实业务数据或连接凭据写进文档、测试和截图。
|
||||
|
||||
88
docs/商品成本占位行规则清单.md
Normal file
88
docs/商品成本占位行规则清单.md
Normal 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`。
|
||||
@ -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 全局不取数(组合装里拆出的这些编码同样剔除)。
|
||||
发货仓库默认排除「常州简尚家居有限公司」「简尚-苏州福利品仓」「简尚-自动发货仓」,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user