feat(配件补发扣款): 配置快递扣款比例及存量导入

This commit is contained in:
limeng 2026-10-16 22:23:41 +08:00
parent b4eaa1e102
commit 245c27615c
17 changed files with 1024 additions and 39 deletions

View File

@ -0,0 +1,32 @@
<?php
namespace App\Console\Commands;
use App\Models\Common\Company;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\AccessoryReshipCourierImporter;
use Modules\EcommerceFinance\Services\AccessoryReshipCourierStore;
class ImportAccessoryReshipCourierConfigs extends Command
{
protected $signature = 'accessory-reship:import-courier-configs {file} {--company= : 导入所属公司ID} {--dry-run : 仅检查文件及输出条数,不写入}';
protected $description = '导入用户提供的存量快递补发扣款比例配置(重复项跳过、冲突整批回滚)';
public function handle(AccessoryReshipCourierImporter $importer, AccessoryReshipCourierStore $store): int
{
$companyId = (int) $this->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;
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\AccessoryReshipCourierImporter;
use Modules\EcommerceFinance\Services\AccessoryReshipCourierStore;
use Modules\EcommerceFinance\Services\AccessoryReshipRateCalculator;
use Modules\EcommerceFinance\View\AccessoryReshipConfigPageData;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class AccessoryReshipCourierConfigs extends Controller
{
public function __construct(private readonly AccessoryReshipCourierStore $store) {}
public function store(Request $request)
{
$this->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);
}
}

View File

@ -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;

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('business_accessory_reship_couriers', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('business_accessory_reship_selections', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -4,11 +4,11 @@
选择业务年月后,读取发货数据中仓库为“常州简尚家居有限公司”的本月包裹,按现有发货数据的 source_id 合并口径汇总补发运费。配件条形码按明细顺序取第一个英文逗号前的值,去掉最后一个短横线及其后内容;首发条码只拆分英文逗号,不删除后缀。
首发候选限定业务月及前两个月(支持跨年)、仓库不为配件仓库、快递单号非空。使用完整交易单号和条码精确匹配。一个补发包裹始终只展示一行,重复候选去重;多首发候选仍归为可扣款,保留快递名称、快递单号和年月供核对,运费只统计一次。
首发候选限定业务月及前两个月(支持跨年)、仓库不为配件仓库、快递单号非空。使用完整交易单号和条码精确匹配。一个补发包裹始终只展示一行,重复候选去重;同一快递名称有多条首发记录时自动任选一条,无需核对;名称不同则由业务选择需要扣款的快递,选择前不计入扣款合计。每个补发包裹只计一次,选择按公司、业务月份和补发包裹保存,页面与导出使用同一归属。
金额来自发货数据的 freight,使用分汇总;缺失、负数或非数字运费显示待核实,不以零代替。页面提供可扣款、未匹配、全部三类局部切换和分页,CSV导出覆盖当前分类全部记录,不受分页限制。导出继承后台登录、公司上下文和本页权限,防止表格公式注入。
补发运费来自发货数据的 freight,扣款金额为补发运费乘以所选首发快递配置比例,按分四舍五入后汇总;缺失、负数或非数字运费显示待核实,不以零代替。页面提供已匹配、未匹配、全部三类局部切换和分页,CSV导出覆盖当前分类全部记录,不受分页限制。导出继承后台登录、公司上下文和本页权限,防止表格公式注入。
本版为匹配和扣款核对清单,不生成应付账记账记录;多候选的扣款归属尚需业务核对。本次无数据库迁移,无上游写入,无财务数据修改。
本版为匹配和扣款核对清单,不生成应付账记账记录;同名快递无需核对,不同名快递通过页面选择扣款归属。新增 business_accessory_reship_selections 表保存归属选择,无上游写入,不生成应付账记账记录。
验证:
@ -17,3 +17,13 @@
- 实际Blade页面检查:分类局部切换、分页、多候选提示、异常金额及手机/桌面布局。
- 导出检查:路由优先级、本页权限、后台鉴权、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 <xlsx文件路径> --company=<公司ID>`。增加 `--dry-run` 仅校验,不写入。上传文件及真实配置不进入Git。

View File

@ -0,0 +1,80 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
class AccessoryReshipCourierImporter
{
public function read(string $path): array
{
$reader = IOFactory::createReader('Xlsx');
$info = $reader->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();
}
}
}

View File

@ -0,0 +1,129 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class AccessoryReshipCourierStore
{
public const TABLE = 'business_accessory_reship_couriers';
public const EVENTS = 'business_accessory_reship_config_events';
public function query(int $companyId)
{
abort_unless($companyId > 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');
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Validation\ValidationException;
class AccessoryReshipRateCalculator
{
/** 界面及文本导入均以百分数为单位;数据库保存万分比整数。 */
public static function basisPoints(string $percent): int
{
$percent = trim(str_replace(['%', '%'], '', $percent));
if (!preg_match('/^\d{1,3}(?:\.\d{1,2})?$/', $percent)) {
throw ValidationException::withMessages(['rate_percent' => '扣款比例须为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;
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Facades\DB;
class AccessoryReshipSelectionStore
{
public const TABLE = 'business_accessory_reship_selections';
public static function key(array $row): string
{
return hash('sha256', json_encode([
(string) ($row['source_id'] ?? ''), (string) ($row['trade_no'] ?? ''),
(string) ($row['express_no'] ?? ''), (string) ($row['ship_time'] ?? ''),
(string) ($row['normalized_barcode'] ?? ''),
], JSON_UNESCAPED_UNICODE));
}
public function forMonth(int $companyId, string $month): array
{
return DB::table(self::TABLE)->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()]
);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace Modules\EcommerceFinance\View;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\AccessoryReshipCourierStore;
class AccessoryReshipConfigPageData
{
public function __construct(private readonly AccessoryReshipCourierStore $store) {}
public function filters(): array
{
$text = static fn (string $key): string => 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];
}
}

View File

@ -5,51 +5,112 @@ 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
{
$month = request()->query('month', now()->format('Y-m'));
abort_unless(is_string($month) && preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) && (int) substr($month, 0, 4) >= 2000, 422, '请选择有效业务月份。');
$status = request()->query('match_status', 'matched');
return ['month' => $month, 'match_status' => in_array($status, ['matched', 'unmatched', 'all'], true) ? $status : 'matched'];
$filters = ['month' => $month, 'match_status' => in_array($status, ['matched', 'unmatched', 'all'], true) ? $status : 'matched'];
foreach (['trade_no','express_no','courier','ship_date','barcode','normalized_barcode','freight','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);
}
return $filters;
}
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) yield $row;
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;
}
}
}
public function matchesFilters(array $row, array $filters): bool
{
$values = [
'trade_no' => $row['trade_no'], 'express_no' => $row['express_no'],
'courier' => $row['express_name1'] ?: ($row['express_name2'] ?? ''),
'ship_date' => substr((string) $row['ship_time'], 0, 10),
'barcode' => $row['barcode'], 'normalized_barcode' => $row['normalized_barcode'],
'freight' => $row['freight_cents'] === null ? '待核实' : number_format($row['freight_cents'] / 100, 2, '.', ''),
'deduction_amount' => $row['deduction_cents'] === null ? '待核实' : number_format($row['deduction_cents'] / 100, 2, '.', ''),
];
foreach ($values as $field => $value) {
if (($filters[$field] ?? '') !== '' && mb_stripos((string) $value, $filters[$field]) === false) return false;
}
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) {
foreach ($originalFilters as $field => $value) {
if (mb_stripos((string) $match[$field], $value) === false) continue 2;
}
return true;
}
return false;
}
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, 'conflicting_rates' => 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')]),
];
}
}

View File

@ -1,27 +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
<div class="reship-page">
<nav class="reship-tabs reship-main-tabs" aria-label="配件补发扣款功能">
<a data-reship-tab href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'accessory-reship-deductions']) }}" class="{{ ($reshipView ?? 'details') === 'details' ? 'is-active' : '' }}">扣款明细</a>
<a data-reship-tab href="{{ route('business.pages.show', ['section' => 'courier-payables', 'page' => 'accessory-reship-deductions', 'reship_view' => 'config']) }}" class="{{ ($reshipView ?? '') === 'config' ? 'is-active' : '' }}">快递公司配置</a>
</nav>
@if (($reshipView ?? 'details') === 'config')
@include('business.pages.courier-payables.partials.accessory-reship-configs')
@else
<div class="reship-heading"><div><h2>配件补发扣款</h2><p>按交易单号及条形码匹配首发快递,核算配件补发运费。</p></div><span class="reship-tag">快递应付</span></div>
<form method="GET" class="reship-toolbar">
<label>业务月份<input type="month" name="month" value="{{ $filters['month'] }}" min="2000-01" required></label>
<input type="hidden" name="match_status" value="{{ $filters['match_status'] }}">
<button type="submit" class="reship-primary">提取并匹配</button>
@if (!$reshipError)<a class="reship-button" href="{{ route('business.accessory-reship-deductions.export', $filters) }}">导出当前分类</a>@endif
@foreach (['trade_no'=>'交易单号','express_no'=>'补发快递单号','courier'=>'补发快递','ship_date'=>'补发日期','barcode'=>'原始条形码','normalized_barcode'=>'处理后条形码','freight'=>'补发运费','original_courier'=>'首发快递','original_express_no'=>'首发单号','original_month'=>'所属月份','deduction_amount'=>'可扣金额'] as $field => $label)
<label>{{ $label }}<input type="{{ $field === 'ship_date' ? 'date' : ($field === 'original_month' ? 'month' : 'text') }}" name="{{ $field }}" value="{{ $filters[$field] ?? '' }}" placeholder="{{ in_array($field, ['freight','deduction_amount']) ? '金额或待核实' : '筛选' . $label }}" maxlength="200"></label>
@endforeach
<label>匹配状态<select name="deduction_state"><option value="">全部状态</option>@foreach (['ready'=>'可扣款','unmatched'=>'未匹配不扣款','missing_config'=>'待配置比例','selection_required'=>'待选择扣款快递','zero_rate'=>'比例0%不扣款','invalid_amount'=>'运费待核实'] as $value => $label)<option value="{{ $value }}" @selected(($filters['deduction_state'] ?? '') === $value)>{{ $label }}</option>@endforeach</select></label>
<button type="submit" class="reship-primary">查询并匹配</button>
<a class="reship-button" href="{{ request()->url() . '?' . http_build_query(['month'=>$filters['month'], 'match_status'=>$filters['match_status']]) }}">清空筛选</a>
@if (!$reshipError)<a class="reship-button" href="{{ route('business.accessory-reship-deductions.export', $filters) }}">导出筛选结果</a>@endif
<span class="reship-range">匹配范围:{{ $startMonth }} 至 {{ \Carbon\Carbon::createFromFormat('!Y-m', $filters['month'])->format('Y年n月') }}</span>
</form>
<div class="reship-metrics">
@foreach (['all' => '配件单数', 'matched' => '可扣款单数', 'unmatched' => '未匹配单数'] as $key => $label)
@foreach (['all' => '配件单数', 'ready' => '可扣款单数', 'unmatched' => '未匹配单数'] as $key => $label)
<div><span>{{ $label }}</span><strong>{{ $reshipError ? '—' : number_format($summary[$key]) }}</strong></div>
@endforeach
<div><span>可扣款总额(运费仅计一次)</span><strong>{{ $reshipError ? '—' : '¥ ' . number_format($summary['amount'] / 100, 2) }}</strong></div>
<div><span>按配置比例扣款合计</span><strong>{{ $reshipError ? '—' : '¥ ' . number_format($summary['amount'] / 100, 2) }}</strong></div>
</div>
<details class="reship-rules"><summary>匹配规则与扣款口径</summary><p>配件仓库:常州简尚家居有限公司。配件条码取第一个英文逗号前的内容,再去掉最后一个“-”及其后内容。首发仓库排除该仓库,首发多个条码拆分后按完整条码匹配。仅匹配到首发快递单号的记录列为可扣款,金额取补发发货数据中的运费。</p><p>本页生成扣款核对清单,尚未记入应付账。多首发候选需确认归属,运费缺失或异常需核实;重复匹配不会新增扣款记录。</p></details>
<details class="reship-rules"><summary>匹配规则与扣款口径</summary><p>配件仓库:常州简尚家居有限公司。配件条码取第一个英文逗号前的内容,再去掉最后一个“-”及其后内容。首发仓库排除该仓库,首发多个条码拆分后按完整条码匹配。仅匹配到首发快递单号的记录列为可扣款,扣款金额=补发运费×首发快递配置比例,按分四舍五入。未配置比例暂不核算,0%不扣款。</p><p>本页生成扣款核对清单,尚未记入应付账。首发快递名称相同自动任选一条,无需核对;名称不同请选择需要扣款的快递,仅计一次;运费缺失或异常需核实;重复匹配不会新增扣款记录。</p></details>
@if ($reshipError)<div class="reship-error" role="alert">{{ $reshipError }}</div>@endif
@if ($summary['ambiguous'] || $summary['invalid_amount'])<div class="reship-notice" role="status">多首发候选 {{ $summary['ambiguous'] }} 单,需确认归属;运费异常 {{ $summary['invalid_amount'] }} 单,暂未计入总额。</div>@endif
@if ($summary['ambiguous'] || $summary['invalid_amount'] || $summary['missing_config'] || $summary['zero_rate'])<div class="reship-notice" role="status">待选择扣款快递 {{ $summary['selection_required'] }} 单;运费异常 {{ $summary['invalid_amount'] }} 单;待配置 {{ $summary['missing_config'] }} 单,0%不扣款 {{ $summary['zero_rate'] }} 单。待核算项不计入合计。</div>@endif
<section class="reship-panel" data-finance-table-surface>
<nav class="reship-tabs" aria-label="匹配分类">
@foreach ($labels as $key => $label)
@ -29,19 +41,37 @@
@endforeach
</nav>
<div class="reship-table-scroll"><table><thead><tr>
@foreach (['交易单号','补发快递单号','补发快递','补发日期','原始条形码','处理后条形码','补发运费','匹配状态','首发快递 / 单号 / 所属月份','可扣金额','说明'] as $heading)<th>{{ $heading }}</th>@endforeach
@foreach (['交易单号','补发快递单号','补发快递','补发日期','原始条形码','处理后条形码','补发运费','匹配状态','首发快递','首发单号','所属月份','扣款比例','可扣金额','说明'] as $heading)<th>{{ $heading }}</th>@endforeach
</tr></thead><tbody>
@forelse ($reshipRows as $row)
<tr><td>{{ $row['trade_no'] }}</td><td>{{ $row['express_no'] }}</td><td>{{ $row['express_name1'] ?: $row['express_name2'] }}</td><td>{{ substr((string) $row['ship_time'], 0, 10) }}</td><td class="reship-barcode">{{ $row['barcode'] }}</td><td>{{ $row['normalized_barcode'] }}</td><td class="reship-money">{{ $row['freight_cents'] === null ? '待核实' : number_format($row['freight_cents'] / 100, 2) }}</td><td><span class="reship-status {{ $row['status'] }}">{{ $row['status'] === 'matched' ? '可扣款' : '未匹配不扣款' }}</span></td><td>
@forelse ($row['matches'] as $match)<div class="reship-candidate">{{ $match['courier'] ?: '快递名称待核实' }}<br>{{ $match['express_no'] }} <span>({{ $match['month'] }})</span></div>@empty — @endforelse
</td><td class="reship-money">{{ $row['deduction_cents'] === null ? '待核实' : number_format($row['deduction_cents'] / 100, 2) }}</td><td>{{ $row['freight_cents'] === null ? '运费缺失或异常' : (count($row['matches']) > 1 ? '多候选,确认归属后扣款;仅计一次' : ($row['status'] === 'unmatched' ? '三个月内未匹配首发快递单号' : '待核对')) }}</td></tr>
@empty<tr><td colspan="11" class="reship-empty">{{ $reshipError ?: '当前月份及分类暂无记录,请调整业务月份或分类。' }}</td></tr>@endforelse
<tr><td>{{ $row['trade_no'] }}</td><td>{{ $row['express_no'] }}</td><td>{{ $row['express_name1'] ?: $row['express_name2'] }}</td><td>{{ substr((string) $row['ship_time'], 0, 10) }}</td><td class="reship-barcode">{{ $row['barcode'] }}</td><td>{{ $row['normalized_barcode'] }}</td><td class="reship-money">{{ $row['freight_cents'] === null ? '待核实' : number_format($row['freight_cents'] / 100, 2) }}</td><td><span class="reship-status {{ $row['status'] }}">{{ $row['deduction_state'] === 'ready' ? '可扣款' : ($row['status'] === 'matched' ? ($row['deduction_state'] === 'zero_rate' ? '比例0%不扣款' : '待核算') : '未匹配不扣款') }}</span></td><td>
@if (count($row['courier_candidates']) > 1)
<form data-reship-selection action="{{ route('business.accessory-reship-deductions.selection') }}" method="POST">
@csrf
<input type="hidden" name="month" value="{{ $filters['month'] }}">
<input type="hidden" name="row_key" value="{{ $row['selection_key'] }}">
<select name="courier" aria-label="选择需要扣款的首发快递" required>
<option value="">请选择扣款快递</option>
@foreach ($row['courier_candidates'] as $candidate)
<option value="{{ $candidate['courier'] }}" @selected($row['selected_courier'] === $candidate['courier'])>{{ $candidate['courier'] ?: '快递名称为空' }}</option>
@endforeach
</select>
<button type="submit" class="reship-button">保存选择</button>
<span role="status" data-selection-message></span>
</form>
@else
@forelse ($row['matches'] as $match)<div class="reship-candidate">{{ $match['courier'] ?: '快递名称待核实' }}</div>@empty — @endforelse
@endif
</td><td>@forelse ($row['matches'] as $match)<div class="reship-candidate">{{ $match['express_no'] }}</div>@empty — @endforelse</td><td>@forelse ($row['matches'] as $match)<div class="reship-candidate">{{ $match['month'] }}</div>@empty — @endforelse
</td><td>{{ $row['deduction_rate_basis_points'] === null ? '—' : number_format($row['deduction_rate_basis_points'] / 100, 2) . '%' }}</td><td class="reship-money">{{ $row['deduction_cents'] === null ? '待核实' : number_format($row['deduction_cents'] / 100, 2) }}</td><td>{{ $row['deduction_note'] }}</td></tr>
@empty<tr><td colspan="14" class="reship-empty">{{ $reshipError ?: '当前筛选条件下暂无记录,请清空筛选或调整业务月份、分类。' }}</td></tr>@endforelse
</tbody></table></div>
<div class="reship-footer">当前分类共 {{ number_format($reshipRows->total()) }} 单,每页50单 {{ $reshipRows->links() }}</div>
</section>
@endif
</div>
<style>
.reship-page{color:var(--vip-content-ink,#171717);font-size:13px}.reship-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.reship-heading h2{font-size:16px;font-weight:600;margin:0 0 6px}.reship-heading p,.reship-range{color:#595959;font-size:12px}.reship-tag,.reship-status{padding:4px 8px;border-radius:4px;background:#F7F0DE;color:#856632;font-size:12px}.reship-toolbar{display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px;margin-bottom:16px}.reship-toolbar label{display:grid;gap:6px;font-size:12px}.reship-toolbar input,.reship-button,.reship-toolbar button{height:34px;padding:0 12px;border:1px solid #D8D2C7;border-radius:8px;background:#fff;color:#171717}.reship-button{display:inline-flex;align-items:center;text-decoration:none}.reship-toolbar .reship-primary{background:#856632;border-color:#856632;color:#fff}.reship-range{align-self:center;padding-top:18px}.reship-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.reship-metrics>div{padding:16px;background:#fff;border:1px solid #D8D2C7;border-radius:12px}.reship-metrics span{display:block;font-size:12px;color:#595959}.reship-metrics strong{display:block;margin-top:8px;font-size:22px;font-variant-numeric:tabular-nums}.reship-metrics>div:last-child strong{color:#856632}.reship-rules{margin:12px 0;color:#595959;font-size:12px}.reship-rules summary{cursor:pointer;color:#856632}.reship-rules p{margin:8px 0;line-height:1.7}.reship-error,.reship-notice{padding:12px;margin:12px 0;border-radius:8px;background:#F7F0DE;color:#935F07}.reship-error{color:#B80000}.reship-panel{border:1px solid #D8D2C7;border-radius:12px;background:#fff;overflow:hidden}.reship-tabs{display:flex;gap:24px;padding:0 16px;border-bottom:1px solid #D8D2C7}.reship-tabs a{padding:13px 0;color:#595959;text-decoration:none}.reship-tabs a.is-active{color:#856632;border-bottom:2px solid #856632}.reship-tabs span{font-size:11px;margin-left:5px}.reship-table-scroll{overflow-x:auto}.reship-page table{width:100%;border-collapse:collapse;font-size:12px}.reship-page th,.reship-page td{padding:12px;text-align:left;vertical-align:top;border-bottom:1px solid #D8D2C7}.reship-page th{white-space:nowrap;background:#F7F0DE;font-weight:500;color:#595959}.reship-page td{min-width:110px;overflow-wrap:anywhere}.reship-page td.reship-barcode{min-width:200px;max-width:300px}.reship-page td.reship-money{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}.reship-status{display:inline-block;white-space:nowrap}.reship-status.matched{background:#eef3e9;color:#53793E}.reship-status.unmatched{background:#f1efeb;color:#595959}.reship-candidate{min-width:220px;margin-bottom:6px;line-height:1.7}.reship-candidate span{color:#595959}.reship-page td.reship-empty{text-align:center;padding:60px 16px;color:#595959}.reship-footer{padding:12px 16px;color:#595959;font-size:12px}.reship-footer nav{margin-top:12px}@media(max-width:700px){.reship-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.reship-range{width:100%;padding:0}.reship-tabs{gap:14px}.reship-metrics strong{font-size:19px}}
.reship-main-tabs{margin-bottom:16px}.reship-page{color:var(--vip-content-ink,#171717);font-size:13px}.reship-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.reship-heading h2{font-size:16px;font-weight:600;margin:0 0 6px}.reship-heading p,.reship-range{color:#595959;font-size:12px}.reship-tag,.reship-status{padding:4px 8px;border-radius:4px;background:#F7F0DE;color:#856632;font-size:12px}.reship-toolbar{display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px;margin-bottom:16px}.reship-toolbar label{display:grid;gap:6px;font-size:12px}.reship-toolbar select,.reship-toolbar input,.reship-button,.reship-toolbar button{height:34px;padding:0 12px;border:1px solid #D8D2C7;border-radius:8px;background:#fff;color:#171717}.reship-button{display:inline-flex;align-items:center;text-decoration:none}.reship-toolbar .reship-primary{background:#856632;border-color:#856632;color:#fff}.reship-range{align-self:center;padding-top:18px}.reship-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.reship-metrics>div{padding:16px;background:#fff;border:1px solid #D8D2C7;border-radius:12px}.reship-metrics span{display:block;font-size:12px;color:#595959}.reship-metrics strong{display:block;margin-top:8px;font-size:22px;font-variant-numeric:tabular-nums}.reship-metrics>div:last-child strong{color:#856632}.reship-rules{margin:12px 0;color:#595959;font-size:12px}.reship-rules summary{cursor:pointer;color:#856632}.reship-rules p{margin:8px 0;line-height:1.7}.reship-error,.reship-notice{padding:12px;margin:12px 0;border-radius:8px;background:#F7F0DE;color:#935F07}.reship-error{color:#B80000}.reship-panel{border:1px solid #D8D2C7;border-radius:12px;background:#fff;overflow:hidden}.reship-tabs{display:flex;gap:24px;padding:0 16px;border-bottom:1px solid #D8D2C7}.reship-tabs a{padding:13px 0;color:#595959;text-decoration:none}.reship-tabs a.is-active{color:#856632;border-bottom:2px solid #856632}.reship-tabs span{font-size:11px;margin-left:5px}.reship-table-scroll{overflow:auto;max-height:65vh}.reship-page thead th{position:sticky;top:0;z-index:1}.reship-page table{width:100%;border-collapse:collapse;font-size:12px}.reship-page th,.reship-page td{padding:12px;text-align:left;vertical-align:top;border-bottom:1px solid #D8D2C7}.reship-page th{white-space:nowrap;background:#F7F0DE;font-weight:500;color:#595959}.reship-page td{min-width:110px;overflow-wrap:anywhere}.reship-page td.reship-barcode{min-width:200px;max-width:300px}.reship-page td.reship-money{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}.reship-status{display:inline-block;white-space:nowrap}.reship-status.matched{background:#eef3e9;color:#53793E}.reship-status.unmatched{background:#f1efeb;color:#595959}.reship-candidate{min-width:110px;min-height:24px;margin-bottom:6px;line-height:24px;white-space:nowrap}.reship-candidate span{color:#595959}.reship-page td.reship-empty{text-align:center;padding:60px 16px;color:#595959}.reship-footer{padding:12px 16px;color:#595959;font-size:12px}.reship-footer nav{margin-top:12px}@media(max-width:700px){.reship-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.reship-range{width:100%;padding:0}.reship-tabs{gap:14px}.reship-metrics strong{font-size:19px}}
</style>
@push('body_end')
<script>
@ -49,7 +79,52 @@
// 分类在当前页面局部切换,保留页面外壳和快递应付导航。
const root = document.querySelector('.reship-page');
if (!root) return;
document.addEventListener('submit', async event => {
const form = event.target.closest('[data-reship-selection]');
if (!form) return;
event.preventDefault();
const button = form.querySelector('button');
const message = form.querySelector('[data-selection-message]');
button.disabled = true;
message.textContent = '保存中…';
let saved = false;
try {
const response = await fetch(form.action, {method: 'POST', body: new FormData(form), headers: {'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest'}});
if (!response.ok) throw new Error('save');
saved = true;
const refresh = await fetch(location.href, {headers: {'X-Requested-With': 'XMLHttpRequest'}});
if (!refresh.ok) throw new Error('refresh');
const doc = new DOMParser().parseFromString(await refresh.text(), 'text/html');
const next = doc.querySelector('.reship-page');
if (!next) throw new Error('refresh');
document.querySelector('.reship-page').replaceWith(next);
} catch (_) {
message.textContent = saved ? '已保存,页面刷新失败,请刷新查看金额。' : '保存失败,请刷新后重试。';
button.disabled = false;
}
});
document.addEventListener('click', async event => {
const action = event.target.closest('[data-config-new], [data-config-edit], [data-config-import], [data-dialog-close], [data-config-refresh]');
if (action) {
if (action.hasAttribute('data-config-new') || action.hasAttribute('data-config-edit')) {
const dialog = document.querySelector('[data-config-editor]');
const form = dialog.querySelector('form');
form.reset();
const editing = action.hasAttribute('data-config-edit');
form.action = editing ? action.dataset.url : form.dataset.createUrl;
form.elements._method.value = editing ? 'PATCH' : 'POST';
dialog.querySelector('[data-editor-title]').textContent = editing ? '编辑快递配置' : '新增快递配置';
if (editing) for (const [key, value] of Object.entries(JSON.parse(action.dataset.record))) form.elements[key].value = value;
dialog.showModal();
} else if (action.hasAttribute('data-config-import')) {
document.querySelector('[data-config-importer]').showModal();
} else if (action.hasAttribute('data-dialog-close')) {
action.closest('dialog').close();
} else {
document.querySelector('.reship-main-tabs .is-active').click();
}
return;
}
const link = event.target.closest('[data-reship-tab]');
if (!link || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0) return;
event.preventDefault();

View File

@ -0,0 +1,63 @@
<div class="reship-config-workspace">
<div class="reship-heading"><div><h2>快递公司配置</h2><p>按首发快递名称精确匹配配置,扣款金额=补发运费×扣款比例。</p></div></div>
@if (session('reship_config_message'))<div class="reship-notice" role="status">{{ session('reship_config_message') }}</div>@endif
@if ($errors->any())<div class="reship-error" role="alert">{{ $errors->first() }}</div>@endif
<form method="GET" class="reship-toolbar" aria-label="快递配置查询">
<input type="hidden" name="reship_view" value="config">
<label>创建时间从<input type="date" name="created_from" value="{{ $configFilters['created_from'] }}"></label>
<label>至<input type="date" name="created_to" value="{{ $configFilters['created_to'] }}"></label>
<label>快递公司<input name="courier_name" maxlength="191" value="{{ $configFilters['courier_name'] }}" placeholder="输入快递名称"></label>
<label>创建人<input name="creator" maxlength="191" value="{{ $configFilters['creator'] }}"></label>
<label>删除状态<select name="deleted_status"><option value="active" @selected($configFilters['deleted_status'] === 'active')>正常</option><option value="deleted" @selected($configFilters['deleted_status'] === 'deleted')>已删除</option><option value="all" @selected($configFilters['deleted_status'] === 'all')>全部</option></select></label>
<button class="reship-primary" type="submit">查询</button>
<a class="reship-button" data-reship-tab href="{{ route('business.pages.show', ['section'=>'courier-payables','page'=>'accessory-reship-deductions','reship_view'=>'config']) }}">重置</a>
</form>
<div class="reship-toolbar">
<button class="reship-primary" type="button" data-config-new>新增</button>
<button type="button" data-config-import>导入Excel</button>
<a class="reship-button" href="{{ route('business.accessory-reship-configs.export', $configFilters) }}">导出配置</a>
<button type="button" data-config-refresh>刷新</button>
<span class="reship-config-hint">比例范围0%~100%,0%表示不扣款。删除可恢复,已删除配置不参与核算。</span>
</div>
<section class="reship-panel" data-finance-table-surface>
<div class="reship-table-scroll"><table><thead><tr><th>创建时间</th><th>快递公司</th><th>快递代码</th><th>扣款比例(%)</th><th>创建人</th><th>删除状态</th><th>操作</th></tr></thead><tbody>
@forelse ($configRows as $config)
<tr><td>{{ $config->source_created_at ?: $config->created_at }}</td><td>{{ $config->courier_name }}</td><td>{{ $config->courier_code }}</td><td class="reship-money">{{ number_format($config->rate_basis_points / 100, 2) }}%</td><td>{{ $config->creator }}</td><td>{{ $config->deleted_at ? '已删除' : '正常' }}</td><td class="reship-config-actions">
@if (!$config->deleted_at)
<button type="button" data-config-edit data-record="{{ json_encode(['courier_code'=>$config->courier_code,'courier_name'=>$config->courier_name,'rate_percent'=>number_format($config->rate_basis_points/100,2,'.','')]) }}" data-url="{{ route('business.accessory-reship-configs.update', $config->id) }}">编辑</button>
<form method="POST" action="{{ route('business.accessory-reship-configs.destroy', $config->id) }}" data-config-delete>@csrf @method('DELETE')<button type="submit">删除</button></form>
@else
<form method="POST" action="{{ route('business.accessory-reship-configs.restore', $config->id) }}">@csrf<button type="submit">恢复</button></form>
@endif
</td></tr>
@empty<tr><td colspan="7" class="reship-empty">暂无符合条件的快递配置。</td></tr>@endforelse
</tbody></table></div>
<div class="reship-footer">共 {{ $configRows->total() }} 条,每页20条 {{ $configRows->links() }}</div>
</section>
<h3 class="reship-config-log-title">操作日志</h3>
<section class="reship-panel"><div class="reship-table-scroll"><table><thead><tr><th>操作时间</th><th>操作用户</th><th>系统功能</th><th>操作类型</th><th>执行结果</th><th>执行结果描述</th></tr></thead><tbody>
@forelse ($configEvents as $event)<tr><td>{{ $event->created_at }}</td><td>{{ $event->actor }}</td><td>配件补发扣款配置</td><td>{{ ['create'=>'新增','update'=>'编辑','delete'=>'删除','restore'=>'恢复','import'=>'导入'][$event->action] ?? $event->action }}</td><td>成功</td><td>{{ $event->description }}</td></tr>@empty<tr><td colspan="6" class="reship-empty">暂无操作记录。</td></tr>@endforelse
</tbody></table></div><div class="reship-footer">{{ $configEvents->links() }}</div></section>
<dialog class="reship-config-dialog" data-config-editor>
<form method="POST" action="{{ route('business.accessory-reship-configs.store') }}" data-create-url="{{ route('business.accessory-reship-configs.store') }}">
@csrf<input type="hidden" name="_method" value="POST">
<h3 data-editor-title>新增快递配置</h3>
<label>快递公司<input name="courier_name" required maxlength="191" autocomplete="off"></label>
<label>快递代码<input name="courier_code" required maxlength="64" autocomplete="off"></label>
<label>扣款比例(%)<input name="rate_percent" type="number" min="0" max="100" step="0.01" required placeholder="例如50表示50%"></label>
<p>补发运费按此比例核算。0%不扣款,配置修改后核对清单重新计算。</p>
<div class="reship-toolbar"><button type="submit" class="reship-primary">保存</button><button type="button" data-dialog-close>取消</button></div>
</form>
</dialog>
<dialog class="reship-config-dialog" data-config-importer>
<form method="POST" enctype="multipart/form-data" action="{{ route('business.accessory-reship-configs.import') }}">@csrf
<h3>导入快递配置</h3><p>列名:快递代码、快递公司、创建时间、创建人、扣款比率(%)。保留原始创建时间和创建人。</p>
<input type="file" name="file" accept=".xlsx" required>
<p>相同配置重复导入会跳过;代码或名称冲突时整批不导入,不覆盖现有配置。</p>
<div class="reship-toolbar"><button class="reship-primary" type="submit">导入</button><button type="button" data-dialog-close>取消</button></div>
</form>
</dialog>
</div>
<style>
.reship-toolbar select{height:34px;padding:0 10px;border:1px solid #D8D2C7;background:#fff;border-radius:8px}.reship-config-hint{font-size:12px;color:#595959;align-self:center}.reship-config-actions{white-space:nowrap}.reship-config-actions form{display:inline}.reship-config-actions button{border:1px solid #D8D2C7;border-radius:4px;background:#fff;color:#856632;padding:4px 8px;margin-right:6px;cursor:pointer}.reship-config-dialog{width:min(460px,90vw);border:1px solid #D8D2C7;border-radius:12px;background:#fff;color:#171717;padding:24px}.reship-config-dialog::backdrop{background:#0006}.reship-config-dialog h3{font-size:16px;margin:0 0 16px}.reship-config-dialog label{display:grid;gap:8px;margin:12px 0;font-size:13px}.reship-config-dialog label input{height:34px;border:1px solid #D8D2C7;border-radius:8px;padding:0 10px}.reship-config-dialog p{font-size:12px;color:#595959;line-height:1.7;margin:12px 0}.reship-config-log-title{font-size:14px;margin:24px 0 12px}
</style>

View File

@ -405,6 +405,22 @@ 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::prefix('workspace/courier-payables/accessory-reship-deductions/configs')
->name('business.accessory-reship-configs.')
->middleware('business.page:courier-payables,accessory-reship-deductions')
->group(function (): void {
$controller = \App\Http\Controllers\Business\AccessoryReshipCourierConfigs::class;
Route::get('export', [$controller, 'export'])->name('export');
Route::post('import', [$controller, 'import'])->name('import');
Route::post('/', [$controller, 'store'])->name('store');
Route::patch('{configId}', [$controller, 'update'])->whereNumber('configId')->name('update');
Route::delete('{configId}', [$controller, 'destroy'])->whereNumber('configId')->name('destroy');
Route::post('{configId}/restore', [$controller, 'restore'])->whereNumber('configId')->name('restore');
});
Route::post('workspace/courier-payables/accessory-reship-deductions/selection', [\App\Http\Controllers\Business\AccessoryReshipDeductions::class, 'selectCourier'])
->middleware('business.page:courier-payables,accessory-reship-deductions')
->name('business.accessory-reship-deductions.selection');
Route::get('workspace/courier-payables/accessory-reship-deductions/export', [\App\Http\Controllers\Business\AccessoryReshipDeductions::class, 'export'])
->middleware('business.page:courier-payables,accessory-reship-deductions')
->name('business.accessory-reship-deductions.export');

View File

@ -0,0 +1,167 @@
<?php
namespace Tests\Unit;
use Illuminate\Config\Repository;
use Illuminate\Database\Capsule\Manager;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Translation\ArrayLoader;
use Illuminate\Translation\Translator;
use Illuminate\Validation\Factory;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Services\AccessoryReshipCourierStore;
use Modules\EcommerceFinance\Services\AccessoryReshipCourierImporter;
use Modules\EcommerceFinance\Services\AccessoryReshipRateCalculator;
use Modules\EcommerceFinance\Services\AccessoryReshipSelectionStore;
use PHPUnit\Framework\TestCase;
foreach (['AccessoryReshipCourierStore', 'AccessoryReshipCourierImporter', 'AccessoryReshipRateCalculator', 'AccessoryReshipSelectionStore'] as $class) {
require_once dirname(__DIR__, 2) . '/modules/EcommerceFinance/Services/' . $class . '.php';
}
class AccessoryReshipCourierConfigTest extends TestCase
{
private AccessoryReshipCourierStore $store;
protected function setUp(): void
{
$app = new Application(dirname(__DIR__, 2));
$app->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());
$app->bind('db.schema', fn () => $db->getConnection()->getSchemaBuilder());
$app->instance('validator', new Factory(new Translator(new ArrayLoader, 'zh_CN'), $app));
Facade::clearResolvedInstances();
Facade::setFacadeApplication($app);
$migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_16_220000_create_accessory_reship_courier_configs.php';
$migration->up();
$this->store = new AccessoryReshipCourierStore;
}
private function row(string $code = 'TEST', string $name = '测试快递', int $rate = 5000): array
{
return ['courier_code' => $code, 'courier_name' => $name, 'rate_basis_points' => $rate, 'source_created_at' => '2025-11-07 17:03:30', 'creator' => '原创建人'];
}
public function test_import_preserves_source_metadata_and_is_idempotent(): void
{
$this->assertSame(['added' => 1, 'skipped' => 0], $this->store->import(1, [$this->row()], '导入操作人', 'hash'));
$this->assertSame(['added' => 0, 'skipped' => 1], $this->store->import(1, [$this->row()], '导入操作人', 'hash'));
$row = DB::table(AccessoryReshipCourierStore::TABLE)->first();
$this->assertSame('2025-11-07 17:03:30', $row->source_created_at);
$this->assertSame('原创建人', $row->creator);
$this->assertSame(1, DB::table(AccessoryReshipCourierStore::TABLE)->count());
$this->assertSame(2, DB::table(AccessoryReshipCourierStore::EVENTS)->count());
}
public function test_import_conflict_rolls_back_entire_batch_and_log(): void
{
$this->store->import(1, [$this->row()], '操作人', 'hash');
try {
$this->store->import(1, [$this->row('NEW', '新增快递'), $this->row('TEST', '不同名称')], '操作人', 'hash2');
$this->fail('必须拒绝冲突');
} catch (ValidationException) {
$this->assertSame(1, DB::table(AccessoryReshipCourierStore::TABLE)->count());
$this->assertSame(1, DB::table(AccessoryReshipCourierStore::EVENTS)->count());
}
}
public function test_crud_soft_delete_restore_and_company_isolation(): void
{
$id = $this->store->save(1, $this->row(), '操作人');
$this->store->save(1, $this->row(rate: 7000), '编辑人', $id);
$this->assertSame(7000, (int) $this->store->activeRates(1)['测试快递']['rate_basis_points']);
$this->assertSame([], $this->store->activeRates(2));
$this->store->changeDeleted(1, $id, true, '操作人');
$this->assertSame([], $this->store->activeRates(1));
$this->store->changeDeleted(1, $id, false, '操作人');
$this->assertCount(1, $this->store->activeRates(1));
$this->assertSame(4, DB::table(AccessoryReshipCourierStore::EVENTS)->count());
try {
$this->store->save(2, $this->row(), '其他公司', $id);
$this->fail('不能修改其他公司');
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $exception) {
$this->assertSame(404, $exception->getStatusCode());
}
}
public function test_rate_calculation_zero_missing_conflicting_and_rounding(): void
{
$calculator = new AccessoryReshipRateCalculator;
$row = ['status' => 'matched', 'freight_cents' => 1235, 'matches' => [['courier' => '测试快递'], ['courier' => '另一快递']]];
$rates = ['测试快递' => $this->row(rate: 5000), '另一快递' => $this->row('SECOND', '另一快递', 5000)];
$this->assertSame('selection_required', $calculator->apply($row, $rates)['deduction_state']);
$result = $calculator->apply($row, $rates, '测试快递');
$this->assertSame(618, $result['deduction_cents']);
$this->assertSame('ready', $result['deduction_state']);
$rates['另一快递']['rate_basis_points'] = 10000;
$this->assertSame('selection_required', $calculator->apply($row, $rates)['deduction_state']);
$this->assertNull($calculator->apply($row, $rates)['deduction_cents']);
$this->assertSame('missing_config', $calculator->apply($row, [], '测试快递')['deduction_state']);
$this->assertSame(1235, $calculator->apply($row, $rates, '另一快递')['deduction_cents']);
$this->assertSame('selection_required', $calculator->apply($row, $rates, '失效快递')['deduction_state']);
$row['matches'] = [['courier' => '测试快递']];
$rates['测试快递']['rate_basis_points'] = 0;
$this->assertSame(0, $calculator->apply($row, $rates)['deduction_cents']);
$this->assertSame('zero_rate', $calculator->apply($row, $rates)['deduction_state']);
$this->assertSame(865, AccessoryReshipRateCalculator::amount(1235, 7000));
$row['status'] = 'unmatched';
$this->assertSame(0, $calculator->apply($row, $rates)['deduction_cents']);
}
public function test_same_courier_selects_one_waybill_without_confirmation(): void
{
$row = ['status' => 'matched', 'freight_cents' => 1235, 'matches' => [
['courier' => '测试快递', 'express_no' => 'FIRST', 'month' => '2026-09'],
['courier' => '测试快递', 'express_no' => 'SECOND', 'month' => '2026-08'],
]];
$result = (new AccessoryReshipRateCalculator)->apply($row, ['测试快递' => $this->row()]);
$this->assertSame('ready', $result['deduction_state']);
$this->assertSame(618, $result['deduction_cents']);
$this->assertCount(1, $result['matches']);
$this->assertSame('FIRST', $result['matches'][0]['express_no']);
$this->assertStringNotContainsString('核对', $result['deduction_note']);
}
public function test_selection_survives_reload_and_is_scoped_to_company_month_and_package(): void
{
$migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_16_230000_create_accessory_reship_selections.php';
$migration->up();
$store = new AccessoryReshipSelectionStore;
$key = AccessoryReshipSelectionStore::key(['source_id' => 1, 'express_no' => 'BF001']);
$other = AccessoryReshipSelectionStore::key(['source_id' => 2, 'express_no' => 'BF002']);
$this->assertNotSame($key, $other);
$store->save(1, '2026-10', $key, '快递A', '操作人');
$store->save(1, '2026-10', $key, '快递B', '操作人');
$store->save(1, '2026-10', $other, '快递C', '操作人');
$this->assertSame([$key => '快递B', $other => '快递C'], (new AccessoryReshipSelectionStore)->forMonth(1, '2026-10'));
$this->assertSame([], $store->forMonth(2, '2026-10'));
$this->assertSame([], $store->forMonth(1, '2026-09'));
$this->assertSame(2, DB::table(AccessoryReshipSelectionStore::TABLE)->count());
}
public function test_importer_reads_string_and_numeric_percent_and_retains_dates(): void
{
$book = new \PhpOffice\PhpSpreadsheet\Spreadsheet;
$sheet = $book->getActiveSheet();
$sheet->fromArray([['快递代码', '快递公司', '创建时间', '创建人', '扣款比率(%)'], ['A', '快递A', '2025-11-07 17:03:30', '原创建人', '50.00%'], ['B', '快递B', '2025-11-08 12:00:00', '原创建人', 0.7]]);
$sheet->getStyle('E3')->getNumberFormat()->setFormatCode('0.00%');
$path = tempnam(sys_get_temp_dir(), 'reship-config-');
try {
(new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($book))->save($path);
$rows = (new AccessoryReshipCourierImporter)->read($path);
$this->assertSame([5000, 7000], array_column($rows, 'rate_basis_points'));
$this->assertSame('2025-11-07 17:03:30', $rows[0]['source_created_at']);
$sheet->setCellValue('E3', '100.01%');
(new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($book))->save($path);
$this->expectException(ValidationException::class);
(new AccessoryReshipCourierImporter)->read($path);
} finally {
$book->disconnectWorksheets();
unlink($path);
}
}
}

View File

@ -6,6 +6,9 @@ use Illuminate\Http\Request;
use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
use Modules\EcommerceFinance\Services\AccessoryReshipMatcher;
use Modules\EcommerceFinance\View\AccessoryReshipPageData;
use Modules\EcommerceFinance\Services\AccessoryReshipCourierStore;
use Modules\EcommerceFinance\Services\AccessoryReshipRateCalculator;
use Modules\EcommerceFinance\Services\AccessoryReshipSelectionStore;
use PHPUnit\Framework\TestCase;
use Mockery;
@ -13,15 +16,32 @@ require_once dirname(__DIR__, 2) . '/modules/EcommerceFinance/Repositories/Shipp
require_once dirname(__DIR__, 2) . '/modules/EcommerceFinance/Services/AccessoryReshipMatcher.php';
require_once dirname(__DIR__, 2) . '/modules/EcommerceFinance/View/AccessoryReshipPageData.php';
require_once dirname(__DIR__, 2) . '/modules/EcommerceFinance/Services/AccessoryReshipCourierStore.php';
require_once dirname(__DIR__, 2) . '/modules/EcommerceFinance/Services/AccessoryReshipRateCalculator.php';
require_once dirname(__DIR__, 2) . '/modules/EcommerceFinance/Services/AccessoryReshipSelectionStore.php';
class AccessoryReshipPageDataTest extends TestCase
{
private function selections(): AccessoryReshipSelectionStore
{
$store = Mockery::mock(AccessoryReshipSelectionStore::class);
$store->shouldReceive('forMonth')->andReturn([]);
return $store;
}
private function configs(): AccessoryReshipCourierStore
{
$store = Mockery::mock(AccessoryReshipCourierStore::class);
$store->shouldReceive('activeRates')->andReturn(['快递' => ['courier_code' => 'TEST', 'rate_basis_points' => 5000]]);
return $store;
}
protected function tearDown(): void { Mockery::close(); parent::tearDown(); }
public function test_january_uses_current_month_and_previous_two_months(): void
{
$repo = Mockery::mock(ShippingOrderRepository::class);
$repo->shouldReceive('accessoryReshipBatches')->once()->with('2026-01-01', '2026-02-01', '2025-11-01')->andReturn((function () { yield ['accessories' => [['trade_no' => '001', 'barcode' => 'SKU-1', 'freight' => '2.50']], 'originals' => []]; })());
$data = new AccessoryReshipPageData($repo, new AccessoryReshipMatcher);
$repo->shouldReceive('accessoryReshipBatches')->once()->with('2026-01-01', '2026-02-01', '2025-11-01')->andReturn((function () { yield ['accessories' => [['trade_no' => '001', 'express_no' => 'BF001', 'express_name1' => '补发快递', 'ship_time' => '2026-09-01', 'barcode' => 'SKU-1', 'freight' => '2.50']], 'originals' => []]; })());
$data = new AccessoryReshipPageData($repo, new AccessoryReshipMatcher, $this->configs(), new AccessoryReshipRateCalculator, $this->selections());
$rows = iterator_to_array($data->rows(['month' => '2026-01']));
$this->assertSame('unmatched', $rows[0]['status']);
$this->assertSame(0, $rows[0]['deduction_cents']);
@ -32,15 +52,16 @@ class AccessoryReshipPageDataTest extends TestCase
$request = Request::create('/workspace/courier-payables/accessory-reship-deductions?month=2026-09&match_status=all&page=2');
Container::getInstance()->instance('request', $request);
$accessories = [];
for ($i = 0; $i < 105; $i++) $accessories[] = ['trade_no' => '001', 'barcode' => 'SKU-1', 'freight' => '0.10'];
for ($i = 0; $i < 105; $i++) $accessories[] = ['trade_no' => '001', 'express_no' => 'BF001', 'express_name1' => '补发快递', 'ship_time' => '2026-09-01', 'barcode' => 'SKU-1', 'freight' => '0.10'];
$original = ['trade_no' => '001', 'barcode' => 'SKU', 'express_no' => 'FIRST', 'express_name1' => '快递', 'ship_time' => '2026-08-01', 'warehouse_name' => '成品仓'];
$repo = Mockery::mock(ShippingOrderRepository::class);
$repo->shouldReceive('accessoryReshipBatches')->andReturn((function () use ($accessories, $original) { yield ['accessories' => $accessories, 'originals' => [$original, array_replace($original, ['express_no' => 'SECOND'])]]; })());
$data = (new AccessoryReshipPageData($repo, new AccessoryReshipMatcher))->page();
$data = (new AccessoryReshipPageData($repo, new AccessoryReshipMatcher, $this->configs(), new AccessoryReshipRateCalculator, $this->selections()))->page();
$this->assertCount(50, $data['reshipRows']);
$this->assertSame(105, $data['reshipRows']->total());
$this->assertSame(105, $data['reshipSummary']['matched']);
$this->assertSame(1050, $data['reshipSummary']['amount']);
$this->assertSame(105, $data['reshipSummary']['ambiguous']);
$this->assertSame(525, $data['reshipSummary']['amount']);
$this->assertSame(105, $data['reshipSummary']['ready']);
$this->assertSame(0, $data['reshipSummary']['ambiguous']);
}
}