新增附加费报价批量导入 #243

Merged
zhoushengchen merged 3 commits from feature/zhoushengchen-courier-surcharge-import into main 2026-09-22 17:31:57 +08:00
6 changed files with 853 additions and 6 deletions

View File

@ -156,6 +156,43 @@ class CourierSurchargeQuoteRepository
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']);
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'] ?? '')),
$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
{
@ -275,6 +312,18 @@ class CourierSurchargeQuoteRepository
];
}
/** @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
{

View File

@ -0,0 +1,382 @@
<?php
namespace App\Business;
use App\Models\Business\CourierSurchargeQuote;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
class CourierSurchargeQuoteWorkbookImporter
{
public const MAX_ROWS = 500;
public const DUPLICATE_SKIP = 'skip';
public const DUPLICATE_UPDATE = 'update';
public const HEADERS = [
'快递名称',
'快递归属',
'对账人',
'附加费类型',
'结算方式',
'计价标准',
'生效条件',
'生效日期',
'状态',
'备注',
];
public function __construct(private readonly CourierSurchargeQuoteRepository $quotes)
{
}
public function template(): StreamedResponse
{
$types = $this->quotes->activeTypeNames();
return response()->streamDownload(function () use ($types): void {
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('附加费报价');
$sheet->fromArray(self::HEADERS, null, 'A1');
$lastColumn = Coordinate::stringFromColumnIndex(count(self::HEADERS));
$headerRange = 'A1:' . $lastColumn . '1';
$sheet->getStyle($headerRange)->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'D8D2C7']]],
]);
$sheet->getRowDimension(1)->setRowHeight(24);
$sheet->freezePane('A2');
$sheet->setAutoFilter($headerRange);
$widths = [18, 14, 14, 24, 18, 30, 32, 14, 12, 30];
foreach ($widths as $index => $width) {
$column = Coordinate::stringFromColumnIndex($index + 1);
$sheet->getColumnDimension($column)->setWidth($width);
$sheet->getStyle($column . '2:' . $column . (self::MAX_ROWS + 1))
->getNumberFormat()
->setFormatCode('@');
}
$instructions = $spreadsheet->createSheet();
$instructions->setTitle('填写说明');
$instructions->fromArray([
['项目', '填写要求'],
['必填字段', '快递名称、快递归属、对账人、附加费类型、结算方式、计价标准、生效条件、生效日期、状态。'],
['附加费类型', '多种类型使用“、”分隔;只能填写当前已启用类型。当前可选:' . implode('、', $types)],
['快递归属', '自有快递 或 代发仓。'],
['结算方式', implode('、', CourierSurchargeQuoteRepository::MODES)],
['生效日期', '使用 YYYY-MM-DD,例如 2026-09-22。'],
['状态', '已启用 或 已停用;也可填写 active 或 inactive。'],
['重复规则', '预览后必须明确选择跳过已有规则或更新已有规则,系统不会静默覆盖。'],
['单次上限', '最多 ' . self::MAX_ROWS . ' 条非空数据。'],
], null, 'A1');
$instructions->getStyle('A1:B1')->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
]);
$instructions->getColumnDimension('A')->setWidth(18);
$instructions->getColumnDimension('B')->setWidth(90);
$instructions->getStyle('A1:B9')->getAlignment()->setVertical(Alignment::VERTICAL_CENTER)->setWrapText(true);
foreach (range(1, 9) as $row) {
$instructions->getRowDimension($row)->setRowHeight($row === 1 ? 24 : 34);
}
$spreadsheet->setActiveSheetIndex(0);
(new Xlsx($spreadsheet))->save('php://output');
$spreadsheet->disconnectWorksheets();
}, '附加费报价导入模板_' . now()->format('Ymd') . '.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
/**
* @return array{
* rows:list<array{line:int,data:array<string,mixed>,display:array<string,string>,errors:list<string>,result:string,result_label:string,existing_id:?int}>,
* summary:array{total:int,valid:int,new:int,duplicate:int,invalid:int}
* }
*/
public function preview(UploadedFile $file): array
{
$extension = mb_strtolower($file->getClientOriginalExtension());
if (! in_array($extension, ['xlsx', 'xls', 'csv'], true)) {
throw ValidationException::withMessages(['import' => '仅支持 xlsx、xls 或 csv 文件。']);
}
try {
$reader = IOFactory::createReader(match ($extension) {
'xlsx' => 'Xlsx',
'xls' => 'Xls',
'csv' => 'Csv',
});
$reader->setReadDataOnly(true);
$spreadsheet = $reader->load($file->getRealPath());
$rows = $spreadsheet->getActiveSheet()->toArray(null, false, false, false);
} catch (Throwable) {
throw ValidationException::withMessages([
'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。',
]);
} finally {
if (isset($spreadsheet)) {
$spreadsheet->disconnectWorksheets();
}
}
$headers = array_map(fn (mixed $value): string => $this->text($value), $rows[0] ?? []);
while ($headers !== [] && end($headers) === '') {
array_pop($headers);
}
if ($headers !== self::HEADERS) {
throw ValidationException::withMessages([
'import' => '表头不正确。请下载最新模板,并保持 10 个字段名称及顺序不变。',
]);
}
$dataRows = [];
foreach (array_slice($rows, 1, null, true) as $index => $row) {
$row = array_slice(array_pad($row, count(self::HEADERS), null), 0, count(self::HEADERS));
if (collect($row)->every(fn (mixed $value): bool => $this->text($value) === '')) {
continue;
}
$dataRows[] = ['line' => $index + 1, 'values' => $row];
if (count($dataRows) > self::MAX_ROWS) {
throw ValidationException::withMessages([
'import' => '单次最多导入 ' . self::MAX_ROWS . ' 条非空数据,请拆分文件后重试。',
]);
}
}
if ($dataRows === []) {
throw ValidationException::withMessages(['import' => '表格中没有可预览的数据行。']);
}
$previewRows = array_map(fn (array $row): array => $this->normalizeRow($row['values'], $row['line']), $dataRows);
$signatures = [];
foreach ($previewRows as $index => $row) {
if ($row['errors'] === []) {
$signatures[$this->quotes->importIdentity($row['data'])][] = $index;
}
}
foreach ($signatures as $indexes) {
if (count($indexes) < 2) {
continue;
}
$lines = implode('、', array_map(fn (int $index): int => $previewRows[$index]['line'], $indexes));
foreach ($indexes as $index) {
$previewRows[$index]['errors'][] = '文件内规则重复(行号:' . $lines . ')。';
}
}
foreach ($previewRows as &$row) {
if ($row['errors'] !== []) {
$row['result'] = 'invalid';
$row['result_label'] = '校验失败';
continue;
}
$existing = $this->quotes->findImportDuplicate($row['data']);
$row['existing_id'] = $existing?->id;
$row['result'] = $existing ? 'duplicate' : 'new';
$row['result_label'] = $existing ? '已有规则' : '可新增';
}
unset($row);
$collection = collect($previewRows);
return [
'rows' => $previewRows,
'summary' => [
'total' => $collection->count(),
'valid' => $collection->where('result', '!=', 'invalid')->count(),
'new' => $collection->where('result', 'new')->count(),
'duplicate' => $collection->where('result', 'duplicate')->count(),
'invalid' => $collection->where('result', 'invalid')->count(),
],
];
}
/**
* @param list<array{line:int,data:array<string,mixed>,errors:list<string>}> $rows
* @return array{created:int,updated:int,skipped:int,total:int}
*/
public function commit(array $rows, string $duplicatePolicy, int $userId): array
{
if (! in_array($duplicatePolicy, [self::DUPLICATE_SKIP, self::DUPLICATE_UPDATE], true)) {
throw ValidationException::withMessages(['duplicate_policy' => '请选择已有规则的处理方式。']);
}
if ($rows === [] || count($rows) > self::MAX_ROWS || collect($rows)->contains(fn (array $row): bool => ($row['errors'] ?? []) !== [])) {
throw ValidationException::withMessages(['import' => '预览数据无效或包含错误,请重新上传文件。']);
}
$activeTypes = $this->quotes->activeTypeNames();
foreach ($rows as $row) {
$inactive = array_values(array_diff((array) ($row['data']['types'] ?? []), $activeTypes));
if ($inactive !== []) {
throw ValidationException::withMessages([
'import' => '附加费类型“' . implode('、', $inactive) . '”已停用或不存在,请重新预览。',
]);
}
}
$counts = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'total' => count($rows)];
DB::transaction(function () use ($rows, $duplicatePolicy, $userId, &$counts): void {
foreach ($rows as $row) {
$data = $row['data'];
$status = $data['status'];
unset($data['status']);
$existing = $this->quotes->findImportDuplicate($data, true);
if ($existing && $duplicatePolicy === self::DUPLICATE_SKIP) {
$counts['skipped']++;
continue;
}
if ($existing) {
$quote = $this->quotes->update($existing, $data, $userId);
$counts['updated']++;
} else {
$quote = $this->quotes->create($data, $userId);
$counts['created']++;
}
if ($quote->status !== $status) {
$this->quotes->setStatus($quote, $status, $userId);
}
}
});
return $counts;
}
/** @return array{line:int,data:array<string,mixed>,display:array<string,string>,errors:list<string>,result:string,result_label:string,existing_id:?int} */
private function normalizeRow(array $values, int $line): array
{
$raw = array_combine(self::HEADERS, $values);
$display = [];
foreach ($raw as $header => $value) {
$display[$header] = $this->text($value);
}
$errors = [];
foreach (array_slice(self::HEADERS, 0, 9) as $header) {
if ($display[$header] === '') {
$errors[] = $header . '不能为空。';
}
}
$this->checkLength($display, '快递名称', 191, $errors);
$this->checkLength($display, '对账人', 100, $errors);
$this->checkLength($display, '计价标准', 2000, $errors);
$this->checkLength($display, '生效条件', 2000, $errors);
$this->checkLength($display, '备注', 2000, $errors);
$types = array_values(array_unique(array_filter(array_map(
'trim',
preg_split('/[、,,;;]+/u', $display['附加费类型']) ?: [],
))));
if (count($types) > 50) {
$errors[] = '附加费类型最多 50 项。';
}
$validTypes = $this->quotes->validTypeNames();
$activeTypes = $this->quotes->activeTypeNames();
$unknownTypes = array_values(array_diff($types, $validTypes));
$inactiveTypes = array_values(array_diff(array_intersect($types, $validTypes), $activeTypes));
if ($unknownTypes !== []) {
$errors[] = '附加费类型不存在:' . implode('、', $unknownTypes) . '。';
}
if ($inactiveTypes !== []) {
$errors[] = '附加费类型已停用:' . implode('、', $inactiveTypes) . '。';
}
if ($display['快递归属'] !== '' && ! in_array($display['快递归属'], CourierSurchargeQuoteRepository::SCOPES, true)) {
$errors[] = '快递归属只能填写“自有快递”或“代发仓”。';
}
if ($display['结算方式'] !== '' && ! in_array($display['结算方式'], CourierSurchargeQuoteRepository::MODES, true)) {
$errors[] = '结算方式不在允许范围内。';
}
$effectiveAt = $this->date($raw['生效日期']);
if ($display['生效日期'] !== '' && $effectiveAt === null) {
$errors[] = '生效日期必须是有效的 YYYY-MM-DD 日期。';
}
$status = match (mb_strtolower($display['状态'])) {
'已启用', CourierSurchargeQuote::STATUS_ACTIVE => CourierSurchargeQuote::STATUS_ACTIVE,
'已停用', CourierSurchargeQuote::STATUS_INACTIVE => CourierSurchargeQuote::STATUS_INACTIVE,
default => null,
};
if ($display['状态'] !== '' && $status === null) {
$errors[] = '状态只能填写“已启用/active”或“已停用/inactive”。';
}
if ($effectiveAt !== null) {
$display['生效日期'] = $effectiveAt;
}
return [
'line' => $line,
'data' => [
'courier' => $display['快递名称'],
'scope' => $display['快递归属'],
'reconciler' => $display['对账人'],
'types' => $types,
'mode' => $display['结算方式'],
'standard' => $display['计价标准'],
'condition' => $display['生效条件'],
'effective_at' => $effectiveAt,
'status' => $status,
'remark' => $display['备注'],
],
'display' => $display,
'errors' => array_values(array_unique($errors)),
'result' => 'invalid',
'result_label' => '校验失败',
'existing_id' => null,
];
}
/** @param array<string,string> $values @param list<string> $errors */
private function checkLength(array $values, string $field, int $max, array &$errors): void
{
if (mb_strlen($values[$field]) > $max) {
$errors[] = $field . '不能超过 ' . $max . ' 个字符。';
}
}
private function date(mixed $value): ?string
{
if (is_numeric($value) && (float) $value > 0) {
try {
return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d');
} catch (Throwable) {
return null;
}
}
$value = $this->text($value);
if (! preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches)) {
return null;
}
return checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1]) ? $value : null;
}
private function text(mixed $value): string
{
return trim(str_replace("\xEF\xBB\xBF", '', (string) ($value ?? '')));
}
}

