商品成本:新增财务经营报表「商品成本」页面与同步命令

以业绩明细的账单行为基数,按「店铺 + 线上订单号」展开订单原始数据的商品行:
- 发货仓库取订单 wms_co_id 对应的聚水潭仓库名称,仓库简称查仓库映射表
- 商品单价按「仓库简称 + 商品编码」取商品月加权平均单价里的加权平均价
- 商品成本 = 商品数量 × 商品单价(四舍五入两位小数)
- 拆单子单重复列出的同一批商品只算一次;同一单分多仓发货按子单实际仓库分行
- 排除虚拟条码(X0000x)与数量金额都为 0 的占位行;默认隐藏仓库映射表里标「-不看」的仓
页面提供月份、平台、店铺、线上单号、商品编码、仓库简称筛选与汇总、CSV 导出。
This commit is contained in:
chenxi 2026-10-09 17:36:11 +08:00
parent 41d0e5169f
commit fe8c271fcf
5 changed files with 870 additions and 0 deletions

View File

@ -0,0 +1,392 @@
<?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);
}
}

View File

@ -171,6 +171,9 @@ class Pages extends Controller
protected const SMALL_PAYMENT_SECTION = 'financial-operations';
/** 商品成本明细的共享镜像表(由 store-receipt:product-cost:sync 生成)。 */
protected const PRODUCT_COST_TABLE = 'store_receipt_product_cost_rows';
protected const ACCOUNT_BALANCE_PAGES = [
'bank-balance',
'store-receiving-balance',
@ -1044,6 +1047,244 @@ class Pages extends Controller
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
/**
* 商品成本:以「业绩明细」的账单行为基数,按「店铺 + 线上订单号」展开订单侧商品行,
* 带出发货仓库、仓库简称(仓库映射表)、商品单价(商品月加权平均单价)与商品成本
* (商品数量 × 商品单价)。
*/
protected function contentForProductCosts(): array
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$table = self::PRODUCT_COST_TABLE;
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$page = max((int) request('page', 1), 1);
$base = static fn () => DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table(self::PRODUCT_COST_TABLE);
try {
$months = cache()->remember(
'product-costs-months-v1',
now()->addMinutes(10),
static fn () => DB::connection($connection)->table($table)
->distinct()->orderByDesc('report_month')->pluck('report_month')
->map(static fn ($value): string => substr((string) $value, 0, 7))->all(),
);
} catch (Throwable) {
$months = [];
}
$monthInput = trim((string) request('month', ''));
$month = preg_match('/^\d{4}-\d{2}$/', $monthInput) === 1
? $monthInput . '-01'
: ($months !== [] ? $months[0] . '-01' : null);
$filters = [
'month' => $month === null ? '' : substr($month, 0, 7),
'platform' => trim((string) request('platform', '')),
'store_name' => trim((string) request('store_name', '')),
'online_order_no' => trim((string) request('online_order_no', '')),
'product_code' => trim((string) request('product_code', '')),
'warehouse_short_name' => trim((string) request('warehouse_short_name', '')),
'missing_price' => request()->boolean('missing_price') ? '1' : '',
'include_excluded' => request()->boolean('include_excluded') ? '1' : '',
];
$empty = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]);
if ($month === null) {
return [
'view' => 'business.pages.financial-operations.product-costs',
'productCostRows' => $empty,
'productCostFilters' => $filters,
'productCostSummary' => [],
'productCostMonths' => [],
'productCostPlatforms' => [],
'productCostWarehouses' => [],
'productCostError' => null,
];
}
$applyFilters = static function ($query, bool $excludedWarehouseOnly = false) use ($filters): void {
$query->where('report_month', $filters['month'] . '-01');
if ($filters['platform'] !== '') {
$query->where('platform', $filters['platform']);
}
foreach (['store_name' => 'store_name', 'online_order_no' => 'online_order_no', 'product_code' => 'product_code'] as $key => $column) {
if ($filters[$key] !== '') {
$query->where($column, 'like', '%' . addcslashes($filters[$key], '%_\\') . '%');
}
}
if ($filters['warehouse_short_name'] !== '') {
$query->where('warehouse_short_name', $filters['warehouse_short_name']);
}
if ($filters['missing_price'] === '1') {
$query->whereNull('unit_price');
}
// 仓库映射表里标「不看」的仓(次品 / 销退 / 虚拟 / 信誉 / 京东云仓等)默认不展示:
// 这些仓在商品月加权平均单价里本来就没有价,展示出来只会让「未匹配单价」虚高。
if ($excludedWarehouseOnly) {
$query->where('warehouse_short_name', 'like', '%-不看%');
return;
}
if ($filters['include_excluded'] !== '1') {
$query->where(static function ($inner): void {
$inner->whereNull('warehouse_short_name')
->orWhere('warehouse_short_name', 'not like', '%-不看%');
});
}
};
$countQuery = $base();
$applyFilters($countQuery);
$total = (int) $countQuery->count();
$excludedQuery = $base();
$applyFilters($excludedQuery, true);
$excludedRows = (int) $excludedQuery->count();
$aggQuery = $base();
$applyFilters($aggQuery);
$agg = $aggQuery->selectRaw('
SUM(CASE WHEN unit_price IS NOT NULL THEN product_quantity ELSE 0 END) as quantity_total,
SUM(CASE WHEN unit_price IS NOT NULL THEN product_cost ELSE 0 END) as cost_total,
SUM(CASE WHEN unit_price IS NULL THEN 1 ELSE 0 END) as missing_price_rows,
SUM(CASE WHEN unit_price IS NULL THEN product_quantity ELSE 0 END) as missing_price_quantity,
SUM(CASE WHEN warehouse_short_name IS NULL THEN 1 ELSE 0 END) as missing_warehouse_rows
')->first();
$options = cache()->remember(
'product-costs-options-v1:' . $filters['month'],
now()->addMinutes(10),
static function () use ($connection, $table, $filters): array {
$maker = static fn () => DB::connection($connection)->table($table)
->where('report_month', $filters['month'] . '-01');
return [
'platforms' => $maker()->whereNotNull('platform')->distinct()->orderBy('platform')->pluck('platform')->all(),
'warehouses' => $maker()->whereNotNull('warehouse_short_name')->where('warehouse_short_name', '!=', '')
->distinct()->orderBy('warehouse_short_name')->pluck('warehouse_short_name')->all(),
];
},
);
$itemsQuery = $base();
$applyFilters($itemsQuery);
$items = $total === 0 ? collect() : $itemsQuery
->orderBy('occurred_date')
->orderBy('store_name')
->orderBy('online_order_no')
->orderBy('warehouse_short_name')
->orderBy('product_code')
->forPage($page, $limit)
->get();
return [
'view' => 'business.pages.financial-operations.product-costs',
'productCostRows' => new LengthAwarePaginator($items, $total, $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]),
'productCostFilters' => $filters,
'productCostSummary' => [
'rows' => $total,
'quantity_total' => round((float) ($agg->quantity_total ?? 0), 2),
'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),
'missing_warehouse_rows' => (int) ($agg->missing_warehouse_rows ?? 0),
'excluded_rows' => $excludedRows,
],
'productCostMonths' => $months,
'productCostPlatforms' => array_values(array_map('strval', $options['platforms'] ?? [])),
'productCostWarehouses' => array_values(array_map('strval', $options['warehouses'] ?? [])),
'productCostError' => null,
];
}
/**
* 商品成本导出:按当前筛选条件流式导出 CSV,列序与页面一致。
*/
public function exportProductCosts(Request $request): StreamedResponse
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$columns = [
['platform', '平台'],
['occurred_date', '发生时间'],
['store_name', '店铺名称'],
['online_order_no', '线上单号'],
['warehouse', '发货仓库'],
['warehouse_short_name', '仓库简称'],
['product_code', '商品编码'],
['product_quantity', '商品数量'],
['unit_price', '商品单价'],
['product_cost', '商品成本'],
['remark', '备注'],
];
$monthInput = trim((string) $request->query('month', ''));
$month = preg_match('/^\d{4}-\d{2}$/', $monthInput) === 1 ? $monthInput . '-01' : null;
$query = DB::connection($connection)->table(self::PRODUCT_COST_TABLE)
->when($month !== null, static fn ($builder) => $builder->where('report_month', $month));
if ($month === null) {
$query->whereRaw('1 = 0');
}
$platform = trim((string) $request->query('platform', ''));
if ($platform !== '') {
$query->where('platform', $platform);
}
foreach (['store_name', 'online_order_no', 'product_code'] as $column) {
$value = trim((string) $request->query($column, ''));
if ($value !== '') {
$query->where($column, 'like', '%' . addcslashes($value, '%_\\') . '%');
}
}
$warehouse = trim((string) $request->query('warehouse_short_name', ''));
if ($warehouse !== '') {
$query->where('warehouse_short_name', $warehouse);
}
if ($request->boolean('missing_price')) {
$query->whereNull('unit_price');
}
if (! $request->boolean('include_excluded')) {
$query->where(static function ($inner): void {
$inner->whereNull('warehouse_short_name')
->orWhere('warehouse_short_name', 'not like', '%-不看%');
});
}
$filename = '商品成本_' . ($month === null ? 'all' : substr($month, 0, 7)) . '.csv';
return response()->streamDownload(function () use ($query, $columns): void {
@set_time_limit(0);
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, array_map(static fn (array $column): string => $column[1], $columns));
$rows = $query->orderBy('occurred_date')->orderBy('store_name')
->orderBy('online_order_no')->orderBy('warehouse_short_name')->orderBy('product_code')
->cursor();
foreach ($rows as $row) {
$line = [];
foreach ($columns as [$key]) {
$value = $row->{$key} ?? '';
if (in_array($key, ['product_quantity', 'unit_price', 'product_cost'], true)) {
$line[] = $value === null || $value === '' ? '' : number_format((float) $value, 2, '.', '');
continue;
}
$line[] = is_string($value) ? $value : (string) $value;
}
fputcsv($out, $line);
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
/**
* 商品月加权平均单价导出:按当前月份与筛选条件导出 CSV,列序跟随页面显示列。
*/
@ -1950,6 +2191,7 @@ class Pages extends Controller
'supplier-payables-report' => $this->contentForSupplierPayablesReport(),
'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(),
'online-daifa-price' => $this->contentForOnlineDaifaPrice(),
'product-costs' => $this->contentForProductCosts(),
'courier-surcharge' => $this->contentForCourierSurcharge(),
'freight-difference', 'other-surcharge-summary', 'return-fee' => $this->contentForCourierPayableSummary($page),
'shipping-data' => $this->contentForShippingData(),

View File

@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 商品成本:以「业绩明细」的账单行为基数,按「店铺 + 线上订单号」展开订单商品行,
* 带出发货仓库、仓库简称(仓库映射表)、商品单价(商品月加权平均单价)和商品成本。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if ($schema->hasTable('store_receipt_product_cost_rows')) {
return;
}
$schema->create('store_receipt_product_cost_rows', function (Blueprint $table): void {
$table->date('report_month');
$table->char('row_key', 64);
$table->string('platform', 64)->nullable();
$table->date('occurred_date')->nullable();
$table->string('store_name', 191)->nullable();
$table->string('online_order_no', 64)->nullable();
$table->string('warehouse', 191)->nullable();
$table->string('warehouse_short_name', 191)->nullable();
$table->string('product_code', 191)->nullable();
$table->decimal('product_quantity', 20, 4)->nullable();
$table->decimal('unit_price', 20, 4)->nullable();
$table->decimal('product_cost', 20, 4)->nullable();
$table->string('remark', 191)->nullable();
$table->dateTime('imported_at');
$table->primary(['report_month', 'row_key'], 'store_receipt_product_cost_primary');
$table->index(['report_month', 'platform'], 'store_receipt_product_cost_platform_index');
$table->index(['report_month', 'store_name'], 'store_receipt_product_cost_store_index');
$table->index(['report_month', 'online_order_no'], 'store_receipt_product_cost_order_index');
$table->index(['report_month', 'warehouse_short_name'], 'store_receipt_product_cost_warehouse_index');
$table->index(['report_month', 'product_code'], 'store_receipt_product_cost_product_index');
});
}
public function down(): void
{
Schema::connection($this->connection())->dropIfExists('store_receipt_product_cost_rows');
}
};

