diff --git a/app/Console/Commands/SyncStoreReceiptProductCost.php b/app/Console/Commands/SyncStoreReceiptProductCost.php new file mode 100644 index 00000000..9fdf037f --- /dev/null +++ b/app/Console/Commands/SyncStoreReceiptProductCost.php @@ -0,0 +1,392 @@ + '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 $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> $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 $rows + * @return list + */ + 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,by_warehouse:array} + */ + 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 键为「仓库简称\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); + } +} diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index ceadf6e5..a21fce10 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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(), diff --git a/database/migrations/2026_10_09_100000_create_store_receipt_product_cost_rows.php b/database/migrations/2026_10_09_100000_create_store_receipt_product_cost_rows.php new file mode 100644 index 00000000..fc43b2fc --- /dev/null +++ b/database/migrations/2026_10_09_100000_create_store_receipt_product_cost_rows.php @@ -0,0 +1,54 @@ +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'); + } +}; diff --git a/resources/views/business/pages/financial-operations/product-costs.blade.php b/resources/views/business/pages/financial-operations/product-costs.blade.php new file mode 100644 index 00000000..22a0fedc --- /dev/null +++ b/resources/views/business/pages/financial-operations/product-costs.blade.php @@ -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 + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + 导出 + 重置 +
+
+ + @if ($error) + + @endif + +
+ 口径:以「业绩明细」的账单行为基数(发生时间 / 店铺名称 / 线上单号),按「店铺 + 线上订单号」展开订单侧商品行, + 一行 = 一个发货仓库的一个商品;发货仓库取订单的聚水潭仓库名称,仓库简称查仓库映射表, + 商品单价按「仓库简称 + 商品编码」取「商品月加权平均单价」里的加权平均价,商品成本 = 商品数量 × 商品单价。 + 拆单子单重复列出的同一批商品只算一次,同一单分多仓发货的按子单实际仓库分行;虚拟条码(X0000x)不计入。 + 仓库映射表里标记为「-不看」的仓库(次品 / 销退 / 虚拟 / 信誉订单 / 京东云仓等)默认不展示, + 需要核对时可以勾选「包含『-不看』仓」。 +
+ +
+
明细行数{{ 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['missing_warehouse_rows'] ?? 0)) }}
+
已隐藏的「-不看」仓行数{{ number_format((int) ($summary['excluded_rows'] ?? 0)) }}
+
+ +
+ + + + + + + + + + + + + + + + @forelse ($rows ?? [] as $row) + + + + + + + + + + + + + + @empty + + @endforelse + +
平台发生时间店铺名称线上单号发货仓库仓库简称商品编码商品数量商品单价商品成本备注
{{ $text($row->platform) }}{{ $text(substr((string) ($row->occurred_date ?? ''), 0, 10)) }}{{ $text($row->store_name) }}{{ $text($row->online_order_no) }}{{ $text($row->warehouse) }}{{ $text($row->warehouse_short_name) }}{{ $text($row->product_code) }}{{ $qty($row->product_quantity) }}{{ $money($row->unit_price) }}{{ $money($row->product_cost) }}{{ $text($row->remark) }}
+ {{ $error ? '镜像读取失败,请稍后重试。' : '暂无符合条件的商品成本数据;生成完成后会自动显示(store-receipt:product-cost:sync)。' }} +
+
+ 当前页 {{ $rows?->count() ?? 0 }} 条 · 第 {{ $rows?->currentPage() ?? 1 }} 页 · 共 {{ $rows?->total() ?? 0 }} 条 + + @if ($rows && ! $rows->onFirstPage())上一页@endif + @if ($rows && $rows->hasMorePages())下一页@endif + +
+
+
diff --git a/routes/admin.php b/routes/admin.php index b67c7cb1..cf627cdb 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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');