商品成本:排除占位行并固定月加权同步顺序 (#477)
This commit is contained in:
parent
1ad248dda6
commit
e3bcffd0f7
@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 固定月度顺序:先生成商品月加权平均单价,再同步商品成本表。
|
||||
*/
|
||||
class AutoMonthlyWeightedAverageAndProductCost extends Command
|
||||
{
|
||||
protected $signature = 'finance:monthly-weighted-product-cost:auto
|
||||
{--month= : 指定月份;默认取最新已成功的进销存月份}
|
||||
{--result-json= : 将两阶段结果写入指定 JSON 文件}';
|
||||
|
||||
protected $description = '先计算商品月加权平均单价,再自动同步商品成本表';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$month = $this->targetMonth($connection);
|
||||
if ($month === null) {
|
||||
return $this->writeResult([
|
||||
'status' => 'blocked',
|
||||
'message' => '未找到已成功导入的进销存月份。',
|
||||
]);
|
||||
}
|
||||
|
||||
$weightedPath = tempnam(sys_get_temp_dir(), 'weighted-result-') . '.json';
|
||||
$costPath = tempnam(sys_get_temp_dir(), 'product-cost-result-') . '.json';
|
||||
try {
|
||||
$weightedExit = $this->call('product:monthly-weighted-average:auto', [
|
||||
'--month' => $month->format('Y-m'),
|
||||
'--result-json' => $weightedPath,
|
||||
]);
|
||||
$weighted = $this->readJson($weightedPath);
|
||||
if (
|
||||
$weightedExit !== self::SUCCESS
|
||||
|| in_array($weighted['status'] ?? 'failed', ['blocked', 'failed'], true)
|
||||
) {
|
||||
return $this->writeResult([
|
||||
'status' => 'failed',
|
||||
'report_month' => $month->toDateString(),
|
||||
'message' => '商品月加权平均单价未完成,商品成本未执行。',
|
||||
'weighted_average' => $weighted,
|
||||
'product_cost' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$costExit = $this->call('store-receipt:product-cost:sync', [
|
||||
'month' => $month->format('Y-m'),
|
||||
'--result-json' => $costPath,
|
||||
]);
|
||||
$cost = $this->readJson($costPath);
|
||||
$status = $costExit !== self::SUCCESS
|
||||
? 'failed'
|
||||
: ((int) ($cost['pending_cost_rows'] ?? 0) > 0 ? 'review_required' : 'success');
|
||||
|
||||
return $this->writeResult([
|
||||
'status' => $status,
|
||||
'report_month' => $month->toDateString(),
|
||||
'message' => match ($status) {
|
||||
'success' => '月加权平均单价与商品成本已按顺序完成。',
|
||||
'review_required' => '月加权平均单价与商品成本已完成,存在待核验成本。',
|
||||
default => '商品成本同步失败。',
|
||||
},
|
||||
'weighted_average' => $weighted,
|
||||
'product_cost' => $cost,
|
||||
]);
|
||||
} finally {
|
||||
@unlink($weightedPath);
|
||||
@unlink($costPath);
|
||||
}
|
||||
}
|
||||
|
||||
private function targetMonth(string $connection): ?CarbonImmutable
|
||||
{
|
||||
$requested = trim((string) $this->option('month'));
|
||||
if ($requested !== '') {
|
||||
return CarbonImmutable::parse($requested)->startOfMonth();
|
||||
}
|
||||
|
||||
$latest = DB::connection($connection)->table('inventory_report_authoritative_months')
|
||||
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
|
||||
->where('status', 'succeeded')
|
||||
->whereNotNull('active_generation')
|
||||
->orderByDesc('source_month')
|
||||
->value('source_month');
|
||||
|
||||
return $latest === null ? null : CarbonImmutable::parse((string) $latest)->startOfMonth();
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function readJson(string $path): array
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
return ['status' => 'failed', 'message' => '子任务未生成结果文件。'];
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) file_get_contents($path), true);
|
||||
|
||||
return is_array($decoded)
|
||||
? $decoded
|
||||
: ['status' => 'failed', 'message' => '子任务结果文件无法解析。'];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $result */
|
||||
private function writeResult(array $result): int
|
||||
{
|
||||
$path = trim((string) $this->option('result-json'));
|
||||
if ($path !== '') {
|
||||
File::ensureDirectoryExists(dirname($path));
|
||||
File::put(
|
||||
$path,
|
||||
json_encode(
|
||||
$result,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
|
||||
) . PHP_EOL,
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($result['message'])) {
|
||||
$this->line((string) $result['message']);
|
||||
}
|
||||
|
||||
return in_array($result['status'] ?? 'failed', ['success', 'review_required'], true)
|
||||
? self::SUCCESS
|
||||
: self::FAILURE;
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use InvalidArgumentException;
|
||||
use Throwable;
|
||||
|
||||
@ -19,7 +20,8 @@ class SyncStoreReceiptProductCost extends Command
|
||||
protected $signature = 'store-receipt:product-cost:sync
|
||||
{month : 账期月份,格式 YYYY-MM}
|
||||
{--platform= : 只重建某个平台页签,留空表示全部}
|
||||
{--chunk=1000 : 临时表批量写入行数}';
|
||||
{--chunk=1000 : 临时表批量写入行数}
|
||||
{--result-json= : 将结果统计和待核验清单写入指定 JSON 文件}';
|
||||
|
||||
protected $description = '展开业绩明细对应的订单商品行,计算商品成本(发货仓库 → 仓库简称 → 商品单价 → 商品成本)';
|
||||
|
||||
@ -92,11 +94,13 @@ class SyncStoreReceiptProductCost extends Command
|
||||
*/
|
||||
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)",
|
||||
@ -196,18 +200,20 @@ class SyncStoreReceiptProductCost extends Command
|
||||
}
|
||||
$this->info('订单侧候选单号:' . count($candidates));
|
||||
|
||||
/** @var array<string,list<array{sku:string,qty:float,amount:float,warehouse:string,o_id:string,parent_o_id:string,row_key:string,status:string}>> $lines */
|
||||
/** @var array<string,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,src_combine_sku_id:string}>> $lines */
|
||||
$lines = [];
|
||||
$excludedSkuSql = "'" . implode("','", self::EXCLUDED_SKUS) . "'";
|
||||
foreach (self::SOURCES as $source) {
|
||||
try {
|
||||
$rows = $mirror->select(
|
||||
"SELECT o.so_id as so_id, "
|
||||
"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.qty as qty, i.amount as amount, o.status as status
|
||||
. " i.sku_id as sku_id, i.qty as qty, i.amount as amount, o.status as status, "
|
||||
. " i.shop_i_id as shop_i_id, 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
|
||||
@ -256,24 +262,21 @@ class SyncStoreReceiptProductCost extends Command
|
||||
}
|
||||
}
|
||||
|
||||
// 组合装条码(ZH-/MKH-条码-数量-条码-数量)按子订单拆成组件行:
|
||||
// 一个订单有几个发货仓库、商品编码、数量就分开列,组件数量 = 明细数量 × 组件倍数。
|
||||
// 解析不了的组合条码(条码被截断等)保持原样,避免丢行。
|
||||
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,
|
||||
// 拆单层级: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,
|
||||
];
|
||||
}
|
||||
$lines[$core][] = [
|
||||
'source' => (string) $row->source_key,
|
||||
// 月加权平均单价表里的商品编码统一大写,订单侧可能小写,统一成大写再比。
|
||||
'sku' => mb_strtoupper($sku),
|
||||
'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 ?? '')),
|
||||
'src_combine_sku_id' => mb_strtoupper(trim((string) ($row->src_combine_sku_id ?? ''))),
|
||||
];
|
||||
}
|
||||
}
|
||||
$this->info('订单侧命中核心单号:' . count($lines));
|
||||
@ -305,6 +308,10 @@ class SyncStoreReceiptProductCost extends Command
|
||||
$fallbackPriceRows = 0;
|
||||
$catalogPriceRows = 0;
|
||||
$mismatchRows = 0;
|
||||
$matchedPriceRows = 0;
|
||||
$placeholderExcludedRows = 0;
|
||||
$bundleParentExcludedRows = 0;
|
||||
$pendingCostRows = [];
|
||||
|
||||
foreach ($billByCore as $core => $bill) {
|
||||
// 展示单号与业绩明细完全一致;$core 只用于订单侧清洗后匹配。
|
||||
@ -312,7 +319,17 @@ class SyncStoreReceiptProductCost extends Command
|
||||
if ($displayOrderNo === '') {
|
||||
$displayOrderNo = $core;
|
||||
}
|
||||
foreach ($this->costLines($lines[$core] ?? []) as $line) {
|
||||
$excludedPlaceholders = 0;
|
||||
$excludedBundleParents = 0;
|
||||
$costLines = $this->costLines(
|
||||
$lines[$core] ?? [],
|
||||
$excludedPlaceholders,
|
||||
$excludedBundleParents,
|
||||
);
|
||||
$placeholderExcludedRows += $excludedPlaceholders;
|
||||
$bundleParentExcludedRows += $excludedBundleParents;
|
||||
|
||||
foreach ($costLines 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;
|
||||
@ -363,8 +380,22 @@ class SyncStoreReceiptProductCost extends Command
|
||||
if (! $unitMismatch) {
|
||||
$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'],
|
||||
'product_quantity' => $quantity,
|
||||
'reason' => $short === ''
|
||||
? '未匹配仓库简称'
|
||||
: '仓库简称 + 商品编码在月加权、线上代发、普通商品资料中均无有效单价',
|
||||
'code_type' => $this->pendingCodeType((string) $line['sku']),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
$matchedPriceRows++;
|
||||
$quantityTotal += $quantity;
|
||||
$costTotal += (float) $cost;
|
||||
}
|
||||
@ -411,10 +442,76 @@ class SyncStoreReceiptProductCost extends Command
|
||||
$missingPriceRows,
|
||||
$mismatchRows
|
||||
));
|
||||
$pendingCostQuantity = array_sum(array_column($pendingCostRows, 'product_quantity'));
|
||||
$this->info(sprintf(
|
||||
' 正常匹配单价:%d 行;排除平台占位行:%d 行;排除组合装主行:%d 行;待核验成本:%d 行 / %s 件',
|
||||
$matchedPriceRows,
|
||||
$placeholderExcludedRows,
|
||||
$bundleParentExcludedRows,
|
||||
$missingPriceRows,
|
||||
number_format((float) $pendingCostQuantity, 2),
|
||||
));
|
||||
|
||||
$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,
|
||||
'excluded_rows' => $placeholderExcludedRows + $bundleParentExcludedRows,
|
||||
'pending_cost_rows' => $missingPriceRows,
|
||||
'pending_cost_quantity' => round((float) $pendingCostQuantity, 4),
|
||||
'unit_mismatch_rows' => $mismatchRows,
|
||||
'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;
|
||||
}
|
||||
|
||||
private function pendingCodeType(string $code): string
|
||||
{
|
||||
$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 '内部商品编码缺少有效价格';
|
||||
}
|
||||
|
||||
/**
|
||||
* 拆单取数:聚水潭拆单后,主单/中间汇总单(被其他行 link 到的 o_id)只是汇总记录,
|
||||
* 最末级订单行(没有任何下级)才是实际发货行。
|
||||
@ -427,11 +524,17 @@ class SyncStoreReceiptProductCost extends Command
|
||||
* 如果带仓库的行全部是 Cancelled,则整单不参与商品成本;
|
||||
* 4) 排除 Cancelled 行,同一订单的多条末级行按「发货仓库 + 商品编码」汇总数量。
|
||||
*
|
||||
* @param list<array{sku:string,qty:float,amount:float,warehouse:string,o_id:string,parent_o_id:string,row_key:string,status:string}> $rows
|
||||
* @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,src_combine_sku_id:string}> $rows
|
||||
* @return list<array{sku:string,qty:float,amount:float,warehouse:string}>
|
||||
*/
|
||||
private function costLines(array $rows): array
|
||||
private function costLines(
|
||||
array $rows,
|
||||
?int &$placeholderExcludedRows = null,
|
||||
?int &$bundleParentExcludedRows = null,
|
||||
): array
|
||||
{
|
||||
$placeholderExcludedRows = 0;
|
||||
$bundleParentExcludedRows = 0;
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
@ -439,11 +542,71 @@ class SyncStoreReceiptProductCost extends Command
|
||||
// 1) 先按订单明细主键去掉上游的完全重复行(同一 o_id + oi_id 只算一次)。
|
||||
$unique = [];
|
||||
foreach ($rows as $row) {
|
||||
$key = $row['row_key'] . "\0" . $row['sku'];
|
||||
$key = $row['source'] . "\0" . $row['row_key'] . "\0" . $row['sku'];
|
||||
$unique[$key] = $row;
|
||||
}
|
||||
|
||||
// 2) 只保留叶子行:o_id 没有被同组其他行的 link_o_id 引用的,才是实际发货行。
|
||||
// 2) 组合装拆分后的组件会记录 src_combine_sku_id。源订单上的组合装主行
|
||||
// 只是汇总占位,不能与组件同时计入成本。
|
||||
$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) {
|
||||
if (isset($bundleParents[$row['source'] . "\0" . $row['o_id'] . "\0" . $row['sku']])) {
|
||||
$bundleParentExcludedRows++;
|
||||
unset($unique[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 平台原始商品编码通常是一长串数字。同一订单、同一 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])
|
||||
) {
|
||||
$placeholderExcludedRows++;
|
||||
unset($unique[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 组合装条码(ZH-/MKH-条码-数量-条码-数量)按子订单拆成组件行:
|
||||
// 一个订单有几个发货仓库、商品编码、数量就分开列,组件数量 = 明细数量 × 组件倍数。
|
||||
// 解析不了的组合条码(条码被截断等)保持原样,避免丢行。
|
||||
$expanded = [];
|
||||
foreach ($unique as $row) {
|
||||
foreach (self::expandComboSku((string) $row['sku'], (float) $row['qty']) 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 [];
|
||||
}
|
||||
|
||||
// 5) 只保留叶子行:o_id 没有被同组其他行的 link_o_id 引用的,才是实际发货行。
|
||||
$parentIds = [];
|
||||
foreach ($unique as $row) {
|
||||
$parent = trim((string) ($row['parent_o_id'] ?? ''));
|
||||
@ -462,7 +625,7 @@ class SyncStoreReceiptProductCost extends Command
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 组内只要出现带仓库的行,空仓库行就是汇总/占位行,直接丢弃。
|
||||
// 6) 组内只要出现带仓库的行,空仓库行就是汇总/占位行,直接丢弃。
|
||||
$hasWarehouse = false;
|
||||
foreach ($unique as $row) {
|
||||
if (trim((string) $row['warehouse']) !== '') {
|
||||
@ -477,13 +640,13 @@ class SyncStoreReceiptProductCost extends Command
|
||||
);
|
||||
}
|
||||
|
||||
// 4) 排除已取消行;若带仓库行全部取消,上面已把空仓库汇总行丢掉,这里结果为空 → 整单不计。
|
||||
// 7) 排除已取消行;若带仓库行全部取消,上面已把空仓库汇总行丢掉,这里结果为空 → 整单不计。
|
||||
$unique = array_filter(
|
||||
$unique,
|
||||
static fn (array $row): bool => strtoupper(trim((string) ($row['status'] ?? ''))) !== 'CANCELLED'
|
||||
);
|
||||
|
||||
// 5) 汇总到「发货仓库 + 商品编码」。
|
||||
// 8) 汇总到「发货仓库 + 商品编码」。
|
||||
$aggregated = [];
|
||||
foreach ($unique as $row) {
|
||||
$key = $row['warehouse'] . "\0" . $row['sku'];
|
||||
|
||||
@ -2335,6 +2335,8 @@ class Pages extends Controller
|
||||
'cost_total' => round((float) ($agg->cost_total ?? 0), 2),
|
||||
'missing_price_rows' => (int) ($agg->missing_price_rows ?? 0),
|
||||
'missing_price_quantity' => round((float) ($agg->missing_price_quantity ?? 0), 2),
|
||||
'pending_cost_rows' => (int) ($agg->missing_price_rows ?? 0),
|
||||
'pending_cost_quantity' => round((float) ($agg->missing_price_quantity ?? 0), 2),
|
||||
'missing_warehouse_rows' => (int) ($agg->missing_warehouse_rows ?? 0),
|
||||
'fallback_price_rows' => (int) ($agg->fallback_price_rows ?? 0),
|
||||
'catalog_price_rows' => (int) ($agg->catalog_price_rows ?? 0),
|
||||
|
||||
@ -213,8 +213,8 @@ CRON_TZ=Asia/Shanghai
|
||||
# 镜像价格观测独立保存,按索引分批续传,不替代正式操作历史。
|
||||
3,13,23,33,43,53 * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --source=snapshot --page-size=500 --max-pages=20 --quiet >/dev/null 2>&1
|
||||
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1
|
||||
# 商品月加权平均单价:每天检查最新上游月份,完整且尚未计算时自动生成;历史 computed 月份跳过。
|
||||
0 18 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh product:monthly-weighted-average:auto >/dev/null 2>&1
|
||||
# 商品月加权与成本:先算月加权单价,再同步商品成本,避免成本先于价格生成。
|
||||
0 18 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh finance:monthly-weighted-product-cost:auto >/dev/null 2>&1
|
||||
# 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。
|
||||
30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1
|
||||
# 店铺违规预警推送:P0/P1 每天 09:00 起每 5 分钟检查;P2 每日 10:00 汇总(未配置 Webhook 时自动跳过)。
|
||||
|
||||
@ -217,8 +217,8 @@ CRON_TZ=Asia/Shanghai
|
||||
# 镜像价格观测独立保存,按索引分批续传,不替代正式操作历史。
|
||||
3,13,23,33,43,53 * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --source=snapshot --page-size=500 --max-pages=20 --quiet >/dev/null 2>&1
|
||||
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1
|
||||
# 商品月加权平均单价:每天检查最新上游月份,完整且尚未计算时自动生成;历史 computed 月份跳过。
|
||||
0 18 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh product:monthly-weighted-average:auto >/dev/null 2>&1
|
||||
# 商品月加权与成本:先算月加权单价,再同步商品成本,避免成本先于价格生成。
|
||||
0 18 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh finance:monthly-weighted-product-cost:auto >/dev/null 2>&1
|
||||
# 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。
|
||||
30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1
|
||||
# 店铺违规预警推送:P0/P1 每天 09:00 起每 5 分钟检查;P2 每日 10:00 汇总(未配置 Webhook 时自动跳过)。
|
||||
|
||||
@ -8,7 +8,7 @@ CAIWU_BATCH_CALLER_DIR="$PWD"
|
||||
export CAIWU_BATCH_CALLER_DIR
|
||||
case "${1:-}" in
|
||||
*:import|*:import-*|*:import:*|product:monthly-weighted-average:base) is_import=1 ;;
|
||||
jushuitan:sync-shipping|store-receipt:order-match:sync|store-receipt:product-cost:sync|store-receipt:auto-resync|order-data:warm-shop-candidates|business-mirror:index-work-order-waybills|business-mirror:refresh-paid-sales|account-bills:exports:run|finance-ledger:recalculate|product:monthly-weighted-average:auto|product:monthly-weighted-average:compute|product:monthly-weighted-average:report) ;;
|
||||
jushuitan:sync-shipping|store-receipt:order-match:sync|store-receipt:product-cost:sync|store-receipt:auto-resync|order-data:warm-shop-candidates|business-mirror:index-work-order-waybills|business-mirror:refresh-paid-sales|account-bills:exports:run|finance-ledger:recalculate|product:monthly-weighted-average:auto|product:monthly-weighted-average:compute|product:monthly-weighted-average:report|finance:monthly-weighted-product-cost:auto) ;;
|
||||
*) echo 'Unsupported finance batch command' >&2; exit 64 ;;
|
||||
esac
|
||||
cd "$REMOTE_DIR"
|
||||
|
||||
@ -112,6 +112,7 @@
|
||||
常州简尚家居有限公司对应映射表里的「配件单仓」行),
|
||||
商品单价按「仓库简称 + 商品编码」取「商品月加权平均单价」里的加权平均价;匹配不到时退回「线上代发价格」
|
||||
(采购单价优先、其次钉钉代发单价),仍匹配不到时用「普通商品资料」里的商品成本价兜底;商品成本 = 商品数量 × 商品单价。
|
||||
同一订单下已有内部商品编码时,平台数字商品占位行不参与成本;组合装拆分后,组合装主行不参与成本,只取组件行。
|
||||
拆单只取最末级订单行(没有任何下级的叶子行),主单和中间汇总层不参与、避免重复计数;
|
||||
同一单分多仓发货的按末级实际仓库分行;虚拟条码(X0000x)不计入;
|
||||
ZH-、MKH- 开头的组合装按「组件条码 + 数量」拆分,无法解析的组合装保留原编码;
|
||||
@ -131,8 +132,7 @@
|
||||
<div class="item"><span>明细行数</span><b>{{ number_format((int) ($summary['rows'] ?? 0)) }}</b></div>
|
||||
<div class="item"><span>商品数量合计(已匹配单价)</span><b>{{ $qty($summary['quantity_total'] ?? null) }}</b></div>
|
||||
<div class="item"><span>商品成本合计(已匹配单价)</span><b>{{ $money($summary['cost_total'] ?? null) }}</b></div>
|
||||
<div class="item {{ ($summary['missing_price_rows'] ?? 0) > 0 ? 'is-warn' : '' }}"><span>未匹配商品单价行数</span><b>{{ number_format((int) ($summary['missing_price_rows'] ?? 0)) }}</b></div>
|
||||
<div class="item"><span>未匹配单价的商品数量</span><b>{{ $qty($summary['missing_price_quantity'] ?? null) }}</b></div>
|
||||
<div class="item {{ ($summary['pending_cost_rows'] ?? 0) > 0 ? 'is-warn' : '' }}"><span>待核验成本(空单价,未计入成本合计)</span><b>{{ number_format((int) ($summary['pending_cost_rows'] ?? 0)) }} 行 / {{ $qty($summary['pending_cost_quantity'] ?? null) }} 件</b></div>
|
||||
<div class="item"><span>其中用线上代发价格兜底</span><b>{{ number_format((int) ($summary['fallback_price_rows'] ?? 0)) }}</b></div>
|
||||
<div class="item"><span>其中用商品资料成本价兜底</span><b>{{ number_format((int) ($summary['catalog_price_rows'] ?? 0)) }}</b></div>
|
||||
<div class="item"><span>数量与单价口径不一致(不计成本)</span><b>{{ number_format((int) ($summary['unit_mismatch_rows'] ?? 0)) }}</b></div>
|
||||
|
||||
@ -79,7 +79,7 @@ if ! command -v docker >/dev/null 2>&1; then
|
||||
record_failure '找不到 docker 命令,无法执行商品月加权平均单价。'
|
||||
fi
|
||||
|
||||
args=(php artisan product:monthly-weighted-average:auto)
|
||||
args=(php artisan finance:monthly-weighted-product-cost:auto)
|
||||
if [ -n "$month" ]; then
|
||||
args+=("--month=$month")
|
||||
fi
|
||||
|
||||
@ -16,6 +16,19 @@ def brief(result: dict) -> str:
|
||||
if status in {"blocked", "failed"}:
|
||||
return f"{month or '本月'} 未执行:{result.get('message') or '上游数据不完整'}"
|
||||
|
||||
weighted = result.get("weighted_average")
|
||||
if isinstance(weighted, dict):
|
||||
weighted_brief = brief(weighted)
|
||||
cost = result.get("product_cost") or {}
|
||||
cost_brief = (
|
||||
f"正常匹配 {int(cost.get('normal_matched_rows') or 0):,} 行;"
|
||||
f"排除占位 {int(cost.get('placeholder_excluded_rows') or 0):,} 行;"
|
||||
f"排除组合装主行 {int(cost.get('bundle_parent_excluded_rows') or 0):,} 行;"
|
||||
f"待核验成本 {int(cost.get('pending_cost_rows') or 0):,} 行 / "
|
||||
f"{float(cost.get('pending_cost_quantity') or 0):,.2f} 件"
|
||||
)
|
||||
return f"{weighted_brief};商品成本:{cost_brief}"
|
||||
|
||||
totals = result.get("totals") or {}
|
||||
inbound = result.get("inbound") or {}
|
||||
anomalies = result.get("anomalies") or {}
|
||||
|
||||
80
tests/Unit/StoreReceiptProductCostLineTest.php
Normal file
80
tests/Unit/StoreReceiptProductCostLineTest.php
Normal file
@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Console\Commands\SyncStoreReceiptProductCost;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionMethod;
|
||||
|
||||
class StoreReceiptProductCostLineTest extends TestCase
|
||||
{
|
||||
public function test_platform_placeholder_is_removed_when_internal_sku_exists(): void
|
||||
{
|
||||
[$lines, $placeholders, $bundles] = $this->costLines([
|
||||
$this->row('platform', '955859844397', '3767501840732782900', 20, 2800),
|
||||
$this->row('internal', 'DSG005016N11V01', '3767501840732782900', 20, 2800),
|
||||
]);
|
||||
|
||||
$this->assertSame(1, $placeholders);
|
||||
$this->assertSame(0, $bundles);
|
||||
$this->assertSame('DSG005016N11V01', $lines[0]['sku']);
|
||||
$this->assertSame(20.0, $lines[0]['qty']);
|
||||
}
|
||||
|
||||
public function test_bundle_parent_is_removed_but_component_remains(): void
|
||||
{
|
||||
[$lines, $placeholders, $bundles] = $this->costLines([
|
||||
$this->row('bundle-parent', '运营组合套装12994', '3832980288011632985', 2, 696),
|
||||
$this->row(
|
||||
'bundle-child',
|
||||
'SJ038017N1410V02',
|
||||
'3832980288011632985',
|
||||
2,
|
||||
625.26,
|
||||
'运营组合套装12994',
|
||||
),
|
||||
]);
|
||||
|
||||
$this->assertSame(0, $placeholders);
|
||||
$this->assertSame(1, $bundles);
|
||||
$this->assertSame('SJ038017N1410V02', $lines[0]['sku']);
|
||||
$this->assertSame(2.0, $lines[0]['qty']);
|
||||
}
|
||||
|
||||
/** @return array{0:list<array<string,mixed>>,1:int,2:int} */
|
||||
private function costLines(array $rows): array
|
||||
{
|
||||
$command = new SyncStoreReceiptProductCost();
|
||||
$method = new ReflectionMethod($command, 'costLines');
|
||||
$method->setAccessible(true);
|
||||
$placeholders = 0;
|
||||
$bundles = 0;
|
||||
$lines = $method->invokeArgs($command, [$rows, &$placeholders, &$bundles]);
|
||||
|
||||
return [$lines, $placeholders, $bundles];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function row(
|
||||
string $rowKey,
|
||||
string $sku,
|
||||
string $shopItemId,
|
||||
float $quantity,
|
||||
float $amount,
|
||||
string $bundleParent = '',
|
||||
): array {
|
||||
return [
|
||||
'source' => 'jst',
|
||||
'sku' => $sku,
|
||||
'qty' => $quantity,
|
||||
'amount' => $amount,
|
||||
'warehouse' => '简尚-新苏北仓',
|
||||
'o_id' => '8986587',
|
||||
'parent_o_id' => '',
|
||||
'row_key' => $rowKey,
|
||||
'status' => 'Sent',
|
||||
'shop_i_id' => $shopItemId,
|
||||
'src_combine_sku_id' => $bundleParent,
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user