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, 20, 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:B10')->getAlignment()->setVertical(Alignment::VERTICAL_CENTER)->setWrapText(true); foreach (range(1, 10) 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' => '表头不正确。请下载最新模板,并保持 11 个字段名称及顺序不变。', ]); } $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 (['快递名称', '快递归属', '对账人', '附加费类型', '结算方式', '计价标准', '生效条件', '生效日期', '状态'] as $header) { if ($display[$header] === '') { $errors[] = $header . '不能为空。'; } } $this->checkLength($display, '快递名称', 191, $errors); $this->checkLength($display, '对账人', 100, $errors); $this->checkLength($display, '供应商', 191, $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['对账人'], 'supplier' => $display['供应商'] !== '' ? $display['供应商'] : null, '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 ?? ''))); } }