diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index dc3ef4af..7c0a9482 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -3265,6 +3265,7 @@ class Pages extends Controller 'board-deductions' => $this->contentForBoardDeductions(), 'supplier-payables-report' => $this->contentForSupplierPayablesReport(), 'supplier-management' => app(\Modules\EcommerceFinance\View\SupplierManagementPageData::class)->page(), + 'accessory-freight-deductions' => app(\Modules\EcommerceFinance\View\SupplierAccessoryFreightPageData::class)->page(), 'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(), 'online-daifa-price' => $this->contentForOnlineDaifaPrice(), 'product-costs' => $this->contentForProductCosts(), diff --git a/app/Http/Controllers/Business/SupplierAccessoryFreight.php b/app/Http/Controllers/Business/SupplierAccessoryFreight.php new file mode 100644 index 00000000..e9fa0018 --- /dev/null +++ b/app/Http/Controllers/Business/SupplierAccessoryFreight.php @@ -0,0 +1,41 @@ +filters(); + $columns = $data->exportColumns(); + $file = tmpfile(); + abort_if($file === false, 500, '无法创建导出文件。'); + try { + fwrite($file, "\xEF\xBB\xBF"); + fputcsv($file, ['业务月份', '发货记录ID', '仓库', ...array_values($columns)]); + foreach ($data->rows($filters) as $row) { + if ($filters['state'] !== 'all' && $filters['state'] !== $row['state']) continue; + $cells = array_merge($row, ['courier' => ($row['express_name1'] ?? '') ?: ($row['express_name2'] ?? ''), + 'freight' => $row['freight_cents'] === null ? '待核实' : number_format($row['freight_cents'] / 100, 2, '.', ''), + 'state' => SupplierAccessoryFreightMatcher::STATES[$row['state']], 'deduction' => number_format($row['deduction_cents'] / 100, 2, '.', '')]); + $values = [$filters['month'], $row['source_id'], \Modules\EcommerceFinance\Services\AccessoryReshipMatcher::WAREHOUSE]; + foreach ($columns as $key => $label) $values[] = $cells[$key]; + fputcsv($file, array_map(static function ($value) { + $value = (string) $value; + // 纯数字标识保留文本,防止 Excel 截断长单号;同时阻止公式执行。 + return preg_match('/^[=+\-@\t\r\n]|^\d{12,}$/u', $value) ? "'" . $value : $value; + }, $values)); + } + rewind($file); + } catch (\Throwable $exception) { + fclose($file); + report($exception); + abort(503, '资料读取失败,未生成不完整的扣款明细,请稍后重试。'); + } + return response()->streamDownload(function () use ($file) { fpassthru($file); fclose($file); }, SupplierAccessoryFreightPageData::TITLE . '-' . $filters['month'] . '.csv', ['Content-Type' => 'text/csv; charset=UTF-8']); + } +} diff --git a/config/business_navigation.php b/config/business_navigation.php index fc30fae1..d1049dd5 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -685,6 +685,7 @@ $navigation = [ 'icon' => 'local_shipping', 'pages' => [ ['slug' => 'supplier-management', 'title' => '供应商管理'], + ['slug' => 'accessory-freight-deductions', 'title' => '新河北仓、曹县仓配件运费扣除明细'], ['slug' => 'product-prices', 'title' => '商品价格'], ['slug' => 'payment-reconciliation', 'title' => '货款对账'], ['slug' => 'return-reconciliation', 'title' => '退货对账'], diff --git a/docs/供应商配件运费扣除.md b/docs/供应商配件运费扣除.md new file mode 100644 index 00000000..0042bfb6 --- /dev/null +++ b/docs/供应商配件运费扣除.md @@ -0,0 +1,15 @@ +# 新河北仓、曹县仓配件运费扣除明细 + +入口:供应商应付 → 新河北仓、曹县仓配件运费扣除明细。 + +按业务月份读取发货数据,仓库固定为“常州简尚家居有限公司”。沿用配件补发扣款的包裹合并及条形码规则:按 `source_id` 合并运费,按明细顺序取第一个逗号前的条形码,删除最后一个 `-` 及其后缀。 + +处理后的条形码匹配普通商品资料 `sku_id`,使用商品的 `supplier_name` 精确匹配供应商管理的 `scmname`,读取 `linkerprovience`(联系人省)。山东、山东省统一为山东;河北、河北省统一为河北。只有这两个省份扣除对应包裹全部运费,其他省份不扣,不套用快递扣款比例,也不要求匹配首发快递。 + +未匹配商品、商品未维护供应商、未匹配供应商档案、联系人省缺失、供应商或省份冲突、运费无效,均列为待核实,扣款额为零。同一条形码的多条商品记录若供应商相同,或同一供应商多份档案的省份经上述归一后一致,只计算一笔运费。零运费列为不扣款。金额按整数分汇总,页面和导出口径一致。 + +默认展示可扣款,可切换全部、不扣款、待核实;支持月份、交易单号、快递单号、供应商、处理后条形码、联系人省筛选及每页50条翻页。上方汇总统计当前文字筛选范围内全部状态。列设置支持隐藏、拖动排序、恢复默认,按用户和公司存于浏览器;CSV导出覆盖当前状态及筛选结果全部行,按可见列顺序导出,额外保留月份、发货记录ID和仓库供核对,长单号按文本保护并转义公式字符。 + +只读取现有共享镜像,不新增数据库表、不写回商品、供应商或发货数据,不自动进入供应商应付汇总或执行财务入账。页面与导出沿用供应商应付页级权限。 + +验证:`SupplierAccessoryFreightTest` 覆盖四种省名、冲突、缺失资料、异常/零运费、真实SQLite关联、翻页汇总、过滤、CSV文本与公式保护、列顺序及不完整数据错误路径;同时运行配件补发扣款测试,验证共享包裹读取重用后的行为。 diff --git a/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php b/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php index 5fb9cb12..290575e7 100644 --- a/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php +++ b/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php @@ -69,6 +69,21 @@ class ShippingOrderRepository /** 仅批量读取本月配件及其交易单号对应的三个月首发候选;复用发货数据合并口径。 */ public function accessoryReshipBatches(string $monthStart, string $nextMonth, string $windowStart): \Generator + { + foreach ($this->accessoryFreightBatches($monthStart, $nextMonth) as $accessories) { + $trades = array_values(array_unique(array_filter(array_column($accessories, 'trade_no'), static fn ($trade) => trim((string) $trade) !== ''))); + $originals = $trades === [] ? [] : $this->db->table('jst_oms_shipping') + ->where('warehouse_name', '<>', \Modules\EcommerceFinance\Services\AccessoryReshipMatcher::WAREHOUSE) + ->where('ship_time', '>=', $windowStart)->where('ship_time', '<', $nextMonth) + ->whereIn('trade_no', $trades)->whereNotNull('express_no')->where('express_no', '<>', '') + ->select(['trade_no', 'barcode', 'express_no', 'express_name1', 'express_name2', 'ship_time', 'warehouse_name']) + ->get()->map(fn ($row) => (array) $row)->all(); + yield compact('accessories', 'originals'); + } + } + + /** 常州配件按发货包裹合并,运费只累计一次;两个扣款明细共用同一口径。 */ + public function accessoryFreightBatches(string $monthStart, string $nextMonth): \Generator { $last = null; while (true) { @@ -80,14 +95,7 @@ class ShippingOrderRepository $select = 'source_id, MAX(express_no) as express_no, MAX(trade_no) as trade_no, MAX(express_name1) as express_name1, MAX(express_name2) as express_name2, MAX(ship_time) as ship_time, SUM(freight) as freight, GROUP_CONCAT(DISTINCT barcode ORDER BY id SEPARATOR \',\' ) as barcode'; $accessories = $query->groupBy('source_id')->selectRaw($select)->orderBy('source_id')->limit(1000)->get()->map(fn ($row) => (array) $row)->all(); if ($accessories === []) return; - $trades = array_values(array_unique(array_filter(array_column($accessories, 'trade_no'), static fn ($trade) => trim((string) $trade) !== ''))); - $originals = $trades === [] ? [] : $this->db->table('jst_oms_shipping') - ->where('warehouse_name', '<>', \Modules\EcommerceFinance\Services\AccessoryReshipMatcher::WAREHOUSE) - ->where('ship_time', '>=', $windowStart)->where('ship_time', '<', $nextMonth) - ->whereIn('trade_no', $trades)->whereNotNull('express_no')->where('express_no', '<>', '') - ->select(['trade_no', 'barcode', 'express_no', 'express_name1', 'express_name2', 'ship_time', 'warehouse_name']) - ->get()->map(fn ($row) => (array) $row)->all(); - yield compact('accessories', 'originals'); + yield $accessories; $last = $accessories[array_key_last($accessories)]['source_id']; } } diff --git a/modules/EcommerceFinance/Repositories/SupplierAccessoryFreightRepository.php b/modules/EcommerceFinance/Repositories/SupplierAccessoryFreightRepository.php new file mode 100644 index 00000000..c2ed3794 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/SupplierAccessoryFreightRepository.php @@ -0,0 +1,46 @@ +datasets->get(ProductCatalogMirrorRepository::DATASET_KEY); + $supplierDataset = $this->datasets->get(SupplierManagementMirrorRepository::DATASET); + if (!$this->mirror->ready($productDataset) || !$this->mirror->ready($supplierDataset)) { + throw new RuntimeException('商品或供应商资料尚未完成首次同步'); + } + $db = $this->mirror->connection(); + // 供应商档案数量较小,一次读取,批量商品查询避免逐单访问数据库。 + $suppliers = $db->table($supplierDataset->targetTable())->get(['scmname', 'linkerprovience']) + ->groupBy(static fn ($row) => trim((string) $row->scmname)) + ->map(static fn ($group) => $group->map(static fn ($row) => (array) $row)->all()); + $start = \Carbon\Carbon::createFromFormat('!Y-m', $month); + foreach ($this->shipping->accessoryFreightBatches($start->format('Y-m-d'), $start->copy()->addMonth()->format('Y-m-d')) as $batch) { + $barcodes = array_values(array_unique(array_filter(array_map(static fn ($row) => AccessoryReshipMatcher::barcode((string) ($row['barcode'] ?? '')), $batch), static fn ($value) => $value !== ''))); + $products = $db->table($productDataset->targetTable())->where('sku_type', 'normal')->whereIn('sku_id', $barcodes) + ->get(['sku_id', 'name', 'supplier_name'])->groupBy(static fn ($row) => trim((string) $row->sku_id)) + ->map(static fn ($group) => $group->map(static fn ($row) => (array) $row)->all()); + foreach ($batch as $row) { + $barcode = AccessoryReshipMatcher::barcode((string) ($row['barcode'] ?? '')); + $candidates = $products->get($barcode, []); + $supplierName = trim((string) ($candidates[0]['supplier_name'] ?? '')); + yield $this->matcher->match($row, $candidates, $suppliers->get($supplierName, [])); + } + } + } +} diff --git a/modules/EcommerceFinance/Services/SupplierAccessoryFreightMatcher.php b/modules/EcommerceFinance/Services/SupplierAccessoryFreightMatcher.php new file mode 100644 index 00000000..8d91572e --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierAccessoryFreightMatcher.php @@ -0,0 +1,50 @@ + '可扣款', 'excluded' => '不扣款', 'pending' => '待核实']; + + public function match(array $row, array $products, array $suppliers): array + { + $barcode = AccessoryReshipMatcher::barcode((string) ($row['barcode'] ?? '')); + $row = array_merge($row, ['normalized_barcode' => $barcode, 'product_name' => '', 'supplier_name' => '', + 'province' => '', 'freight_cents' => AccessoryReshipMatcher::cents($row['freight'] ?? null), + 'deduction_cents' => 0, 'state' => 'pending', 'reason' => '未匹配到商品资料']); + if ($barcode === '' || $products === []) return $row; + $names = array_values(array_unique(array_map(static fn ($product) => trim((string) ($product['supplier_name'] ?? '')), $products))); + $row['product_name'] = implode(' / ', array_unique(array_column($products, 'name'))); + $row['supplier_name'] = implode(' / ', $names); + if (count($names) !== 1 || $names[0] === '') { + $row['reason'] = count($names) > 1 ? '同一条形码对应多个供应商' : '商品资料未维护供应商'; + return $row; + } + if ($suppliers === []) { + $row['reason'] = '未匹配到供应商管理资料'; + return $row; + } + $provinces = array_values(array_unique(array_map(static function ($supplier) { + $province = trim((string) ($supplier['linkerprovience'] ?? '')); + return match ($province) { '山东省' => '山东', '河北省' => '河北', default => $province }; + }, $suppliers))); + $row['province'] = implode(' / ', array_filter($provinces, static fn ($value) => $value !== '')); + if (count($provinces) !== 1 || $provinces[0] === '') { + $row['reason'] = count($provinces) > 1 ? '供应商联系人省存在冲突' : '供应商未维护联系人省'; + return $row; + } + if (!in_array($provinces[0], ['山东', '河北'], true)) { + $row['state'] = 'excluded'; + $row['reason'] = '联系人省不属于山东、河北'; + return $row; + } + if ($row['freight_cents'] === null) { + $row['reason'] = '运费无效,需核实'; + return $row; + } + $row['state'] = $row['freight_cents'] > 0 ? 'ready' : 'excluded'; + $row['deduction_cents'] = $row['freight_cents']; + $row['reason'] = $row['freight_cents'] > 0 ? '按山东、河北供应商扣除全部配件运费' : '运费为零,不扣款'; + return $row; + } +} diff --git a/modules/EcommerceFinance/View/SupplierAccessoryFreightPageData.php b/modules/EcommerceFinance/View/SupplierAccessoryFreightPageData.php new file mode 100644 index 00000000..b6785284 --- /dev/null +++ b/modules/EcommerceFinance/View/SupplierAccessoryFreightPageData.php @@ -0,0 +1,81 @@ + '交易单号', 'express_no' => '快递单号', 'supplier_name' => '供应商', 'normalized_barcode' => '处理后条形码', 'province' => '联系人省']; + public const COLUMNS = ['state' => '扣款状态', 'trade_no' => '交易单号', 'express_no' => '快递单号', 'courier' => '快递名称', 'ship_time' => '发货时间', 'barcode' => '原始条形码', 'normalized_barcode' => '处理后条形码', 'product_name' => '商品名称', 'supplier_name' => '供应商', 'province' => '联系人省', 'freight' => '配件运费(元)', 'deduction' => '扣款金额(元)', 'reason' => '说明']; + + public function exportColumns(): array + { + $requested = request()->query('columns'); + if ($requested === null) return self::COLUMNS; + abort_unless(is_string($requested) && strlen($requested) <= 500, 422, '导出列无效。'); + $keys = explode(',', $requested); + abort_unless($keys !== [] && count($keys) === count(array_unique($keys)) && array_diff($keys, array_keys(self::COLUMNS)) === [], 422, '导出列无效。'); + return array_replace(array_flip($keys), array_intersect_key(self::COLUMNS, array_flip($keys))); + } + + public function __construct(private readonly SupplierAccessoryFreightRepository $repository) {} + + public function filters(): array + { + $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, '请选择有效业务月份。'); + $state = request()->query('state', 'ready'); + abort_unless(is_string($state) && in_array($state, ['all', ...array_keys(SupplierAccessoryFreightMatcher::STATES)], true), 422, '扣款状态无效。'); + $filters = compact('month', 'state'); + foreach (self::TEXT_FILTERS as $key => $label) { + $value = request()->query($key, ''); + abort_unless(is_string($value) && mb_strlen($value) <= 200, 422, '筛选条件格式无效。'); + $filters[$key] = trim($value); + } + $filters['province'] = match ($filters['province']) { '山东省' => '山东', '河北省' => '河北', default => $filters['province'] }; + return $filters; + } + + public function rows(array $filters): \Generator + { + foreach ($this->repository->rows($filters['month']) as $row) { + foreach (self::TEXT_FILTERS as $key => $label) { + if (($filters[$key] ?? '') !== '' && mb_stripos((string) ($row[$key] ?? ''), $filters[$key]) === false) continue 2; + } + yield $row; + } + } + + public function page(): array + { + $filters = $this->filters(); + $page = max(1, (int) request('page', 1)); + $summary = ['all' => 0, 'ready' => 0, 'excluded' => 0, 'pending' => 0, 'amount' => 0]; + $items = []; + $total = 0; + $error = null; + try { + foreach ($this->rows($filters) as $row) { + $summary['all']++; + $summary[$row['state']]++; + $summary['amount'] += $row['deduction_cents']; + if ($filters['state'] !== 'all' && $filters['state'] !== $row['state']) continue; + if ($total >= ($page - 1) * 50 && count($items) < 50) $items[] = $row; + $total++; + } + } catch (\Throwable $exception) { + report($exception); + $error = '发货、商品或供应商资料暂时无法读取,或尚未完成同步,请稍后重试。'; + $summary = array_fill_keys(array_keys($summary), 0); + $items = []; + $total = 0; + } + return ['view' => 'business.pages.supplier-payables.accessory-freight-deductions', + 'freightFilters' => $filters, 'freightSummary' => $summary, 'freightError' => $error, + 'freightRows' => new LengthAwarePaginator($items, $total, 50, $page, ['path' => request()->url(), 'query' => request()->except('page')])]; + } +} diff --git a/resources/views/business/pages/supplier-payables/accessory-freight-deductions.blade.php b/resources/views/business/pages/supplier-payables/accessory-freight-deductions.blade.php new file mode 100644 index 00000000..ba44ae5f --- /dev/null +++ b/resources/views/business/pages/supplier-payables/accessory-freight-deductions.blade.php @@ -0,0 +1,85 @@ +@php + $states = \Modules\EcommerceFinance\Services\SupplierAccessoryFreightMatcher::STATES; + $title = \Modules\EcommerceFinance\View\SupplierAccessoryFreightPageData::TITLE; +@endphp +
+

