diff --git a/app/Business/CourierSurchargeQuoteRepository.php b/app/Business/CourierSurchargeQuoteRepository.php index e5512285..8f43052b 100644 --- a/app/Business/CourierSurchargeQuoteRepository.php +++ b/app/Business/CourierSurchargeQuoteRepository.php @@ -156,6 +156,43 @@ class CourierSurchargeQuoteRepository return $this->types->activeNames(); } + /** @param array $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 $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 $data */ public function create(array $data, int $userId): CourierSurchargeQuote { @@ -275,6 +312,18 @@ class CourierSurchargeQuoteRepository ]; } + /** @param array $types @return list */ + 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 */ private function present(CourierSurchargeQuote $quote): array { diff --git a/app/Business/CourierSurchargeQuoteWorkbookImporter.php b/app/Business/CourierSurchargeQuoteWorkbookImporter.php new file mode 100644 index 00000000..22ecff3a --- /dev/null +++ b/app/Business/CourierSurchargeQuoteWorkbookImporter.php @@ -0,0 +1,382 @@ +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,display:array,errors:list,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,errors:list}> $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,display:array,errors:list,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 $values @param list $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 ?? ''))); + } +} diff --git a/app/Http/Controllers/Business/CourierSurchargeQuotes.php b/app/Http/Controllers/Business/CourierSurchargeQuotes.php index 43893dc5..5825c7f6 100644 --- a/app/Http/Controllers/Business/CourierSurchargeQuotes.php +++ b/app/Http/Controllers/Business/CourierSurchargeQuotes.php @@ -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 */ private function validated(Request $request): array { diff --git a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php index 490f76da..5b72ec04 100644 --- a/resources/views/business/pages/courier-payables/courier-surcharge.blade.php +++ b/resources/views/business/pages/courier-payables/courier-surcharge.blade.php @@ -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); } }
{{ $ready ? '正式维护数据' : '数据表尚未就绪' }} {{ $ready ? '报价规则已保存到当前公司数据表,刷新页面后仍会保留;后续结算页面将按启用中的规则计算。' : ($dataReady && !$auditReady ? '操作日志表尚未完成迁移;现有报价仍可查看,新增、编辑和启停暂时禁用,避免产生无日志修改。' : '附加费报价数据表尚未完成迁移,当前不能保存规则。请由管理员在目标数据库完成迁移后再使用本页。') }}
@@ -88,7 +121,7 @@
-
重置
+
重置
@@ -117,7 +150,7 @@
没有符合条件的报价规则,请点击“新增报价规则”。
@endforelse -
维护建议:固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。当前筛选结果可导出为 CSV;本页暂未启用批量导入,避免在未确认字段规则前覆盖维护数据。
+
维护建议:固定单价、按重量、按体积、按尺寸/最长边等方式直接维护标准;聚工单客服核实、取发出运费和按账单等方式只维护触发条件,具体金额由后续结算链路读取业务凭证。批量导入必须先预览校验,已有规则需明确选择跳过或更新。
@@ -146,6 +179,61 @@ @endif
+ +