diff --git a/app/Console/Commands/AutoMonthlyWeightedAverageAndProductCost.php b/app/Console/Commands/AutoMonthlyWeightedAverageAndProductCost.php new file mode 100644 index 00000000..382f70ca --- /dev/null +++ b/app/Console/Commands/AutoMonthlyWeightedAverageAndProductCost.php @@ -0,0 +1,133 @@ +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 */ + 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 $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; + } +} diff --git a/app/Console/Commands/SyncStoreReceiptProductCost.php b/app/Console/Commands/SyncStoreReceiptProductCost.php index 75a3848a..9a2dd7d5 100644 --- a/app/Console/Commands/SyncStoreReceiptProductCost.php +++ b/app/Console/Commands/SyncStoreReceiptProductCost.php @@ -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> $lines */ + /** @var array> $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 $rows + * @param list $rows * @return list */ - 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']; diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 2d1372a4..076a1fb6 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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), diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index 68ddd1d5..b2fedfd9 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -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 时自动跳过)。 diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index 6c2d8495..3ce8626d 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -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 时自动跳过)。 diff --git a/deploy/run-finance-batch.sh b/deploy/run-finance-batch.sh index cc623a5a..eb3c5aa7 100644 --- a/deploy/run-finance-batch.sh +++ b/deploy/run-finance-batch.sh @@ -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" diff --git a/resources/views/business/pages/financial-operations/product-costs.blade.php b/resources/views/business/pages/financial-operations/product-costs.blade.php index 3f43c36a..03da87fa 100644 --- a/resources/views/business/pages/financial-operations/product-costs.blade.php +++ b/resources/views/business/pages/financial-operations/product-costs.blade.php @@ -112,6 +112,7 @@ 常州简尚家居有限公司对应映射表里的「配件单仓」行), 商品单价按「仓库简称 + 商品编码」取「商品月加权平均单价」里的加权平均价;匹配不到时退回「线上代发价格」 (采购单价优先、其次钉钉代发单价),仍匹配不到时用「普通商品资料」里的商品成本价兜底;商品成本 = 商品数量 × 商品单价。 + 同一订单下已有内部商品编码时,平台数字商品占位行不参与成本;组合装拆分后,组合装主行不参与成本,只取组件行。 拆单只取最末级订单行(没有任何下级的叶子行),主单和中间汇总层不参与、避免重复计数; 同一单分多仓发货的按末级实际仓库分行;虚拟条码(X0000x)不计入; ZH-、MKH- 开头的组合装按「组件条码 + 数量」拆分,无法解析的组合装保留原编码; @@ -131,8 +132,7 @@
明细行数{{ number_format((int) ($summary['rows'] ?? 0)) }}
商品数量合计(已匹配单价){{ $qty($summary['quantity_total'] ?? null) }}
商品成本合计(已匹配单价){{ $money($summary['cost_total'] ?? null) }}
-
未匹配商品单价行数{{ number_format((int) ($summary['missing_price_rows'] ?? 0)) }}
-
未匹配单价的商品数量{{ $qty($summary['missing_price_quantity'] ?? null) }}
+
待核验成本(空单价,未计入成本合计){{ number_format((int) ($summary['pending_cost_rows'] ?? 0)) }} 行 / {{ $qty($summary['pending_cost_quantity'] ?? null) }} 件
其中用线上代发价格兜底{{ number_format((int) ($summary['fallback_price_rows'] ?? 0)) }}
其中用商品资料成本价兜底{{ number_format((int) ($summary['catalog_price_rows'] ?? 0)) }}
数量与单价口径不一致(不计成本){{ number_format((int) ($summary['unit_mismatch_rows'] ?? 0)) }}
diff --git a/scripts/run-product-monthly-weighted-average-macos.sh b/scripts/run-product-monthly-weighted-average-macos.sh index c728c2ae..47045cc5 100755 --- a/scripts/run-product-monthly-weighted-average-macos.sh +++ b/scripts/run-product-monthly-weighted-average-macos.sh @@ -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 diff --git a/scripts/summarize_product_weighted_average_result.py b/scripts/summarize_product_weighted_average_result.py index b69d670d..e463dfb2 100755 --- a/scripts/summarize_product_weighted_average_result.py +++ b/scripts/summarize_product_weighted_average_result.py @@ -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 {} diff --git a/tests/Unit/StoreReceiptProductCostLineTest.php b/tests/Unit/StoreReceiptProductCostLineTest.php new file mode 100644 index 00000000..c530064f --- /dev/null +++ b/tests/Unit/StoreReceiptProductCostLineTest.php @@ -0,0 +1,80 @@ +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>,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 */ + 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, + ]; + } +}