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 @@
+
+
+
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(),
+ ];
+ }
+}