聚水潭拆单后主单保留原始下单行、子单才是实际发货行;之前按「商品+数量+金额+仓库」去重, 主单 10 件与子单各 1 件键不同,会重复累加。改为:有子单时只取子单(按 o_id+oi_id 去重), 整单没有子单才用主单;再按「发货仓库 + 商品编码」汇总数量。 回归样例 5122123095012119232:31202C 计 10 件(10 个子单各 1 件)、DNZ032916N887V01 计 10 件。
609 lines
27 KiB
PHP
609 lines
27 KiB
PHP
<?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 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 = '线上代发价格-代发单价';
|
||
|
||
/** 线上代发价格的采购单来源(与「线上代发价格」页面同一口径)。 */
|
||
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 的行是补出来的优惠/冲减/无收款账单行,
|
||
// 没有对应的收款商品,不参与商品成本。
|
||
$billQuery = $mirror->table(self::SOURCE_TABLE)
|
||
->where('report_month', $reportMonth)
|
||
->whereNotNull('income_amount')
|
||
->where('income_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 = [];
|
||
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%'"
|
||
);
|
||
} 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 = '';
|
||
}
|
||
}
|
||
// 组合装条码(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);
|
||
$this->info(sprintf(
|
||
'仓库映射条目:%d,月加权单价条目:%d,线上代发价格条目:%d',
|
||
count($shortNames['by_provider']),
|
||
count($prices),
|
||
count($fallbackPrices)
|
||
));
|
||
|
||
$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;
|
||
$mismatchRows = 0;
|
||
|
||
foreach ($billByCore as $core => $bill) {
|
||
foreach ($this->costLines($lines[$core] ?? []) as $line) {
|
||
$short = $shortNames['by_warehouse'][$line['warehouse']]
|
||
?? $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++;
|
||
}
|
||
}
|
||
$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 行',
|
||
$missingWarehouseRows,
|
||
$fallbackPriceRows,
|
||
$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]) {
|
||
$expanded[] = [$code, $quantity * $multiplier, true];
|
||
}
|
||
|
||
return $expanded;
|
||
}
|
||
|
||
/**
|
||
* 仓库映射表:发货仓库 → 仓库简称。
|
||
*
|
||
* 映射表按「仓储方 + 仓库」登记,仓库名带「主仓」的那条代表该仓储方的正式出货仓,
|
||
* 因此优先用它作为发货仓库对应的简称;同时保留「完整仓库名 → 简称」的精确匹配。
|
||
*
|
||
* @return array{by_provider:array<string,string>,by_warehouse:array<string,string>}
|
||
*/
|
||
private function warehouseShortNames(): array
|
||
{
|
||
$byProvider = [];
|
||
$byWarehouse = [];
|
||
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 ($warehouse !== '' && ! isset($byWarehouse[$warehouse])) {
|
||
$byWarehouse[$warehouse] = $short;
|
||
}
|
||
if ($provider === '') {
|
||
continue;
|
||
}
|
||
$isMain = str_contains($warehouse, '主仓');
|
||
if ($isMain || ! isset($byProvider[$provider])) {
|
||
$byProvider[$provider] = $short;
|
||
}
|
||
}
|
||
|
||
return ['by_provider' => $byProvider, 'by_warehouse' => $byWarehouse];
|
||
}
|
||
|
||
/**
|
||
* 商品月加权平均单价:按「仓库简称 + 商品编码」取加权平均价。
|
||
*
|
||
* @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;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|