376 lines
14 KiB
PHP
376 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Business;
|
|
|
|
use App\Models\Business\CourierSurchargeAuditLog;
|
|
use App\Models\Business\CourierSurchargeQuote;
|
|
use App\Models\Business\CourierSurchargeType;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
/**
|
|
* 快递附加费报价的固定读写入口。
|
|
*
|
|
* 页面和控制器不直接操作数据库,所有公司范围、字段白名单和状态转换
|
|
* 都在这里集中处理,便于后续结算页面复用同一套报价规则。
|
|
*/
|
|
class CourierSurchargeQuoteRepository
|
|
{
|
|
public const TYPES = [
|
|
'退回费', '拦截费', '改地址', '异形件', '超长费',
|
|
'上楼费', '送货费', '超区费', '安装费', '保费', '发出运费附加',
|
|
];
|
|
|
|
public const MODES = [
|
|
'固定单价', '按重量', '按体积', '按尺寸/最长边',
|
|
'聚工单客服核实', '取发出运费', '发出运费倍数', '按账单',
|
|
];
|
|
|
|
public const SCOPES = ['自有快递', '代发仓'];
|
|
|
|
public function __construct(
|
|
private readonly CourierSurchargeTypeRepository $types,
|
|
private readonly CourierSurchargeAuditLogRepository $audits,
|
|
)
|
|
{
|
|
}
|
|
|
|
public function ready(): bool
|
|
{
|
|
try {
|
|
$table = (new CourierSurchargeQuote())->getTable();
|
|
|
|
return Schema::hasTable($table)
|
|
&& Schema::hasColumn($table, 'supplier')
|
|
&& $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()
|
|
->orderBy('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', 'supplier', '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,
|
|
(string) $quote->supplier,
|
|
$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(),
|
|
'suppliers' => $all->pluck('supplier')->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 findImportDuplicate(array $data, bool $lockForUpdate = false): ?CourierSurchargeQuote
|
|
{
|
|
$query = CourierSurchargeQuote::query()
|
|
->where('company_id', company_id())
|
|
->where('courier', trim((string) $data['courier']))
|
|
->where('scope', trim((string) $data['scope']))
|
|
->where('reconciler', trim((string) $data['reconciler']))
|
|
->where('mode', trim((string) $data['mode']))
|
|
->where('condition', trim((string) $data['condition']))
|
|
->whereDate('effective_at', (string) $data['effective_at']);
|
|
|
|
$supplier = trim((string) ($data['supplier'] ?? ''));
|
|
$supplier === '' ? $query->whereNull('supplier') : $query->where('supplier', $supplier);
|
|
|
|
if ($lockForUpdate) {
|
|
$query->lockForUpdate();
|
|
}
|
|
|
|
$types = $this->normalizedTypes((array) $data['types']);
|
|
|
|
return $query->get()->first(
|
|
fn (CourierSurchargeQuote $quote): bool => $this->normalizedTypes((array) $quote->types) === $types,
|
|
);
|
|
}
|
|
|
|
/** @param array<string,mixed> $data */
|
|
public function importIdentity(array $data): string
|
|
{
|
|
return hash('sha256', json_encode([
|
|
trim((string) ($data['courier'] ?? '')),
|
|
trim((string) ($data['scope'] ?? '')),
|
|
trim((string) ($data['reconciler'] ?? '')),
|
|
trim((string) ($data['supplier'] ?? '')),
|
|
$this->normalizedTypes((array) ($data['types'] ?? [])),
|
|
trim((string) ($data['mode'] ?? '')),
|
|
trim((string) ($data['effective_at'] ?? '')),
|
|
trim((string) ($data['condition'] ?? '')),
|
|
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
|
}
|
|
|
|
/** @param array<string,mixed> $data */
|
|
public function create(array $data, int $userId): CourierSurchargeQuote
|
|
{
|
|
return DB::transaction(function () use ($data, $userId): CourierSurchargeQuote {
|
|
$quote = CourierSurchargeQuote::query()->create($this->attributes($data, $userId) + [
|
|
'status' => CourierSurchargeQuote::STATUS_ACTIVE,
|
|
'created_by' => $userId,
|
|
]);
|
|
|
|
$this->audits->record(
|
|
CourierSurchargeAuditLog::ACTION_CREATED,
|
|
CourierSurchargeAuditLog::TARGET_QUOTE,
|
|
(int) $quote->id,
|
|
$this->targetLabel($quote),
|
|
['after' => $this->snapshot($quote)],
|
|
$userId,
|
|
);
|
|
|
|
return $quote;
|
|
});
|
|
}
|
|
|
|
/** @param array<string,mixed> $data */
|
|
public function update(CourierSurchargeQuote $quote, array $data, int $userId): CourierSurchargeQuote
|
|
{
|
|
return DB::transaction(function () use ($quote, $data, $userId): CourierSurchargeQuote {
|
|
$before = $this->snapshot($quote);
|
|
$quote->update($this->attributes($data, $userId));
|
|
$fresh = $quote->fresh();
|
|
|
|
$this->audits->record(
|
|
CourierSurchargeAuditLog::ACTION_UPDATED,
|
|
CourierSurchargeAuditLog::TARGET_QUOTE,
|
|
(int) $fresh->id,
|
|
$this->targetLabel($fresh),
|
|
['before' => $before, 'after' => $this->snapshot($fresh)],
|
|
$userId,
|
|
);
|
|
|
|
return $fresh;
|
|
});
|
|
}
|
|
|
|
public function setStatus(CourierSurchargeQuote $quote, string $status, int $userId): CourierSurchargeQuote
|
|
{
|
|
abort_unless(in_array($status, [CourierSurchargeQuote::STATUS_ACTIVE, CourierSurchargeQuote::STATUS_INACTIVE], true), 422, '报价规则状态不合法。');
|
|
|
|
return DB::transaction(function () use ($quote, $status, $userId): CourierSurchargeQuote {
|
|
$oldStatus = $quote->status;
|
|
$quote->forceFill(['status' => $status, 'updated_by' => $userId])->save();
|
|
$fresh = $quote->fresh();
|
|
|
|
if ($oldStatus !== $status) {
|
|
$this->audits->record(
|
|
$status === CourierSurchargeQuote::STATUS_ACTIVE
|
|
? CourierSurchargeAuditLog::ACTION_ACTIVATED
|
|
: CourierSurchargeAuditLog::ACTION_DEACTIVATED,
|
|
CourierSurchargeAuditLog::TARGET_QUOTE,
|
|
(int) $fresh->id,
|
|
$this->targetLabel($fresh),
|
|
[
|
|
'before' => ['status' => $this->statusLabel($oldStatus)],
|
|
'after' => ['status' => $this->statusLabel($status)],
|
|
],
|
|
$userId,
|
|
);
|
|
}
|
|
|
|
return $fresh;
|
|
});
|
|
}
|
|
|
|
private function targetLabel(CourierSurchargeQuote $quote): string
|
|
{
|
|
$type = $quote->typeLabel();
|
|
|
|
return trim(sprintf('%s%s', $quote->courier, $type === '' ? '' : ' · ' . $type));
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
private function snapshot(CourierSurchargeQuote $quote): array
|
|
{
|
|
return [
|
|
'courier' => $quote->courier,
|
|
'scope' => $quote->scope,
|
|
'reconciler' => $quote->reconciler,
|
|
'supplier' => $quote->supplier,
|
|
'types' => array_values((array) $quote->types),
|
|
'mode' => $quote->mode,
|
|
'standard' => $quote->standard,
|
|
'condition' => $quote->condition,
|
|
'effective_at' => optional($quote->effective_at)->format('Y-m-d'),
|
|
'status' => $this->statusLabel($quote->status),
|
|
'remark' => $quote->remark,
|
|
];
|
|
}
|
|
|
|
private function statusLabel(?string $status): string
|
|
{
|
|
return $status === CourierSurchargeQuote::STATUS_ACTIVE ? '已启用' : '已停用';
|
|
}
|
|
|
|
/** @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']),
|
|
'supplier' => trim((string) ($data['supplier'] ?? '')) ?: null,
|
|
'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,
|
|
];
|
|
}
|
|
|
|
/** @param array<int,mixed> $types @return list<string> */
|
|
private function normalizedTypes(array $types): array
|
|
{
|
|
$types = array_values(array_unique(array_filter(array_map(
|
|
static fn (mixed $type): string => trim((string) $type),
|
|
$types,
|
|
))));
|
|
sort($types, SORT_STRING);
|
|
|
|
return $types;
|
|
}
|
|
|
|
/** @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,
|
|
'supplier' => $quote->supplier,
|
|
'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(),
|
|
'suppliers' => collect(),
|
|
'statuses' => ['已启用', '已停用'],
|
|
];
|
|
}
|
|
}
|