商品成本:增加待核验明细弹框

待核验成本卡片增加明细弹框,按当前筛选分页展示单价或仓库未匹配行,修复后刷新即可移除。
This commit is contained in:
chenxi 2026-10-25 19:03:46 +08:00
parent 32724712ba
commit 6fd059ae98
5 changed files with 759 additions and 18 deletions

View File

@ -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',
]);
}
/**
* 财务经营分析表:按「时间(账期)- 平台 - 店铺」聚合。
*

View File

@ -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
<div class="product-cost-page">
@ -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);
}
}
</style>
@ -696,7 +949,7 @@
<div class="product-cost-metrics-head">
<div>
<strong>成本指标</strong>
<span>指标按当前筛选条件统计;点击任一指标可定位到明细列表。</span>
<span>指标按当前筛选条件统计;商品成本合计可定位明细,待核验成本可查看待处理明细。</span>
</div>
<details class="product-cost-rules">
<summary>口径说明</summary>
@ -749,27 +1002,40 @@
<div class="product-cost-metrics-grid">
@foreach ($metricCards as $card)
@php($summaryItems = $card['items'] ?? null)
<a
class="product-cost-metric {{ ($card['tone'] ?? '') === 'warn' ? 'is-warn' : '' }} {{ $summaryItems ? 'is-summary' : '' }}"
href="#product-cost-table"
aria-label="查看{{ $card['label'] }}明细"
data-product-cost-metric-card="{{ $summaryItems ? 'pending-summary' : 'value' }}"
>
<span class="product-cost-metric__label">{{ $card['label'] }}</span>
@if ($summaryItems)
@if ($summaryItems)
<div
class="product-cost-metric is-warn is-summary"
aria-label="待核验成本汇总"
data-product-cost-metric-card="pending-summary"
>
<span class="product-cost-metric__label">{{ $card['label'] }}</span>
<div class="product-cost-metric__summary-items">
@foreach ($summaryItems as $item)
<div class="product-cost-metric__summary-item">
<span class="product-cost-metric__summary-label">{{ $item['label'] }}</span>
<strong class="product-cost-metric__value">{{ $item['value'] }}<small>{{ $item['unit'] }}</small></strong>
<strong class="product-cost-metric__value"><span data-product-cost-summary="{{ $item['key'] }}">{{ $item['value'] }}</span><small>{{ $item['unit'] }}</small></strong>
</div>
@endforeach
</div>
@else
<button
type="button"
class="product-cost-metric__action product-cost-metric__action--button"
data-product-cost-pending-open
data-product-cost-pending-url="{{ $pendingDetailsUrl }}"
>查看明细 →</button>
</div>
@else
<a
class="product-cost-metric"
href="#product-cost-table"
aria-label="查看{{ $card['label'] }}明细"
data-product-cost-metric-card="value"
>
<span class="product-cost-metric__label">{{ $card['label'] }}</span>
<strong class="product-cost-metric__value">{{ $card['value'] }}<small>{{ $card['unit'] }}</small></strong>
@endif
<span class="product-cost-metric__action">查看明细 →</span>
</a>
<span class="product-cost-metric__action">查看明细 →</span>
</a>
@endif
@endforeach
</div>
</section>
@ -923,6 +1189,71 @@
</div>
</section>
<dialog
class="product-cost-pending-dialog"
id="product-cost-pending-dialog"
aria-labelledby="product-cost-pending-dialog-title"
data-product-cost-pending-dialog
>
<header class="product-cost-pending-dialog__head">
<div class="product-cost-pending-dialog__title">
<h2 id="product-cost-pending-dialog-title">待核验成本明细</h2>
<span>按当前月份及筛选条件展示单价缺失或仓库简称未匹配的明细;数据修复后刷新即可移除。</span>
</div>
<div class="product-cost-pending-dialog__metrics" aria-label="待核验成本汇总">
<span class="product-cost-pending-dialog__metric">
<small>待核验成本</small>
<strong><span data-product-cost-pending-cost>{{ number_format((int) ($summary['pending_cost_rows'] ?? 0)) }}</span><em>行</em></strong>
</span>
<span class="product-cost-pending-dialog__metric">
<small>待核验商品数量</small>
<strong><span data-product-cost-pending-quantity>{{ $qty($summary['pending_cost_quantity'] ?? null) }}</span><em>件</em></strong>
</span>
<span class="product-cost-pending-dialog__metric">
<small>未匹配仓库简称</small>
<strong><span data-product-cost-pending-warehouse>{{ number_format((int) ($summary['missing_warehouse_rows'] ?? 0)) }}</span><em>行</em></strong>
</span>
</div>
<div class="product-cost-pending-dialog__actions">
<button type="button" data-product-cost-pending-refresh>刷新</button>
<button type="button" class="product-cost-pending-dialog__close" aria-label="关闭待核验成本明细" data-product-cost-pending-close>×</button>
</div>
</header>
<div class="product-cost-pending-dialog__body">
<div class="product-cost-pending-dialog__status" role="status" data-product-cost-pending-status>正在加载待核验明细…</div>
<div class="product-cost-pending-dialog__table-wrap" data-product-cost-pending-table hidden>
<table class="product-cost-pending-dialog__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 data-product-cost-pending-rows></tbody>
</table>
</div>
<div class="product-cost-pending-dialog__empty" data-product-cost-pending-empty hidden>
当前筛选条件下没有待核验明细。
</div>
<div class="product-cost-pending-dialog__pagination" data-product-cost-pending-pagination hidden>
<span data-product-cost-pending-page-summary></span>
<span class="product-cost-pending-dialog__pagination-actions">
<button type="button" data-product-cost-pending-prev>上一页</button>
<button type="button" data-product-cost-pending-next>下一页</button>
</span>
</div>
</div>
</dialog>
<script>
(function () {
var card = document.querySelector('[data-product-cost-table-card]');
@ -998,4 +1329,169 @@
}
})();
</script>
<script>
(function () {
var trigger = document.querySelector('[data-product-cost-pending-open]');
var dialog = document.querySelector('[data-product-cost-pending-dialog]');
if (!trigger || !dialog) { return; }
var rowsBody = dialog.querySelector('[data-product-cost-pending-rows]');
var table = dialog.querySelector('[data-product-cost-pending-table]');
var status = dialog.querySelector('[data-product-cost-pending-status]');
var empty = dialog.querySelector('[data-product-cost-pending-empty]');
var pagination = dialog.querySelector('[data-product-cost-pending-pagination]');
var pageSummary = dialog.querySelector('[data-product-cost-pending-page-summary]');
var prev = dialog.querySelector('[data-product-cost-pending-prev]');
var next = dialog.querySelector('[data-product-cost-pending-next]');
var refresh = dialog.querySelector('[data-product-cost-pending-refresh]');
var close = dialog.querySelector('[data-product-cost-pending-close]');
var costCount = dialog.querySelector('[data-product-cost-pending-cost]');
var quantityCount = dialog.querySelector('[data-product-cost-pending-quantity]');
var warehouseCount = dialog.querySelector('[data-product-cost-pending-warehouse]');
var currentPage = 1;
var lastPage = 1;
function text(value) {
return value === null || value === undefined || value === '' ? '-' : String(value);
}
function integer(value) {
return Number(value || 0).toLocaleString('zh-CN');
}
function quantity(value) {
if (value === null || value === undefined || value === '') { return '-'; }
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 4 });
}
function money(value) {
if (value === null || value === undefined || value === '') { return '-'; }
return Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
function cell(value, className) {
var td = document.createElement('td');
td.textContent = value;
if (className) { td.className = className; }
return td;
}
function setLoading(loading) {
refresh.disabled = loading;
prev.disabled = loading || currentPage <= 1;
next.disabled = loading || currentPage >= lastPage;
if (loading) {
status.hidden = false;
status.classList.remove('is-error');
status.textContent = '正在加载待核验明细…';
table.hidden = true;
empty.hidden = true;
pagination.hidden = true;
}
}
function updateSummary(summary) {
costCount.textContent = integer(summary.pending_cost_rows);
quantityCount.textContent = quantity(summary.pending_cost_quantity);
warehouseCount.textContent = integer(summary.missing_warehouse_rows);
var cardCost = document.querySelector('[data-product-cost-summary="pending_cost_rows"]');
var cardQuantity = document.querySelector('[data-product-cost-summary="pending_cost_quantity"]');
var cardWarehouse = document.querySelector('[data-product-cost-summary="missing_warehouse_rows"]');
if (cardCost) { cardCost.textContent = integer(summary.pending_cost_rows); }
if (cardQuantity) { cardQuantity.textContent = quantity(summary.pending_cost_quantity); }
if (cardWarehouse) { cardWarehouse.textContent = integer(summary.missing_warehouse_rows); }
}
function renderRows(rows) {
rowsBody.replaceChildren();
rows.forEach(function (row) {
var tr = document.createElement('tr');
tr.append(
cell(text(row.occurred_date)),
cell(text(row.platform)),
cell(text(row.store_name)),
cell(text(row.online_order_no)),
cell(text(row.warehouse)),
cell(text(row.warehouse_short_name), row.warehouse_short_name ? '' : 'is-missing'),
cell(text(row.product_code)),
cell(quantity(row.product_quantity), 'num'),
cell(money(row.unit_price), row.unit_price === null ? 'num is-missing' : 'num'),
cell(money(row.product_cost), row.product_cost === null ? 'num is-missing' : 'num')
);
var reason = document.createElement('td');
reason.className = 'product-cost-pending-dialog__reason';
reason.textContent = (row.reasons || []).join('、') || '-';
tr.append(reason);
rowsBody.append(tr);
});
}
async function load(page) {
setLoading(true);
var url = new URL(trigger.dataset.productCostPendingUrl, window.location.href);
url.searchParams.set('pending_page', String(page));
url.searchParams.set('per_page', '25');
try {
var response = await fetch(url.toString(), {
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
var payload = await response.json().catch(function () { return {}; });
if (!response.ok) {
throw new Error(payload.message || '待核验明细加载失败,请稍后重试。');
}
updateSummary(payload.summary || {});
var paginationData = payload.pagination || {};
currentPage = Number(paginationData.page || 1);
lastPage = Number(paginationData.last_page || 1);
var rows = Array.isArray(payload.rows) ? payload.rows : [];
status.hidden = true;
if (rows.length === 0) {
table.hidden = true;
empty.hidden = false;
pagination.hidden = true;
} else {
renderRows(rows);
table.hidden = false;
empty.hidden = true;
pagination.hidden = false;
pageSummary.textContent = '第 ' + currentPage + ' / ' + lastPage + ' 页 · 共 ' + integer(paginationData.total || rows.length) + ' 行';
}
} catch (error) {
table.hidden = true;
empty.hidden = true;
pagination.hidden = true;
status.hidden = false;
status.classList.add('is-error');
status.textContent = error && error.message ? error.message : '待核验明细加载失败,请稍后重试。';
} finally {
refresh.disabled = false;
prev.disabled = currentPage <= 1;
next.disabled = currentPage >= lastPage;
}
}
trigger.addEventListener('click', function () {
if (!dialog.open) { dialog.showModal(); }
load(1);
});
refresh.addEventListener('click', function () { load(currentPage); });
prev.addEventListener('click', function () { if (currentPage > 1) { load(currentPage - 1); } });
next.addEventListener('click', function () { if (currentPage < lastPage) { load(currentPage + 1); } });
close.addEventListener('click', function () { dialog.close(); });
dialog.addEventListener('click', function (event) {
if (event.target === dialog) { dialog.close(); }
});
})();
</script>
</div>

View File

@ -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');

View File

@ -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);

View File

@ -0,0 +1,109 @@
<?php
namespace Tests\Feature\Business;
use Illuminate\Support\Facades\DB;
use Tests\Feature\FeatureTestCase;
class ProductCostPendingDetailsTest extends FeatureTestCase
{
public function test_pending_details_use_current_filters_and_include_both_unresolved_reasons(): void
{
$mirror = DB::connection('business_mirror');
$mirror->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(),
];
}
}