以业绩明细的账单行为基数,按「店铺 + 线上订单号」展开订单原始数据的商品行: - 发货仓库取订单 wms_co_id 对应的聚水潭仓库名称,仓库简称查仓库映射表 - 商品单价按「仓库简称 + 商品编码」取商品月加权平均单价里的加权平均价 - 商品成本 = 商品数量 × 商品单价(四舍五入两位小数) - 拆单子单重复列出的同一批商品只算一次;同一单分多仓发货按子单实际仓库分行 - 排除虚拟条码(X0000x)与数量金额都为 0 的占位行;默认隐藏仓库映射表里标「-不看」的仓 页面提供月份、平台、店铺、线上单号、商品编码、仓库简称筛选与汇总、CSV 导出。
393 lines
16 KiB
PHP
393 lines
16 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 = '未匹配商品单价';
|
||
|
||
/**
|
||
* 订单侧两个来源:淘系(企微客户订单)与聚水潭订单。
|
||
* 两边的仓库字段不同:淘系明细没有 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);
|
||
|
||
// 账单侧取数:业绩明细里本月(可按平台限定)的账单行,一行 = 一个店铺的一个订单。
|
||
$billQuery = $mirror->table(self::SOURCE_TABLE)->where('report_month', $reportMonth);
|
||
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);
|
||
$this->info('仓库映射条目:' . count($shortNames['by_provider']) . ',月加权单价条目:' . count($prices));
|
||
|
||
$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;
|
||
|
||
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;
|
||
$quantity = round($line['qty'], 4);
|
||
$unitPrice = $price === null ? null : round((float) $price, 2);
|
||
$cost = $unitPrice === null ? null : round($quantity * $unitPrice, 2);
|
||
|
||
$remarks = [];
|
||
if ($short === '') {
|
||
$remarks[] = self::REMARK_NO_WAREHOUSE;
|
||
$missingWarehouseRows++;
|
||
}
|
||
if ($unitPrice === null) {
|
||
$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,
|
||
'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 行', $missingWarehouseRows, $missingPriceRows));
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
/**
|
||
* 拆单去重:聚水潭的拆单子单会把主单的商品行再列一遍,同一批商品只算一次;
|
||
* 同一商品在同一订单里既有真实发货仓库行、又有无仓库的占位行时,丢掉占位行,
|
||
* 这样「同一单分多仓发货」会按子单的实际仓库分行。
|
||
*
|
||
* @param list<array{sku:string,qty:float,amount:float,warehouse:string}> $rows
|
||
* @return list<array{sku:string,qty: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'],
|
||
];
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|