快递应付:首发信息拆列及明细字段筛选 #356

Merged
limeng merged 1 commits from feature/limeng-reship-column-filters into main 2026-10-16 22:17:53 +08:00
4 changed files with 76 additions and 9 deletions

View File

@ -17,3 +17,5 @@
- 实际Blade页面检查:分类局部切换、分页、多候选提示、异常金额及手机/桌面布局。
- 导出检查:路由优先级、本页权限、后台鉴权、UTF-8 BOM及公式保护。
- 使用现有数据连接进行了真实业务月份只读匹配及性能验证。
首发快递、首发单号、所属月份以独立三列显示,多候选按同一顺序对应。支持交易单号、补发单号、补发快递、补发日期、原始/处理后条码、补发运费、匹配状态、首发快递、首发单号、所属月份及可扣金额筛选;文本和金额支持包含匹配。筛选覆盖全部业务月记录,汇总、分类数量、分页和CSV导出沿用相同条件。多个首发筛选必须由同一候选满足;清空筛选保留业务月份。

View File

@ -15,17 +15,49 @@ class AccessoryReshipPageData
$month = request()->query('month', now()->format('Y-m'));
abort_unless(is_string($month) && preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) && (int) substr($month, 0, 4) >= 2000, 422, '请选择有效业务月份。');
$status = request()->query('match_status', 'matched');
return ['month' => $month, 'match_status' => in_array($status, ['matched', 'unmatched', 'all'], true) ? $status : 'matched'];
$filters = ['month' => $month, 'match_status' => in_array($status, ['matched', 'unmatched', 'all'], true) ? $status : 'matched'];
foreach (['trade_no','express_no','courier','ship_date','barcode','normalized_barcode','freight','original_courier','original_express_no','original_month','deduction_amount'] as $field) {
$value = request()->query($field, '');
abort_unless(is_string($value) && mb_strlen($value) <= 200, 422, '筛选条件格式无效。');
$filters[$field] = trim($value);
}
return $filters;
}
public function rows(array $filters): \Generator
{
$start = \Carbon\Carbon::createFromFormat('!Y-m', $filters['month']);
foreach ($this->shipping->accessoryReshipBatches($start->format('Y-m-d'), $start->copy()->addMonth()->format('Y-m-d'), $start->copy()->subMonths(2)->format('Y-m-d')) as $batch) {
foreach ($this->matcher->match($batch['accessories'], $batch['originals']) as $row) yield $row;
foreach ($this->matcher->match($batch['accessories'], $batch['originals']) as $row) {
if ($this->matchesFilters($row, $filters)) yield $row;
}
}
}
public function matchesFilters(array $row, array $filters): bool
{
$values = [
'trade_no' => $row['trade_no'], 'express_no' => ($row['express_no'] ?? ''),
'courier' => ($row['express_name1'] ?? '') ?: ($row['express_name2'] ?? ''),
'ship_date' => substr((string) ($row['ship_time'] ?? ''), 0, 10),
'barcode' => $row['barcode'], 'normalized_barcode' => $row['normalized_barcode'],
'freight' => $row['freight_cents'] === null ? '待核实' : number_format($row['freight_cents'] / 100, 2, '.', ''),
'deduction_amount' => $row['deduction_cents'] === null ? '待核实' : number_format($row['deduction_cents'] / 100, 2, '.', ''),
];
foreach ($values as $field => $value) {
if (($filters[$field] ?? '') !== '' && mb_stripos((string) $value, $filters[$field]) === false) return false;
}
$originalFilters = array_filter(['courier' => $filters['original_courier'] ?? '', 'express_no' => $filters['original_express_no'] ?? '', 'month' => $filters['original_month'] ?? ''], static fn ($value) => $value !== '');
if (!$originalFilters) return true;
foreach ($row['matches'] as $match) {
foreach ($originalFilters as $field => $value) {
if (mb_stripos((string) $match[$field], $value) === false) continue 2;
}
return true;
}
return false;
}
public function page(): array
{
$filters = $this->filters();

View File

@ -8,9 +8,13 @@
<div class="reship-heading"><div><h2>配件补发扣款</h2><p>按交易单号及条形码匹配首发快递,核算配件补发运费。</p></div><span class="reship-tag">快递应付</span></div>
<form method="GET" class="reship-toolbar">
<label>业务月份<input type="month" name="month" value="{{ $filters['month'] }}" min="2000-01" required></label>
<input type="hidden" name="match_status" value="{{ $filters['match_status'] }}">
<button type="submit" class="reship-primary">提取并匹配</button>
@if (!$reshipError)<a class="reship-button" href="{{ route('business.accessory-reship-deductions.export', $filters) }}">导出当前分类</a>@endif
@foreach (['trade_no'=>'交易单号','express_no'=>'补发快递单号','courier'=>'补发快递','ship_date'=>'补发日期','barcode'=>'原始条形码','normalized_barcode'=>'处理后条形码','freight'=>'补发运费','original_courier'=>'首发快递','original_express_no'=>'首发单号','original_month'=>'所属月份','deduction_amount'=>'可扣金额'] as $field => $label)
<label>{{ $label }}<input type="{{ $field === 'ship_date' ? 'date' : ($field === 'original_month' ? 'month' : 'text') }}" name="{{ $field }}" value="{{ $filters[$field] ?? '' }}" placeholder="{{ in_array($field, ['freight','deduction_amount']) ? '金额或待核实' : '筛选' . $label }}" maxlength="200"></label>
@endforeach
<label>匹配状态<select name="match_status">@foreach (['all'=>'全部状态','matched'=>'可扣款','unmatched'=>'未匹配不扣款'] as $value => $label)<option value="{{ $value }}" @selected($filters['match_status'] === $value)>{{ $label }}</option>@endforeach</select></label>
<button type="submit" class="reship-primary">查询并匹配</button>
<a class="reship-button" href="{{ request()->url() . '?' . http_build_query(['month'=>$filters['month'], 'match_status'=>'all']) }}">清空筛选</a>
@if (!$reshipError)<a class="reship-button" href="{{ route('business.accessory-reship-deductions.export', $filters) }}">导出筛选结果</a>@endif
<span class="reship-range">匹配范围:{{ $startMonth }} 至 {{ \Carbon\Carbon::createFromFormat('!Y-m', $filters['month'])->format('Y年n月') }}</span>
</form>
<div class="reship-metrics">
@ -29,19 +33,20 @@
@endforeach
</nav>
<div class="reship-table-scroll"><table><thead><tr>
@foreach (['交易单号','补发快递单号','补发快递','补发日期','原始条形码','处理后条形码','补发运费','匹配状态','首发快递 / 单号 / 所属月份','可扣金额','说明'] as $heading)<th>{{ $heading }}</th>@endforeach
@foreach (['交易单号','补发快递单号','补发快递','补发日期','原始条形码','处理后条形码','补发运费','匹配状态','首发快递','首发单号','所属月份','可扣金额','说明'] as $heading)<th>{{ $heading }}</th>@endforeach
</tr></thead><tbody>
@forelse ($reshipRows as $row)
<tr><td>{{ $row['trade_no'] }}</td><td>{{ $row['express_no'] }}</td><td>{{ $row['express_name1'] ?: $row['express_name2'] }}</td><td>{{ substr((string) $row['ship_time'], 0, 10) }}</td><td class="reship-barcode">{{ $row['barcode'] }}</td><td>{{ $row['normalized_barcode'] }}</td><td class="reship-money">{{ $row['freight_cents'] === null ? '待核实' : number_format($row['freight_cents'] / 100, 2) }}</td><td><span class="reship-status {{ $row['status'] }}">{{ $row['status'] === 'matched' ? '可扣款' : '未匹配不扣款' }}</span></td><td>
@forelse ($row['matches'] as $match)<div class="reship-candidate">{{ $match['courier'] ?: '快递名称待核实' }}<br>{{ $match['express_no'] }} <span>({{ $match['month'] }})</span></div>@empty — @endforelse
@forelse ($row['matches'] as $match)<div class="reship-candidate">{{ $match['courier'] ?: '快递名称待核实' }}</div>@empty — @endforelse
</td><td>@forelse ($row['matches'] as $match)<div class="reship-candidate">{{ $match['express_no'] }}</div>@empty — @endforelse</td><td>@forelse ($row['matches'] as $match)<div class="reship-candidate">{{ $match['month'] }}</div>@empty — @endforelse
</td><td class="reship-money">{{ $row['deduction_cents'] === null ? '待核实' : number_format($row['deduction_cents'] / 100, 2) }}</td><td>{{ $row['freight_cents'] === null ? '运费缺失或异常' : (count($row['matches']) > 1 ? '多候选,确认归属后扣款;仅计一次' : ($row['status'] === 'unmatched' ? '三个月内未匹配首发快递单号' : '待核对')) }}</td></tr>
@empty<tr><td colspan="11" class="reship-empty">{{ $reshipError ?: '当前月份及分类暂无记录,请调整业务月份或分类。' }}</td></tr>@endforelse
@empty<tr><td colspan="13" class="reship-empty">{{ $reshipError ?: '当前筛选条件下暂无记录,请清空筛选或调整业务月份、分类。' }}</td></tr>@endforelse
</tbody></table></div>
<div class="reship-footer">当前分类共 {{ number_format($reshipRows->total()) }} 单,每页50单 {{ $reshipRows->links() }}</div>
</section>
</div>
<style>
.reship-page{color:var(--vip-content-ink,#171717);font-size:13px}.reship-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.reship-heading h2{font-size:16px;font-weight:600;margin:0 0 6px}.reship-heading p,.reship-range{color:#595959;font-size:12px}.reship-tag,.reship-status{padding:4px 8px;border-radius:4px;background:#F7F0DE;color:#856632;font-size:12px}.reship-toolbar{display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px;margin-bottom:16px}.reship-toolbar label{display:grid;gap:6px;font-size:12px}.reship-toolbar input,.reship-button,.reship-toolbar button{height:34px;padding:0 12px;border:1px solid #D8D2C7;border-radius:8px;background:#fff;color:#171717}.reship-button{display:inline-flex;align-items:center;text-decoration:none}.reship-toolbar .reship-primary{background:#856632;border-color:#856632;color:#fff}.reship-range{align-self:center;padding-top:18px}.reship-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.reship-metrics>div{padding:16px;background:#fff;border:1px solid #D8D2C7;border-radius:12px}.reship-metrics span{display:block;font-size:12px;color:#595959}.reship-metrics strong{display:block;margin-top:8px;font-size:22px;font-variant-numeric:tabular-nums}.reship-metrics>div:last-child strong{color:#856632}.reship-rules{margin:12px 0;color:#595959;font-size:12px}.reship-rules summary{cursor:pointer;color:#856632}.reship-rules p{margin:8px 0;line-height:1.7}.reship-error,.reship-notice{padding:12px;margin:12px 0;border-radius:8px;background:#F7F0DE;color:#935F07}.reship-error{color:#B80000}.reship-panel{border:1px solid #D8D2C7;border-radius:12px;background:#fff;overflow:hidden}.reship-tabs{display:flex;gap:24px;padding:0 16px;border-bottom:1px solid #D8D2C7}.reship-tabs a{padding:13px 0;color:#595959;text-decoration:none}.reship-tabs a.is-active{color:#856632;border-bottom:2px solid #856632}.reship-tabs span{font-size:11px;margin-left:5px}.reship-table-scroll{overflow-x:auto}.reship-page table{width:100%;border-collapse:collapse;font-size:12px}.reship-page th,.reship-page td{padding:12px;text-align:left;vertical-align:top;border-bottom:1px solid #D8D2C7}.reship-page th{white-space:nowrap;background:#F7F0DE;font-weight:500;color:#595959}.reship-page td{min-width:110px;overflow-wrap:anywhere}.reship-page td.reship-barcode{min-width:200px;max-width:300px}.reship-page td.reship-money{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}.reship-status{display:inline-block;white-space:nowrap}.reship-status.matched{background:#eef3e9;color:#53793E}.reship-status.unmatched{background:#f1efeb;color:#595959}.reship-candidate{min-width:220px;margin-bottom:6px;line-height:1.7}.reship-candidate span{color:#595959}.reship-page td.reship-empty{text-align:center;padding:60px 16px;color:#595959}.reship-footer{padding:12px 16px;color:#595959;font-size:12px}.reship-footer nav{margin-top:12px}@media(max-width:700px){.reship-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.reship-range{width:100%;padding:0}.reship-tabs{gap:14px}.reship-metrics strong{font-size:19px}}
.reship-page{color:var(--vip-content-ink,#171717);font-size:13px}.reship-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.reship-heading h2{font-size:16px;font-weight:600;margin:0 0 6px}.reship-heading p,.reship-range{color:#595959;font-size:12px}.reship-tag,.reship-status{padding:4px 8px;border-radius:4px;background:#F7F0DE;color:#856632;font-size:12px}.reship-toolbar{display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px;margin-bottom:16px}.reship-toolbar label{display:grid;gap:6px;font-size:12px}.reship-toolbar select,.reship-toolbar input,.reship-button,.reship-toolbar button{height:34px;padding:0 12px;border:1px solid #D8D2C7;border-radius:8px;background:#fff;color:#171717}.reship-button{display:inline-flex;align-items:center;text-decoration:none}.reship-toolbar .reship-primary{background:#856632;border-color:#856632;color:#fff}.reship-range{align-self:center;padding-top:18px}.reship-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.reship-metrics>div{padding:16px;background:#fff;border:1px solid #D8D2C7;border-radius:12px}.reship-metrics span{display:block;font-size:12px;color:#595959}.reship-metrics strong{display:block;margin-top:8px;font-size:22px;font-variant-numeric:tabular-nums}.reship-metrics>div:last-child strong{color:#856632}.reship-rules{margin:12px 0;color:#595959;font-size:12px}.reship-rules summary{cursor:pointer;color:#856632}.reship-rules p{margin:8px 0;line-height:1.7}.reship-error,.reship-notice{padding:12px;margin:12px 0;border-radius:8px;background:#F7F0DE;color:#935F07}.reship-error{color:#B80000}.reship-panel{border:1px solid #D8D2C7;border-radius:12px;background:#fff;overflow:hidden}.reship-tabs{display:flex;gap:24px;padding:0 16px;border-bottom:1px solid #D8D2C7}.reship-tabs a{padding:13px 0;color:#595959;text-decoration:none}.reship-tabs a.is-active{color:#856632;border-bottom:2px solid #856632}.reship-tabs span{font-size:11px;margin-left:5px}.reship-table-scroll{overflow:auto;max-height:65vh}.reship-page thead th{position:sticky;top:0;z-index:1}.reship-page table{width:100%;border-collapse:collapse;font-size:12px}.reship-page th,.reship-page td{padding:12px;text-align:left;vertical-align:top;border-bottom:1px solid #D8D2C7}.reship-page th{white-space:nowrap;background:#F7F0DE;font-weight:500;color:#595959}.reship-page td{min-width:110px;overflow-wrap:anywhere}.reship-page td.reship-barcode{min-width:200px;max-width:300px}.reship-page td.reship-money{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}.reship-status{display:inline-block;white-space:nowrap}.reship-status.matched{background:#eef3e9;color:#53793E}.reship-status.unmatched{background:#f1efeb;color:#595959}.reship-candidate{min-width:110px;min-height:24px;margin-bottom:6px;line-height:24px;white-space:nowrap}.reship-candidate span{color:#595959}.reship-page td.reship-empty{text-align:center;padding:60px 16px;color:#595959}.reship-footer{padding:12px 16px;color:#595959;font-size:12px}.reship-footer nav{margin-top:12px}@media(max-width:700px){.reship-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.reship-range{width:100%;padding:0}.reship-tabs{gap:14px}.reship-metrics strong{font-size:19px}}
</style>
@push('body_end')
<script>

View File

@ -43,4 +43,32 @@ class AccessoryReshipPageDataTest extends TestCase
$this->assertSame(1050, $data['reshipSummary']['amount']);
$this->assertSame(105, $data['reshipSummary']['ambiguous']);
}
public function test_filters_search_all_rows_and_keep_summary_pagination_consistent(): void
{
Container::getInstance()->instance('request', Request::create('/?month=2026-09&match_status=all&courier=补发&original_month=2026-08&page=2'));
$accessories = [];
for ($i = 0; $i < 105; $i++) $accessories[] = ['trade_no'=>'001', 'express_no'=>'BF'.$i, 'express_name1'=>'补发快递', 'ship_time'=>'2026-09-15', 'barcode'=>'SKU-1', 'freight'=>'0.10'];
$accessories[] = array_replace($accessories[0], ['trade_no'=>'NO-MATCH']);
$repo = Mockery::mock(ShippingOrderRepository::class);
$repo->shouldReceive('accessoryReshipBatches')->andReturn((function () use ($accessories) { yield ['accessories'=>$accessories, 'originals'=>[['trade_no'=>'001','barcode'=>'SKU','express_no'=>'SF001','express_name1'=>'顺丰','ship_time'=>'2026-08-01','warehouse_name'=>'成品仓']]]; })());
$data = (new AccessoryReshipPageData($repo, new AccessoryReshipMatcher))->page();
$this->assertNull($data['reshipError']);
$this->assertCount(50, $data['reshipRows']);
$this->assertSame(105, $data['reshipRows']->total());
$this->assertSame(105, $data['reshipSummary']['all']);
$this->assertSame(1050, $data['reshipSummary']['amount']);
}
public function test_filters_require_same_candidate_and_support_fields_and_unknown_amount(): void
{
$data = new AccessoryReshipPageData(Mockery::mock(ShippingOrderRepository::class), new AccessoryReshipMatcher);
$row = ['trade_no'=>'T001','express_no'=>'BF001','express_name1'=>'','express_name2'=>'补发快递','ship_time'=>'2026-09-15','barcode'=>'SKU-1','normalized_barcode'=>'SKU','freight_cents'=>1230,'deduction_cents'=>1230,'matches'=>[['courier'=>'顺丰','express_no'=>'SF001','month'=>'2026-08'],['courier'=>'圆通','express_no'=>'YT001','month'=>'2026-09']]];
$this->assertTrue($data->matchesFilters($row, ['trade_no'=>'T001','express_no'=>'bf001','courier'=>'补发','ship_date'=>'2026-09-15','barcode'=>'SKU-1','normalized_barcode'=>'SKU','freight'=>'12.30','deduction_amount'=>'12.30','original_courier'=>'顺丰','original_express_no'=>'sf001','original_month'=>'2026-08']));
$this->assertFalse($data->matchesFilters($row, ['original_courier'=>'顺丰','original_express_no'=>'YT001']));
$this->assertFalse($data->matchesFilters($row, ['trade_no'=>'missing']));
$row['freight_cents'] = $row['deduction_cents'] = null;
$this->assertTrue($data->matchesFilters($row, ['freight'=>'待核实','deduction_amount'=>'待核实']));
$this->assertFalse($data->matchesFilters($row, ['deduction_amount'=>'0.00']));
}
}