商品成本:排除占位行并固定月加权同步顺序 (#477)

This commit is contained in:
chenxi 2026-10-23 17:27:31 +08:00
parent 1ad248dda6
commit e3bcffd0f7
10 changed files with 429 additions and 38 deletions

View File

@ -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;
}
}

View File

@ -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'];

View File

@ -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),

View File

@ -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 时自动跳过)。

View File

@ -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 时自动跳过)。

View File

@ -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"

View File

@ -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>

View File

@ -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

View File

@ -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 {}

View 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,
];
}
}