商品成本:增加待核验明细弹框
待核验成本卡片增加明细弹框,按当前筛选分页展示单价或仓库未匹配行,修复后刷新即可移除。
This commit is contained in:
parent
32724712ba
commit
6fd059ae98
@ -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',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 财务经营分析表:按「时间(账期)- 平台 - 店铺」聚合。
|
||||
*
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
|
||||
109
tests/Feature/Business/ProductCostPendingDetailsTest.php
Normal file
109
tests/Feature/Business/ProductCostPendingDetailsTest.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user