常州配件发货运费,按商品供应商的联系人省归属核算。

按月核算
+
+ + + @foreach(\Modules\EcommerceFinance\View\SupplierAccessoryFreightPageData::TEXT_FILTERS as $key => $label) + + @endforeach + + 重置 + 导出当前筛选 +
列设置

勾选显示,拖动调整顺序

@foreach(\Modules\EcommerceFinance\View\SupplierAccessoryFreightPageData::COLUMNS as $key => $label)@endforeach
+
+
+
筛选范围内配件发货{{ number_format($freightSummary['all']) }}
+
可扣款 / 不扣款{{ number_format($freightSummary['ready']) }} / {{ number_format($freightSummary['excluded']) }}
+
待核实{{ number_format($freightSummary['pending']) }}
+
可扣运费合计(元){{ number_format($freightSummary['amount'] / 100, 2) }}
+
+
扣款规则与金额口径

仅取所选月份仓库为“常州简尚家居有限公司”的发货包裹。取第一个逗号前的条形码,再去掉最后一个“-”及其后缀,用处理后的条形码匹配商品资料条形码,再按商品供应商名称匹配供应商管理。联系人省为山东、山东省、河北、河北省的供应商,扣除对应包裹全部运费。每个发货包裹只计一次;不需要匹配首发单。其他省份不扣,未匹配、资料缺失、供应商或省份冲突、运费异常的记录待核实。此处展示扣除明细,不自动入账。

