CaiWuXiTong/app/Business/CourierSurchargeQuoteRepository.php
zhoushengchen 88526b9619 附加费类型字典自维护(含进仓费) (#98)
## 任务占用与边界
- 任务名称:附加费类型字典自维护
- 实际开发者:zhoushengchen
- 目标结果:在快递附加费报价维护页支持附加费类型的新增、启用/停用;新增报价仅可选择启用类型,历史报价保留原类型。
- 预计修改路径:附加费类型 Repository、Model、Controller、报价 Repository/Controller、页面、管理路由、类型表迁移和专项测试。
- 将写入的公共契约:business.courier-surcharge.types.store、business.courier-surcharge.types.status、business_courier_surcharge_types。
- 仅复用、不修改的公共契约:business.pages.show、business.courier-surcharge.store。
- 明确不修改的范围:上游数据库、业务镜像、七牛、生产配置和无关页面。

## 开工碰撞检查
- [x] 已检查最新 main 和所有远程任务分支
- [x] 已运行 scripts/codex-task-preflight.ps1
- [x] 未发现与其他远程任务的实际写入重叠

## 实际改动
- 新增附加费类型字典及公司范围隔离。
- 新增类型管理弹窗、创建接口、启用/停用接口。
- 报价表单和后端校验动态读取启用类型;筛选仍可查看全部类型。
- 默认初始化原有 11 种类型,并增加“进仓费”。
- 增加幂等增量迁移,补齐已执行建表迁移环境中的“进仓费”。

## 验证证据
- tests/Feature/Business/CourierSurchargePageTest.php:6 个用例、60 个断言通过。
- 相关 PHP 文件语法检查通过。
- business.courier-surcharge 路由共 5 条,路由检查通过。
- git diff --check 通过。
- 本地共享系统库现状:类型表已存在,当前公司有 11 个旧类型;增量迁移待执行。

## 风险检查
- 未提交 .env、密码、Token、私钥、真实财务数据、生产日志或构建产物。
- 前端遵循 DESIGN.md 的 VIP 黑金令牌。
- 数据库影响:仅新增类型表(若环境尚未建表)或幂等补充“进仓费”,不删除、不覆盖已有类型。
- 回滚:类型字典不做物理删除;迁移 down 保持空操作以保护业务引用。
- 已获得本次“合并 PR 并执行类型表迁移”的明确授权;迁移目标为本机当前连接的团队系统数据库,不涉及生产部署。

## 合并前复查
- [x] 已重新 fetch 远程引用并再次运行碰撞检查
- [x] 已逐文件审核 origin/main...任务分支完整 diff
- [ ] PR 创建后移除 WIP 并执行 Squash merge
- [ ] 合并后验证 main、执行迁移并验证页面

Reviewed-on: http://git.1080p.hk/WuYongJie/CaiWuXiTong/pulls/98
2026-09-02 18:22:17 +08:00

234 lines
8.3 KiB
PHP

<?php
namespace App\Business;
use App\Models\Business\CourierSurchargeQuote;
use App\Models\Business\CourierSurchargeType;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
/**
* 快递附加费报价的固定读写入口。
*
* 页面和控制器不直接操作数据库,所有公司范围、字段白名单和状态转换
* 都在这里集中处理,便于后续结算页面复用同一套报价规则。
*/
class CourierSurchargeQuoteRepository
{
public const TYPES = [
'退回费', '拦截费', '改地址', '异形件', '超长费',
'上楼费', '送货费', '超区费', '安装费', '保费', '发出运费附加',
];
public const MODES = [
'固定单价', '按重量', '按体积', '按尺寸/最长边',
'聚工单客服核实', '取发出运费', '发出运费倍数', '按账单',
];
public const SCOPES = ['自有快递', '代发仓'];
public function __construct(private readonly CourierSurchargeTypeRepository $types)
{
}
public function ready(): bool
{
try {
return Schema::hasTable((new CourierSurchargeQuote())->getTable()) && $this->types->ready();
} catch (QueryException) {
return false;
}
}
/**
* @param array<string,string> $filters
* @return array{ready:bool, rows:Collection, summary:array<string,int>, options:array<string,Collection|array>}
*/
public function pageData(array $filters): array
{
if (! $this->ready()) {
return [
'ready' => false,
'rows' => collect(),
'summary' => ['total' => 0, 'active' => 0, 'inactive' => 0, 'couriers' => 0],
'options' => $this->emptyOptions(),
];
}
try {
$all = CourierSurchargeQuote::query()
->orderByDesc('effective_at')
->orderByDesc('id')
->get();
} catch (QueryException) {
return [
'ready' => false,
'rows' => collect(),
'summary' => ['total' => 0, 'active' => 0, 'inactive' => 0, 'couriers' => 0],
'options' => $this->emptyOptions(),
];
}
$filtered = $all->filter(function (CourierSurchargeQuote $quote) use ($filters): bool {
foreach (['courier', 'scope', 'reconciler', 'mode'] as $key) {
if (($filters[$key] ?? '') !== '' && (string) $quote->{$key} !== $filters[$key]) {
return false;
}
}
if (($filters['type'] ?? '') !== '' && ! in_array($filters['type'], (array) $quote->types, true)) {
return false;
}
if (($filters['status'] ?? '') !== '' && $quote->statusLabel() !== $filters['status']) {
return false;
}
$keyword = trim((string) ($filters['keyword'] ?? ''));
if ($keyword !== '') {
$searchable = implode('|', [
$quote->courier,
$quote->scope,
$quote->reconciler,
$quote->typeLabel(),
$quote->mode,
$quote->standard,
$quote->condition,
(string) $quote->remark,
]);
if (! str_contains($searchable, $keyword)) {
return false;
}
}
return true;
})->values();
$typeRecords = $this->types->all();
$activeTypeNames = $typeRecords
->where('status', CourierSurchargeType::STATUS_ACTIVE)
->pluck('name')
->values();
return [
'ready' => true,
'rows' => $filtered->map(fn (CourierSurchargeQuote $quote): array => $this->present($quote)),
'summary' => [
'total' => $filtered->count(),
'active' => $filtered->where('status', CourierSurchargeQuote::STATUS_ACTIVE)->count(),
'inactive' => $filtered->where('status', CourierSurchargeQuote::STATUS_INACTIVE)->count(),
'couriers' => $filtered->pluck('courier')->unique()->count(),
],
'options' => [
'couriers' => $all->pluck('courier')->filter()->unique()->sort()->values(),
'types' => $activeTypeNames,
'filter_types' => $typeRecords->pluck('name')->values(),
'type_records' => $typeRecords->map(fn ($type): array => [
'id' => $type->id,
'name' => $type->name,
'status' => $type->status,
'status_label' => $type->statusLabel(),
])->values(),
'modes' => collect(self::MODES),
'scopes' => self::SCOPES,
'reconcilers' => $all->pluck('reconciler')->filter()->unique()->sort()->values(),
'statuses' => ['已启用', '已停用'],
],
];
}
/** @return list<string> */
public function validTypeNames(): array
{
return $this->types->validNames();
}
/** @return list<string> */
public function activeTypeNames(): array
{
return $this->types->activeNames();
}
/** @param array<string,mixed> $data */
public function create(array $data, int $userId): CourierSurchargeQuote
{
return CourierSurchargeQuote::query()->create($this->attributes($data, $userId) + [
'status' => CourierSurchargeQuote::STATUS_ACTIVE,
'created_by' => $userId,
]);
}
/** @param array<string,mixed> $data */
public function update(CourierSurchargeQuote $quote, array $data, int $userId): CourierSurchargeQuote
{
$quote->update($this->attributes($data, $userId));
return $quote->fresh();
}
public function setStatus(CourierSurchargeQuote $quote, string $status, int $userId): CourierSurchargeQuote
{
abort_unless(in_array($status, [CourierSurchargeQuote::STATUS_ACTIVE, CourierSurchargeQuote::STATUS_INACTIVE], true), 422, '报价规则状态不合法。');
$quote->forceFill(['status' => $status, 'updated_by' => $userId])->save();
return $quote->fresh();
}
/** @param array<string,mixed> $data */
private function attributes(array $data, int $userId): array
{
return [
'company_id' => company_id(),
'courier' => trim((string) $data['courier']),
'scope' => trim((string) $data['scope']),
'reconciler' => trim((string) $data['reconciler']),
'types' => array_values(array_unique(array_map('strval', $data['types']))),
'mode' => trim((string) $data['mode']),
'standard' => trim((string) $data['standard']),
'condition' => trim((string) $data['condition']),
'effective_at' => $data['effective_at'],
'remark' => trim((string) ($data['remark'] ?? '')) ?: null,
'updated_by' => $userId,
];
}
/** @return array<string,mixed> */
private function present(CourierSurchargeQuote $quote): array
{
return [
'id' => 'SUR-' . str_pad((string) $quote->id, 3, '0', STR_PAD_LEFT),
'db_id' => $quote->id,
'courier' => $quote->courier,
'scope' => $quote->scope,
'reconciler' => $quote->reconciler,
'types' => array_values((array) $quote->types),
'type' => $quote->typeLabel(),
'mode' => $quote->mode,
'standard' => $quote->standard,
'condition' => $quote->condition,
'effective_at' => optional($quote->effective_at)->format('Y-m-d'),
'status_code' => $quote->status,
'status' => $quote->statusLabel(),
'remark' => $quote->remark,
];
}
/** @return array<string,Collection|array> */
private function emptyOptions(): array
{
return [
'couriers' => collect(),
'types' => collect(self::TYPES),
'filter_types' => collect(self::TYPES),
'type_records' => collect(),
'modes' => collect(self::MODES),
'scopes' => self::SCOPES,
'reconcilers' => collect(),
'statuses' => ['已启用', '已停用'],
];
}
}