View File

@ -0,0 +1,178 @@
@php
$rows = $productCostRows ?? null;
$filters = $productCostFilters ?? [];
$summary = $productCostSummary ?? [];
$months = $productCostMonths ?? [];
$platforms = $productCostPlatforms ?? [];
$warehouses = $productCostWarehouses ?? [];
$error = $productCostError ?? null;
$money = static fn (mixed $value): string => $value === null ? '-' : number_format((float) $value, 2);
$text = static fn (mixed $value): string => $value === null || $value === '' ? '-' : (string) $value;
$qty = static function (mixed $value): string {
if ($value === null) {
return '-';
}
$formatted = number_format((float) $value, 2, '.', ',');
return str_contains($formatted, '.')
? rtrim(rtrim($formatted, '0'), '.')
: $formatted;
};
@endphp
<div class="product-cost-page">
<style>
.product-cost-page { color: var(--vip-content-ink, #171717); font-size: .8125rem; }
.product-cost-toolbar { display:flex; flex-wrap:wrap; align-items:flex-end; gap:.75rem; padding:1rem; margin-bottom:1rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:var(--vip-surface,#fff); }
.product-cost-field { display:flex; flex-direction:column; gap:.35rem; min-width:12rem; }
.product-cost-field label { color:var(--vip-content-muted,#595959); font-size:.75rem; font-weight:600; }
.product-cost-field input,.product-cost-field select { height:2.25rem; padding:0 .65rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; color:var(--vip-content-ink,#171717); }
.product-cost-field input:focus,.product-cost-field select:focus { border-color:var(--vip-content-accent-dark,#856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
.product-cost-check { display:flex; align-items:center; gap:.4rem; height:2.25rem; color:var(--vip-content-muted,#595959); font-size:.75rem; }
.product-cost-actions { display:flex; gap:.5rem; }
.product-cost-button { display:inline-flex; align-items:center; gap:.35rem; height:2.25rem; padding:0 .85rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; color:var(--vip-content-ink,#171717); text-decoration:none; font-weight:600; }
.product-cost-button.primary { border-color:var(--vip-content-accent-dark,#856632); background:var(--vip-content-accent-dark,#856632); color:#fff; }
.product-cost-alert { padding:.75rem 1rem; margin-bottom:1rem; border:1px solid var(--vip-danger,#B80000); border-radius:.5rem; background:#fff5f5; color:var(--vip-danger,#B80000); }
.product-cost-summary { display:flex; flex-wrap:wrap; gap:1rem; padding:.75rem 1rem; margin-bottom:1rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:var(--vip-content-accent-soft,#F7F0DE); }
.product-cost-summary .item { display:flex; flex-direction:column; gap:.15rem; }
.product-cost-summary .item span { color:var(--vip-content-muted,#595959); font-size:.7rem; }
.product-cost-summary .item b { font-size:.95rem; font-variant-numeric:tabular-nums; }
.product-cost-summary .item.is-warn b { color:var(--vip-danger,#B80000); }
.product-cost-note { margin-bottom:.75rem; color:var(--vip-content-muted,#595959); font-size:.75rem; line-height:1.6; }
.product-cost-table-wrap { overflow:auto; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; }
.product-cost-table { width:100%; min-width:78rem; border-collapse:separate; border-spacing:0; font-size:.75rem; }
.product-cost-table th,.product-cost-table td { padding:.55rem .65rem; border-right:1px solid var(--vip-border,#D8D2C7); border-bottom:1px solid var(--vip-border,#D8D2C7); white-space:nowrap; vertical-align:top; }
.product-cost-table th { position:sticky; top:0; z-index:1; background:var(--vip-content-accent-soft,#F7F0DE); text-align:left; font-weight:700; }
.product-cost-table tr:hover td { background:#fcfaf4; }
.product-cost-table .num { text-align:right; font-variant-numeric:tabular-nums; }
.product-cost-table .missing { color:var(--vip-danger,#B80000); }
.product-cost-empty { padding:4rem 1rem; text-align:center; color:var(--vip-content-muted,#595959); }
.product-cost-pagination { display:flex; justify-content:space-between; padding:.75rem 1rem; color:var(--vip-content-muted,#595959); }
.product-cost-pagination a { color:var(--vip-content-accent-dark,#856632); margin-left:.75rem; }
</style>
<form method="GET" class="product-cost-toolbar" aria-label="商品成本筛选">
<div class="product-cost-field">
<label for="product-cost-month">月份</label>
<select id="product-cost-month" name="month">
@forelse ($months as $option)
<option value="{{ $option }}" @selected(($filters['month'] ?? '') === $option)>{{ $option }}</option>
@empty
<option value="{{ $filters['month'] ?? '' }}">{{ $filters['month'] ?? '暂无数据' }}</option>
@endforelse
</select>
</div>
<div class="product-cost-field">
<label for="product-cost-platform">平台</label>
<select id="product-cost-platform" name="platform">
<option value="">全部</option>
@foreach ($platforms as $option)
<option value="{{ $option }}" @selected(($filters['platform'] ?? '') === $option)>{{ $option }}</option>
@endforeach
</select>
</div>
<div class="product-cost-field">
<label for="product-cost-store">店铺名称</label>
<input id="product-cost-store" name="store_name" value="{{ $filters['store_name'] ?? '' }}" placeholder="支持包含查询">
</div>
<div class="product-cost-field">
<label for="product-cost-order">线上单号</label>
<input id="product-cost-order" name="online_order_no" value="{{ $filters['online_order_no'] ?? '' }}" placeholder="支持包含查询">
</div>
<div class="product-cost-field">
<label for="product-cost-code">商品编码</label>
<input id="product-cost-code" name="product_code" value="{{ $filters['product_code'] ?? '' }}" placeholder="支持包含查询">
</div>
<div class="product-cost-field">
<label for="product-cost-warehouse">仓库简称</label>
<select id="product-cost-warehouse" name="warehouse_short_name">
<option value="">全部</option>
@foreach ($warehouses as $option)
<option value="{{ $option }}" @selected(($filters['warehouse_short_name'] ?? '') === $option)>{{ $option }}</option>
@endforeach
</select>
</div>
<label class="product-cost-check">
<input type="checkbox" name="missing_price" value="1" @checked(($filters['missing_price'] ?? '') === '1')>
只看未匹配商品单价
</label>
<label class="product-cost-check">
<input type="checkbox" name="include_excluded" value="1" @checked(($filters['include_excluded'] ?? '') === '1')>
包含「-不看」仓
</label>
<div class="product-cost-actions">
<button class="product-cost-button primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button>
<a class="product-cost-button" href="{{ route('business.financial-operations.product-costs.export', array_merge(['company_id' => company_id()], request()->except('page'))) }}" aria-label="导出当前筛选结果" title="导出当前筛选结果"><span class="material-icons-outlined" aria-hidden="true">download</span>导出</a>
<a class="product-cost-button" href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span>重置</a>
</div>
</form>
@if ($error)
<div class="product-cost-alert" role="alert">{{ $error }}</div>
@endif
<div class="product-cost-note">
口径:以「业绩明细」的账单行为基数(发生时间 / 店铺名称 / 线上单号),按「店铺 + 线上订单号」展开订单侧商品行,
一行 = 一个发货仓库的一个商品;发货仓库取订单的聚水潭仓库名称,仓库简称查仓库映射表,
商品单价按「仓库简称 + 商品编码」取「商品月加权平均单价」里的加权平均价,商品成本 = 商品数量 × 商品单价。
拆单子单重复列出的同一批商品只算一次,同一单分多仓发货的按子单实际仓库分行;虚拟条码(X0000x)不计入。
仓库映射表里标记为「-不看」的仓库(次品 / 销退 / 虚拟 / 信誉订单 / 京东云仓等)默认不展示,
需要核对时可以勾选「包含『-不看』仓」。
</div>
<div class="product-cost-summary">
<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['missing_warehouse_rows'] ?? 0) > 0 ? 'is-warn' : '' }}"><span>未匹配仓库简称行数</span><b>{{ number_format((int) ($summary['missing_warehouse_rows'] ?? 0)) }}</b></div>
<div class="item"><span>已隐藏的「-不看」仓行数</span><b>{{ number_format((int) ($summary['excluded_rows'] ?? 0)) }}</b></div>
</div>
<div class="product-cost-table-wrap">
<table class="product-cost-table">
<thead><tr>
<th>平台</th>
<th>发生时间</th>
<th>店铺名称</th>
<th>线上单号</th>
<th>发货仓库</th>
<th>仓库简称</th>
<th>商品编码</th>
<th class="num">商品数量</th>
<th class="num">商品单价</th>
<th class="num">商品成本</th>
<th>备注</th>
</tr></thead>
<tbody>
@forelse ($rows ?? [] as $row)
<tr>
<td>{{ $text($row->platform) }}</td>
<td>{{ $text(substr((string) ($row->occurred_date ?? ''), 0, 10)) }}</td>
<td>{{ $text($row->store_name) }}</td>
<td>{{ $text($row->online_order_no) }}</td>
<td>{{ $text($row->warehouse) }}</td>
<td class="{{ ($row->warehouse_short_name ?? '') === '' ? 'missing' : '' }}">{{ $text($row->warehouse_short_name) }}</td>
<td>{{ $text($row->product_code) }}</td>
<td class="num">{{ $qty($row->product_quantity) }}</td>
<td class="num {{ $row->unit_price === null ? 'missing' : '' }}">{{ $money($row->unit_price) }}</td>
<td class="num {{ $row->product_cost === null ? 'missing' : '' }}">{{ $money($row->product_cost) }}</td>
<td>{{ $text($row->remark) }}</td>
</tr>
@empty
<tr><td colspan="11"><div class="product-cost-empty">
{{ $error ? '镜像读取失败,请稍后重试。' : '暂无符合条件的商品成本数据;生成完成后会自动显示(store-receipt:product-cost:sync)。' }}
</div></td></tr>
@endforelse
</tbody>
</table>
<div class="product-cost-pagination">
<span>当前页 {{ $rows?->count() ?? 0 }} 条 · 第 {{ $rows?->currentPage() ?? 1 }} 页 · 共 {{ $rows?->total() ?? 0 }} 条</span>
<span>
@if ($rows && ! $rows->onFirstPage())<a href="{{ $rows->previousPageUrl() }}">上一页</a>@endif
@if ($rows && $rows->hasMorePages())<a href="{{ $rows->nextPageUrl() }}">下一页</a>@endif
</span>
</div>
</div>
</div>

View File

@ -198,6 +198,10 @@ Route::get('workspace/product-inventory/monthly-weighted-average-price/export',
->middleware('business.page:financial-operations,performance-details')
->name('business.financial-operations.performance-details.filter-options');
Route::get('workspace/financial-operations/product-costs/export', 'Business\Pages@exportProductCosts')
->middleware('business.page:financial-operations,product-costs')
->name('business.financial-operations.product-costs.export');
Route::get('workspace/planting-data/jst-order-labels/export', 'Business\Pages@exportJstOrderLabels')
->middleware('business.page:planting-data,jst-order-labels')
->name('business.planting-data.jst-order-labels.export');