+ @if($freightError)@endif +
+
+ @foreach(\Modules\EcommerceFinance\View\SupplierAccessoryFreightPageData::COLUMNS as $key => $column)@endforeach + + @forelse($freightRows as $row) + + @empty + + @endforelse +
{{ $column }}
{{ $states[$row['state']] }}{{ $row['trade_no'] ?: '—' }}{{ $row['express_no'] ?: '—' }}{{ ($row['express_name1'] ?? '') ?: ($row['express_name2'] ?? '') ?: '—' }}{{ $row['ship_time'] }}{{ $row['barcode'] ?: '—' }}{{ $row['normalized_barcode'] ?: '—' }}{{ $row['product_name'] ?: '—' }}{{ $row['supplier_name'] ?: '—' }}{{ $row['province'] ?: '—' }}{{ $row['freight_cents'] === null ? '待核实' : number_format($row['freight_cents'] / 100, 2) }}{{ number_format($row['deduction_cents'] / 100, 2) }}{{ $row['reason'] }}
{{ $freightError ?: '当前筛选条件下暂无记录,请调整月份、状态或筛选条件。' }}
+ +
+
+ + +@push('body_end') + +@endpush diff --git a/routes/admin.php b/routes/admin.php index 3ef35907..c197461a 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -465,6 +465,10 @@ Route::get('workspace/courier-payables/accessory-reship-deductions/export', [\Ap ->middleware('business.page:courier-payables,accessory-reship-deductions') ->name('business.accessory-reship-deductions.export'); +Route::get('workspace/supplier-payables/accessory-freight-deductions/export', [\App\Http\Controllers\Business\SupplierAccessoryFreight::class, 'export']) + ->middleware('business.page:supplier-payables,accessory-freight-deductions') + ->name('business.supplier-accessory-freight.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'); diff --git a/tests/Unit/SupplierAccessoryFreightTest.php b/tests/Unit/SupplierAccessoryFreightTest.php new file mode 100644 index 00000000..6952c3bb --- /dev/null +++ b/tests/Unit/SupplierAccessoryFreightTest.php @@ -0,0 +1,205 @@ +instance('config', new Repository(['app' => ['platform_timezone' => 'Asia/Shanghai']])); + $db = new Manager($app); + $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']); + $app->instance('db', $db->getDatabaseManager()); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($app); + $app->instance('request', Request::create('/?month=2026-09&state=all')); + $app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Illuminate\Routing\ResponseFactory( + Mockery::mock(\Illuminate\Contracts\View\Factory::class), Mockery::mock(\Illuminate\Routing\Redirector::class) + )); + } + + protected function tearDown(): void { Mockery::close(); parent::tearDown(); } + + private function row(array $patch = []): array + { + return array_replace(['source_id' => 1, 'trade_no' => '123456789012345678', 'express_no' => 'BF001', 'express_name1' => '补发快递', + 'ship_time' => '2026-09-15', 'barcode' => 'GBGJ-SJ038024N1410V02-10,GBGJ-SJ038024N1410V02-2', 'freight' => '12.35'], $patch); + } + + public function test_four_province_spellings_deduct_full_freight_once(): void + { + foreach (['山东', '山东省', '河北', '河北省'] as $province) { + $result = (new SupplierAccessoryFreightMatcher)->match($this->row(), [['name' => '成品', 'supplier_name' => '供应商甲'], ['name' => '成品', 'supplier_name' => '供应商甲']], [['linkerprovience' => $province]]); + $this->assertSame('GBGJ-SJ038024N1410V02', $result['normalized_barcode']); + $this->assertSame('ready', $result['state']); + $this->assertSame(1235, $result['deduction_cents']); + } + } + + public function test_missing_or_ambiguous_product_supplier_province_never_deducts(): void + { + $product = [['name' => '成品', 'supplier_name' => '甲']]; + foreach ([ [[], []], [[['name' => '成品', 'supplier_name' => '']], []], [$product, []], + [[...$product, ['name' => '成品', 'supplier_name' => '乙']], [['linkerprovience' => '山东']]], + [$product, [['linkerprovience' => '']]], [$product, [['linkerprovience' => '山东'], ['linkerprovience' => '河北']]], + [$product, [['linkerprovience' => '山东'], ['linkerprovience' => '']]] ] as [$products, $suppliers]) { + $result = (new SupplierAccessoryFreightMatcher)->match($this->row(), $products, $suppliers); + $this->assertSame('pending', $result['state']); + $this->assertSame(0, $result['deduction_cents']); + } + } + + public function test_other_provinces_zero_invalid_and_equivalent_duplicate_provinces(): void + { + $matcher = new SupplierAccessoryFreightMatcher; + $products = [['name' => '成品', 'supplier_name' => '甲']]; + foreach (['江苏省', '山东济南', '河北省石家庄市'] as $province) { + $result = $matcher->match($this->row(), $products, [['linkerprovience' => $province]]); + $this->assertSame('excluded', $result['state']); + $this->assertSame(0, $result['deduction_cents']); + } + foreach ([null, '', '-5', 'abc'] as $amount) { + $result = $matcher->match($this->row(['freight' => $amount]), $products, [['linkerprovience' => '河北']]); + $this->assertSame('pending', $result['state']); + $this->assertSame(0, $result['deduction_cents']); + } + $result = $matcher->match($this->row(['freight' => '0']), $products, [['linkerprovience' => '河北']]); + $this->assertSame('excluded', $result['state']); + $result = $matcher->match($this->row(['freight' => '0.10']), $products, [['linkerprovience' => ' 山东 '], ['linkerprovience' => '山东省']]); + $this->assertSame('ready', $result['state']); + $this->assertSame(10, $result['deduction_cents']); + } + + public function test_repository_uses_product_supplier_not_shipping_supplier_and_ignores_combinations(): void + { + $connection = app('db')->connection(); + $connection->statement('CREATE TABLE products (sku_id TEXT, sku_type TEXT, name TEXT, supplier_name TEXT)'); + $connection->statement('CREATE TABLE suppliers (scmname TEXT, linkerprovience TEXT)'); + $connection->table('products')->insert([ + ['sku_id' => 'GBGJ-SJ038024N1410V02', 'sku_type' => 'normal', 'name' => '成品', 'supplier_name' => '商品供应商'], + ['sku_id' => 'GBGJ-SJ038024N1410V02', 'sku_type' => 'combine', 'name' => '组合', 'supplier_name' => '其他供应商'], + ]); + $connection->table('suppliers')->insert(['scmname' => '商品供应商', 'linkerprovience' => '河北省']); + $registry = Mockery::mock(BusinessMirrorDatasetRegistry::class); + foreach ([ProductCatalogMirrorRepository::DATASET_KEY => 'products', SupplierManagementMirrorRepository::DATASET => 'suppliers'] as $key => $table) { + $dataset = Mockery::mock(BusinessMirrorDataset::class); + $dataset->shouldReceive('targetTable')->andReturn($table); + $registry->shouldReceive('get')->with($key)->andReturn($dataset); + } + $mirror = Mockery::mock(GenericBusinessMirrorRepository::class); + $mirror->shouldReceive('ready')->andReturnTrue(); + $mirror->shouldReceive('connection')->andReturn($connection); + $shipping = Mockery::mock(ShippingOrderRepository::class); + $shipping->shouldReceive('accessoryFreightBatches')->once()->with('2026-09-01', '2026-10-01')->andReturn((function () { yield [$this->row(['supplier_name' => '错误的历史供应商'])]; })()); + $repo = new SupplierAccessoryFreightRepository($shipping, $registry, $mirror, new SupplierAccessoryFreightMatcher); + $rows = iterator_to_array($repo->rows('2026-09')); + $this->assertCount(1, $rows); + $this->assertSame('商品供应商', $rows[0]['supplier_name']); + $this->assertSame(1235, $rows[0]['deduction_cents']); + } + + private function data(): SupplierAccessoryFreightPageData + { + $repository = Mockery::mock(SupplierAccessoryFreightRepository::class); + $repository->shouldReceive('rows')->with('2026-09')->andReturnUsing(function () { + $matcher = new SupplierAccessoryFreightMatcher; + for ($i = 0; $i < 105; $i++) yield $matcher->match($this->row(['source_id' => $i, 'freight' => '0.10']), [['name' => '=危险商品', 'supplier_name' => '山东供应商']], [['linkerprovience' => '山东']]); + yield $matcher->match($this->row(['source_id' => 200]), [], []); + }); + return new SupplierAccessoryFreightPageData($repository); + } + + public function test_summary_pagination_and_filters_use_full_month_without_multiplying_amount(): void + { + app()->instance('request', Request::create('/?month=2026-09&state=all&page=2')); + $page = $this->data()->page(); + $this->assertNull($page['freightError']); + $this->assertCount(50, $page['freightRows']); + $this->assertSame(106, $page['freightRows']->total()); + $this->assertSame(1050, $page['freightSummary']['amount']); + app()->instance('request', Request::create('/?month=2026-09&state=ready&supplier_name=山东&page=3')); + $page = $this->data()->page(); + $this->assertCount(5, $page['freightRows']); + $this->assertSame(105, $page['freightRows']->total()); + $this->assertSame(0, $page['freightSummary']['pending']); + $this->assertSame(1050, $page['freightSummary']['amount']); + } + + public function test_csv_matches_filters_preserves_long_ids_and_escapes_formulas(): void + { + app()->instance('request', Request::create('/?month=2026-09&state=pending')); + $controller = new \App\Http\Controllers\Business\SupplierAccessoryFreight; + $response = $controller->export($this->data()); + ob_start(); $response->sendContent(); $csv = ob_get_clean(); + $this->assertStringStartsWith("\xEF\xBB\xBF", $csv); + $this->assertSame(2, substr_count($csv, "\n")); + $this->assertStringContainsString("'123456789012345678", $csv); + $this->assertStringContainsString('待核实', $csv); + app()->instance('request', Request::create('/?month=2026-09&state=ready')); + $response = $controller->export($this->data()); + ob_start(); $response->sendContent(); $csv = ob_get_clean(); + $this->assertStringContainsString("'=危险商品", $csv); + app()->instance('request', Request::create('/?month=2026-09&state=ready&columns=deduction,supplier_name')); + $response = $controller->export($this->data()); + ob_start(); $response->sendContent(); $csv = ob_get_clean(); + $lines = explode("\n", trim(substr($csv, 3))); + $this->assertSame(['业务月份', '发货记录ID', '仓库', '扣款金额(元)', '供应商'], str_getcsv($lines[0])); + $this->assertCount(5, str_getcsv($lines[1])); + } + + public function test_filters_accept_province_alias_and_reject_invalid_month_state_and_columns(): void + { + app()->instance('request', Request::create('/?month=2026-09&state=all&province=山东省')); + $this->assertSame('山东', $this->data()->filters()['province']); + foreach (['month=2026-13', 'month[]=2026-09', 'month=1999-01', 'month=2026-09&state=unknown', 'month=2026-09&supplier_name[]=x', 'month=2026-09&columns=', 'month=2026-09&columns=state,state', 'month=2026-09&columns=password'] as $query) { + app()->instance('request', Request::create('/?' . $query)); + try { + $this->data()->filters(); + $this->data()->exportColumns(); + $this->fail('应拒绝无效条件'); + } catch (\Symfony\Component\HttpKernel\Exception\HttpException $exception) { + $this->assertSame(422, $exception->getStatusCode()); + } + } + } + + public function test_data_error_clears_partial_rows_and_totals(): void + { + $repository = Mockery::mock(SupplierAccessoryFreightRepository::class); + $repository->shouldReceive('rows')->andReturnUsing(function () { + yield (new SupplierAccessoryFreightMatcher)->match($this->row(), [['name'=>'商品','supplier_name'=>'甲']], [['linkerprovience'=>'河北']]); + throw new \RuntimeException('读取失败'); + }); + $handler = Mockery::mock(\Illuminate\Contracts\Debug\ExceptionHandler::class); + $handler->shouldReceive('report')->once(); + app()->instance(\Illuminate\Contracts\Debug\ExceptionHandler::class, $handler); + $page = (new SupplierAccessoryFreightPageData($repository))->page(); + $this->assertNotNull($page['freightError']); + $this->assertSame(0, $page['freightRows']->total()); + $this->assertSame(0, $page['freightSummary']['amount']); + } +}