feat: 增加发货数据筛选日期选择与导出

This commit is contained in:
limeng 2026-09-16 10:55:27 +08:00
parent b038909cb0
commit 34b68bb431
5 changed files with 352 additions and 33 deletions

View File

@ -756,16 +756,7 @@ class Pages extends Controller
protected function contentForShippingData(): array
{
$request = request();
$date = static function (mixed $value): string {
$value = trim((string) $value);
return preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $value) === 1 ? $value : '';
};
$filters = [
'express_no' => mb_substr(trim((string) $request->query('express_no', '')), 0, 80),
'sourceno' => mb_substr(trim((string) $request->query('sourceno', '')), 0, 80),
'ship_from' => $date($request->query('ship_from', '')),
'ship_to' => $date($request->query('ship_to', '')),
];
$filters = $this->shippingDataFilters($request);
$page = max((int) $request->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new LengthAwarePaginator([], 0, $limit, $page, [
@ -796,6 +787,121 @@ class Pages extends Controller
'shippingFilters' => $filters,
'shippingSync' => $sync,
'shippingError' => $error,
'shippingExportUrl' => route('business.courier-payables.shipping-data.export'),
];
}
public function exportShippingData(Request $request): StreamedResponse
{
$filters = $this->shippingDataFilters($request);
$columns = $this->shippingDataColumns();
return response()->streamDownload(function () use ($filters, $columns): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建发货数据导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_column($columns, 'label'));
foreach ($this->shippingOrders->exportRows($filters) as $row) {
fputcsv($csv, array_map(function (array $column) use ($row): mixed {
$value = data_get($row, $column['field']);
return match ($column['type']) {
'identifier' => $this->csvIdentifier($value),
'number' => $value === null || $value === '' ? '' : $value,
default => $this->csvText($value),
};
}, $columns));
}
fclose($csv);
}, '发货数据_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @return array<string,string> */
private function shippingDataFilters(Request $request): array
{
$text = static fn (string $key, int $length = 128): string => mb_substr(
trim((string) $request->query($key, '')),
0,
$length,
);
$date = static function (mixed $value): string {
$value = trim((string) $value);
return preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $value) === 1 ? $value : '';
};
return [
'express_no' => $text('express_no', 80),
'sourceno' => $text('sourceno', 80),
'barcode' => $text('barcode'),
'supplier_name' => $text('supplier_name'),
'shop_name' => $text('shop_name', 255),
'warehouse_name' => $text('warehouse_name'),
'express_company' => $text('express_company'),
'ship_from' => $date($request->query('ship_from', '')),
'ship_to' => $date($request->query('ship_to', '')),
];
}
/** @return list<array{field:string,label:string,type:string}> */
private function shippingDataColumns(): array
{
return [
['field' => 'express_no', 'label' => '快递单号', 'type' => 'identifier'],
['field' => 'trade_no', 'label' => '交易单号', 'type' => 'identifier'],
['field' => 'item_id', 'label' => '商品ID', 'type' => 'identifier'],
['field' => 'barcode', 'label' => '条形码', 'type' => 'identifier'],
['field' => 'product_name', 'label' => '商品名称', 'type' => 'text'],
['field' => 'qty', 'label' => '数量', 'type' => 'number'],
['field' => 'receiver_state', 'label' => '省', 'type' => 'text'],
['field' => 'buyer_nick', 'label' => '买家昵称', 'type' => 'text'],
['field' => 'receiver_name', 'label' => '收货姓名', 'type' => 'text'],
['field' => 'shop_name', 'label' => '店铺名称', 'type' => 'text'],
['field' => 'express_name1', 'label' => '快递名称1', 'type' => 'text'],
['field' => 'warehouse_name', 'label' => '仓库', 'type' => 'text'],
['field' => 'ship_time', 'label' => '发货时间', 'type' => 'text'],
['field' => 'freight', 'label' => '运费', 'type' => 'number'],
['field' => 'online_weight', 'label' => '线上重量', 'type' => 'number'],
['field' => 'actual_weight', 'label' => '实称重量', 'type' => 'number'],
['field' => 'length_cm', 'label' => '长(cm)', 'type' => 'number'],
['field' => 'width_cm', 'label' => '宽(cm)', 'type' => 'number'],
['field' => 'height_cm', 'label' => '高(cm)', 'type' => 'number'],
['field' => 'volume_cm3', 'label' => '体积(cm³)', 'type' => 'number'],
['field' => 'receiver_city', 'label' => '市', 'type' => 'text'],
['field' => 'receiver_district', 'label' => '区', 'type' => 'text'],
['field' => 'supplier_name', 'label' => '供应商', 'type' => 'text'],
['field' => 'receiver_address', 'label' => '收货详细地址', 'type' => 'text'],
['field' => 'receiver_mobile', 'label' => '手机号码', 'type' => 'identifier'],
['field' => 'receiver_phone', 'label' => '座机号码', 'type' => 'identifier'],
['field' => 'express_name2', 'label' => '快递名称2', 'type' => 'text'],
['field' => 'platform', 'label' => '平台', 'type' => 'text'],
['field' => 'buyer_memo', 'label' => '买家留言', 'type' => 'text'],
['field' => 'seller_memo', 'label' => '卖家备注', 'type' => 'text'],
['field' => 'package_no', 'label' => '配货单号', 'type' => 'identifier'],
['field' => 'amount', 'label' => '金额', 'type' => 'number'],
['field' => 'product_code', 'label' => '货号', 'type' => 'identifier'],
['field' => 'download_time', 'label' => '下载时间', 'type' => 'text'],
['field' => 'pay_time', 'label' => '付款时间', 'type' => 'text'],
['field' => 'freight_rate', 'label' => '费率', 'type' => 'number'],
['field' => 'freight_formula', 'label' => '公式', 'type' => 'text'],
['field' => 'weight_kg', 'label' => '重量(kg)', 'type' => 'number'],
['field' => 'settle_weight_kg', 'label' => '结算重量(kg)', 'type' => 'number'],
['field' => 'settle_volume_cm3', 'label' => '结算体积(cm³)', 'type' => 'number'],
['field' => 'recon_weight_kg', 'label' => '对账重量(kg)', 'type' => 'number'],
['field' => 'recon_volume_cm3', 'label' => '对账体积(cm³)', 'type' => 'number'],
['field' => 'finished_barcode', 'label' => '成品条码', 'type' => 'identifier'],
['field' => 'finished_name', 'label' => '成品名称', 'type' => 'text'],
['field' => 'finished_supplier', 'label' => '成品供应商', 'type' => 'text'],
['field' => 'settle_freight', 'label' => '结算运费', 'type' => 'number'],
['field' => 'front_no', 'label' => '换单前单号', 'type' => 'identifier'],
];
}

