新增附加费报价批量导入 #243
@ -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
|
||||
{
|
||||
|
||||
382
app/Business/CourierSurchargeQuoteWorkbookImporter.php
Normal file
382
app/Business/CourierSurchargeQuoteWorkbookImporter.php
Normal 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 ?? '')));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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);
|
||||
})();
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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([
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user