From 0a54775b3bcb849452e4e6b9e0fe6614d5fcb05f Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Fri, 16 Oct 2026 22:27:46 +0800 Subject: [PATCH] =?UTF-8?q?=E9=85=8D=E4=BB=B6=E8=A1=A5=E5=8F=91=E6=89=A3?= =?UTF-8?q?=E6=AC=BE=EF=BC=9A=E5=BF=AB=E9=80=92=E6=AF=94=E4=BE=8B=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E3=80=81Excel=E5=AF=BC=E5=85=A5=E5=92=8C=E5=BD=92?= =?UTF-8?q?=E5=B1=9E=E9=80=89=E6=8B=A9=20(#357)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ImportAccessoryReshipCourierConfigs.php | 32 ++++ .../AccessoryReshipCourierConfigs.php | 93 ++++++++++ .../Business/AccessoryReshipDeductions.php | 23 ++- ...reate_accessory_reship_courier_configs.php | 40 +++++ ...000_create_accessory_reship_selections.php | 26 +++ docs/配件补发扣款.md | 16 +- .../AccessoryReshipCourierImporter.php | 80 +++++++++ .../Services/AccessoryReshipCourierStore.php | 129 ++++++++++++++ .../AccessoryReshipRateCalculator.php | 85 +++++++++ .../AccessoryReshipSelectionStore.php | 33 ++++ .../View/AccessoryReshipConfigPageData.php | 35 ++++ .../View/AccessoryReshipPageData.php | 51 ++++-- .../accessory-reship-deductions.blade.php | 100 +++++++++-- .../accessory-reship-configs.blade.php | 63 +++++++ routes/admin.php | 16 ++ .../Unit/AccessoryReshipCourierConfigTest.php | 167 ++++++++++++++++++ tests/Unit/AccessoryReshipPageDataTest.php | 37 +++- 17 files changed, 987 insertions(+), 39 deletions(-) create mode 100644 app/Console/Commands/ImportAccessoryReshipCourierConfigs.php create mode 100644 app/Http/Controllers/Business/AccessoryReshipCourierConfigs.php create mode 100644 database/migrations/2026_10_16_220000_create_accessory_reship_courier_configs.php create mode 100644 database/migrations/2026_10_16_230000_create_accessory_reship_selections.php create mode 100644 modules/EcommerceFinance/Services/AccessoryReshipCourierImporter.php create mode 100644 modules/EcommerceFinance/Services/AccessoryReshipCourierStore.php create mode 100644 modules/EcommerceFinance/Services/AccessoryReshipRateCalculator.php create mode 100644 modules/EcommerceFinance/Services/AccessoryReshipSelectionStore.php create mode 100644 modules/EcommerceFinance/View/AccessoryReshipConfigPageData.php create mode 100644 resources/views/business/pages/courier-payables/partials/accessory-reship-configs.blade.php create mode 100644 tests/Unit/AccessoryReshipCourierConfigTest.php diff --git a/app/Console/Commands/ImportAccessoryReshipCourierConfigs.php b/app/Console/Commands/ImportAccessoryReshipCourierConfigs.php new file mode 100644 index 00000000..c8de25e0 --- /dev/null +++ b/app/Console/Commands/ImportAccessoryReshipCourierConfigs.php @@ -0,0 +1,32 @@ +option('company'); + if ($companyId <= 0 || !Company::query()->where('id', $companyId)->exists()) { + $this->error('请指定有效的 --company,公司配置不会跨公司写入。'); + return self::FAILURE; + } + $path = $this->argument('file'); + $rows = $importer->read($path); + if ($this->option('dry-run')) { + $this->info('文件校验通过,共' . count($rows) . '条;未写入。'); + return self::SUCCESS; + } + $result = $store->import($companyId, $rows, '用户授权存量配置导入', hash_file('sha256', $path)); + $this->info('导入完成:新增' . $result['added'] . '条,重复跳过' . $result['skipped'] . '条。'); + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Business/AccessoryReshipCourierConfigs.php b/app/Http/Controllers/Business/AccessoryReshipCourierConfigs.php new file mode 100644 index 00000000..afc7a7c8 --- /dev/null +++ b/app/Http/Controllers/Business/AccessoryReshipCourierConfigs.php @@ -0,0 +1,93 @@ +store->save((int) company_id(), $this->validated($request), $this->actor()); + return $this->back('快递扣款配置已新增。'); + } + + public function update(Request $request, int $configId) + { + $this->store->save((int) company_id(), $this->validated($request), $this->actor(), $configId); + return $this->back('快递扣款配置已更新,核对清单将按新比例重新计算。'); + } + + public function destroy(int $configId) + { + $this->store->changeDeleted((int) company_id(), $configId, true, $this->actor()); + return $this->back('配置已删除,该快递暂不参与扣款计算。'); + } + + public function restore(int $configId) + { + $this->store->changeDeleted((int) company_id(), $configId, false, $this->actor()); + return $this->back('快递扣款配置已恢复。'); + } + + public function import(Request $request, AccessoryReshipCourierImporter $importer) + { + $request->validate(['file' => ['required', 'file', 'mimes:xlsx', 'max:10240']]); + $path = $request->file('file')->getRealPath(); + $rows = $importer->read($path); + $result = $this->store->import((int) company_id(), $rows, $this->actor(), hash_file('sha256', $path)); + return $this->back("导入完成:新增{$result['added']}条,重复跳过{$result['skipped']}条。"); + } + + public function export(AccessoryReshipConfigPageData $data) + { + $rows = $this->store->filtered((int) company_id(), $data->filters())->get(); + $book = new Spreadsheet(); + $sheet = $book->getActiveSheet(); + $sheet->setTitle('快递配置'); + $sheet->fromArray([['快递代码', '快递公司', '创建时间', '创建人', '扣款比率(%)', '删除状态']]); + foreach ($rows as $index => $row) { + $number = $index + 2; + foreach ([$row->courier_code, $row->courier_name, $row->source_created_at ?: $row->created_at, $row->creator] as $column => $value) { + $sheet->setCellValueExplicit([$column + 1, $number], (string) $value, DataType::TYPE_STRING); + } + $sheet->setCellValue([5, $number], $row->rate_basis_points / 10000); + $sheet->getStyle('E' . $number)->getNumberFormat()->setFormatCode('0.00%'); + $sheet->setCellValueExplicit([6, $number], $row->deleted_at ? '已删除' : '正常', DataType::TYPE_STRING); + } + foreach (['A' => 24, 'B' => 30, 'C' => 24, 'D' => 18, 'E' => 18, 'F' => 14] as $column => $width) { + $sheet->getColumnDimension($column)->setWidth($width); + } + $sheet->freezePane('A2'); + return response()->streamDownload(function () use ($book): void { + try { (new Xlsx($book))->save('php://output'); } finally { $book->disconnectWorksheets(); } + }, '快递扣款配置.xlsx', ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']); + } + + private function validated(Request $request): array + { + $data = $request->validate(['courier_code' => ['required', 'string', 'max:64'], 'courier_name' => ['required', 'string', 'max:191'], 'rate_percent' => ['required', 'string']]); + $data['rate_basis_points'] = AccessoryReshipRateCalculator::basisPoints($data['rate_percent']); + return $data; + } + + private function actor(): string + { + return mb_substr((string) user()->name, 0, 191); + } + + private function back(string $message) + { + return redirect()->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'accessory-reship-deductions', 'reship_view' => 'config'])->with('reship_config_message', $message); + } +} diff --git a/app/Http/Controllers/Business/AccessoryReshipDeductions.php b/app/Http/Controllers/Business/AccessoryReshipDeductions.php index 1efd7c30..a9b8a3b4 100644 --- a/app/Http/Controllers/Business/AccessoryReshipDeductions.php +++ b/app/Http/Controllers/Business/AccessoryReshipDeductions.php @@ -7,6 +7,25 @@ use Modules\EcommerceFinance\View\AccessoryReshipPageData; class AccessoryReshipDeductions extends Controller { + public function selectCourier(\Illuminate\Http\Request $request, AccessoryReshipPageData $data, \Modules\EcommerceFinance\Services\AccessoryReshipSelectionStore $store) + { + $input = $request->validate([ + 'month' => ['required', 'date_format:Y-m', 'after_or_equal:2000-01'], + 'row_key' => ['required', 'regex:/^[a-f0-9]{64}$/'], + 'courier' => ['required', 'string', 'max:191'], + ]); + // 重新读取候选,禁止提交任意快递名称或其他公司的包裹。 + $found = false; + foreach ($data->rows(['month' => $input['month']]) as $row) { + if ($row['selection_key'] !== $input['row_key']) continue; + $found = in_array($input['courier'], array_column($row['courier_candidates'], 'courier'), true); + break; + } + abort_unless($found, 422, '该快递已不在当前首发候选中,请刷新后重新选择。'); + $store->save((int) company_id(), $input['month'], $input['row_key'], $input['courier'], mb_substr((string) user()->name, 0, 191)); + return response()->json(['message' => '扣款快递已保存。']); + } + public function export(AccessoryReshipPageData $data) { $filters = $data->filters(); @@ -14,10 +33,10 @@ class AccessoryReshipDeductions extends Controller $file = tmpfile(); if ($file === false) abort(500, '无法创建导出文件。'); fwrite($file, "\xEF\xBB\xBF"); - fputcsv($file, ['交易单号','补发快递单号','补发快递','补发时间','原始条形码','处理后条形码','补发运费','匹配状态','首发快递公司(候选)','首发快递单号(候选)','首发所属月份(候选)','扣款金额','说明']); + fputcsv($file, ['交易单号','补发快递单号','补发快递','补发时间','原始条形码','处理后条形码','补发运费','匹配状态','首发快递公司(候选)','首发快递单号(候选)','首发所属月份(候选)','扣款比例(%)','扣款金额','说明']); foreach ($data->rows($filters) as $row) { if ($filters['match_status'] !== 'all' && $row['status'] !== $filters['match_status']) continue; - $values = [$row['trade_no'], $row['express_no'], ($row['express_name1'] ?: ($row['express_name2'] ?? '')), $row['ship_time'], $row['barcode'], $row['normalized_barcode'], $row['freight'], $row['status'] === 'matched' ? '可扣款' : '未匹配不扣款', implode(' / ', array_column($row['matches'], 'courier')), implode(' / ', array_column($row['matches'], 'express_no')), implode(' / ', array_column($row['matches'], 'month')), $row['deduction_cents'] === null ? '' : number_format($row['deduction_cents'] / 100, 2, '.', ''), $row['freight_cents'] === null ? '运费缺失或异常,待核实' : (count($row['matches']) > 1 ? '多候选待确认归属;运费只计一次' : '')]; + $values = [$row['trade_no'], $row['express_no'], ($row['express_name1'] ?: ($row['express_name2'] ?? '')), $row['ship_time'], $row['barcode'], $row['normalized_barcode'], $row['freight'], $row['deduction_state'] === 'ready' ? '可扣款' : ($row['status'] === 'matched' ? '已匹配待核对' : '未匹配不扣款'), implode(' / ', array_column($row['matches'], 'courier')), implode(' / ', array_column($row['matches'], 'express_no')), implode(' / ', array_column($row['matches'], 'month')), $row['deduction_rate_basis_points'] === null ? '' : number_format($row['deduction_rate_basis_points'] / 100, 2, '.', '') . '%', $row['deduction_cents'] === null ? '' : number_format($row['deduction_cents'] / 100, 2, '.', ''), $row['deduction_note']]; fputcsv($file, array_map(static function ($value) { $value = (string) $value; return preg_match('/^[=+\-@\t\r\n]/u', $value) ? "'" . $value : $value; diff --git a/database/migrations/2026_10_16_220000_create_accessory_reship_courier_configs.php b/database/migrations/2026_10_16_220000_create_accessory_reship_courier_configs.php new file mode 100644 index 00000000..561be69e --- /dev/null +++ b/database/migrations/2026_10_16_220000_create_accessory_reship_courier_configs.php @@ -0,0 +1,40 @@ +increments('id'); + $table->unsignedInteger('company_id'); + $table->string('courier_code', 64); + $table->string('courier_name', 191); + $table->unsignedInteger('rate_basis_points'); + $table->dateTime('source_created_at')->nullable(); + $table->string('creator', 191); + $table->timestamps(); + $table->softDeletes(); + $table->unique(['company_id', 'courier_code'], 'reship_courier_code_unique'); + $table->unique(['company_id', 'courier_name'], 'reship_courier_name_unique'); + }); + Schema::create('business_accessory_reship_config_events', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('company_id')->index(); + $table->unsignedInteger('config_id')->nullable(); + $table->string('actor', 191); + $table->string('action', 32); + $table->text('description'); + $table->longText('details'); + $table->dateTime('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('business_accessory_reship_config_events'); + Schema::dropIfExists('business_accessory_reship_couriers'); + } +}; diff --git a/database/migrations/2026_10_16_230000_create_accessory_reship_selections.php b/database/migrations/2026_10_16_230000_create_accessory_reship_selections.php new file mode 100644 index 00000000..68b35aa9 --- /dev/null +++ b/database/migrations/2026_10_16_230000_create_accessory_reship_selections.php @@ -0,0 +1,26 @@ +increments('id'); + $table->unsignedInteger('company_id'); + $table->string('month', 7); + $table->string('row_key', 64); + $table->string('courier_name', 191); + $table->string('actor', 191); + $table->dateTime('updated_at'); + $table->unique(['company_id', 'month', 'row_key'], 'reship_selection_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('business_accessory_reship_selections'); + } +}; diff --git a/docs/配件补发扣款.md b/docs/配件补发扣款.md index 15839847..68dbd406 100644 --- a/docs/配件补发扣款.md +++ b/docs/配件补发扣款.md @@ -4,11 +4,11 @@ 选择业务年月后,读取发货数据中仓库为“常州简尚家居有限公司”的本月包裹,按现有发货数据的 source_id 合并口径汇总补发运费。配件条形码按明细顺序取第一个英文逗号前的值,去掉最后一个短横线及其后内容;首发条码只拆分英文逗号,不删除后缀。 -首发候选限定业务月及前两个月(支持跨年)、仓库不为配件仓库、快递单号非空。使用完整交易单号和条码精确匹配。一个补发包裹始终只展示一行,重复候选去重;多首发候选仍归为可扣款,保留快递名称、快递单号和年月供核对,运费只统计一次。 +首发候选限定业务月及前两个月(支持跨年)、仓库不为配件仓库、快递单号非空。使用完整交易单号和条码精确匹配。一个补发包裹始终只展示一行,重复候选去重;同一快递名称有多条首发记录时自动任选一条,无需核对;名称不同则由业务选择需要扣款的快递,选择前不计入扣款合计。每个补发包裹只计一次,选择按公司、业务月份和补发包裹保存,页面与导出使用同一归属。 -金额来自发货数据的 freight,使用分汇总;缺失、负数或非数字运费显示待核实,不以零代替。页面提供可扣款、未匹配、全部三类局部切换和分页,CSV导出覆盖当前分类全部记录,不受分页限制。导出继承后台登录、公司上下文和本页权限,防止表格公式注入。 +补发运费来自发货数据的 freight,扣款金额为补发运费乘以所选首发快递配置比例,按分四舍五入后汇总;缺失、负数或非数字运费显示待核实,不以零代替。页面提供已匹配、未匹配、全部三类局部切换和分页,CSV导出覆盖当前分类全部记录,不受分页限制。导出继承后台登录、公司上下文和本页权限,防止表格公式注入。 -本版为匹配和扣款核对清单,不生成应付账记账记录;多候选的扣款归属尚需业务核对。本次无数据库迁移,无上游写入,无财务数据修改。 +本版为匹配和扣款核对清单,不生成应付账记账记录;同名快递无需核对,不同名快递通过页面选择扣款归属。新增 business_accessory_reship_selections 表保存归属选择,无上游写入,不生成应付账记账记录。 验证: @@ -18,4 +18,14 @@ - 导出检查:路由优先级、本页权限、后台鉴权、UTF-8 BOM及公式保护。 - 使用现有数据连接进行了真实业务月份只读匹配及性能验证。 +## 快递公司配置 + +在配件补发扣款内切换“快递公司配置”,可按创建时间、快递公司、创建人和删除状态查询,并新增、编辑、软删除、恢复及导入导出。配置按当前公司隔离,快递代码及名称在公司内唯一;配置及操作日志保存在系统数据库 business_accessory_reship_couriers、business_accessory_reship_config_events。 + +以完整首发快递名称精确匹配配置。比例支持0%至100%,最多两位小数;0%不扣款,缺少配置的记录显示待配置,不默认按100%计算。修改、删除和恢复配置后,未记账的核对清单按当前配置重新核算,页面及CSV使用同一结果。 + +Excel导入列为快递代码、快递公司、创建时间、创建人、扣款比率(%)。原创建时间和创建人保留。导入前整表校验,写入及操作日志使用同一事务;相同代码、名称、比例再次导入跳过,冲突整批回滚,不自动覆盖或恢复已删除记录。 + +命令行导入:`php artisan accessory-reship:import-courier-configs --company=<公司ID>`。增加 `--dry-run` 仅校验,不写入。上传文件及真实配置不进入Git。 + 首发快递、首发单号、所属月份以独立三列显示,多候选按同一顺序对应。支持交易单号、补发单号、补发快递、补发日期、原始/处理后条码、补发运费、匹配状态、首发快递、首发单号、所属月份及可扣金额筛选;文本和金额支持包含匹配。筛选覆盖全部业务月记录,汇总、分类数量、分页和CSV导出沿用相同条件。多个首发筛选必须由同一候选满足;清空筛选保留业务月份。 diff --git a/modules/EcommerceFinance/Services/AccessoryReshipCourierImporter.php b/modules/EcommerceFinance/Services/AccessoryReshipCourierImporter.php new file mode 100644 index 00000000..ec44854f --- /dev/null +++ b/modules/EcommerceFinance/Services/AccessoryReshipCourierImporter.php @@ -0,0 +1,80 @@ +listWorksheetInfo($path); + if (count($info) !== 1 || $info[0]['totalRows'] > 5001 || $info[0]['totalColumns'] > 20) { + throw ValidationException::withMessages(['file' => '请使用单工作表、最多5000条配置的Excel文件。']); + } + $book = $reader->load($path); + try { + $sheet = $book->getSheet(0); + $headers = []; + for ($column = 1; $column <= $info[0]['totalColumns']; $column++) { + $label = trim((string) $sheet->getCell([$column, 1])->getValue()); + $label = str_replace(['(', ')', '%', ' '], ['(', ')', '%', ''], $label); + $headers[$label] = $column; + } + $rateColumn = $headers['扣款比率(%)'] ?? $headers['扣款比例(%)'] ?? null; + foreach (['快递代码', '快递公司', '创建时间', '创建人'] as $header) { + if (!isset($headers[$header])) { + throw ValidationException::withMessages(['file' => '缺少列:' . $header]); + } + } + if ($rateColumn === null) { + throw ValidationException::withMessages(['file' => '缺少列:扣款比率(%)']); + } + $rows = []; + $codes = []; + $names = []; + for ($number = 2; $number <= $sheet->getHighestDataRow(); $number++) { + $values = []; + foreach (['courier_code' => $headers['快递代码'], 'courier_name' => $headers['快递公司'], 'source_created_at' => $headers['创建时间'], 'creator' => $headers['创建人'], 'rate' => $rateColumn] as $key => $column) { + $cell = $sheet->getCell([$column, $number]); + if ($cell->getDataType() === 'f') { + throw ValidationException::withMessages(['file' => "第{$number}行含公式,请使用实际配置值。"]); + } + $values[$key] = trim((string) $cell->getFormattedValue()); + if ($key === 'source_created_at' && is_numeric($cell->getValue()) && ExcelDate::isDateTime($cell)) { + $values[$key] = ExcelDate::excelToDateTimeObject($cell->getValue())->format('Y-m-d H:i:s'); + } + } + if (implode('', $values) === '') { + continue; + } + if ($values['courier_code'] === '' || mb_strlen($values['courier_code']) > 64 || $values['courier_name'] === '' || mb_strlen($values['courier_name']) > 191 || $values['creator'] === '' || mb_strlen($values['creator']) > 191) { + throw ValidationException::withMessages(['file' => "第{$number}行快递代码、名称或创建人为空或超长。"]); + } + if (isset($codes[mb_strtolower($values['courier_code'])]) || isset($names[mb_strtolower($values['courier_name'])])) { + throw ValidationException::withMessages(['file' => "第{$number}行快递代码或名称重复。"]); + } + $codes[mb_strtolower($values['courier_code'])] = true; + $names[mb_strtolower($values['courier_name'])] = true; + $rate = AccessoryReshipRateCalculator::basisPoints($values['rate']); + $date = $values['source_created_at']; + if ($date !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $date) || !checkdate((int) substr($date, 5, 2), (int) substr($date, 8, 2), (int) substr($date, 0, 4)) || substr($date, 11, 2) > '23' || substr($date, 14, 2) > '59' || substr($date, 17, 2) > '59')) { + throw ValidationException::withMessages(['file' => "第{$number}行创建时间无效。"]); + } + unset($values['rate']); + $values['source_created_at'] = $date === '' ? null : $date; + $values['rate_basis_points'] = $rate; + $rows[] = $values; + } + if ($rows === []) { + throw ValidationException::withMessages(['file' => '配置表没有有效记录。']); + } + return $rows; + } finally { + $book->disconnectWorksheets(); + } + } +} diff --git a/modules/EcommerceFinance/Services/AccessoryReshipCourierStore.php b/modules/EcommerceFinance/Services/AccessoryReshipCourierStore.php new file mode 100644 index 00000000..25d54aad --- /dev/null +++ b/modules/EcommerceFinance/Services/AccessoryReshipCourierStore.php @@ -0,0 +1,129 @@ + 0, 422, '缺少当前公司。'); + return DB::table(self::TABLE)->where('company_id', $companyId); + } + + public function activeRates(int $companyId): array + { + $result = []; + foreach ($this->query($companyId)->whereNull('deleted_at')->get() as $row) { + $result[trim($row->courier_name)] = (array) $row; + } + return $result; + } + + public function filtered(int $companyId, array $filters) + { + $query = $this->query($companyId); + $status = $filters['deleted_status'] ?? 'active'; + if ($status === 'deleted') { + $query->whereNotNull('deleted_at'); + } elseif ($status !== 'all') { + $query->whereNull('deleted_at'); + } + foreach (['courier_name', 'creator'] as $field) { + if (($filters[$field] ?? '') !== '') { + $query->where($field, 'like', '%' . addcslashes($filters[$field], '\\%_') . '%'); + } + } + if (($filters['created_from'] ?? '') !== '') { + $query->whereRaw('COALESCE(source_created_at, created_at) >= ?', [$filters['created_from'] . ' 00:00:00']); + } + if (($filters['created_to'] ?? '') !== '') { + $query->whereRaw('COALESCE(source_created_at, created_at) < ?', [Carbon::parse($filters['created_to'])->addDay()->format('Y-m-d') . ' 00:00:00']); + } + return $query->orderByRaw('COALESCE(source_created_at, created_at) DESC')->orderByDesc('id'); + } + + public function save(int $companyId, array $data, string $actor, ?int $id = null): int + { + return DB::transaction(function () use ($companyId, $data, $actor, $id): int { + $before = $id === null ? null : $this->query($companyId)->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first(); + abort_if($id !== null && $before === null, 404); + $this->assertIdentity($companyId, $data, $id); + $time = $this->time(); + $values = ['courier_code' => trim($data['courier_code']), 'courier_name' => trim($data['courier_name']), 'rate_basis_points' => (int) $data['rate_basis_points'], 'updated_at' => $time]; + if ($id === null) { + $id = DB::table(self::TABLE)->insertGetId($values + ['company_id' => $companyId, 'creator' => $actor, 'source_created_at' => null, 'created_at' => $time]); + } else { + $this->query($companyId)->where('id', $id)->update($values); + } + $this->event($companyId, $id, $actor, $before ? 'update' : 'create', $before ? '修改快递扣款配置' : '新增快递扣款配置', ['before' => $before, 'after' => $values]); + return $id; + }); + } + + public function changeDeleted(int $companyId, int $id, bool $deleted, string $actor): void + { + DB::transaction(function () use ($companyId, $id, $deleted, $actor): void { + $row = $this->query($companyId)->where('id', $id)->lockForUpdate()->first(); + abort_if($row === null, 404); + if (($row->deleted_at !== null) === $deleted) { + return; + } + $this->query($companyId)->where('id', $id)->update(['deleted_at' => $deleted ? $this->time() : null, 'updated_at' => $this->time()]); + $this->event($companyId, $id, $actor, $deleted ? 'delete' : 'restore', ($deleted ? '删除' : '恢复') . '快递扣款配置', ['courier_code' => $row->courier_code, 'courier_name' => $row->courier_name]); + }); + } + + /** 整批事务:重复导入相同配置跳过,冲突不覆盖、删除状态不自动恢复。 */ + public function import(int $companyId, array $rows, string $actor, string $sourceHash): array + { + return DB::transaction(function () use ($companyId, $rows, $actor, $sourceHash): array { + $added = 0; + $skipped = 0; + foreach ($rows as $row) { + $existing = $this->query($companyId)->where('courier_code', $row['courier_code'])->lockForUpdate()->first(); + if ($existing !== null) { + if ($existing->courier_name !== $row['courier_name'] || (int) $existing->rate_basis_points !== $row['rate_basis_points'] || $existing->deleted_at !== null) { + throw ValidationException::withMessages(['file' => '快递代码 ' . $row['courier_code'] . ' 已存在不同配置或已删除,整批未导入,请核对后重试。']); + } + $skipped++; + continue; + } + $this->assertIdentity($companyId, $row); + DB::table(self::TABLE)->insert($row + ['company_id' => $companyId, 'created_at' => $this->time(), 'updated_at' => $this->time()]); + $added++; + } + $this->event($companyId, null, $actor, 'import', "导入快递配置:新增{$added}条,重复跳过{$skipped}条", ['added' => $added, 'skipped' => $skipped, 'source_sha256' => $sourceHash]); + return compact('added', 'skipped'); + }); + } + + private function assertIdentity(int $companyId, array $data, ?int $exceptId = null): void + { + $query = $this->query($companyId)->where(function ($query) use ($data): void { + $query->where('courier_code', trim($data['courier_code']))->orWhere('courier_name', trim($data['courier_name'])); + }); + if ($exceptId !== null) { + $query->where('id', '<>', $exceptId); + } + if ($query->exists()) { + throw ValidationException::withMessages(['courier_name' => '快递代码或名称已存在(含已删除配置),请编辑或恢复原配置。']); + } + } + + private function event(int $companyId, ?int $id, string $actor, string $action, string $description, array $details): void + { + DB::table(self::EVENTS)->insert(['company_id' => $companyId, 'config_id' => $id, 'actor' => $actor, 'action' => $action, 'description' => $description, 'details' => json_encode($details, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'created_at' => $this->time()]); + } + + private function time(): string + { + return Carbon::now(config('app.platform_timezone', 'Asia/Shanghai'))->format('Y-m-d H:i:s'); + } +} diff --git a/modules/EcommerceFinance/Services/AccessoryReshipRateCalculator.php b/modules/EcommerceFinance/Services/AccessoryReshipRateCalculator.php new file mode 100644 index 00000000..b5b30e97 --- /dev/null +++ b/modules/EcommerceFinance/Services/AccessoryReshipRateCalculator.php @@ -0,0 +1,85 @@ + '扣款比例须为0至100的数字,最多两位小数。']); + } + [$whole, $decimal] = array_pad(explode('.', $percent, 2), 2, ''); + $points = (int) $whole * 100 + (int) str_pad($decimal, 2, '0'); + if ($points > 10000) { + throw ValidationException::withMessages(['rate_percent' => '扣款比例不能超过100%。']); + } + return $points; + } + + public static function amount(int $freightCents, int $basisPoints): int + { + return intdiv($freightCents * $basisPoints + 5000, 10000); + } + + /** 名称精确匹配;不默认比例,不对多候选累计运费。 */ + public function apply(array $row, array $configs, ?string $selectedCourier = null): array + { + // 同名快递只保留一个首发包裹;不同名称必须由业务选择,不能按比例猜归属。 + $candidates = []; + foreach ($row['matches'] as $match) { + $name = trim((string) $match['courier']); + $candidates[$name] ??= array_replace($match, ['courier' => $name]); + } + $row['courier_candidates'] = array_values($candidates); + $selected = $selectedCourier !== null ? ($candidates[$selectedCourier] ?? null) : null; + if (count($candidates) === 1) $selected = reset($candidates); + $row['selected_courier'] = $selected['courier'] ?? null; + $row['matches'] = $selected === null ? array_values($candidates) : [$selected]; + $row['deduction_cents'] = $row['status'] === 'unmatched' ? 0 : null; + $row['deduction_rate_basis_points'] = null; + $row['deduction_state'] = 'unmatched'; + $row['deduction_note'] = '三个月内未匹配首发快递单号'; + if ($row['status'] === 'unmatched') { + return $row; + } + if ($selected === null) { + $row['deduction_state'] = 'selection_required'; + $row['deduction_note'] = '请选择需要扣款的首发快递,补发运费仅计一次'; + return $row; + } + $rates = []; + $missing = false; + foreach ($row['matches'] as &$match) { + $config = $configs[trim((string) $match['courier'])] ?? null; + $match['rate_basis_points'] = $config['rate_basis_points'] ?? null; + $match['courier_code'] = $config['courier_code'] ?? null; + $match['deduction_cents'] = $config !== null && $row['freight_cents'] !== null + ? self::amount($row['freight_cents'], (int) $config['rate_basis_points']) : null; + if ($config === null) { + $missing = true; + } else { + $rates[] = (int) $config['rate_basis_points']; + } + } + unset($match); + if ($missing) { + $row['deduction_state'] = 'missing_config'; + $row['deduction_note'] = '首发快递未配置扣款比例,暂不计入扣款合计'; + } elseif ($row['freight_cents'] === null) { + $row['deduction_state'] = 'invalid_amount'; + $row['deduction_note'] = '补发运费缺失或异常,待核实'; + } else { + $rate = $rates[0]; + $row['deduction_rate_basis_points'] = $rate; + $row['deduction_cents'] = self::amount($row['freight_cents'], $rate); + $row['deduction_state'] = $rate === 0 ? 'zero_rate' : 'ready'; + $row['deduction_note'] = $rate === 0 ? '扣款比例0%,不扣款' : '按所选首发快递配置比例核算,仅计一次'; + } + return $row; + } +} diff --git a/modules/EcommerceFinance/Services/AccessoryReshipSelectionStore.php b/modules/EcommerceFinance/Services/AccessoryReshipSelectionStore.php new file mode 100644 index 00000000..474de428 --- /dev/null +++ b/modules/EcommerceFinance/Services/AccessoryReshipSelectionStore.php @@ -0,0 +1,33 @@ +where('company_id', $companyId)->where('month', $month) + ->pluck('courier_name', 'row_key')->all(); + } + + public function save(int $companyId, string $month, string $key, string $courier, string $actor): void + { + DB::table(self::TABLE)->updateOrInsert( + ['company_id' => $companyId, 'month' => $month, 'row_key' => $key], + ['courier_name' => $courier, 'actor' => $actor, 'updated_at' => now()] + ); + } +} diff --git a/modules/EcommerceFinance/View/AccessoryReshipConfigPageData.php b/modules/EcommerceFinance/View/AccessoryReshipConfigPageData.php new file mode 100644 index 00000000..d3723cde --- /dev/null +++ b/modules/EcommerceFinance/View/AccessoryReshipConfigPageData.php @@ -0,0 +1,35 @@ + mb_substr(trim((string) request()->query($key, '')), 0, 191); + $filters = ['courier_name' => $text('courier_name'), 'creator' => $text('creator'), 'created_from' => $text('created_from'), 'created_to' => $text('created_to'), 'deleted_status' => $text('deleted_status') ?: 'active']; + foreach (['created_from', 'created_to'] as $key) { + $value = $filters[$key]; + abort_if($value !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) || !checkdate((int) substr($value, 5, 2), (int) substr($value, 8, 2), (int) substr($value, 0, 4))), 422, '请选择有效创建日期。'); + } + abort_if($filters['created_from'] !== '' && $filters['created_to'] !== '' && $filters['created_from'] > $filters['created_to'], 422, '结束日期不能早于开始日期。'); + return $filters; + } + + public function page(): array + { + $companyId = (int) company_id(); + $filters = $this->filters(); + $page = max(1, (int) request('page', 1)); + $rows = $this->store->filtered($companyId, $filters)->paginate(20, ['*'], 'page', $page)->withQueryString(); + $events = DB::table(AccessoryReshipCourierStore::EVENTS)->where('company_id', $companyId)->orderByDesc('id')->paginate(20, ['*'], 'event_page')->withQueryString(); + return ['view' => 'business.pages.courier-payables.accessory-reship-deductions', 'reshipView' => 'config', 'configRows' => $rows, 'configEvents' => $events, 'configFilters' => $filters]; + } +} diff --git a/modules/EcommerceFinance/View/AccessoryReshipPageData.php b/modules/EcommerceFinance/View/AccessoryReshipPageData.php index a52f118e..0019b53b 100644 --- a/modules/EcommerceFinance/View/AccessoryReshipPageData.php +++ b/modules/EcommerceFinance/View/AccessoryReshipPageData.php @@ -5,10 +5,19 @@ namespace Modules\EcommerceFinance\View; use Illuminate\Pagination\LengthAwarePaginator; use Modules\EcommerceFinance\Repositories\ShippingOrderRepository; use Modules\EcommerceFinance\Services\AccessoryReshipMatcher; +use Modules\EcommerceFinance\Services\AccessoryReshipCourierStore; +use Modules\EcommerceFinance\Services\AccessoryReshipRateCalculator; +use Modules\EcommerceFinance\Services\AccessoryReshipSelectionStore; class AccessoryReshipPageData { - public function __construct(private ShippingOrderRepository $shipping, private AccessoryReshipMatcher $matcher) {} + public function __construct( + private readonly ShippingOrderRepository $shipping, + private readonly AccessoryReshipMatcher $matcher, + private readonly AccessoryReshipCourierStore $configs, + private readonly AccessoryReshipRateCalculator $calculator, + private readonly ?AccessoryReshipSelectionStore $selections = null, + ) {} public function filters(): array { @@ -16,7 +25,7 @@ class AccessoryReshipPageData 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'); $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) { + foreach (['trade_no','express_no','courier','ship_date','barcode','normalized_barcode','freight','deduction_state','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); @@ -26,9 +35,13 @@ class AccessoryReshipPageData public function rows(array $filters): \Generator { + $configs = $this->configs->activeRates((int) company_id()); + $selections = ($this->selections ?? app(AccessoryReshipSelectionStore::class))->forMonth((int) company_id(), $filters['month']); $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) { + $row['selection_key'] = AccessoryReshipSelectionStore::key($row); + $row = $this->calculator->apply($row, $configs, $selections[$row['selection_key']] ?? null); if ($this->matchesFilters($row, $filters)) yield $row; } } @@ -47,6 +60,7 @@ class AccessoryReshipPageData foreach ($values as $field => $value) { if (($filters[$field] ?? '') !== '' && mb_stripos((string) $value, $filters[$field]) === false) return false; } + if (($filters['deduction_state'] ?? '') !== '' && $filters['deduction_state'] !== $row['deduction_state']) 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) { @@ -60,28 +74,43 @@ class AccessoryReshipPageData public function page(): array { + if (request('reship_view') === 'config') { + return app(AccessoryReshipConfigPageData::class)->page(); + } $filters = $this->filters(); $page = max(1, (int) request('page', 1)); - $summary = ['all' => 0, 'matched' => 0, 'unmatched' => 0, 'amount' => 0, 'invalid_amount' => 0, 'ambiguous' => 0]; - $items = []; $total = 0; $error = null; + $summary = ['all' => 0, 'matched' => 0, 'unmatched' => 0, 'amount' => 0, 'invalid_amount' => 0, 'ambiguous' => 0, 'ready' => 0, 'missing_config' => 0, 'selection_required' => 0, 'zero_rate' => 0]; + $items = []; + $total = 0; + $error = null; try { foreach ($this->rows($filters) as $row) { - $summary['all']++; $summary[$row['status']]++; + $summary['all']++; + $summary[$row['status']]++; if ($row['status'] === 'matched') { $summary['amount'] += $row['deduction_cents'] ?? 0; - if ($row['deduction_cents'] === null) $summary['invalid_amount']++; - if (count($row['matches']) > 1) $summary['ambiguous']++; + if ($row['freight_cents'] === null) $summary['invalid_amount']++; + if ($row['deduction_state'] === 'selection_required') $summary['ambiguous']++; + if (isset($summary[$row['deduction_state']])) $summary[$row['deduction_state']]++; } if ($filters['match_status'] !== 'all' && $filters['match_status'] !== $row['status']) continue; if ($total >= ($page - 1) * 50 && count($items) < 50) $items[] = $row; $total++; } } catch (\Throwable $exception) { - report($exception); $error = '发货数据暂时无法读取,请检查数据连接或同步状态后重试。'; - $items = []; $total = 0; + report($exception); + $error = '发货数据或扣款配置暂时无法读取,请检查连接及配置数据后重试。'; + $items = []; + $total = 0; $summary = array_fill_keys(array_keys($summary), 0); } - return ['view' => 'business.pages.courier-payables.accessory-reship-deductions', 'reshipFilters' => $filters, 'reshipSummary' => $summary, 'reshipError' => $error, - 'reshipRows' => new LengthAwarePaginator($items, $total, 50, $page, ['path' => request()->url(), 'query' => request()->except('page')])]; + return [ + 'view' => 'business.pages.courier-payables.accessory-reship-deductions', + 'reshipView' => 'details', + 'reshipFilters' => $filters, + 'reshipSummary' => $summary, + 'reshipError' => $error, + 'reshipRows' => new LengthAwarePaginator($items, $total, 50, $page, ['path' => request()->url(), 'query' => request()->except('page')]), + ]; } } diff --git a/resources/views/business/pages/courier-payables/accessory-reship-deductions.blade.php b/resources/views/business/pages/courier-payables/accessory-reship-deductions.blade.php index 8325d9d9..13d7f429 100644 --- a/resources/views/business/pages/courier-payables/accessory-reship-deductions.blade.php +++ b/resources/views/business/pages/courier-payables/accessory-reship-deductions.blade.php @@ -1,31 +1,39 @@ @php - $filters = $reshipFilters; - $summary = $reshipSummary; - $labels = ['matched' => '可扣款项', 'unmatched' => '未匹配项', 'all' => '全部配件单']; - $startMonth = \Carbon\Carbon::createFromFormat('!Y-m', $filters['month'])->subMonths(2)->format('Y年n月'); + $filters = $reshipFilters ?? []; + $summary = $reshipSummary ?? []; + $labels = ['matched' => '已匹配项', 'unmatched' => '未匹配项', 'all' => '全部配件单']; + $startMonth = \Carbon\Carbon::createFromFormat('!Y-m', ($filters['month'] ?? now()->format('Y-m')))->subMonths(2)->format('Y年n月'); @endphp
+ + @if (($reshipView ?? 'details') === 'config') + @include('business.pages.courier-payables.partials.accessory-reship-configs') + @else

配件补发扣款

按交易单号及条形码匹配首发快递,核算配件补发运费。

快递应付
@foreach (['trade_no'=>'交易单号','express_no'=>'补发快递单号','courier'=>'补发快递','ship_date'=>'补发日期','barcode'=>'原始条形码','normalized_barcode'=>'处理后条形码','freight'=>'补发运费','original_courier'=>'首发快递','original_express_no'=>'首发单号','original_month'=>'所属月份','deduction_amount'=>'可扣金额'] as $field => $label) @endforeach - + + 清空筛选 @if (!$reshipError)导出筛选结果@endif 匹配范围:{{ $startMonth }} 至 {{ \Carbon\Carbon::createFromFormat('!Y-m', $filters['month'])->format('Y年n月') }}
- @foreach (['all' => '配件单数', 'matched' => '可扣款单数', 'unmatched' => '未匹配单数'] as $key => $label) + @foreach (['all' => '配件单数', 'ready' => '可扣款单数', 'unmatched' => '未匹配单数'] as $key => $label)
{{ $label }}{{ $reshipError ? '—' : number_format($summary[$key]) }}
@endforeach -
可扣款总额(运费仅计一次){{ $reshipError ? '—' : '¥ ' . number_format($summary['amount'] / 100, 2) }}
+
按配置比例扣款合计{{ $reshipError ? '—' : '¥ ' . number_format($summary['amount'] / 100, 2) }}
-
匹配规则与扣款口径

配件仓库:常州简尚家居有限公司。配件条码取第一个英文逗号前的内容,再去掉最后一个“-”及其后内容。首发仓库排除该仓库,首发多个条码拆分后按完整条码匹配。仅匹配到首发快递单号的记录列为可扣款,金额取补发发货数据中的运费。

本页生成扣款核对清单,尚未记入应付账。多首发候选需确认归属,运费缺失或异常需核实;重复匹配不会新增扣款记录。

+
匹配规则与扣款口径

配件仓库:常州简尚家居有限公司。配件条码取第一个英文逗号前的内容,再去掉最后一个“-”及其后内容。首发仓库排除该仓库,首发多个条码拆分后按完整条码匹配。仅匹配到首发快递单号的记录列为可扣款,扣款金额=补发运费×首发快递配置比例,按分四舍五入。未配置比例暂不核算,0%不扣款。

本页生成扣款核对清单,尚未记入应付账。首发快递名称相同自动任选一条,无需核对;名称不同请选择需要扣款的快递,仅计一次;运费缺失或异常需核实;重复匹配不会新增扣款记录。

@if ($reshipError)@endif - @if ($summary['ambiguous'] || $summary['invalid_amount'])
多首发候选 {{ $summary['ambiguous'] }} 单,需确认归属;运费异常 {{ $summary['invalid_amount'] }} 单,暂未计入总额。
@endif + @if ($summary['ambiguous'] || $summary['invalid_amount'] || $summary['missing_config'] || $summary['zero_rate'])
待选择扣款快递 {{ $summary['selection_required'] }} 单;运费异常 {{ $summary['invalid_amount'] }} 单;待配置 {{ $summary['missing_config'] }} 单,0%不扣款 {{ $summary['zero_rate'] }} 单。待核算项不计入合计。
@endif
- @foreach (['交易单号','补发快递单号','补发快递','补发日期','原始条形码','处理后条形码','补发运费','匹配状态','首发快递','首发单号','所属月份','可扣金额','说明'] as $heading)@endforeach + @foreach (['交易单号','补发快递单号','补发快递','补发日期','原始条形码','处理后条形码','补发运费','匹配状态','首发快递','首发单号','所属月份','扣款比例','可扣金额','说明'] as $heading)@endforeach @forelse ($reshipRows as $row) - - @empty@endforelse + + @empty@endforelse
{{ $heading }}{{ $heading }}
{{ $row['trade_no'] }}{{ $row['express_no'] }}{{ $row['express_name1'] ?: $row['express_name2'] }}{{ substr((string) $row['ship_time'], 0, 10) }}{{ $row['barcode'] }}{{ $row['normalized_barcode'] }}{{ $row['freight_cents'] === null ? '待核实' : number_format($row['freight_cents'] / 100, 2) }}{{ $row['status'] === 'matched' ? '可扣款' : '未匹配不扣款' }} - @forelse ($row['matches'] as $match)
{{ $match['courier'] ?: '快递名称待核实' }}
@empty — @endforelse +
{{ $row['trade_no'] }}{{ $row['express_no'] }}{{ $row['express_name1'] ?: $row['express_name2'] }}{{ substr((string) $row['ship_time'], 0, 10) }}{{ $row['barcode'] }}{{ $row['normalized_barcode'] }}{{ $row['freight_cents'] === null ? '待核实' : number_format($row['freight_cents'] / 100, 2) }}{{ $row['deduction_state'] === 'ready' ? '可扣款' : ($row['status'] === 'matched' ? ($row['deduction_state'] === 'zero_rate' ? '比例0%不扣款' : '待核算') : '未匹配不扣款') }} + @if (count($row['courier_candidates']) > 1) +
+ @csrf + + + + + +
+ @else + @forelse ($row['matches'] as $match)
{{ $match['courier'] ?: '快递名称待核实' }}
@empty — @endforelse + @endif
@forelse ($row['matches'] as $match)
{{ $match['express_no'] }}
@empty — @endforelse
@forelse ($row['matches'] as $match)
{{ $match['month'] }}
@empty — @endforelse -
{{ $row['deduction_cents'] === null ? '待核实' : number_format($row['deduction_cents'] / 100, 2) }}{{ $row['freight_cents'] === null ? '运费缺失或异常' : (count($row['matches']) > 1 ? '多候选,确认归属后扣款;仅计一次' : ($row['status'] === 'unmatched' ? '三个月内未匹配首发快递单号' : '待核对')) }}
{{ $reshipError ?: '当前筛选条件下暂无记录,请清空筛选或调整业务月份、分类。' }}
{{ $row['deduction_rate_basis_points'] === null ? '—' : number_format($row['deduction_rate_basis_points'] / 100, 2) . '%' }}{{ $row['deduction_cents'] === null ? '待核实' : number_format($row['deduction_cents'] / 100, 2) }}{{ $row['deduction_note'] }}
{{ $reshipError ?: '当前筛选条件下暂无记录,请清空筛选或调整业务月份、分类。' }}
+ @endif
@push('body_end')