View File

@ -3,15 +3,20 @@
namespace App\Http\Controllers\Business;
use App\Business\CourierSurchargeQuoteRepository;
use App\Business\CourierSurchargeQuoteWorkbookImporter;
use App\Models\Business\CourierSurchargeQuote;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CourierSurchargeQuotes extends Controller
{
public function __construct(private readonly CourierSurchargeQuoteRepository $quotes)
{
public function __construct(
private readonly CourierSurchargeQuoteRepository $quotes,
private readonly CourierSurchargeQuoteWorkbookImporter $workbookImporter,
) {
$this->middleware('auth');
$this->middleware('company.identify');
$this->middleware('business.page:courier-payables,courier-surcharge');
@ -49,6 +54,79 @@ class CourierSurchargeQuotes extends Controller
return $this->redirect()->with('success', '报价规则状态已更新。');
}
public function importTemplate(): StreamedResponse
{
abort_unless($this->quotes->ready(), 503, '附加费报价数据表尚未完成迁移,请联系管理员。');
return $this->workbookImporter->template();
}
public function importPreview(Request $request): RedirectResponse
{
abort_unless($this->quotes->ready(), 503, '附加费报价数据表尚未完成迁移,请联系管理员。');
$file = $request->validate([
'import_file' => ['required', 'file', 'max:10240'],
])['import_file'];
$preview = $this->workbookImporter->preview($file);
$token = Str::random(48);
$previews = collect((array) $request->session()->get('courier_surcharge_import_previews', []))
->filter(fn (mixed $item): bool => is_array($item) && (int) ($item['expires_at'] ?? 0) >= now()->timestamp)
->take(-2)
->all();
$previews[$token] = [
'company_id' => (int) company_id(),
'user_id' => (int) user_id(),
'expires_at' => now()->addMinutes(20)->timestamp,
'rows' => $preview['rows'],
];
$request->session()->put('courier_surcharge_import_previews', $previews);
return $this->redirect()->with('courierSurchargeImportPreview', $preview + ['token' => $token]);
}
public function importCommit(Request $request): RedirectResponse
{
abort_unless($this->quotes->ready(), 503, '附加费报价数据表尚未完成迁移,请联系管理员。');
$validated = $request->validate([
'preview_token' => ['required', 'string', 'size:48'],
'duplicate_policy' => ['required', 'in:' . CourierSurchargeQuoteWorkbookImporter::DUPLICATE_SKIP . ',' . CourierSurchargeQuoteWorkbookImporter::DUPLICATE_UPDATE],
]);
$previews = (array) $request->session()->get('courier_surcharge_import_previews', []);
$payload = $previews[$validated['preview_token']] ?? null;
if (! is_array($payload)
|| (int) ($payload['company_id'] ?? 0) !== (int) company_id()
|| (int) ($payload['user_id'] ?? 0) !== (int) user_id()
|| (int) ($payload['expires_at'] ?? 0) < now()->timestamp) {
unset($previews[$validated['preview_token']]);
$request->session()->put('courier_surcharge_import_previews', $previews);
return $this->redirect()->withErrors([
'import' => '导入预览已失效或不属于当前账号,请重新选择文件预览。',
]);
}
$counts = $this->workbookImporter->commit(
(array) ($payload['rows'] ?? []),
$validated['duplicate_policy'],
(int) user_id(),
);
unset($previews[$validated['preview_token']]);
$request->session()->put('courier_surcharge_import_previews', $previews);
return $this->redirect()->with(
'success',
sprintf(
'附加费报价导入完成:新增 %d 条,更新 %d 条,跳过 %d 条。',
$counts['created'],
$counts['updated'],
$counts['skipped'],
),
);
}
/** @return array<string,mixed> */
private function validated(Request $request): array
{

View File

@ -8,6 +8,9 @@
$auditReady = (bool) ($courierSurchargeAuditReady ?? $ready);
$typeUsage = $courierSurchargeTypeUsage ?? [];
$auditLogs = $courierSurchargeAuditLogs ?? collect();
$importPreview = session('courierSurchargeImportPreview');
$importHeaders = \App\Business\CourierSurchargeQuoteWorkbookImporter::HEADERS;
$openImport = is_array($importPreview) || $errors->hasAny(['import', 'import_file', 'duplicate_policy', 'preview_token']);
$statusClass = static fn (string $status): string => $status === '已启用' ? 'is-active' : 'is-inactive';
$columns = [
['key' => 'id', 'label' => '规则编号', 'fixed' => true, 'exportable' => true],
@ -64,8 +67,38 @@
.sm-empty { padding:3rem 1rem; text-align:center; color:var(--sm-muted); } .sm-rule-note { padding:1rem 1.15rem; color:var(--sm-muted); font-size:.75rem; line-height:1.65; background:#fffdf8; }
.sm-type-manager { display:grid; gap:1rem; padding:1.15rem; } .sm-type-create { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:.7rem; align-items:end; padding-bottom:1rem; border-bottom:1px solid var(--sm-border); } .sm-type-list { display:grid; gap:.55rem; } .sm-type-list__head { display:grid; gap:.2rem; color:var(--sm-muted); font-size:.72rem; line-height:1.5; } .sm-type-list__head strong { color:var(--sm-ink); font-size:.8rem; } .sm-type-row { display:flex; align-items:center; justify-content:space-between; gap:.75rem; min-height:2.6rem; padding:.5rem .65rem; border:1px solid var(--sm-border); border-radius:.45rem; background:#fffdf8; } .sm-type-row > div { display:flex; align-items:center; flex-wrap:wrap; gap:.45rem; min-width:0; } .sm-type-row small { color:var(--sm-muted); font-size:.68rem; } .sm-type-row form { flex:none; }
.sm-modal { position:fixed; inset:0; z-index:70; display:none; align-items:flex-start; justify-content:center; padding:4rem 1rem; overflow-y:auto; background:rgba(23,23,23,.42); } .sm-modal.is-open { display:flex; } .sm-modal__panel { width:min(42rem,100%); overflow:hidden; border:1px solid var(--sm-border); border-radius:.75rem; background:#fff; box-shadow:0 1rem 3rem rgba(23,23,23,.2); } .sm-modal__head { display:flex; align-items:center; justify-content:space-between; padding:1rem 1.15rem; border-bottom:1px solid var(--sm-border); } .sm-modal__head h2 { margin:0; font-size:1rem; } .sm-modal__close { border:0; background:transparent; color:var(--sm-muted); cursor:pointer; } .sm-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.8rem; padding:1.15rem; } .sm-form__field { display:grid; gap:.3rem; } .sm-form__field--full { grid-column:span 2; } .sm-form__field label,.sm-form__field legend { color:var(--sm-muted); font-size:.72rem; font-weight:600; } .sm-form__types { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:.45rem; margin:0; padding:0; border:0; } .sm-type-option { display:flex; align-items:center; gap:.35rem; min-height:2.1rem; padding:.35rem .45rem; border:1px solid var(--sm-border); border-radius:.4rem; background:#fffdf8; color:var(--sm-ink); font-size:.72rem; cursor:pointer; } .sm-type-option:has(input:checked) { border-color:var(--sm-accent); background:var(--sm-soft); color:var(--sm-accent); font-weight:700; } .sm-type-option input { width:1rem; height:1rem; accent-color:var(--sm-accent); } .sm-form input,.sm-form select { height:2.25rem; } .sm-form textarea { min-height:4rem; padding:.55rem .6rem; resize:vertical; } .sm-form__foot { grid-column:span 2; display:flex; justify-content:flex-end; gap:.5rem; margin-top:.2rem; }
.sm-import-panel { width:min(76rem,100%); }
.sm-import-body { display:grid; gap:1rem; padding:1.15rem; }
.sm-import-upload { display:grid; grid-template-columns:auto minmax(14rem,1fr) auto; align-items:end; gap:.7rem; padding-bottom:1rem; border-bottom:1px solid var(--sm-border); }
.sm-import-template { align-self:center; }
.sm-import-file { display:grid; gap:.3rem; min-width:0; }
.sm-import-file label { color:var(--sm-muted); font-size:.72rem; font-weight:600; }
.sm-import-file input { width:100%; height:2.2rem; padding:.3rem .45rem; border:1px solid var(--sm-border); border-radius:.45rem; background:#fff; color:var(--sm-ink); font:inherit; }
.sm-import-help { grid-column:1 / -1; margin:0; color:var(--sm-muted); font-size:.72rem; line-height:1.55; }
.sm-import-error { padding:.7rem .8rem; border:1px solid #e2a6a6; border-radius:.45rem; background:#fff4f4; color:#8c0000; line-height:1.55; }
.sm-import-summary { display:grid; grid-template-columns:repeat(5,minmax(7rem,1fr)); border:1px solid var(--sm-border); border-radius:.5rem; overflow:hidden; }
.sm-import-summary > div { padding:.65rem .75rem; border-right:1px solid var(--sm-border); background:#fffdf8; }
.sm-import-summary > div:last-child { border-right:0; }
.sm-import-summary span { display:block; color:var(--sm-muted); font-size:.68rem; }
.sm-import-summary strong { display:block; margin-top:.15rem; font-size:1rem; font-variant-numeric:tabular-nums; }
.sm-import-summary .is-error strong { color:#b80000; }
.sm-import-preview { max-height:min(48vh,32rem); overflow:auto; border:1px solid var(--sm-border); border-radius:.45rem; scrollbar-gutter:stable both-edges; }
.sm-import-table { width:max-content; min-width:100%; border-collapse:separate; border-spacing:0; font-size:.72rem; }
.sm-import-table th,.sm-import-table td { max-width:18rem; padding:.55rem .65rem; border-right:1px solid var(--sm-border); border-bottom:1px solid var(--sm-border); text-align:left; vertical-align:top; white-space:nowrap; }
.sm-import-table th { position:sticky; top:0; z-index:2; background:var(--sm-soft); font-weight:700; }
.sm-import-table td.is-wrap { min-width:13rem; white-space:normal; line-height:1.45; }
.sm-import-status { display:inline-flex; padding:.15rem .45rem; border-radius:999px; background:#edf5e8; color:#53793e; font-size:.68rem; font-weight:700; }
.sm-import-status.is-duplicate { background:#fff4df; color:#935f07; }
.sm-import-status.is-invalid { background:#fff0f0; color:#b80000; }
.sm-import-errors { margin:.3rem 0 0; padding-left:1rem; color:#b80000; white-space:normal; }
.sm-import-policy { display:grid; gap:.45rem; padding:.8rem; border:1px solid var(--sm-border); border-radius:.45rem; background:#fffdf8; }
.sm-import-policy strong { font-size:.78rem; }
.sm-import-policy label { display:flex; align-items:flex-start; gap:.45rem; line-height:1.45; cursor:pointer; }
.sm-import-policy input { margin-top:.18rem; accent-color:var(--sm-accent); }
.sm-import-foot { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.7rem; }
.sm-import-foot p { margin:0; color:var(--sm-muted); font-size:.72rem; line-height:1.5; }
@media (max-width:1100px) { .sm-summary { grid-template-columns:repeat(3,1fr); } .sm-summary__intro { grid-column:span 3; border-bottom:1px solid var(--sm-border); } .sm-toolbar { grid-template-columns:repeat(3,minmax(0,1fr)); } .sm-toolbar > .sm-actions { grid-column:span 3; } }
@media (max-width:620px) { .sm-summary { grid-template-columns:repeat(2,1fr); } .sm-summary__intro { grid-column:span 2; } .sm-metric { border-left:0; border-top:1px solid var(--sm-border); } .sm-toolbar,.sm-form,.sm-type-create { grid-template-columns:minmax(0,1fr); } .sm-form__types { grid-template-columns:repeat(2,minmax(0,1fr)); } .sm-actions,.sm-form__field--full,.sm-form__foot { grid-column:auto; } .sm-actions .sm-button { flex:1; } }
@media (max-width:620px) { .sm-summary { grid-template-columns:repeat(2,1fr); } .sm-summary__intro { grid-column:span 2; } .sm-metric { border-left:0; border-top:1px solid var(--sm-border); } .sm-toolbar,.sm-form,.sm-type-create,.sm-import-upload { grid-template-columns:minmax(0,1fr); } .sm-form__types { grid-template-columns:repeat(2,minmax(0,1fr)); } .sm-actions,.sm-form__field--full,.sm-form__foot { grid-column:auto; } .sm-actions .sm-button { flex:1; } .sm-import-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .sm-import-summary > div { border-bottom:1px solid var(--sm-border); } }
</style>
<div class="sm-notice" role="note"><span class="material-icons-outlined" aria-hidden="true">{{ $ready ? 'verified' : 'info' }}</span><div><strong>{{ $ready ? '正式维护数据' : '数据表尚未就绪' }}</strong> {{ $ready ? '报价规则已保存到当前公司数据表,刷新页面后仍会保留;后续结算页面将按启用中的规则计算。' : ($dataReady && !$auditReady ? '操作日志表尚未完成迁移;现有报价仍可查看,新增、编辑和启停暂时禁用,避免产生无日志修改。' : '附加费报价数据表尚未完成迁移,当前不能保存规则。请由管理员在目标数据库完成迁移后再使用本页。') }}</div></div>
@ -88,7 +121,7 @@
<div class="sm-field"><label for="surcharge-type">附加费类型</label><select id="surcharge-type" name="type"><option value="">全部类型</option>@foreach (($options['filter_types'] ?? $options['types'] ?? []) as $option)<option value="{{ $option }}" @selected(($filters['type'] ?? '') === $option)>{{ $option }}</option>@endforeach</select></div>
<div class="sm-field"><label for="surcharge-mode">结算方式</label><select id="surcharge-mode" name="mode"><option value="">全部方式</option>@foreach (($options['modes'] ?? []) as $option)<option value="{{ $option }}" @selected(($filters['mode'] ?? '') === $option)>{{ $option }}</option>@endforeach</select></div>
<div class="sm-field"><label for="surcharge-status">状态</label><select id="surcharge-status" name="status"><option value="">全部状态</option>@foreach (($options['statuses'] ?? []) as $option)<option value="{{ $option }}" @selected(($filters['status'] ?? '') === $option)>{{ $option }}</option>@endforeach</select></div>
<div class="sm-actions"><button class="sm-button sm-button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button><a class="sm-button" href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span>重置</a></div>
<div class="sm-actions"><button class="sm-button sm-button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button><a class="sm-button" href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span>重置</a><button class="sm-button" type="button" data-surcharge-import-open @disabled(!$ready)><span class="material-icons-outlined" aria-hidden="true">upload_file</span>导入报价</button></div>
</form>
</div>
@ -117,7 +150,7 @@
<tr data-surcharge-empty><td data-empty-cell colspan="12"><div class="sm-empty">没有符合条件的报价规则,请点击“新增报价规则”。</div></td></tr>
@endforelse
</tbody></table></div>
<div class="sm-rule-note"><strong>维护建议:</strong>固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。当前筛选结果可导出为 CSV;本页暂未启用批量导入,避免在未确认字段规则前覆盖维护数据。</div>
<div class="sm-rule-note"><strong>维护建议:</strong>固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。批量导入必须先预览校验,已有规则需明确选择跳过或更新。</div>
</div>
<div class="sm-card" aria-label="附加费维护操作日志">
@ -146,6 +179,61 @@
@endif
</div>
<div class="sm-modal {{ $openImport ? 'is-open' : '' }}" role="dialog" aria-modal="true" aria-labelledby="surcharge-import-title" data-surcharge-import-modal data-auto-open="{{ $openImport ? 'true' : 'false' }}">
<div class="sm-modal__panel sm-import-panel">
<div class="sm-modal__head"><h2 id="surcharge-import-title">导入附加费报价</h2><button class="sm-modal__close" type="button" aria-label="关闭" data-surcharge-import-close><span class="material-icons-outlined" aria-hidden="true">close</span></button></div>
<div class="sm-import-body">
@if ($errors->hasAny(['import', 'import_file', 'duplicate_policy', 'preview_token']))
<div class="sm-import-error" role="alert">{{ $errors->first('import') ?: $errors->first('import_file') ?: $errors->first('duplicate_policy') ?: $errors->first('preview_token') }}</div>
@endif
<form class="sm-import-upload" method="POST" enctype="multipart/form-data" action="{{ route('business.courier-surcharge.import.preview') }}" data-surcharge-import-preview-form>
@csrf
<a class="sm-button sm-import-template" href="{{ route('business.courier-surcharge.import.template') }}"><span class="material-icons-outlined" aria-hidden="true">download</span>下载 Excel 模板</a>
<div class="sm-import-file"><label for="surcharge-import-file">选择已填写文件</label><input id="surcharge-import-file" type="file" name="import_file" accept=".xlsx,.xls,.csv" required data-surcharge-import-file></div>
<button class="sm-button sm-button--primary" type="submit" data-surcharge-import-preview-button><span class="material-icons-outlined" aria-hidden="true">preview</span>预览并校验</button>
<p class="sm-import-help">支持 xlsx、xls、csv,单次最多 500 条。附加费类型可用“、”分隔多项;上传只生成预览,确认后才写入。</p>
</form>
@if (is_array($importPreview))
<div class="sm-import-summary" aria-label="导入预览汇总">
<div><span>文件数据</span><strong>{{ $importPreview['summary']['total'] }}</strong></div>
<div><span>校验通过</span><strong>{{ $importPreview['summary']['valid'] }}</strong></div>
<div><span>可新增</span><strong>{{ $importPreview['summary']['new'] }}</strong></div>
<div><span>已有规则</span><strong>{{ $importPreview['summary']['duplicate'] }}</strong></div>
<div class="{{ $importPreview['summary']['invalid'] > 0 ? 'is-error' : '' }}"><span>校验失败</span><strong>{{ $importPreview['summary']['invalid'] }}</strong></div>
</div>
<div class="sm-import-preview">
<table class="sm-import-table">
<thead><tr><th scope="col">行号</th>@foreach ($importHeaders as $header)<th scope="col">{{ $header }}</th>@endforeach<th scope="col">校验结果</th></tr></thead>
<tbody>
@foreach ($importPreview['rows'] as $previewRow)
<tr>
<td>{{ $previewRow['line'] }}</td>
@foreach ($importHeaders as $header)<td class="{{ in_array($header, ['计价标准', '生效条件', '备注'], true) ? 'is-wrap' : '' }}">{{ $previewRow['display'][$header] !== '' ? $previewRow['display'][$header] : '—' }}</td>@endforeach
<td class="is-wrap"><span class="sm-import-status {{ $previewRow['result'] === 'duplicate' ? 'is-duplicate' : ($previewRow['result'] === 'invalid' ? 'is-invalid' : '') }}">{{ $previewRow['result_label'] }}</span>@if ($previewRow['errors'] !== [])<ul class="sm-import-errors">@foreach ($previewRow['errors'] as $error)<li>{{ $error }}</li>@endforeach</ul>@endif</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<form method="POST" action="{{ route('business.courier-surcharge.import.commit') }}" data-surcharge-import-commit-form>
@csrf
<input type="hidden" name="preview_token" value="{{ $importPreview['token'] }}">
@if ($importPreview['summary']['duplicate'] > 0)
<fieldset class="sm-import-policy"><strong>已有规则处理方式</strong><label><input type="radio" name="duplicate_policy" value="skip" checked><span><b>跳过已有规则</b>,只新增不存在的规则。</span></label><label><input type="radio" name="duplicate_policy" value="update"><span><b>更新已有规则</b>,使用文件中的计价标准、状态和备注覆盖对应规则。</span></label></fieldset>
@else
<input type="hidden" name="duplicate_policy" value="skip">
@endif
<div class="sm-import-foot">
<p>{{ $importPreview['summary']['invalid'] > 0 ? '存在校验失败行,修正文件并重新预览后才能导入。' : '确认导入后将写入当前公司,并记录新增、更改、启用或停用日志。' }}</p>
<div class="sm-actions"><button class="sm-button" type="button" data-surcharge-import-close>取消</button><button class="sm-button sm-button--primary" type="submit" @disabled($importPreview['summary']['invalid'] > 0) data-surcharge-import-commit><span class="material-icons-outlined" aria-hidden="true">save</span>确认导入 {{ $importPreview['summary']['valid'] }} 条</button></div>
</div>
</form>
@endif
</div>
</div>
</div>
<div class="sm-modal" role="dialog" aria-modal="true" aria-labelledby="surcharge-modal-title" data-surcharge-modal>
<div class="sm-modal__panel"><div class="sm-modal__head"><h2 id="surcharge-modal-title">新增报价规则</h2><button class="sm-modal__close" type="button" aria-label="关闭" data-surcharge-close><span class="material-icons-outlined" aria-hidden="true">close</span></button></div>
<form class="sm-form" method="POST" action="{{ route('business.courier-surcharge.store') }}" data-surcharge-form>
@ -367,18 +455,31 @@
const modal = root.querySelector('[data-surcharge-modal]');
const typeModal = root.querySelector('[data-type-modal]');
const importModal = root.querySelector('[data-surcharge-import-modal]');
const form = root.querySelector('[data-surcharge-form]');
const title = root.querySelector('#surcharge-modal-title');
const methodField = form.querySelector('[data-surcharge-method]');
const createAction = form.action;
const close = () => { modal.classList.remove('is-open'); form.reset(); form.action = createAction; methodField.value = 'POST'; title.textContent = '新增报价规则'; };
const closeType = () => { typeModal.classList.remove('is-open'); };
const closeImport = () => { importModal?.classList.remove('is-open'); };
root.querySelector('[data-surcharge-open]')?.addEventListener('click', () => { close(); modal.classList.add('is-open'); root.querySelector('#surcharge-form-courier').focus(); });
root.querySelector('[data-type-manage]')?.addEventListener('click', () => { typeModal.classList.add('is-open'); root.querySelector('#new-surcharge-type').focus(); });
root.querySelector('[data-surcharge-import-open]')?.addEventListener('click', () => { importModal?.classList.add('is-open'); root.querySelector('[data-surcharge-import-file]')?.focus(); });
root.querySelectorAll('[data-surcharge-close]').forEach((button) => button.addEventListener('click', close));
root.querySelectorAll('[data-type-close]').forEach((button) => button.addEventListener('click', closeType));
root.querySelectorAll('[data-surcharge-import-close]').forEach((button) => button.addEventListener('click', closeImport));
modal.addEventListener('click', (event) => { if (event.target === modal) close(); });
typeModal.addEventListener('click', (event) => { if (event.target === typeModal) closeType(); });
importModal?.addEventListener('click', (event) => { if (event.target === importModal) closeImport(); });
root.querySelector('[data-surcharge-import-preview-form]')?.addEventListener('submit', () => {
const button = root.querySelector('[data-surcharge-import-preview-button]');
if (button) { button.disabled = true; button.textContent = '正在校验...'; }
});
root.querySelector('[data-surcharge-import-commit-form]')?.addEventListener('submit', () => {
const button = root.querySelector('[data-surcharge-import-commit]');
if (button) { button.disabled = true; button.textContent = '正在导入...'; }
});
root.querySelectorAll('[data-surcharge-edit]').forEach((button) => button.addEventListener('click', () => {
const row = button.closest('[data-surcharge-row]'); const data = JSON.parse(row.dataset.rule); form.action = row.dataset.updateUrl; methodField.value = 'PATCH'; title.textContent = '编辑报价规则';
['courier', 'scope', 'mode', 'standard', 'condition', 'effective_at', 'reconciler', 'remark'].forEach((key) => { const field = form.elements.namedItem(key); if (field) field.value = data[key] || ''; });
@ -390,7 +491,7 @@
const selectedTypes = Array.from(form.querySelectorAll('input[name="types[]"]:checked')).map((checkbox) => checkbox.value);
if (!selectedTypes.length) { event.preventDefault(); form.querySelector('#surcharge-form-types').focus(); }
});
document.addEventListener('keydown', (event) => { if (event.key !== 'Escape') return; if (modal.classList.contains('is-open')) close(); if (typeModal.classList.contains('is-open')) closeType(); if (columnPanel?.hidden === false) { columnPanel.hidden = true; columnToggle?.setAttribute('aria-expanded', 'false'); } });
document.addEventListener('keydown', (event) => { if (event.key !== 'Escape') return; if (modal.classList.contains('is-open')) close(); if (typeModal.classList.contains('is-open')) closeType(); if (importModal?.classList.contains('is-open')) closeImport(); if (columnPanel?.hidden === false) { columnPanel.hidden = true; columnToggle?.setAttribute('aria-expanded', 'false'); } });
readState();
applyColumns(false);
})();

View File

@ -228,6 +228,9 @@ Route::prefix('workspace/courier-payables/courier-surcharge')
->name('business.courier-surcharge.')
->middleware('business.page:courier-payables,courier-surcharge')
->group(function () {
Route::get('import-template', [CourierSurchargeQuotes::class, 'importTemplate'])->name('import.template');
Route::post('import/preview', [CourierSurchargeQuotes::class, 'importPreview'])->name('import.preview');
Route::post('import/commit', [CourierSurchargeQuotes::class, 'importCommit'])->name('import.commit');
Route::post('/', [CourierSurchargeQuotes::class, 'store'])->name('store');
Route::patch('{quote}', [CourierSurchargeQuotes::class, 'update'])->name('update');
Route::post('{quote}/status', [CourierSurchargeQuotes::class, 'setStatus'])->name('status');

View File

@ -2,16 +2,24 @@
namespace Tests\Feature\Business;
use App\Business\CourierSurchargeQuoteWorkbookImporter;
use App\Business\CourierSurchargeTypeRepository;
use App\Models\Auth\Permission;
use App\Models\Business\CourierSurchargeAuditLog;
use App\Models\Business\CourierSurchargeQuote;
use App\Models\Business\CourierSurchargeType;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Schema;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\Feature\FeatureTestCase;
class CourierSurchargePageTest extends FeatureTestCase
{
/** @var list<string> */
private array $temporaryImportFiles = [];
public function test_courier_surcharge_page_renders_persisted_quote_rules(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
@ -42,6 +50,9 @@ class CourierSurchargePageTest extends FeatureTestCase
->assertSeeText('列设置')
->assertSeeText('导出当前列')
->assertSeeText('导出全部字段')
->assertSeeText('导入报价')
->assertSeeText('下载 Excel 模板')
->assertSee('data-surcharge-import-modal', false)
->assertSeeText('规则编号与操作列固定显示')
->assertSee('data-column-storage-key="caiwu:table-columns:', false)
->assertSee('data-surcharge-table', false)
@ -359,6 +370,229 @@ class CourierSurchargePageTest extends FeatureTestCase
$this->assertDatabaseMissing('business_courier_surcharge_quotes', ['courier' => '不应保存的快递']);
}
public function test_courier_surcharge_import_template_contains_exact_headers_and_instructions(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$response = $this->loginAs()->get(route('business.courier-surcharge.import.template'));
$response->assertOk()->assertDownload();
$path = $this->temporaryPath('surcharge-template-');
file_put_contents($path, $response->streamedContent());
$spreadsheet = IOFactory::load($path);
try {
$this->assertSame(
CourierSurchargeQuoteWorkbookImporter::HEADERS,
$spreadsheet->getSheet(0)->rangeToArray('A1:J1')[0],
);
$this->assertSame('填写说明', $spreadsheet->getSheet(1)->getTitle());
$this->assertStringContainsString('不会静默覆盖', (string) $spreadsheet->getSheet(1)->getCell('B8')->getValue());
} finally {
$spreadsheet->disconnectWorksheets();
}
}
public function test_valid_workbook_can_be_previewed_and_committed_with_audit_logs(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs();
$file = $this->workbook([[
'导入测试快递', '代发仓', '李会计', '进仓费、超长费', '固定单价',
'12.50 元/单', '入仓即收取', '2026-09-22', '已停用', '批量导入测试',
]]);
$previewResponse = $this->post(route('business.courier-surcharge.import.preview'), ['import_file' => $file]);
$previewResponse->assertRedirect()->assertSessionHas('courierSurchargeImportPreview', function (array $preview): bool {
return $preview['summary'] === ['total' => 1, 'valid' => 1, 'new' => 1, 'duplicate' => 0, 'invalid' => 0]
&& $preview['rows'][0]['line'] === 2
&& $preview['rows'][0]['data']['types'] === ['进仓费', '超长费'];
});
$preview = $previewResponse->getSession()->get('courierSurchargeImportPreview');
$this->post(route('business.courier-surcharge.import.commit'), [
'preview_token' => $preview['token'],
'duplicate_policy' => CourierSurchargeQuoteWorkbookImporter::DUPLICATE_SKIP,
])->assertRedirect()->assertSessionHas('success', '附加费报价导入完成:新增 1 条,更新 0 条,跳过 0 条。');
$quote = CourierSurchargeQuote::query()->where('courier', '导入测试快递')->firstOrFail();
$this->assertSame(['进仓费', '超长费'], $quote->types);
$this->assertSame(CourierSurchargeQuote::STATUS_INACTIVE, $quote->status);
$this->assertSame('批量导入测试', $quote->remark);
$this->assertDatabaseHas('business_courier_surcharge_audit_logs', [
'target_id' => $quote->id,
'action' => CourierSurchargeAuditLog::ACTION_CREATED,
'actor_id' => $this->user->id,
]);
$this->assertDatabaseHas('business_courier_surcharge_audit_logs', [
'target_id' => $quote->id,
'action' => CourierSurchargeAuditLog::ACTION_DEACTIVATED,
'actor_id' => $this->user->id,
]);
}
public function test_import_preview_reports_row_errors_and_blocks_commit(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs();
$type = app(CourierSurchargeTypeRepository::class)->all()->firstWhere('name', '超长费');
$this->post(route('business.courier-surcharge.types.status', ['type' => $type->id]), [
'status' => CourierSurchargeType::STATUS_INACTIVE,
])->assertRedirect();
$previewResponse = $this->post(route('business.courier-surcharge.import.preview'), [
'import_file' => $this->workbook([[
'', '未知归属', '李会计', '超长费、虚构费', '未知方式', '',
'测试条件', '2026-02-31', '未知状态', '',
]]),
]);
$previewResponse->assertRedirect()->assertSessionHas('courierSurchargeImportPreview', function (array $preview): bool {
$errors = implode('|', $preview['rows'][0]['errors']);
return $preview['summary']['invalid'] === 1
&& str_contains($errors, '快递名称不能为空')
&& str_contains($errors, '附加费类型不存在:虚构费')
&& str_contains($errors, '附加费类型已停用:超长费')
&& str_contains($errors, '生效日期必须是有效的')
&& str_contains($errors, '状态只能填写');
});
$preview = $previewResponse->getSession()->get('courierSurchargeImportPreview');
$this->withExceptionHandling()->post(route('business.courier-surcharge.import.commit'), [
'preview_token' => $preview['token'],
'duplicate_policy' => CourierSurchargeQuoteWorkbookImporter::DUPLICATE_SKIP,
])->assertSessionHasErrors('import');
$this->assertDatabaseMissing('business_courier_surcharge_quotes', ['reconciler' => '李会计', 'condition' => '测试条件']);
}
public function test_import_requires_exact_headers_and_enforces_row_limit(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs();
$wrongHeaders = CourierSurchargeQuoteWorkbookImporter::HEADERS;
$wrongHeaders[0] = '快递公司';
$this->withExceptionHandling()->post(route('business.courier-surcharge.import.preview'), [
'import_file' => $this->workbook([], $wrongHeaders),
])->assertSessionHasErrors('import');
$rows = array_fill(0, CourierSurchargeQuoteWorkbookImporter::MAX_ROWS + 1, [
'测试快递', '自有快递', '周会计', '进仓费', '固定单价',
'1 元/单', '测试条件', '2026-09-22', '已启用', '',
]);
$this->withExceptionHandling()->post(route('business.courier-surcharge.import.preview'), [
'import_file' => $this->workbook($rows),
])->assertSessionHasErrors('import');
}
public function test_file_duplicates_are_reported_and_existing_rules_require_an_explicit_policy(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs();
$duplicateRow = [
'文件内重复快递', '自有快递', '周会计', '进仓费', '固定单价',
'5 元/单', '重复条件', '2026-09-22', '已启用', '',
];
$response = $this->post(route('business.courier-surcharge.import.preview'), [
'import_file' => $this->workbook([$duplicateRow, $duplicateRow]),
]);
$response->assertSessionHas('courierSurchargeImportPreview', function (array $preview): bool {
return $preview['summary']['invalid'] === 2
&& str_contains($preview['rows'][0]['errors'][0], '文件内规则重复')
&& str_contains($preview['rows'][1]['errors'][0], '文件内规则重复');
});
$existing = $this->createQuote();
$existingRow = [
$existing->courier, $existing->scope, $existing->reconciler, $existing->typeLabel(), $existing->mode,
'新计价标准', $existing->condition, $existing->effective_at->format('Y-m-d'), '已停用', '更新后的备注',
];
$skipPreview = $this->post(route('business.courier-surcharge.import.preview'), [
'import_file' => $this->workbook([$existingRow]),
])->getSession()->get('courierSurchargeImportPreview');
$this->post(route('business.courier-surcharge.import.commit'), [
'preview_token' => $skipPreview['token'],
'duplicate_policy' => CourierSurchargeQuoteWorkbookImporter::DUPLICATE_SKIP,
])->assertSessionHas('success', '附加费报价导入完成:新增 0 条,更新 0 条,跳过 1 条。');
$this->assertSame('最长边 > 160 cm:8 元/单', $existing->fresh()->standard);
$updatePreview = $this->post(route('business.courier-surcharge.import.preview'), [
'import_file' => $this->workbook([$existingRow]),
])->getSession()->get('courierSurchargeImportPreview');
$this->post(route('business.courier-surcharge.import.commit'), [
'preview_token' => $updatePreview['token'],
'duplicate_policy' => CourierSurchargeQuoteWorkbookImporter::DUPLICATE_UPDATE,
])->assertSessionHas('success', '附加费报价导入完成:新增 0 条,更新 1 条,跳过 0 条。');
$existing->refresh();
$this->assertSame('新计价标准', $existing->standard);
$this->assertSame('更新后的备注', $existing->remark);
$this->assertSame(CourierSurchargeQuote::STATUS_INACTIVE, $existing->status);
$this->assertDatabaseHas('business_courier_surcharge_audit_logs', [
'target_id' => $existing->id,
'action' => CourierSurchargeAuditLog::ACTION_UPDATED,
'actor_id' => $this->user->id,
]);
}
public function test_import_commit_rejects_expired_or_other_context_preview(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs();
$token = str_repeat('a', 48);
$this->withSession([
'courier_surcharge_import_previews' => [
$token => [
'company_id' => (int) company_id() + 999,
'user_id' => (int) $this->user->id,
'expires_at' => now()->subMinute()->timestamp,
'rows' => [],
],
],
])->post(route('business.courier-surcharge.import.commit'), [
'preview_token' => $token,
'duplicate_policy' => CourierSurchargeQuoteWorkbookImporter::DUPLICATE_SKIP,
])->assertRedirect()->assertSessionHasErrors('import');
}
/** @param list<list<mixed>> $rows @param list<string>|null $headers */
private function workbook(array $rows, ?array $headers = null): UploadedFile
{
$path = $this->temporaryPath('surcharge-import-');
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->fromArray($headers ?? CourierSurchargeQuoteWorkbookImporter::HEADERS, null, 'A1');
if ($rows !== []) {
$sheet->fromArray($rows, null, 'A2');
}
(new Xlsx($spreadsheet))->save($path);
$spreadsheet->disconnectWorksheets();
return new UploadedFile(
$path,
'附加费报价.xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
null,
true,
);
}
private function temporaryPath(string $prefix): string
{
$path = tempnam(sys_get_temp_dir(), $prefix);
$this->temporaryImportFiles[] = $path;
return $path;
}
protected function tearDown(): void
{
foreach ($this->temporaryImportFiles as $path) {
@unlink($path);
}
parent::tearDown();
}
private function createQuote(): CourierSurchargeQuote
{
return CourierSurchargeQuote::query()->create([