3558 lines
160 KiB
PHP
3558 lines
160 KiB
PHP
<?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);
|
||
}
|
||
}
|