CaiWuXiTong/app/Console/Commands/SyncStoreReceiptProductCost.php

3558 lines
160 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Console\Commands;
use App\Business\StoreReceiptPlatformSkuMapper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use InvalidArgumentException;
use Modules\EcommerceFinance\Repositories\AfterSalesCostRowRepository;
use Modules\EcommerceFinance\Repositories\GoodsProfitAfsRepository;
use Modules\EcommerceFinance\Repositories\ProductMaterialBarcodeMappingRepository;
use Throwable;
/**
* 商品成本:以「业绩明细」(店铺收款账户账单 × 订单原始数据)的账单行为基数,
* 按「店铺 + 线上订单号」展开订单侧的商品行,带出发货仓库、仓库简称、商品单价和商品成本。
*
* 发货仓库取订单上的 wms_co_id 对应的聚水潭仓库名称;仓库简称查仓库映射表;
* 商品单价按「仓库简称 + 商品编码」取商品月加权平均单价里的加权平均价;商品成本 = 商品数量 × 商品单价。
*/
class SyncStoreReceiptProductCost extends Command
{
protected $signature = 'store-receipt:product-cost:sync
{month : 账期月份,格式 YYYY-MM}
{--platform= : 只重建某个平台页签,留空表示全部}
{--chunk=1000 : 临时表批量写入行数}
{--remap-warehouses-only : 只按最新仓库映射表刷新仓库简称,不重算其他字段}
{--result-json= : 将结果统计和待核验清单写入指定 JSON 文件}';
protected $description = '展开业绩明细对应的订单商品行,计算商品成本(发货仓库 → 仓库简称 → 商品单价 → 商品成本)';
private const SOURCE_TABLE = 'store_receipt_order_match_rows';
private const TARGET_TABLE = 'store_receipt_product_cost_rows';
private const PRICE_TABLE = 'product_monthly_weighted_average_rows';
private const WAREHOUSE_MAPPING_TABLE = 'business_inventory_warehouse_name_mappings';
/** 虚拟条码(信誉订单/虚拟订单)不是真实商品,不计入商品成本。 */
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',
'FUWU',
'AZF',
'DF-PJZY0001',
'JIAXIANGJIANCAIJIAJUZIZHUANG964A',
'YUNFEI',
'BCJ',
];
/** 组合装条码前缀;条码结构为「前缀-组件条码-数量-组件条码-数量」。 */
private const COMBO_PREFIXES = ['ZH-', 'MKH-'];
/**
* 发货仓库黑名单:这些仓不是实际销售发货仓,整仓不参与商品成本;
* 例外:常州简尚家居有限公司里商品编码以 DF- 开头的行保留。
*/
private const EXCLUDED_WAREHOUSES = [
'常州简尚家居有限公司',
'简尚-苏州福利品仓',
'简尚-自动发货仓',
];
/** 配件仓不是销售商品成本核算范围;整仓订单行在匹配前排除。 */
private const EXCLUDED_WAREHOUSE_PREFIXES = ['配件-'];
private const REMARK_NO_WAREHOUSE = '未匹配仓库简称';
private const REMARK_NO_PRICE = '未匹配商品单价';
/** 单价来源:月加权平均单价(首选)与线上代发价格(兜底)。 */
private const PRICE_SOURCE_WEIGHTED = '月加权平均单价';
private const PRICE_SOURCE_PURCHASE = '线上代发价格-采购单价';
private const PRICE_SOURCE_DING = '线上代发价格-代发单价';
/** 最后兜底:普通商品资料(operations_replica.rpt.jst_sku)里的成本价。 */
private const PRICE_SOURCE_CATALOG = '普通商品资料成本价';
/** 线上代发价格的采购单来源(与「线上代发价格」页面同一口径)。 */
private const PURCHASE_PRICE_TABLE = 'product_purchase_price_mirror_rows';
/** 线上代发价格的钉钉代发来源。 */
private const DING_DAIFA_TABLE = 'ding_online_daifa_price_mirror';
/** @var array<string,list<array{shop_sku_id:string,components:array<string,array{qty:float,amount:float}>,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<string,object> $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<string,list<array<string,mixed>>> $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<int,object>
*/
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<string,list<array<string,mixed>>> $lines
* @param array<string,object> $billByCore
* @return array{
* history:StoreReceiptPlatformSkuMapper,
* afs:array<string,list<string>>
* }
*/
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<string,mixed> $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<string,list<array<string,mixed>>> $lines
* @param array<string,object> $billByCore
* @param array{by_provider:array<string,string>,by_warehouse:array<string,string>} $shortNames
* @param array<string,float> $prices
* @param array<string,array{price:float,source:string}> $fallbackPrices
* @param array<string,float> $catalogPrices
* @return array{
* profiles:array<string,array<string,mixed>>,
* 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<string,mixed> $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<string,list<array<string,mixed>>> $lines
* @param array<string,object> $billByCore
* @return array{profiles:array<string,array<string,mixed>>,summary:array<string,int>}
*/
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<string,list<array<string,mixed>>> $lines
* @param array<string,object> $billByCore
* @return array{profiles:array<string,array<string,mixed>>,summary:array<string,int>}
*/
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<string,list<array<string,mixed>>> $lines
* @return list<string>
*/
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<array<string,mixed>> $lines
* @param array{unique:array<string,string>,ambiguous:array<string,list<string>>,missing:list<string>,source_rows:int} $mappings
* @return array{
* lines:list<array<string,mixed>>,
* 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<array<string,mixed>> $lines
* @return array{
* lines:list<array<string,mixed>>,
* auto_replaced_rows:int,
* afs_replaced_rows:int,
* ambiguous_rows:int,
* missing_rows:int
* }
* @param array<string,list<string>> $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<string,mixed> $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<string,mixed> $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<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' => [],
'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<string,mixed> $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<string,array<string,mixed>> $unique
* @param array<string,mixed> $stats
* @param array<string,array<string,mixed>> $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<string,array<string,mixed>> $unique
* @param array<string,mixed> $stats
* @param array<string,array<string,mixed>> $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<string,array<string,mixed>> $unique
* @param array<string,mixed> $stats
* @param array<string,array<string,mixed>> $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<string,array<string,mixed>> $profiles
* @return array<string,list<array{shop_sku_id:string,components:array<string,array{qty:float,amount:float}>,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<string,mixed> $components
* @return array<string,array{qty:float,amount:float}>
*/
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<string,array<string,mixed>> $unique
* @param array<string,mixed> $firstParent
* @param array<string,array{qty:float,amount:float}> $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<string,mixed> $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<string,array<string,mixed>> $unique
* @param array<string,mixed> $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<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,
?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<array{0:string,1:float,2:bool}>
*/
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<string,string>,by_warehouse:array<string,string>}
*/
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<string,float> 键为「仓库简称\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<string,array{price:float,source:string}>
*/
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<string,string> $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<string,float>
*/
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);
}
}