CaiWuXiTong/app/Console/Commands/SyncStoreReceiptProductCost.php

696 lines
31 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

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

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Throwable;
/**
* 商品成本:以「业绩明细」(店铺收款账户账单 × 订单原始数据)的账单行为基数,
* 按「店铺 + 线上订单号」展开订单侧的商品行,带出发货仓库、仓库简称、商品单价和商品成本。
*
* 发货仓库取订单上的 wms_co_id 对应的聚水潭仓库名称;仓库简称查仓库映射表;
* 商品单价按「仓库简称 + 商品编码」取商品月加权平均单价里的加权平均价;商品成本 = 商品数量 × 商品单价。
*/
class SyncStoreReceiptProductCost extends Command
{
protected $signature = 'store-receipt:product-cost:sync
{month : 账期月份,格式 YYYY-MM}
{--platform= : 只重建某个平台页签,留空表示全部}
{--chunk=1000 : 临时表批量写入行数}';
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 EXCLUDED_SKUS = ['TCXJ', 'FUWU'];
/**
* 发货仓库黑名单:这些仓不是实际销售发货仓,整仓不参与商品成本;
* 例外:常州简尚家居有限公司里商品编码以 DF- 开头的行保留。
*/
private const EXCLUDED_WAREHOUSES = [
'常州简尚家居有限公司',
'简尚-苏州福利品仓',
'简尚-自动发货仓',
];
private const REMARK_NO_WAREHOUSE = '未匹配仓库简称';
private const REMARK_NO_PRICE = '未匹配商品单价';
/**
* 数量单位与单价口径不一致:定制配件这类明细行,聚水潭把配件尺寸/件数写进了「商品数量」
* (例如数量 9524、金额 2000.04,实际单价 0.21),再乘成品的月加权/代发单价会放大几百倍。
* 成本单价高于该行明细单价 3 倍以上时判定为口径不一致,成本留空不计入合计。
*/
private const REMARK_UNIT_MISMATCH = '数量与单价口径不一致';
private const UNIT_MISMATCH_FACTOR = 3.0;
/** 单价来源:月加权平均单价(首选)与线上代发价格(兜底)。 */
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';
/**
* 订单侧两个来源:淘系(企微客户订单)与聚水潭订单。
* 两边的仓库字段不同:淘系明细没有 send_warehouse,用订单上的 wms_co_id;
* 聚水潭优先取明细行的 send_warehouse,为空时退回订单的 wms_co_id(与「订单原始数据」一致)。
*/
private const SOURCES = [
[
'orders' => 'ops_jst_qmcust_orders',
'items' => 'ops_jst_qmcust_order_items',
'warehouse' => 'o.wms_co_id',
],
[
'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);
// 账单侧取数:业绩明细里本月(可按平台限定)的账单行,一行 = 一个店铺的一个订单。
// 只取「收入金额有数据且不为 0」的账单行:收入为 0 的行是补出来的优惠/冲减/无收款账单行,
// 没有对应的收款商品,不参与商品成本。
// 「种菜金额」有数据(>0)的账单行整条排除,不参与匹配。
$billQuery = $mirror->table(self::SOURCE_TABLE)
->where('report_month', $reportMonth)
->whereNotNull('income_amount')
->where('income_amount', '!=', 0)
->where(static function ($query): void {
$query->whereNull('planting_amount')
->orWhere('planting_amount', '<=', 0);
});
if ($platform !== '') {
$billQuery->where('platform', $platform);
}
$bills = $billQuery->get([
'platform', 'store_name', 'order_core_no', 'online_order_no', 'occurred_date',
]);
$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{sku:string,qty:float,amount:float,warehouse:string,is_main:bool,row_key:string}>> $lines */
$lines = [];
$excludedSkuSql = "'" . implode("','", self::EXCLUDED_SKUS) . "'";
foreach (self::SOURCES as $source) {
try {
$rows = $mirror->select(
"SELECT o.so_id as so_id, "
. $source['warehouse'] . " as warehouse_code, "
. " COALESCE(wh.name, '') as warehouse_name, "
. " CASE WHEN o.link_o_id IS NULL OR o.link_o_id = 0 THEN 1 ELSE 0 END as is_main, "
. " CONCAT(o.o_id, '-', i.oi_id) as row_key, "
. " i.sku_id as sku_id, i.qty as qty, i.amount as amount
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) {
continue;
}
$warehouse = trim((string) $row->warehouse_name);
if ($warehouse === '') {
$warehouse = trim((string) ($row->warehouse_code ?? ''));
// 订单上的 wms_co_id 为 0 表示没有仓库信息,不能当成仓库名。
if ($warehouse === '0') {
$warehouse = '';
}
}
// 发货仓库过滤(在 TCXJ/FU WU 之后执行):
// 默认排除常州简尚家居有限公司 / 简尚-苏州福利品仓 / 简尚-自动发货仓;
// 例外:常州简尚家居有限公司里「DF-」开头的商品行保留。
if (in_array($warehouse, self::EXCLUDED_WAREHOUSES, true)) {
$isDfException = $warehouse === '常州简尚家居有限公司'
&& str_starts_with(mb_strtoupper($sku), 'DF-');
if (! $isDfException) {
continue;
}
}
// 组合装条码(ZH-条码-数量-条码-数量)按子订单拆成组件行:
// 一个订单有几个发货仓库、商品编码、数量就分开列,组件数量 = 明细数量 × 组件倍数。
// 解析不了的组合条码(条码被截断等)保持原样,避免丢行。
foreach (self::expandComboSku($sku, $qty) as [$code, $codeQty, $isComponent]) {
$lines[$core][] = [
// 月加权平均单价表里的商品编码统一大写,订单侧可能小写,统一成大写再比。
'sku' => mb_strtoupper($code),
'qty' => $codeQty,
// 组合装明细金额对应整包,拆到组件后不再参与「数量×单价 vs 明细金额」校验。
'amount' => $isComponent ? 0.0 : $amount,
'warehouse' => $warehouse,
// 拆单后子单(link_o_id 非空)才是实际发货行;主单只是原始下单记录。
'is_main' => (int) $row->is_main === 1,
'row_key' => (string) $row->row_key,
];
}
}
}
$this->info('订单侧命中核心单号:' . count($lines));
$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)
));
$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;
$mismatchRows = 0;
foreach ($billByCore as $core => $bill) {
foreach ($this->costLines($lines[$core] ?? []) as $line) {
$short = $shortNames['by_provider'][$line['warehouse']] ?? '';
$price = $short !== '' ? ($prices[$short . "\0" . $line['sku']] ?? null) : null;
$priceSource = $price === null ? null : self::PRICE_SOURCE_WEIGHTED;
// 月加权平均单价按「仓库简称 + 商品编码」匹配不到时,退回线上代发价格(按商品编码)。
if ($price === null) {
$fallback = $fallbackPrices[$line['sku']] ?? null;
if ($fallback !== null) {
$price = $fallback['price'];
$priceSource = $fallback['source'];
$fallbackPriceRows++;
}
}
// 线上代发价格也没有时,最后用普通商品资料的商品成本价兜底。
if ($price === null) {
$catalogPrice = $catalogPrices[$line['sku']] ?? null;
if ($catalogPrice !== null) {
$price = $catalogPrice;
$priceSource = self::PRICE_SOURCE_CATALOG;
$catalogPriceRows++;
}
}
$quantity = round($line['qty'], 4);
$unitPrice = $price === null ? null : round((float) $price, 2);
$cost = $unitPrice === null ? null : round($quantity * $unitPrice, 2);
// 数量单位与单价口径校验:该行明细单价 = 明细金额 ÷ 数量。
// 成本单价高于明细单价 3 倍以上说明两边单位不是一回事(定制配件行),成本留空。
$detailUnitPrice = $quantity > 0 ? (float) $line['amount'] / $quantity : 0.0;
$unitMismatch = $unitPrice !== null
&& $detailUnitPrice > 0
&& $unitPrice > $detailUnitPrice * self::UNIT_MISMATCH_FACTOR;
if ($unitMismatch) {
$cost = null;
$unitPrice = null;
$priceSource = null;
}
$remarks = [];
if ($unitMismatch) {
$remarks[] = self::REMARK_UNIT_MISMATCH;
$mismatchRows++;
}
if ($short === '') {
$remarks[] = self::REMARK_NO_WAREHOUSE;
$missingWarehouseRows++;
}
if ($unitPrice === null) {
if (! $unitMismatch) {
$remarks[] = self::REMARK_NO_PRICE;
$missingPriceRows++;
}
} else {
$quantityTotal += $quantity;
$costTotal += (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' => $core,
'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 行;数量与单价口径不一致(不计成本):%d 行',
$missingWarehouseRows,
$fallbackPriceRows,
$catalogPriceRows,
$missingPriceRows,
$mismatchRows
));
return self::SUCCESS;
}
/**
* 拆单取数:聚水潭拆单后,主单(link_o_id 为空)保留的是原始下单行,
* 子单(link_o_id 指向主单)才是实际发货行。一个订单只要出现子单,就只取子单,
* 否则主单和子单会被各算一遍(主单的原始商品 + 子单的实际发货商品)。
* 没有子单的订单才用主单行。
*
* 同一订单的多条子单(不同仓库 / 分批发货)按「发货仓库 + 商品编码」汇总,数量相加,
* 这样「一个订单有几个发货仓库、商品编码、数量」会分开列。
*
* @param list<array{sku:string,qty:float,amount:float,warehouse:string,is_main:bool,row_key:string}> $rows
* @return list<array{sku:string,qty:float,amount:float,warehouse:string}>
*/
private function costLines(array $rows): array
{
if ($rows === []) {
return [];
}
// 1) 先按订单明细主键去掉上游的完全重复行(同一 o_id + oi_id 只算一次)。
$unique = [];
foreach ($rows as $row) {
$key = $row['row_key'] . "\0" . $row['sku'];
$unique[$key] = $row;
}
// 2) 有子单就只取子单(实际发货行);整单没有子单才用主单行。
$hasSubOrder = false;
foreach ($unique as $row) {
if (! $row['is_main']) {
$hasSubOrder = true;
break;
}
}
if ($hasSubOrder) {
$unique = array_filter($unique, static fn (array $row): bool => ! $row['is_main']);
}
// 3) 汇总到「发货仓库 + 商品编码」。
$aggregated = [];
foreach ($unique as $row) {
$key = $row['warehouse'] . "\0" . $row['sku'];
$aggregated[$key] = [
'sku' => $row['sku'],
'warehouse' => $row['warehouse'],
'qty' => ($aggregated[$key]['qty'] ?? 0.0) + $row['qty'],
'amount' => ($aggregated[$key]['amount'] ?? 0.0) + $row['amount'],
];
}
return array_values($aggregated);
}
/**
* 组合装条码拆组件:聚水潭的组合装 sku 形如
* ZH-<条码>-<数量>[-<条码>-<数量>...]
* 条码本身含横杠,所以按「纯数字段」识别数量,再按组件展开。
*
* 返回 [条码, 数量, 是否组件];非组合装或解析失败时返回原条码 / 原数量。
*
* @return list<array{0:string,1:float,2:bool}>
*/
private static function expandComboSku(string $sku, float $quantity): array
{
if (! str_starts_with(mb_strtoupper($sku), 'ZH-')) {
return [[$sku, $quantity, false]];
}
$parts = explode('-', substr($sku, 3));
$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];
}
/**
* 商品月加权平均单价:按「仓库简称 + 商品编码」取加权平均价。
*
* @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);
}
}