From 6fd059ae9843e2040a67a54ea35e7872b0f7e372 Mon Sep 17 00:00:00 2001 From: chenxi Date: Sun, 25 Oct 2026 19:03:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=95=86=E5=93=81=E6=88=90=E6=9C=AC=EF=BC=9A?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=BE=85=E6=A0=B8=E9=AA=8C=E6=98=8E=E7=BB=86?= =?UTF-8?q?=E5=BC=B9=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 待核验成本卡片增加明细弹框,按当前筛选分页展示单价或仓库未匹配行,修复后刷新即可移除。 --- app/Http/Controllers/Business/Pages.php | 130 ++++- .../product-costs.blade.php | 530 +++++++++++++++++- routes/admin.php | 4 + .../Business/ProductCostPageLayoutTest.php | 4 + .../ProductCostPendingDetailsTest.php | 109 ++++ 5 files changed, 759 insertions(+), 18 deletions(-) create mode 100644 tests/Feature/Business/ProductCostPendingDetailsTest.php diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 03651e8f..167ad4d2 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -2746,7 +2746,7 @@ class Pages extends Controller 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, + SUM(CASE WHEN warehouse_short_name IS NULL OR warehouse_short_name = \'\' THEN 1 ELSE 0 END) as missing_warehouse_rows, SUM(CASE WHEN price_source LIKE \'线上代发价格%\' THEN 1 ELSE 0 END) as fallback_price_rows, SUM(CASE WHEN price_source = \'普通商品资料成本价\' THEN 1 ELSE 0 END) as catalog_price_rows ')->first(); @@ -2803,6 +2803,134 @@ class Pages extends Controller ]; } + /** + * 商品成本待核验明细:当前单价为空或仓库简称未匹配的行。 + * 每次打开或刷新弹框都重新查询共享镜像,已修复的行会自然从结果中消失。 + */ + public function productCostPendingDetails(Request $request): \Illuminate\Http\JsonResponse + { + $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); + $monthInput = trim((string) $request->query('month', '')); + if (preg_match('/^\d{4}-\d{2}$/', $monthInput) !== 1) { + return response()->json(['message' => '请选择有效月份。'], 422); + } + + $filters = [ + 'month' => $monthInput, + 'platform' => trim((string) $request->query('platform', '')), + 'store_name' => trim((string) $request->query('store_name', '')), + 'online_order_no' => trim((string) $request->query('online_order_no', '')), + 'product_code' => trim((string) $request->query('product_code', '')), + 'warehouse_short_name' => trim((string) $request->query('warehouse_short_name', '')), + ]; + + $base = static fn () => DB::connection($connection)->table(self::PRODUCT_COST_TABLE); + $applyFilters = static function ($query) 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']); + } + }; + $wherePending = static function ($query): void { + $query->where(static function ($pending): void { + $pending->whereNull('unit_price') + ->orWhereNull('warehouse_short_name') + ->orWhere('warehouse_short_name', ''); + }); + }; + + $summaryQuery = $base(); + $applyFilters($summaryQuery); + $wherePending($summaryQuery); + $summary = $summaryQuery->selectRaw(' + SUM(CASE WHEN unit_price IS NULL OR warehouse_short_name IS NULL OR warehouse_short_name = \'\' THEN 1 ELSE 0 END) as pending_rows, + SUM(CASE WHEN unit_price IS NULL THEN 1 ELSE 0 END) as pending_cost_rows, + SUM(CASE WHEN unit_price IS NULL THEN product_quantity ELSE 0 END) as pending_cost_quantity, + SUM(CASE WHEN warehouse_short_name IS NULL OR warehouse_short_name = \'\' THEN 1 ELSE 0 END) as missing_warehouse_rows + ')->first(); + + $total = (int) ($summary->pending_rows ?? 0); + $perPage = min(max((int) $request->query('per_page', 25), 1), 100); + $lastPage = max((int) ceil($total / $perPage), 1); + $page = min(max((int) $request->query('pending_page', 1), 1), $lastPage); + + $rows = collect(); + if ($total > 0) { + $rowsQuery = $base(); + $applyFilters($rowsQuery); + $wherePending($rowsQuery); + $rows = $rowsQuery + ->select([ + 'occurred_date', + 'platform', + 'store_name', + 'online_order_no', + 'warehouse', + 'warehouse_short_name', + 'product_code', + 'product_quantity', + 'unit_price', + 'product_cost', + ]) + ->orderByDesc('occurred_date') + ->orderBy('store_name') + ->orderBy('online_order_no') + ->orderBy('warehouse_short_name') + ->orderBy('product_code') + ->forPage($page, $perPage) + ->get() + ->map(static function ($row): array { + $reasons = []; + if ($row->unit_price === null) { + $reasons[] = '商品单价缺失'; + } + if (trim((string) ($row->warehouse_short_name ?? '')) === '') { + $reasons[] = '仓库简称未匹配'; + } + + return [ + 'occurred_date' => substr((string) ($row->occurred_date ?? ''), 0, 10), + 'platform' => $row->platform, + 'store_name' => $row->store_name, + 'online_order_no' => $row->online_order_no, + 'warehouse' => $row->warehouse, + 'warehouse_short_name' => $row->warehouse_short_name, + 'product_code' => $row->product_code, + 'product_quantity' => $row->product_quantity, + 'unit_price' => $row->unit_price, + 'product_cost' => $row->product_cost, + 'reasons' => $reasons, + ]; + }) + ->values(); + } + + return response()->json([ + 'summary' => [ + 'pending_cost_rows' => (int) ($summary->pending_cost_rows ?? 0), + 'pending_cost_quantity' => round((float) ($summary->pending_cost_quantity ?? 0), 2), + 'missing_warehouse_rows' => (int) ($summary->missing_warehouse_rows ?? 0), + ], + 'pagination' => [ + 'page' => $page, + 'per_page' => $perPage, + 'total' => $total, + 'last_page' => $lastPage, + ], + 'rows' => $rows, + ], 200, [ + 'Cache-Control' => 'private, no-store, max-age=0', + ]); + } + /** * 财务经营分析表:按「时间(账期)- 平台 - 店铺」聚合。 * diff --git a/resources/views/business/pages/financial-operations/product-costs.blade.php b/resources/views/business/pages/financial-operations/product-costs.blade.php index 5914c669..b274a00d 100644 --- a/resources/views/business/pages/financial-operations/product-costs.blade.php +++ b/resources/views/business/pages/financial-operations/product-costs.blade.php @@ -24,9 +24,9 @@ 'label' => '待核验成本', 'tone' => 'warn', 'items' => [ - ['label' => '待核验成本', 'value' => number_format((int) ($summary['pending_cost_rows'] ?? 0)), 'unit' => '行'], - ['label' => '待核验商品数量', 'value' => $qty($summary['pending_cost_quantity'] ?? null), 'unit' => '件'], - ['label' => '未匹配仓库简称', 'value' => number_format((int) ($summary['missing_warehouse_rows'] ?? 0)), 'unit' => '行'], + ['key' => 'pending_cost_rows', 'label' => '待核验成本', 'value' => number_format((int) ($summary['pending_cost_rows'] ?? 0)), 'unit' => '行'], + ['key' => 'pending_cost_quantity', 'label' => '待核验商品数量', 'value' => $qty($summary['pending_cost_quantity'] ?? null), 'unit' => '件'], + ['key' => 'missing_warehouse_rows', 'label' => '未匹配仓库简称', 'value' => number_format((int) ($summary['missing_warehouse_rows'] ?? 0)), 'unit' => '行'], ], ], ]; @@ -41,6 +41,14 @@ 'unit_price' => '商品单价', 'product_cost' => '商品成本', ]; + $pendingDetailsUrl = route( + 'business.financial-operations.product-costs.pending-details', + array_merge( + ['company_id' => company_id()], + request()->except(['page', 'pending_page', 'per_page']), + ['month' => $filters['month'] ?? ''] + ) + ); @endphp
@@ -232,6 +240,22 @@ font-size: .75rem; } + .product-cost-metric__action--button { + justify-self: start; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + } + + .product-cost-metric__action--button:focus-visible { + outline: 2px solid var(--vip-content-accent-dark, #856632); + outline-offset: 2px; + } + .product-cost-metric.is-summary { grid-template-rows: auto 1fr auto; } @@ -602,6 +626,205 @@ text-decoration: none; } + .product-cost-pending-dialog { + width: min(96rem, calc(100vw - 2rem)); + max-height: min(88vh, 56rem); + padding: 0; + overflow: hidden; + border: 1px solid var(--vip-border, #d8d2c7); + border-radius: .75rem; + background: #fff; + color: var(--vip-content-ink, #171717); + box-shadow: 0 .75rem 2rem rgba(23, 23, 23, .16); + } + + .product-cost-pending-dialog::backdrop { + background: rgba(5, 5, 6, .48); + } + + .product-cost-pending-dialog__head { + display: flex; + align-items: flex-start; + gap: 1rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--vip-border, #d8d2c7); + background: #faf6ee; + } + + .product-cost-pending-dialog__title { + display: grid; + flex: 1 1 18rem; + gap: .25rem; + min-width: 0; + } + + .product-cost-pending-dialog__title h2 { + margin: 0; + font-size: 1rem; + font-weight: 700; + } + + .product-cost-pending-dialog__title span { + color: var(--vip-content-muted, #595959); + font-size: .75rem; + line-height: 1.5; + } + + .product-cost-pending-dialog__metrics { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .45rem 1rem; + } + + .product-cost-pending-dialog__metric { + display: grid; + gap: .08rem; + min-width: 7.5rem; + padding-left: .85rem; + border-left: 1px solid var(--vip-border, #d8d2c7); + } + + .product-cost-pending-dialog__metric small { + color: var(--vip-content-muted, #595959); + font-size: .68rem; + } + + .product-cost-pending-dialog__metric strong { + color: var(--vip-danger, #b80000); + font-size: 1rem; + font-variant-numeric: tabular-nums; + } + + .product-cost-pending-dialog__metric em { + margin-left: .12rem; + color: var(--vip-content-muted, #595959); + font-size: .68rem; + font-style: normal; + font-weight: 500; + } + + .product-cost-pending-dialog__actions { + display: flex; + align-items: center; + gap: .45rem; + } + + .product-cost-pending-dialog__actions button, + .product-cost-pending-dialog__pagination button { + min-height: 2rem; + padding: 0 .7rem; + border: 1px solid var(--vip-border, #d8d2c7); + border-radius: .5rem; + background: #fff; + color: var(--vip-content-ink, #171717); + font: inherit; + font-size: .72rem; + cursor: pointer; + } + + .product-cost-pending-dialog__actions button:hover, + .product-cost-pending-dialog__pagination button:hover:not(:disabled) { + border-color: var(--vip-content-accent-dark, #856632); + color: var(--vip-content-accent-dark, #856632); + } + + .product-cost-pending-dialog__pagination button:disabled { + cursor: not-allowed; + opacity: .45; + } + + .product-cost-pending-dialog__close { + width: 2rem; + padding: 0 !important; + border-radius: 999px !important; + font-size: 1.1rem !important; + } + + .product-cost-pending-dialog__body { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + max-height: calc(88vh - 6.5rem); + min-height: 12rem; + overflow: hidden; + padding: 1rem 1.25rem 1.1rem; + } + + .product-cost-pending-dialog__status, + .product-cost-pending-dialog__empty { + margin: auto; + padding: 2.5rem 1rem; + color: var(--vip-content-muted, #595959); + font-size: .78rem; + text-align: center; + } + + .product-cost-pending-dialog__status.is-error { + color: var(--vip-danger, #b80000); + } + + .product-cost-pending-dialog__table-wrap { + min-height: 0; + overflow: auto; + border: 1px solid var(--vip-border, #d8d2c7); + border-radius: .55rem; + } + + .product-cost-pending-dialog__table { + width: 100%; + min-width: 80rem; + border-collapse: separate; + border-spacing: 0; + font-size: .72rem; + } + + .product-cost-pending-dialog__table th, + .product-cost-pending-dialog__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-pending-dialog__table th { + position: sticky; + top: 0; + z-index: 1; + background: #f2eadc; + font-weight: 700; + text-align: left; + } + + .product-cost-pending-dialog__table .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + .product-cost-pending-dialog__table .is-missing { + color: var(--vip-danger, #b80000); + } + + .product-cost-pending-dialog__reason { + color: var(--vip-danger, #b80000); + font-weight: 600; + } + + .product-cost-pending-dialog__pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding-top: .8rem; + color: var(--vip-content-muted, #595959); + font-size: .72rem; + } + + .product-cost-pending-dialog__pagination-actions { + display: inline-flex; + gap: .45rem; + } + @media (max-width: 1100px) { .product-cost-filter-form { grid-template-columns: repeat(2, minmax(10rem, 1fr)); @@ -666,6 +889,36 @@ margin-top: .45rem; box-shadow: none; } + + .product-cost-pending-dialog { + width: calc(100vw - 1rem); + max-height: calc(100vh - 1rem); + } + + .product-cost-pending-dialog__head, + .product-cost-pending-dialog__pagination { + align-items: stretch; + flex-direction: column; + } + + .product-cost-pending-dialog__metrics { + display: grid; + grid-template-columns: 1fr; + } + + .product-cost-pending-dialog__metric { + padding: .45rem 0 0; + border-top: 1px solid var(--vip-border, #d8d2c7); + border-left: 0; + } + + .product-cost-pending-dialog__actions { + justify-content: flex-end; + } + + .product-cost-pending-dialog__body { + max-height: calc(100vh - 4rem); + } } @@ -696,7 +949,7 @@
成本指标 - 指标按当前筛选条件统计;点击任一指标可定位到明细列表。 + 指标按当前筛选条件统计;商品成本合计可定位明细,待核验成本可查看待处理明细。
口径说明 @@ -749,27 +1002,40 @@ @@ -923,6 +1189,71 @@
+ +
+
+

待核验成本明细

+ 按当前月份及筛选条件展示单价缺失或仓库简称未匹配的明细;数据修复后刷新即可移除。 +
+
+ + 待核验成本 + {{ number_format((int) ($summary['pending_cost_rows'] ?? 0)) }}行 + + + 待核验商品数量 + {{ $qty($summary['pending_cost_quantity'] ?? null) }}件 + + + 未匹配仓库简称 + {{ number_format((int) ($summary['missing_warehouse_rows'] ?? 0)) }}行 + +
+
+ + +
+
+
+
正在加载待核验明细…
+ + + +
+
+ +
diff --git a/routes/admin.php b/routes/admin.php index 72a61f12..5438e132 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -272,6 +272,10 @@ Route::get('workspace/product-inventory/monthly-weighted-average-price/export', ->middleware('business.page:financial-operations,product-costs') ->name('business.financial-operations.product-costs.export'); + Route::get('workspace/financial-operations/product-costs/pending-details', 'Business\Pages@productCostPendingDetails') + ->middleware('business.page:financial-operations,product-costs') + ->name('business.financial-operations.product-costs.pending-details'); + Route::get('workspace/financial-operations/financial-analysis/export', 'Business\Pages@exportFinancialAnalysis') ->middleware('business.page:financial-operations,financial-analysis') ->name('business.financial-operations.financial-analysis.export'); diff --git a/tests/Feature/Business/ProductCostPageLayoutTest.php b/tests/Feature/Business/ProductCostPageLayoutTest.php index 5e0f3ae9..be03eec8 100644 --- a/tests/Feature/Business/ProductCostPageLayoutTest.php +++ b/tests/Feature/Business/ProductCostPageLayoutTest.php @@ -59,6 +59,10 @@ class ProductCostPageLayoutTest extends TestCase $this->assertStringContainsString('待核验商品数量', $html); $this->assertStringContainsString('未匹配仓库简称', $html); $this->assertStringContainsString('data-product-cost-metric-card="pending-summary"', $html); + $this->assertStringContainsString('data-product-cost-pending-open', $html); + $this->assertStringContainsString('data-product-cost-pending-dialog', $html); + $this->assertStringContainsString('待核验成本明细', $html); + $this->assertStringContainsString('data-product-cost-pending-refresh', $html); $this->assertSame(2, substr_count($html, 'data-product-cost-metric-card=')); $this->assertSame(3, substr_count($html, 'class="product-cost-metric__summary-item"')); $this->assertStringNotContainsString('明细行数', $html); diff --git a/tests/Feature/Business/ProductCostPendingDetailsTest.php b/tests/Feature/Business/ProductCostPendingDetailsTest.php new file mode 100644 index 00000000..e7d313a2 --- /dev/null +++ b/tests/Feature/Business/ProductCostPendingDetailsTest.php @@ -0,0 +1,109 @@ +table('store_receipt_product_cost_rows')->insert([ + $this->row('pending-both', 'HX-A', null, null, 2, null), + $this->row('pending-price', 'HX-B', '崔桥工厂仓', null, 3, null), + $this->row('pending-warehouse', 'HX-C', '', 10, 1, 10), + $this->row('valid', 'HX-D', '崔桥工厂仓', 10, 1, 10), + $this->row('other-store', 'OTHER', null, null, 1, null, '其他店铺'), + $this->row('other-month', 'OLD', null, null, 1, null, '京东-HX旗舰店104', '2026-08-01'), + ]); + + $response = $this->loginAs()->getJson(route( + 'business.financial-operations.product-costs.pending-details', + [ + 'month' => '2026-09', + 'store_name' => 'HX', + ], + )); + + $response + ->assertOk() + ->assertJsonPath('summary.pending_cost_rows', 2) + ->assertJsonPath('summary.pending_cost_quantity', 5) + ->assertJsonPath('summary.missing_warehouse_rows', 2) + ->assertJsonPath('pagination.total', 3) + ->assertJsonPath('pagination.page', 1); + + $cacheControl = (string) $response->headers->get('Cache-Control'); + $this->assertStringContainsString('private', $cacheControl); + $this->assertStringContainsString('no-store', $cacheControl); + + $rows = collect($response->json('rows'))->keyBy('online_order_no'); + $this->assertCount(3, $rows); + $this->assertSame(['商品单价缺失', '仓库简称未匹配'], $rows['HX-A']['reasons']); + $this->assertSame(['商品单价缺失'], $rows['HX-B']['reasons']); + $this->assertSame(['仓库简称未匹配'], $rows['HX-C']['reasons']); + $this->assertFalse($rows->has('HX-D')); + } + + public function test_repaired_row_disappears_when_modal_data_is_reloaded(): void + { + $mirror = DB::connection('business_mirror'); + $mirror->table('store_receipt_product_cost_rows')->insert( + $this->row('repair-me', 'REPAIR-1', null, null, 1, null) + ); + $this->loginAs(); + $url = route('business.financial-operations.product-costs.pending-details', [ + 'month' => '2026-09', + ]); + + $this->getJson($url) + ->assertOk() + ->assertJsonPath('pagination.total', 1) + ->assertJsonPath('rows.0.online_order_no', 'REPAIR-1'); + + $mirror->table('store_receipt_product_cost_rows') + ->where('row_key', 'repair-me') + ->update([ + 'warehouse_short_name' => '崔桥工厂仓', + 'unit_price' => 10, + 'product_cost' => 10, + ]); + + $this->getJson($url) + ->assertOk() + ->assertJsonPath('summary.pending_cost_rows', 0) + ->assertJsonPath('summary.missing_warehouse_rows', 0) + ->assertJsonPath('pagination.total', 0) + ->assertJsonCount(0, 'rows'); + } + + private function row( + string $rowKey, + string $onlineOrderNo, + ?string $warehouseShortName, + int|float|null $unitPrice, + int|float $quantity, + int|float|null $productCost, + string $storeName = '京东-HX旗舰店104', + string $reportMonth = '2026-09-01' + ): array { + return [ + 'report_month' => $reportMonth, + 'row_key' => $rowKey, + 'platform' => '京东账单', + 'occurred_date' => $reportMonth, + 'store_name' => $storeName, + 'online_order_no' => $onlineOrderNo, + 'warehouse' => '简尚-崔桥工厂仓', + 'warehouse_short_name' => $warehouseShortName, + 'product_code' => 'SKU-' . $rowKey, + 'product_quantity' => $quantity, + 'unit_price' => $unitPrice, + 'product_cost' => $productCost, + 'remark' => null, + 'imported_at' => now(), + ]; + } +}