diff --git a/app/Http/Controllers/Business/SupplierManagement.php b/app/Http/Controllers/Business/SupplierManagement.php index 58f5ab3b..836691bd 100644 --- a/app/Http/Controllers/Business/SupplierManagement.php +++ b/app/Http/Controllers/Business/SupplierManagement.php @@ -2,26 +2,41 @@ namespace App\Http\Controllers\Business; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; +use Illuminate\Validation\ValidationException; use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository; +use Modules\EcommerceFinance\Services\SupplierFreeShippingStore; +use Modules\EcommerceFinance\Services\SupplierFreeShippingWorkbook; use Modules\EcommerceFinance\View\SupplierManagementPageData; +use Symfony\Component\HttpFoundation\StreamedResponse; class SupplierManagement extends Controller { - public function export(Request $request, SupplierManagementMirrorRepository $repository, SupplierManagementPageData $page) - { + public function export( + Request $request, + SupplierManagementMirrorRepository $repository, + SupplierManagementPageData $page, + SupplierFreeShippingStore $freeShipping, + ): StreamedResponse { abort_unless($repository->ready(), 503, '供应商资料尚未完成首次同步,暂时不能导出。'); $filters = $page->filters($request->query()); + $columns = SupplierManagementPageData::columns(); + $companyId = (int) (company_id() ?: 1); - return response()->streamDownload(static function () use ($repository, $filters): void { + return response()->streamDownload(static function () use ($repository, $filters, $columns, $freeShipping, $companyId): void { $csv = fopen('php://output', 'w'); fputs($csv, "\xEF\xBB\xBF"); - fputcsv($csv, array_values(SupplierManagementMirrorRepository::COLUMNS)); + fputcsv($csv, array_values($columns)); foreach ($repository->exportRows($filters) as $row) { + $row = $freeShipping->decorateRow($row, $companyId); $values = []; - foreach (array_keys(SupplierManagementMirrorRepository::COLUMNS) as $column) { - $value = (string) ($row->{$column} ?? ''); + foreach (array_keys($columns) as $column) { + $value = $column === SupplierManagementPageData::FREE_SHIPPING_COLUMN + ? ((bool) ($row->{$column} ?? false) ? '包邮' : '') + : (string) ($row->{$column} ?? ''); if ($column === 'coopersgn') $value = ['1' => '合作', '2' => '不合作'][$value] ?? '未维护'; // Excel 不能执行供应商提供的公式文本,且长编码保留为文本。 if (preg_match('/^[=+\-@\t\r\n]/', ltrim($value)) || preg_match('/^\d{12,}$/', $value)) $value = "'" . $value; @@ -33,4 +48,114 @@ class SupplierManagement extends Controller }, 'supplier_management_' . now()->format('Ymd_His') . '.csv', ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store']); } + + public function template(SupplierFreeShippingWorkbook $workbook): StreamedResponse + { + return $workbook->template(); + } + + public function import( + Request $request, + SupplierManagementMirrorRepository $repository, + SupplierFreeShippingStore $freeShipping, + SupplierFreeShippingWorkbook $workbook, + ): RedirectResponse { + $request->validate(['import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240']]); + abort_unless($repository->ready(), 503, '供应商资料尚未完成首次同步,暂时不能导入。'); + + $valuesByCode = $workbook->read($request->file('import')); + $suppliers = $repository->suppliersByCodes(array_keys($valuesByCode)); + $matched = []; + foreach ($suppliers as $supplier) { + $code = mb_strtolower(trim((string) ($supplier->scmcode ?? ''))); + if ($code !== '') { + $matched[$code][] = $supplier; + } + } + + $missing = []; + $ambiguous = []; + $valuesBySupplierId = []; + foreach ($valuesByCode as $code => $isFreeShipping) { + $key = mb_strtolower(trim((string) $code)); + if (! isset($matched[$key])) { + $missing[] = (string) $code; + continue; + } + if (count($matched[$key]) !== 1) { + $ambiguous[] = (string) $code; + continue; + } + $valuesBySupplierId[(int) $matched[$key][0]->Id] = $isFreeShipping; + } + if ($ambiguous !== []) { + throw ValidationException::withMessages([ + 'import' => '以下供应商代码在供应商主档中存在多条记录,未导入:' . implode('、', array_slice($ambiguous, 0, 8)), + ]); + } + if ($missing !== []) { + throw ValidationException::withMessages([ + 'import' => '以下供应商代码未找到,未导入:' . implode('、', array_slice($missing, 0, 8)) + . (count($missing) > 8 ? ' 等 ' . count($missing) . ' 条' : ''), + ]); + } + + $result = $freeShipping->saveMany( + $valuesBySupplierId, + (int) (company_id() ?: 1), + user_id() ? (int) user_id() : null, + 'import', + ); + + return $this->redirectToPage($request)->with( + 'supplier_management_message', + "导入完成:新增 {$result['created']} 家供应商维护记录,更新 {$result['updated']} 家供应商维护记录。", + ); + } + + public function updateFreeShipping( + Request $request, + int $supplier, + SupplierManagementMirrorRepository $repository, + SupplierFreeShippingStore $freeShipping, + ): JsonResponse|RedirectResponse { + $request->validate(['value' => ['nullable', 'string', 'in:,包邮']]); + abort_unless($repository->ready(), 503, '供应商资料尚未完成首次同步,暂时不能编辑。'); + abort_if($repository->supplierById($supplier) === null, 404, '供应商不存在。'); + + $isFreeShipping = $request->input('value') === '包邮'; + $freeShipping->save( + $supplier, + $isFreeShipping, + (int) (company_id() ?: 1), + user_id() ? (int) user_id() : null, + 'manual', + ); + + if ($request->expectsJson()) { + return response()->json([ + 'ok' => true, + 'value' => $isFreeShipping ? '包邮' : '', + 'display' => $isFreeShipping ? '包邮' : '-', + ]); + } + + return $this->redirectToPage($request)->with('supplier_management_message', '是否包邮厂家已保存。'); + } + + private function redirectToPage(Request $request): RedirectResponse + { + $query = array_filter([ + ...app(SupplierManagementPageData::class)->filters($request->query()), + 'page' => max((int) $request->query('page', 1), 1), + ], static fn (mixed $value): bool => $value !== ''); + + $url = route('business.pages.show', [ + 'company_id' => company_id() ?: 1, + 'section' => 'supplier-payables', + 'page' => 'supplier-management', + ]); + + return redirect()->to($url . ($query === [] ? '' : '?' . http_build_query($query))); + } } diff --git a/modules/EcommerceFinance/Repositories/SupplierManagementMirrorRepository.php b/modules/EcommerceFinance/Repositories/SupplierManagementMirrorRepository.php index 0ee66367..df0b69ed 100644 --- a/modules/EcommerceFinance/Repositories/SupplierManagementMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/SupplierManagementMirrorRepository.php @@ -88,6 +88,40 @@ class SupplierManagementMirrorRepository ]; } + public function supplierById(int $id): ?object + { + if (! $this->ready() || $id <= 0) { + return null; + } + + return $this->mirror->connection()->table($this->datasets->get(self::DATASET)->targetTable()) + ->where('Id', $id) + ->first(['Id', 'scmcode', 'scmname']); + } + + /** @param list $codes + * @return list + */ + public function suppliersByCodes(array $codes): array + { + $codes = array_values(array_unique(array_filter( + array_map('trim', $codes), + static fn (string $code): bool => $code !== '', + ))); + if ($codes === []) { + return []; + } + if (! $this->ready()) { + throw new RuntimeException('supplier_management_mirror_not_ready'); + } + + return $this->mirror->connection()->table($this->datasets->get(self::DATASET)->targetTable()) + ->whereIn('scmcode', $codes) + ->orderBy('Id') + ->get(['Id', 'scmcode', 'scmname']) + ->all(); + } + /** 全部筛选结果,按上游稳定键小批读取,保留编码/账号文本。 */ public function exportRows(array $filters): \Generator { diff --git a/modules/EcommerceFinance/Services/SupplierFreeShippingStore.php b/modules/EcommerceFinance/Services/SupplierFreeShippingStore.php new file mode 100644 index 00000000..5532f7e1 --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierFreeShippingStore.php @@ -0,0 +1,101 @@ +>> */ + private array $cache = []; + + public function __construct(private readonly SupplierPayableOverrideStore $overrides) + { + } + + /** @param list $rows + * @return list + */ + public function decorateRows(array $rows, int $companyId): array + { + foreach ($rows as $row) { + $this->decorateRow($row, $companyId); + } + + return $rows; + } + + public function decorateRow(object $row, int $companyId): object + { + $id = (int) ($row->Id ?? 0); + $values = $this->values($companyId); + $row->{self::FIELD} = $id > 0 && (bool) ($values[$this->key($id)][self::FIELD] ?? false); + + return $row; + } + + /** @return array{created:int,updated:int} */ + public function save(int $supplierId, bool $isFreeShipping, int $companyId, ?int $userId, string $source = 'manual'): array + { + $result = $this->overrides->save( + self::DATASET, + $this->key($supplierId), + [self::FIELD => $isFreeShipping], + $companyId, + $userId, + $source, + ); + unset($this->cache[$companyId]); + + return $result; + } + + /** + * @param array $values 供应商 ID => 是否包邮厂家 + * @return array{created:int,updated:int} + */ + public function saveMany(array $values, int $companyId, ?int $userId, string $source = 'import'): array + { + $records = []; + foreach ($values as $supplierId => $isFreeShipping) { + if ((int) $supplierId <= 0) { + continue; + } + $records[$this->key((int) $supplierId)] = [self::FIELD => (bool) $isFreeShipping]; + } + + $result = $this->overrides->saveBatch(self::DATASET, $records, $companyId, $userId, $source); + unset($this->cache[$companyId]); + + return $result; + } + + /** @return array> */ + private function values(int $companyId): array + { + if (! isset($this->cache[$companyId])) { + $this->cache[$companyId] = []; + foreach ($this->overrides->listRows(self::DATASET, $companyId) as $row) { + $key = (string) ($row['_action_key'] ?? ''); + if ($key !== '') { + $this->cache[$companyId][$key] = $row; + } + } + } + + return $this->cache[$companyId]; + } + + private function key(int $supplierId): string + { + return 'supplier:' . $supplierId; + } +} diff --git a/modules/EcommerceFinance/Services/SupplierFreeShippingWorkbook.php b/modules/EcommerceFinance/Services/SupplierFreeShippingWorkbook.php new file mode 100644 index 00000000..b4c326d7 --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierFreeShippingWorkbook.php @@ -0,0 +1,200 @@ + 是否包邮厂家。空白或“-”表示否。 + * + * @return array + */ + public function read(UploadedFile $file): array + { + try { + $reader = IOFactory::createReader(match (mb_strtolower($file->getClientOriginalExtension())) { + 'xlsx' => 'Xlsx', + 'xls' => 'Xls', + 'csv' => 'Csv', + default => throw new \RuntimeException('Unsupported workbook format.'), + }); + $book = $reader->load($file->getRealPath()); + } catch (Throwable) { + throw ValidationException::withMessages([ + 'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。', + ]); + } + + try { + [$sheet, $headerRow, $codeColumn, $fieldColumn] = $this->locateColumns($book); + $highestRow = $sheet->getHighestDataRow(); + if ($highestRow > 5001) { + throw ValidationException::withMessages(['import' => '单次最多导入 5,000 条供应商记录,请拆分文件后重试。']); + } + + $records = []; + $lineByCode = []; + for ($row = $headerRow + 1; $row <= $highestRow; $row++) { + $code = $this->text($sheet, $codeColumn, $row); + if (str_starts_with($code, "'")) { + $code = substr($code, 1); + } + $field = $this->text($sheet, $fieldColumn, $row); + if ($code === '' && $field === '') { + continue; + } + if ($code === '') { + throw ValidationException::withMessages(['import' => "第 {$row} 行供应商代码为空。"]); + } + if (mb_strlen($code) > 191) { + throw ValidationException::withMessages(['import' => "第 {$row} 行供应商代码超过 191 个字符。"]); + } + $codeKey = mb_strtolower($code); + if (isset($lineByCode[$codeKey])) { + throw ValidationException::withMessages([ + 'import' => "第 {$row} 行供应商代码“{$code}”与第 {$lineByCode[$codeKey]} 行重复。", + ]); + } + + $records[$code] = $this->parseFlag($field, $row); + $lineByCode[$codeKey] = $row; + } + + if ($records === []) { + throw ValidationException::withMessages(['import' => '表格中没有可导入的供应商记录。']); + } + + return $records; + } finally { + $book->disconnectWorksheets(); + } + } + + public function template(): StreamedResponse + { + return response()->streamDownload(function (): void { + $book = new Spreadsheet(); + $sheet = $book->getActiveSheet(); + $sheet->setTitle('是否包邮厂家'); + $sheet->fromArray([self::SUPPLIER_CODE_HEADER, '供应商名称', self::FIELD_HEADER], null, 'A1'); + $sheet->getStyle('A1:C1')->applyFromArray([ + 'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']], + 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER], + 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'D8D2C7']]], + ]); + $sheet->getRowDimension(1)->setRowHeight(24); + $sheet->freezePane('A2'); + $sheet->setAutoFilter('A1:C1'); + foreach (['A' => 22, 'B' => 34, 'C' => 20] as $column => $width) { + $sheet->getColumnDimension($column)->setWidth($width); + } + $sheet->getStyle('A2:A5001')->getNumberFormat()->setFormatCode('@'); + + $instructions = $book->createSheet(); + $instructions->setTitle('填写说明'); + $instructions->fromArray([ + ['项目', '说明'], + [self::SUPPLIER_CODE_HEADER, '必填。请填写供应商管理页面中的供应商代码,按代码精确关联。'], + [self::FIELD_HEADER, '填写“包邮”表示是包邮厂家;空白或“-”表示不是包邮厂家。'], + ['兼容导出表', '也可以使用本页面导出的文件,在此列补充后直接导入。'], + ['覆盖规则', '同一供应商再次导入会更新该字段;其他列不会修改。'], + ], 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(22); + $instructions->getColumnDimension('B')->setWidth(76); + $instructions->getStyle('A1:B5')->getAlignment()->setVertical(Alignment::VERTICAL_CENTER)->setWrapText(true); + foreach (range(1, 5) as $row) { + $instructions->getRowDimension($row)->setRowHeight($row === 1 ? 24 : 34); + } + + $book->setActiveSheetIndex(0); + (new Xlsx($book))->save('php://output'); + $book->disconnectWorksheets(); + }, '供应商是否包邮厂家_导入模板_' . now()->format('Ymd') . '.xlsx', [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Cache-Control' => 'private, no-store', + ]); + } + + /** @return array{0:Worksheet,1:int,2:int,3:int} */ + private function locateColumns(Spreadsheet $book): array + { + $best = null; + foreach ($book->getWorksheetIterator() as $sheet) { + $rows = $sheet->toArray(null, false, false, false); + foreach (array_slice($rows, 0, 20, true) as $rowIndex => $row) { + $headers = []; + foreach ($row as $columnIndex => $value) { + $header = $this->header($value); + if ($header !== '') { + $headers[$header] = $columnIndex + 1; + } + } + if (isset($headers[self::SUPPLIER_CODE_HEADER], $headers[self::FIELD_HEADER])) { + $candidate = [$sheet, $rowIndex + 1, $headers[self::SUPPLIER_CODE_HEADER], $headers[self::FIELD_HEADER]]; + $best = $candidate; + break 2; + } + } + } + + if ($best === null) { + throw ValidationException::withMessages([ + 'import' => '缺少表头:需要“供应商代码”和“是否包邮厂家”两列。', + ]); + } + + return $best; + } + + private function parseFlag(string $value, int $row): bool + { + $normalized = mb_strtolower(preg_replace('/[\s\x{FEFF}]+/u', '', $value)); + if (in_array($normalized, ['', '-', '否', '不是', '0', 'false', 'no'], true)) { + return false; + } + if (in_array($normalized, ['包邮', '是', '是包邮厂家', '1', 'true', 'yes'], true)) { + return true; + } + + throw ValidationException::withMessages([ + 'import' => "第 {$row} 行“是否包邮厂家”只能填写“包邮”,或留空表示否。", + ]); + } + + private function text(Worksheet $sheet, int $column, int $row): string + { + $cell = $sheet->getCell([$column, $row]); + if ($cell->getDataType() === 'f') { + throw ValidationException::withMessages(['import' => "第 {$row} 行含公式,请填写实际值。"]); + } + + return trim((string) $cell->getFormattedValue()); + } + + private function header(mixed $value): string + { + return str_replace([' ', ' ', "\n", "\r", "\t", "\x{FEFF}"], '', trim((string) $value)); + } +} diff --git a/modules/EcommerceFinance/View/SupplierManagementPageData.php b/modules/EcommerceFinance/View/SupplierManagementPageData.php index 25dcd1e7..2f75139e 100644 --- a/modules/EcommerceFinance/View/SupplierManagementPageData.php +++ b/modules/EcommerceFinance/View/SupplierManagementPageData.php @@ -4,14 +4,35 @@ namespace Modules\EcommerceFinance\View; use Illuminate\Pagination\LengthAwarePaginator; use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository; +use Modules\EcommerceFinance\Services\SupplierFreeShippingStore; use Throwable; class SupplierManagementPageData { public const TABS = ['contracts' => '合同信息', 'contacts' => '联系信息', 'payments' => '打款信息', 'logs' => '操作日志']; - public function __construct(private readonly SupplierManagementMirrorRepository $repository) + public const FREE_SHIPPING_COLUMN = SupplierFreeShippingStore::FIELD; + + public const FREE_SHIPPING_LABEL = '是否包邮厂家'; + + public function __construct( + private readonly SupplierManagementMirrorRepository $repository, + private readonly SupplierFreeShippingStore $freeShipping, + ) { + } + + /** 是否包邮厂家紧跟在供应商名称后展示。 */ + public static function columns(): array { + $columns = []; + foreach (SupplierManagementMirrorRepository::COLUMNS as $key => $label) { + $columns[$key] = $label; + if ($key === 'scmname') { + $columns[self::FREE_SHIPPING_COLUMN] = self::FREE_SHIPPING_LABEL; + } + } + + return $columns; } public function filters(array $input): array @@ -39,6 +60,10 @@ class SupplierManagementPageData $ready = $this->repository->ready(); if ($ready) { $result = $this->repository->paginate($filters, $page, $limit); + $result['rows'] = $this->freeShipping->decorateRows( + $result['rows'], + (int) (company_id() ?: 1), + ); if ((int) request('supplier_id', 0) > 0) { // 选择必须仍在当前列表中;筛选和翻页后清除旧选择。 $selected = collect($result['rows'])->firstWhere('Id', (int) request('supplier_id')); @@ -61,7 +86,7 @@ class SupplierManagementPageData return [ 'view' => 'business.pages.supplier-payables.supplier-management', 'supplierFilters' => $filters, 'supplierState' => $state, 'supplierReady' => $ready, - 'supplierError' => $error, 'supplierColumns' => SupplierManagementMirrorRepository::COLUMNS, + 'supplierError' => $error, 'supplierColumns' => self::columns(), 'suppliers' => new LengthAwarePaginator($result['rows'], $result['total'], $limit, $page, ['path' => request()->url(), 'query' => request()->except('page', 'supplier_id', 'detail_page')]), 'selectedSupplier' => $selected, 'supplierDetailTab' => $tab, diff --git a/resources/views/business/pages/supplier-payables/supplier-management.blade.php b/resources/views/business/pages/supplier-payables/supplier-management.blade.php index bacfc86f..e4203e41 100644 --- a/resources/views/business/pages/supplier-payables/supplier-management.blade.php +++ b/resources/views/business/pages/supplier-payables/supplier-management.blade.php @@ -2,6 +2,7 @@ $listUrl = route('business.pages.show', ['section' => 'supplier-payables', 'page' => 'supplier-management']); $localUrl = static fn (array $changes): string => $listUrl . '?' . http_build_query(array_merge(request()->except('detail_page'), $changes)); $cell = static fn ($value): string => $value === null || $value === '' ? '—' : (string) $value; + $freeShippingColumn = \Modules\EcommerceFinance\View\SupplierManagementPageData::FREE_SHIPPING_COLUMN; $detailColumns = match ($supplierDetailTab) { 'contacts' => ['sourcefrom' => '数据来源', 'linker' => '联系人', 'linkjob' => '职务', 'linkmobile' => '联系电话', 'linkweixin' => '微信', 'memo' => '备注'], 'payments' => ['enable' => '是否启用', 'sourcefrom' => '数据来源', 'accounttype' => '账户类型', 'account' => '打款账号', 'accountname' => '账户名称', 'accountbank' => '开户银行', 'accountnature' => '账户性质', 'invoice' => '开票信息', 'memo' => '备注'], @@ -9,7 +10,7 @@ default => ['ordernum' => '合同编号', 'originname' => '数据来源', 'title' => '合同名称', 'tradebody' => '合同主体', 'company' => '签订公司', 'eSignDocTemplateId' => '合同类型', 'type' => '合同类型名称', 'tradebtime' => '合同开始时间', 'tradeetime' => '合同截止时间', 'sendTime' => '合同发送时间', 'completeTime' => '完成时间', 'state' => '签署状态'], }; @endphp -
+
@@ -68,11 +84,19 @@ 重置 @if ($supplierReady && !$supplierError) + 导入模板 + 导出 @endif

+ @if (session('supplier_management_message')) +
{{ session('supplier_management_message') }}
+ @endif + @if ($errors->has('import')) + + @endif @if ($supplierError) @@ -87,8 +111,11 @@
- - @foreach ($supplierColumns as $label)@endforeach + {{-- 列结构变更后使用新版本键,避免旧列设置把新列及其后字段隐藏。 --}} +
序号{{ $label }}
+ @foreach ($supplierColumns as $key => $label)@endforeach @forelse ($suppliers as $supplier) @php $selectUrl = $localUrl(['supplier_id' => $supplier->Id]); @endphp @@ -100,6 +127,14 @@ (int) $supplier->coopersgn === 1])>{{ ['1' => '合作', '2' => '不合作'][(string) $supplier->coopersgn] ?? '未维护' }} @elseif ($key === 'scmname') Id == $supplier->Id) aria-current="true" @endif>{{ $cell($supplier->{$key} ?? null) }} + @elseif ($key === $freeShippingColumn) + @else {{ $cell($supplier->{$key} ?? null) }} @endif @@ -151,6 +186,29 @@ @endif + @if ($supplierReady && !$supplierError) + + @endif @push('body_end')
序号{{ $label }}