View File

@ -3,6 +3,7 @@
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
/**
@ -19,7 +20,7 @@ class ShippingOrderRepository
}
/**
* @param array<string,string> $filters express_no / sourceno / ship_from / ship_to
* @param array<string,string> $filters
* @return array{data:array,total:int}
*/
public function paginate(array $filters, int $page, int $perPage): array
@ -27,19 +28,7 @@ class ShippingOrderRepository
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$q = $this->db->table('jst_oms_shipping');
if (($filters['express_no'] ?? '') !== '') {
$q->where('express_no', '=', trim((string) $filters['express_no']));
}
if (($filters['sourceno'] ?? '') !== '') {
$q->where('trade_no', '=', trim((string) $filters['sourceno']));
}
if (($filters['ship_from'] ?? '') !== '') {
$q->where('ship_time', '>=', $filters['ship_from'] . ' 00:00:00');
}
if (($filters['ship_to'] ?? '') !== '') {
$q->where('ship_time', '<', date('Y-m-d 00:00:00', strtotime($filters['ship_to'] . ' +1 day')));
}
$q = $this->filteredQuery($filters);
$total = (clone $q)->count();
$rows = (clone $q)
@ -53,6 +42,78 @@ class ShippingOrderRepository
return ['data' => $rows, 'total' => $total];
}
/**
* @param array<string,string> $filters
* @return \Generator<int,array<string,mixed>>
*/
public function exportRows(array $filters, int $batchSize = 1000): \Generator
{
$batchSize = min(max($batchSize, 1), 5000);
$lastId = null;
while (true) {
$query = $this->filteredQuery($filters);
if ($lastId !== null) {
$query->where('id', '>', $lastId);
}
$rows = $query->orderBy('id')->limit($batchSize)->get()->all();
if ($rows === []) {
return;
}
foreach ($rows as $row) {
yield (array) $row;
}
$lastId = (int) $rows[array_key_last($rows)]->id;
if (count($rows) < $batchSize) {
return;
}
}
}
/** @param array<string,string> $filters */
private function filteredQuery(array $filters): Builder
{
$query = $this->db->table('jst_oms_shipping');
if (($filters['express_no'] ?? '') !== '') {
$query->where('express_no', '=', trim((string) $filters['express_no']));
}
if (($filters['sourceno'] ?? '') !== '') {
$query->where('trade_no', '=', trim((string) $filters['sourceno']));
}
foreach (['barcode', 'supplier_name', 'shop_name', 'warehouse_name'] as $column) {
if (($filters[$column] ?? '') !== '') {
$query->whereRaw($column . " LIKE ? ESCAPE '!'", [$this->contains((string) $filters[$column])]);
}
}
if (($filters['express_company'] ?? '') !== '') {
$value = $this->contains((string) $filters['express_company']);
$query->where(static function (Builder $courier) use ($value): void {
$courier->whereRaw("express_name1 LIKE ? ESCAPE '!'", [$value])
->orWhereRaw("express_name2 LIKE ? ESCAPE '!'", [$value]);
});
}
if (($filters['ship_from'] ?? '') !== '') {
$query->where('ship_time', '>=', $filters['ship_from'] . ' 00:00:00');
}
if (($filters['ship_to'] ?? '') !== '') {
$query->where('ship_time', '<', date('Y-m-d 00:00:00', strtotime($filters['ship_to'] . ' +1 day')));
}
return $query;
}
private function contains(string $value): string
{
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%';
}
/** 同步状态(供页面显示同步中/最近完成)。 */
public function syncStatus(): array
{

View File

@ -68,11 +68,18 @@
<div class="shipping-data-page">
<style>
.shipping-data-page { color: #29251f; }
.shipping-data-page .sd-control { border: 1px solid #d9d0c2; border-radius: .375rem; padding: .5rem .7rem; font-size: .8125rem; background: #fff; outline: none; }
.shipping-data-page .sd-control { width: 100%; height: 2.125rem; border: 1px solid #d8d2c7; border-radius: .5rem; padding: 0 .625rem; font-size: .8125rem; background: #fff; color: #171717; outline: none; }
.shipping-data-page .sd-control:focus { border-color: #b18a4a; box-shadow: 0 0 0 2px rgba(177,138,74,.15); }
.shipping-data-page .sd-button { border-radius: .375rem; padding: .5rem 1rem; font-size: .8125rem; }
.shipping-data-page .sd-button--primary { background: #5d492c; color: #fff; }
.shipping-data-page .sd-button--secondary { border: 1px solid #d9d0c2; color: #655b4e; background: #fff; }
.shipping-data-page .sd-filter { min-width: 10rem; flex: 1 1 10rem; }
.shipping-data-page .sd-filter--wide { min-width: 12rem; flex-basis: 12rem; }
.shipping-data-page .sd-date-picker { position: relative; display: block; }
.shipping-data-page .sd-date-picker .sd-control { padding-right: 2.5rem; }
.shipping-data-page .sd-date-picker button { position: absolute; top: 0; right: 0; width: 2.125rem; height: 2.125rem; border: 0; border-left: 1px solid #d8d2c7; background: transparent; color: #856632; cursor: pointer; }
.shipping-data-page .sd-button { display: inline-flex; height: 2.125rem; align-items: center; gap: .375rem; border-radius: .5rem; padding: 0 .875rem; font-size: .8125rem; font-weight: 500; white-space: nowrap; }
.shipping-data-page .sd-button--primary { background: #856632; color: #fff; }
.shipping-data-page .sd-button--primary:hover { background: #6a5129; }
.shipping-data-page .sd-button--secondary { border: 1px solid #d8d2c7; color: #171717; background: #fff; }
.shipping-data-page .sd-button .material-icons-outlined { font-size: 1rem; }
.shipping-data-page .sd-table th { background: #f5f1ea; color: #6f675c; font-weight: 600; position: sticky; top: 0; }
.shipping-data-page .sd-table td, .shipping-data-page .sd-table th { border-bottom: 1px solid #eee8de; border-right: 1px solid #f1ece3; padding: .5rem .6rem; white-space: nowrap; font-size: .78rem; }
.shipping-data-page .sd-table tbody tr:hover { background: #fcfaf7; }
@ -97,11 +104,20 @@
<div class="mb-5 rounded-lg border border-[#e7e0d5] bg-white p-4 shadow-sm">
<form method="GET" class="flex flex-wrap items-end gap-3" aria-label="发货数据筛选">
<label class="flex min-w-[160px] flex-col gap-1 text-xs text-gray-500"><span>快递单号</span><input class="sd-control" type="text" name="express_no" value="{{ $filters['express_no'] ?? '' }}" placeholder="精确匹配"></label>
<label class="flex min-w-[160px] flex-col gap-1 text-xs text-gray-500"><span>交易单号</span><input class="sd-control" type="text" name="sourceno" value="{{ $filters['sourceno'] ?? '' }}" placeholder="精确匹配"></label>
<label class="flex min-w-[145px] flex-col gap-1 text-xs text-gray-500"><span>发货时间起</span><input class="sd-control" type="date" name="ship_from" value="{{ $filters['ship_from'] ?? '' }}"></label>
<label class="flex min-w-[145px] flex-col gap-1 text-xs text-gray-500"><span>发货时间止</span><input class="sd-control" type="date" name="ship_to" value="{{ $filters['ship_to'] ?? '' }}"></label>
<div class="flex gap-2"><button class="sd-button sd-button--primary" type="submit">查询</button><a class="sd-button sd-button--secondary" href="{{ url()->current() }}">重置</a></div>
<label class="sd-filter sd-filter--wide flex flex-col gap-1 text-xs text-gray-500" for="shipping-express-no"><span>快递单号</span><input id="shipping-express-no" class="sd-control" type="text" name="express_no" value="{{ $filters['express_no'] ?? '' }}" placeholder="精确匹配"></label>
<label class="sd-filter sd-filter--wide flex flex-col gap-1 text-xs text-gray-500" for="shipping-trade-no"><span>交易单号</span><input id="shipping-trade-no" class="sd-control" type="text" name="sourceno" value="{{ $filters['sourceno'] ?? '' }}" placeholder="精确匹配"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-barcode"><span>条形码</span><input id="shipping-barcode" class="sd-control" type="text" name="barcode" value="{{ $filters['barcode'] ?? '' }}" placeholder="条形码关键词"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-supplier"><span>供应商</span><input id="shipping-supplier" class="sd-control" type="text" name="supplier_name" value="{{ $filters['supplier_name'] ?? '' }}" placeholder="名称关键词"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-shop"><span>店铺名称</span><input id="shipping-shop" class="sd-control" type="text" name="shop_name" value="{{ $filters['shop_name'] ?? '' }}" placeholder="名称关键词"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-warehouse"><span>仓库</span><input id="shipping-warehouse" class="sd-control" type="text" name="warehouse_name" value="{{ $filters['warehouse_name'] ?? '' }}" placeholder="仓库关键词"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-courier"><span>快递公司</span><input id="shipping-courier" class="sd-control" type="text" name="express_company" value="{{ $filters['express_company'] ?? '' }}" placeholder="匹配快递名称1/2"></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-from"><span>发货时间起</span><span class="sd-date-picker"><input id="shipping-from" class="sd-control" type="date" name="ship_from" value="{{ $filters['ship_from'] ?? '' }}"><button type="button" data-date-picker-input="shipping-from" aria-label="选择发货开始日期" title="选择发货开始日期"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button></span></label>
<label class="sd-filter flex flex-col gap-1 text-xs text-gray-500" for="shipping-to"><span>发货时间止</span><span class="sd-date-picker"><input id="shipping-to" class="sd-control" type="date" name="ship_to" value="{{ $filters['ship_to'] ?? '' }}"><button type="button" data-date-picker-input="shipping-to" aria-label="选择发货结束日期" title="选择发货结束日期"><span class="material-icons-outlined" aria-hidden="true">calendar_today</span></button></span></label>
<div class="flex gap-2">
<button class="sd-button sd-button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span><span>查询</span></button>
<a class="sd-button sd-button--secondary" href="{{ $shippingExportUrl ?? route('business.courier-payables.shipping-data.export') }}?{{ http_build_query(array_filter($filters, static fn ($value) => $value !== '')) }}" aria-label="导出当前筛选结果" title="导出当前筛选结果"><span class="material-icons-outlined" aria-hidden="true">download</span><span>导出</span></a>
<a class="sd-button sd-button--secondary" href="{{ url()->current() }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span><span>重置</span></a>
</div>
</form>
</div>
@ -132,3 +148,20 @@
@endif
</div>
</div>
<script>
(() => {
document.querySelectorAll('.shipping-data-page [data-date-picker-input]').forEach((trigger) => {
if (trigger.dataset.datePickerBound === 'true') return;
const input = document.getElementById(trigger.dataset.datePickerInput || '');
if (!input) return;
trigger.dataset.datePickerBound = 'true';
trigger.addEventListener('click', () => {
input.focus();
if (typeof input.showPicker === 'function') input.showPicker();
else input.click();
});
});
})();
</script>

View File

@ -143,6 +143,10 @@ Route::get('workspace/courier-payables/courier-information/export', 'Business\Pa
->middleware('business.page:courier-payables,courier-information')
->name('business.courier-payables.courier-information.export');
Route::get('workspace/courier-payables/shipping-data/export', 'Business\Pages@exportShippingData')
->middleware('business.page:courier-payables,shipping-data')
->name('business.courier-payables.shipping-data.export');
Route::get('workspace/courier-payables/third-party-warehouse-storage/export', 'Business\Pages@exportThirdPartyWarehouseStorage')
->middleware('business.page:courier-payables,third-party-warehouse-storage')
->name('business.courier-payables.third-party-warehouse-storage.export');

View File

@ -0,0 +1,115 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Mockery;
use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
use Tests\Feature\FeatureTestCase;
class ShippingDataPageTest extends FeatureTestCase
{
public function test_page_passes_all_filters_and_renders_export_and_date_pickers(): void
{
$this->grantPermission();
$repository = Mockery::mock(ShippingOrderRepository::class);
$repository->shouldReceive('paginate')->once()->withArgs(function (array $filters, int $page, int $limit): bool {
return $filters === [
'express_no' => 'EXP-001',
'sourceno' => 'TRADE-001',
'barcode' => 'BAR-001',
'supplier_name' => '新北供应商',
'shop_name' => '测试店铺',
'warehouse_name' => '一号仓',
'express_company' => '顺丰',
'ship_from' => '2026-09-01',
'ship_to' => '2026-09-16',
] && $page === 1 && $limit > 0;
})->andReturn(['data' => [], 'total' => 0]);
$repository->shouldReceive('syncStatus')->once()->andReturn([
'code' => 'succeeded',
'last_succeeded_at' => '2026-09-16 10:16:36',
]);
$this->app->instance(ShippingOrderRepository::class, $repository);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'shipping-data',
'express_no' => 'EXP-001',
'sourceno' => 'TRADE-001',
'barcode' => 'BAR-001',
'supplier_name' => '新北供应商',
'shop_name' => '测试店铺',
'warehouse_name' => '一号仓',
'express_company' => '顺丰',
'ship_from' => '2026-09-01',
'ship_to' => '2026-09-16',
]))
->assertOk()
->assertSee('name="barcode"', false)
->assertSee('name="supplier_name"', false)
->assertSee('name="shop_name"', false)
->assertSee('name="warehouse_name"', false)
->assertSee('name="express_company"', false)
->assertSee('type="date" name="ship_from"', false)
->assertSee('data-date-picker-input="shipping-from"', false)
->assertSeeText('导出');
}
public function test_export_uses_current_filters_and_preserves_identifiers_as_text(): void
{
$this->grantPermission();
$repository = Mockery::mock(ShippingOrderRepository::class);
$repository->shouldReceive('exportRows')->once()->withArgs(function (array $filters): bool {
return $filters['barcode'] === '6901234567890'
&& $filters['supplier_name'] === '新北供应商'
&& $filters['shop_name'] === '测试店铺'
&& $filters['warehouse_name'] === '一号仓'
&& $filters['express_company'] === '顺丰'
&& $filters['ship_from'] === '2026-09-01'
&& $filters['ship_to'] === '2026-09-16';
})->andReturn((static function (): \Generator {
yield [
'express_no' => '01234567890123456789',
'trade_no' => 'TRADE-001',
'barcode' => '6901234567890',
'product_name' => '测试商品',
'qty' => 2,
'shop_name' => '测试店铺',
'warehouse_name' => '一号仓',
'supplier_name' => '新北供应商',
'express_name1' => '顺丰速运',
'ship_time' => '2026-09-10 12:00:00',
];
})());
$this->app->instance(ShippingOrderRepository::class, $repository);
$response = $this->loginAs()->get(route('business.courier-payables.shipping-data.export', [
'barcode' => '6901234567890',
'supplier_name' => '新北供应商',
'shop_name' => '测试店铺',
'warehouse_name' => '一号仓',
'express_company' => '顺丰',
'ship_from' => '2026-09-01',
'ship_to' => '2026-09-16',
]));
$response->assertOk();
$this->assertStringContainsString('attachment;', (string) $response->headers->get('content-disposition'));
$content = $response->streamedContent();
$this->assertStringStartsWith(chr(0xEF) . chr(0xBB) . chr(0xBF), $content);
$this->assertStringContainsString('快递单号', $content);
$this->assertStringContainsString("'01234567890123456789", $content);
$this->assertStringContainsString("'6901234567890", $content);
$this->assertStringContainsString('新北供应商', $content);
}
private function grantPermission(): void
{
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail(),
);
}
}