CaiWuXiTong/app/Console/Commands/SyncStoreReceiptProductCost.php
chenxi 5b89692318 商品成本:收入为 0 的账单行不取数、单价加线上代发价格兜底
- 账单侧只取收入金额有数据且不为 0 的业绩明细账单行(收入为 0 的是补出来的优惠/冲减/无收款账单行)
- 商品单价按「仓库简称 + 商品编码」取月加权平均价,匹配不到时退回「线上代发价格」
  (采购单管理的采购单价优先,其次钉钉线上代发的代发订单金额 ÷ 发货数量),并记录 price_source
- 新增数量与单价口径校验:定制配件行把配件件数写进商品数量,乘成品单价会放大几百倍,
  成本单价高于该行明细单价 3 倍时成本留空、标注「数量与单价口径不一致」,不计入合计
- 页面与导出列改为业务口径的 9 列(发生时间/店铺名称/线上单号/发货仓库/仓库简称/商品编码/商品数量/商品单价/商品成本),
  汇总卡片新增「线上代发价格兜底」「数量与单价口径不一致」两项
2026-10-16 17:34:06 +08:00

552 lines
24 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 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}>> $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, "
. " 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 = '';
}
}
$lines[$core][] = [
// 月加权平均单价表里的商品编码统一大写,订单侧可能小写,统一成大写再比。
'sku' => mb_strtoupper($sku),
'qty' => $qty,
'amount' => $amount,
'warehouse' => $warehouse,
];
}
}
$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;
}
/**
* 拆单去重:聚水潭的拆单子单会把主单的商品行再列一遍,同一批商品只算一次;
* 同一商品在同一订单里既有真实发货仓库行、又有无仓库的占位行时,丢掉占位行,
* 这样「同一单分多仓发货」会按子单的实际仓库分行。
*
* @param list<array{sku:string,qty:float,amount:float,warehouse:string}> $rows
* @return list<array{sku:string,qty:float,amount:float,warehouse:string}>
*/
private function costLines(array $rows): array
{
if ($rows === []) {
return [];
}
// 1) 完全相同的行(商品 + 数量 + 金额 + 仓库)只保留一条。
$unique = [];
foreach ($rows as $row) {
$key = $row['sku'] . "\0" . $row['qty'] . "\0" . $row['amount'] . "\0" . $row['warehouse'];
$unique[$key] = $row;
}
// 2) 有真实仓库的同一(商品 + 数量 + 金额),优先于无仓库的占位行。
$withWarehouse = [];
foreach ($unique as $row) {
if ($row['warehouse'] !== '') {
$withWarehouse[$row['sku'] . "\0" . $row['qty'] . "\0" . $row['amount']] = true;
}
}
foreach ($unique as $key => $row) {
if ($row['warehouse'] === ''
&& isset($withWarehouse[$row['sku'] . "\0" . $row['qty'] . "\0" . $row['amount']])) {
unset($unique[$key]);
}
}
// 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);
}
/**
* 仓库映射表:发货仓库 → 仓库简称。
*
* 映射表按「仓储方 + 仓库」登记,仓库名带「主仓」的那条代表该仓储方的正式出货仓,
* 因此优先用它作为发货仓库对应的简称;同时保留「完整仓库名 → 简称」的精确匹配。
*
* @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);
}
}