商品成本:新增财务经营报表「商品成本」页面与同步命令
以业绩明细的账单行为基数,按「店铺 + 线上订单号」展开订单原始数据的商品行: - 发货仓库取订单 wms_co_id 对应的聚水潭仓库名称,仓库简称查仓库映射表 - 商品单价按「仓库简称 + 商品编码」取商品月加权平均单价里的加权平均价 - 商品成本 = 商品数量 × 商品单价(四舍五入两位小数) - 拆单子单重复列出的同一批商品只算一次;同一单分多仓发货按子单实际仓库分行 - 排除虚拟条码(X0000x)与数量金额都为 0 的占位行;默认隐藏仓库映射表里标「-不看」的仓 页面提供月份、平台、店铺、线上单号、商品编码、仓库简称筛选与汇总、CSV 导出。
This commit is contained in:
parent
41d0e5169f
commit
fe8c271fcf
392
app/Console/Commands/SyncStoreReceiptProductCost.php
Normal file
392
app/Console/Commands/SyncStoreReceiptProductCost.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
@ -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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user