From 34b68bb431a1d638ff1c03633cb4905ff6881cb2 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Wed, 16 Sep 2026 10:55:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=8F=91=E8=B4=A7?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=AD=9B=E9=80=89=E6=97=A5=E6=9C=9F=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E4=B8=8E=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Business/Pages.php | 126 ++++++++++++++++-- .../Repositories/ShippingOrderRepository.php | 89 +++++++++++-- .../courier-payables/shipping-data.blade.php | 51 +++++-- routes/admin.php | 4 + .../Feature/Business/ShippingDataPageTest.php | 115 ++++++++++++++++ 5 files changed, 352 insertions(+), 33 deletions(-) create mode 100644 tests/Feature/Business/ShippingDataPageTest.php diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 36db9cfe..7cbfe97b 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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 */ + 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 */ + 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'], ]; } diff --git a/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php b/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php index d2cb5af6..e2605f67 100644 --- a/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php +++ b/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php @@ -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 $filters express_no / sourceno / ship_from / ship_to + * @param array $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 $filters + * @return \Generator> + */ + 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 $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 { diff --git a/resources/views/business/pages/courier-payables/shipping-data.blade.php b/resources/views/business/pages/courier-payables/shipping-data.blade.php index e7ad2b9d..75344ff6 100644 --- a/resources/views/business/pages/courier-payables/shipping-data.blade.php +++ b/resources/views/business/pages/courier-payables/shipping-data.blade.php @@ -68,11 +68,18 @@