feat: 供应商管理新增是否包邮厂家 #415

Merged
renshen merged 1 commits from feature/sumu-supplier-free-shipping-column into main 2026-10-21 11:20:59 +08:00
11 changed files with 805 additions and 14 deletions

View File

@ -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)));
}
}

View File

@ -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<string> $codes
* @return list<object>
*/
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
{

View File

@ -0,0 +1,101 @@
<?php
namespace Modules\EcommerceFinance\Services;
/**
* 供应商是否包邮厂家的本地补充资料。
*
* 供应商主档来自只读共享镜像,包邮标记保存在现有供应商维护表中,
* 以供应商镜像的稳定 ID 关联,不写回上游。
*/
class SupplierFreeShippingStore
{
public const DATASET = 'supplier-management.free-shipping-manufacturer';
public const FIELD = 'free_shipping_manufacturer';
/** @var array<int,array<string,array<string,mixed>>> */
private array $cache = [];
public function __construct(private readonly SupplierPayableOverrideStore $overrides)
{
}
/** @param list<object> $rows
* @return list<object>
*/
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<int,bool> $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<string,array<string,mixed>> */
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;
}
}

View File

@ -0,0 +1,200 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
/** 读取和生成「是否包邮厂家」导入文件。 */
class SupplierFreeShippingWorkbook
{
public const SUPPLIER_CODE_HEADER = '供应商代码';
public const FIELD_HEADER = '是否包邮厂家';
/**
* 返回供应商代码 => 是否包邮厂家。空白或“-”表示否。
*
* @return array<string,bool>
*/
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));
}
}

View File

@ -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,

View File

@ -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
<section class="supplier-management" data-supplier-management aria-label="供应商管理">
<section class="supplier-management" data-supplier-management data-csrf="{{ csrf_token() }}" aria-label="供应商管理">
<style>
.supplier-management { color:var(--vip-content-ink,#171717); font-size:.8125rem; }
.supplier-management [hidden] { display:none !important; }
@ -35,6 +36,10 @@
.supplier-management-empty { height:120px; text-align:center !important; color:var(--vip-content-muted,#595959); white-space:normal !important; }
.supplier-management-badge { padding:3px 8px; border-radius:4px; background:var(--vip-content-bg,#F8F6F1); color:var(--vip-content-muted,#595959); }
.supplier-management-badge.is-cooperating { color:var(--vip-success,#53793E); }
.supplier-management-free-shipping { width:76px; height:28px; padding:0 6px; border:1px solid transparent; border-radius:6px; background:transparent; color:var(--vip-content-ink,#171717); font:inherit; cursor:pointer; }
.supplier-management-free-shipping:hover,.supplier-management-free-shipping:focus { border-color:var(--vip-border,#D8D2C7); background:#fff; }
.supplier-management-free-shipping.is-saving { cursor:wait; opacity:.55; }
.supplier-management-free-shipping[data-value="包邮"] { color:var(--vip-content-accent-dark,#856632); font-weight:600; }
.supplier-management-pagination { padding:12px; display:flex; align-items:center; flex-wrap:wrap; justify-content:space-between; gap:12px; }
.supplier-management-pagination nav { display:flex; align-items:center; gap:12px; }
.supplier-management-pagination a { color:var(--vip-content-accent-dark,#856632); }
@ -45,6 +50,17 @@
.supplier-management-detail-tabs a[aria-selected="true"] { color:var(--vip-content-accent-dark,#856632); border-color:var(--vip-content-accent,#B9954F); }
.supplier-management-alert { margin-bottom:12px; padding:12px; border:1px solid var(--vip-border,#D8D2C7); border-radius:8px; background:var(--vip-content-accent-soft,#F7F0DE); }
.supplier-management-alert.is-error { color:var(--vip-danger,#B80000); border-color:var(--vip-danger,#B80000); }
.supplier-management-import { position:fixed; inset:0; z-index:1060; display:grid; place-items:center; padding:16px; }
.supplier-management-import[hidden] { display:none; }
.supplier-management-import__backdrop { position:absolute; inset:0; background:rgba(5,5,6,.58); }
.supplier-management-import__dialog { position:relative; width:min(520px,94vw); overflow:hidden; border:1px solid var(--vip-border,#D8D2C7); border-radius:8px; background:var(--vip-surface,#fff); box-shadow:0 4px 8px rgba(23,23,23,.10); }
.supplier-management-import__dialog header { display:flex; align-items:center; justify-content:space-between; padding:16px; border-bottom:1px solid var(--vip-border,#D8D2C7); }
.supplier-management-import__dialog h2 { margin:0; font-size:.875rem; }
.supplier-management-import__dialog header button { border:0; background:transparent; color:var(--vip-content-muted,#595959); cursor:pointer; }
.supplier-management-import__body { display:grid; gap:12px; padding:16px; }
.supplier-management-import__body p { margin:0; color:var(--vip-content-muted,#595959); font-size:.75rem; }
.supplier-management-import__body input[type="file"] { width:100%; padding:12px; border:1px dashed var(--vip-border,#D8D2C7); border-radius:8px; background:var(--vip-content-bg,#F8F6F1); }
.supplier-management-import__dialog footer { display:flex; justify-content:flex-end; gap:8px; padding:16px; border-top:1px solid var(--vip-border,#D8D2C7); }
@media (max-width:640px) { .supplier-management-field { width:100%; } .supplier-management-detail-tabs { gap:12px; overflow-x:auto; white-space:nowrap; } }
</style>
<form method="GET" action="{{ $listUrl }}" class="supplier-management-toolbar" data-supplier-query aria-label="供应商管理筛选">
@ -68,11 +84,19 @@
<button type="submit" class="supplier-management-button is-primary">查询</button>
<a href="{{ $listUrl }}" class="supplier-management-button" data-supplier-local>重置</a>
@if ($supplierReady && !$supplierError)
<a class="supplier-management-button" href="{{ route('business.supplier-management.free-shipping.template') }}">导入模板</a>
<button type="button" class="supplier-management-button" data-supplier-free-shipping-import-open>导入</button>
<a class="supplier-management-button" href="{{ route('business.supplier-management.export', $supplierFilters) }}">导出</a>
@endif
</div>
</form>
<p role="status" aria-live="polite" data-supplier-load-status></p>
@if (session('supplier_management_message'))
<div class="supplier-management-alert" role="status">{{ session('supplier_management_message') }}</div>
@endif
@if ($errors->has('import'))
<div class="supplier-management-alert is-error" role="alert">{{ $errors->first('import') }}</div>
@endif
<div class="supplier-management-alert is-error" role="alert" data-supplier-load-error hidden>加载失败,当前内容已保留,请重新查询。</div>
@if ($supplierError)
<div class="supplier-management-alert is-error" role="alert">{{ $supplierError }}</div>
@ -87,8 +111,11 @@
</div>
<div class="supplier-management-panel" data-finance-table-surface>
<div class="supplier-management-scroll">
<table class="supplier-management-table" data-finance-table aria-label="供应商列表">
<thead><tr><th scope="col">序号</th>@foreach ($supplierColumns as $label)<th scope="col">{{ $label }}</th>@endforeach</tr></thead>
{{-- 列结构变更后使用新版本键,避免旧列设置把新列及其后字段隐藏。 --}}
<table class="supplier-management-table" data-finance-table
data-finance-table-key="{{ auth()->id() }}:{{ company_id() }}:supplier-payables:supplier-management:v2"
aria-label="供应商列表">
<thead><tr><th scope="col">序号</th>@foreach ($supplierColumns as $key => $label)<th scope="col" @if ($key === $freeShippingColumn) data-finance-column-key="{{ $freeShippingColumn }}" @endif>{{ $label }}</th>@endforeach</tr></thead>
<tbody>
@forelse ($suppliers as $supplier)
@php $selectUrl = $localUrl(['supplier_id' => $supplier->Id]); @endphp
@ -100,6 +127,14 @@
<span @class(['supplier-management-badge', 'is-cooperating' => (int) $supplier->coopersgn === 1])>{{ ['1' => '合作', '2' => '不合作'][(string) $supplier->coopersgn] ?? '未维护' }}</span>
@elseif ($key === 'scmname')
<a href="{{ $selectUrl }}" data-supplier-local @if ($selectedSupplier && $selectedSupplier->Id == $supplier->Id) aria-current="true" @endif>{{ $cell($supplier->{$key} ?? null) }}</a>
@elseif ($key === $freeShippingColumn)
<select class="supplier-management-free-shipping" data-supplier-free-shipping
data-url="{{ route('business.supplier-management.free-shipping.update', ['supplier' => $supplier->Id]) }}"
data-value="{{ !empty($supplier->{$freeShippingColumn}) ? '包邮' : '' }}"
aria-label="{{ $cell($supplier->scmname ?? null) }}是否包邮厂家">
<option value="" @selected(empty($supplier->{$freeShippingColumn}))>-</option>
<option value="包邮" @selected(!empty($supplier->{$freeShippingColumn}))>包邮</option>
</select>
@else
{{ $cell($supplier->{$key} ?? null) }}
@endif
@ -151,6 +186,29 @@
</div>
@endif
</div>
@if ($supplierReady && !$supplierError)
<div class="supplier-management-import" data-supplier-free-shipping-import hidden>
<div class="supplier-management-import__backdrop" data-supplier-free-shipping-import-close></div>
<section class="supplier-management-import__dialog" role="dialog" aria-modal="true" aria-labelledby="supplier-free-shipping-import-title">
<header>
<h2 id="supplier-free-shipping-import-title">导入是否包邮厂家</h2>
<button type="button" data-supplier-free-shipping-import-close aria-label="关闭"><span class="material-icons-outlined">close</span></button>
</header>
<form method="POST" enctype="multipart/form-data"
action="{{ route('business.supplier-management.free-shipping.import', request()->only(['supplier_name', 'buyer', 'cooperation_status', 'page'])) }}">
@csrf
<div class="supplier-management-import__body">
<p>按供应商代码匹配。“是否包邮厂家”填写“包邮”表示是;空白或“-”表示不是。可下载模板,也可使用本页导出的文件填写后导入。</p>
<input type="file" name="import" accept=".xlsx,.xls,.csv" required>
</div>
<footer>
<button type="button" class="supplier-management-button" data-supplier-free-shipping-import-close>取消</button>
<button type="submit" class="supplier-management-button is-primary">开始导入</button>
</footer>
</form>
</section>
</div>
@endif
</section>
@push('body_end')
<script data-caiwu-replay>
@ -158,6 +216,7 @@
if (window.caiwuSupplierManagementBound) return;
window.caiwuSupplierManagementBound = true;
let pending = false;
let importOpener = null;
async function load(href) {
const current = document.querySelector('[data-supplier-management]');
if (!current || pending) return;
@ -188,13 +247,69 @@
}
}
document.addEventListener('click', event => {
const importOpen = event.target.closest('[data-supplier-free-shipping-import-open]');
if (importOpen) {
event.preventDefault();
const modal = document.querySelector('[data-supplier-free-shipping-import]');
if (!modal) return;
modal.hidden = false;
importOpener = importOpen;
modal.querySelector('input[type="file"]')?.focus();
return;
}
const importClose = event.target.closest('[data-supplier-free-shipping-import-close]');
if (importClose) {
const modal = importClose.closest('[data-supplier-free-shipping-import]');
if (modal) {
modal.hidden = true;
importOpener?.focus();
}
return;
}
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
const link = event.target.closest('[data-supplier-management] a[data-supplier-local]');
const row = event.target.closest('[data-supplier-select]');
if (!link && !row) return;
if (!link && (!row || event.target.closest('select,button,input'))) return;
event.preventDefault();
load(link ? link.href : row.dataset.supplierSelect);
});
document.addEventListener('change', async event => {
const select = event.target.closest('[data-supplier-free-shipping]');
if (!select) return;
const root = select.closest('[data-supplier-management]');
const status = root?.querySelector('[data-supplier-load-status]');
const previous = select.dataset.value || '';
select.disabled = true;
select.classList.add('is-saving');
if (status) status.textContent = '正在保存是否包邮厂家…';
try {
const data = new FormData();
data.append('_token', root?.dataset.csrf || '');
data.append('_method', 'PATCH');
data.append('value', select.value);
const response = await fetch(select.dataset.url, {
method: 'POST',
credentials: 'same-origin',
headers: { Accept: 'application/json' },
body: data,
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.ok) {
const first = payload?.errors ? Object.values(payload.errors).flat()[0] : null;
throw new Error(first || payload?.message || `保存失败(HTTP ${response.status})`);
}
select.dataset.value = payload.value || '';
select.value = payload.value || '';
select.title = payload.display || '-';
if (status) status.textContent = '是否包邮厂家已保存。';
} catch (error) {
select.value = previous;
if (status) status.textContent = String(error?.message || error);
} finally {
select.disabled = false;
select.classList.remove('is-saving');
}
});
document.addEventListener('submit', event => {
const form = event.target.closest('[data-supplier-query]');
if (!form) return;
@ -202,6 +317,14 @@
load(form.action + '?' + new URLSearchParams(new FormData(form)).toString());
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape') {
const modal = document.querySelector('[data-supplier-free-shipping-import]:not([hidden])');
if (modal) {
modal.hidden = true;
importOpener?.focus();
return;
}
}
const tab = event.target.closest('[data-supplier-management] [role="tab"]');
if (!tab || !['ArrowLeft','ArrowRight','Home','End'].includes(event.key)) return;
const tabs = [...tab.parentElement.querySelectorAll('[role="tab"]')];

View File

@ -636,6 +636,19 @@ Route::get('workspace/supplier-payables/supplier-management/export', [\App\Http\
->middleware('business.page:supplier-payables,supplier-management')
->name('business.supplier-management.export');
Route::get('workspace/supplier-payables/supplier-management/free-shipping-manufacturer-template', [\App\Http\Controllers\Business\SupplierManagement::class, 'template'])
->middleware('business.page:supplier-payables,supplier-management')
->name('business.supplier-management.free-shipping.template');
Route::post('workspace/supplier-payables/supplier-management/free-shipping-manufacturer-import', [\App\Http\Controllers\Business\SupplierManagement::class, 'import'])
->middleware(['business.page:supplier-payables,supplier-management', 'import'])
->name('business.supplier-management.free-shipping.import');
Route::patch('workspace/supplier-payables/supplier-management/free-shipping-manufacturer/{supplier}', [\App\Http\Controllers\Business\SupplierManagement::class, 'updateFreeShipping'])
->whereNumber('supplier')
->middleware('business.page:supplier-payables,supplier-management')
->name('business.supplier-management.free-shipping.update');
Route::post('workspace/financial-ledger/mapping/{id}', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateMapping'])
->whereNumber('id')
->middleware('business.page:financial-ledger,ledger')

View File

@ -0,0 +1,30 @@
<?php
namespace Tests\Feature\Business;
use Modules\EcommerceFinance\Services\SupplierFreeShippingStore;
use Tests\Feature\FeatureTestCase;
class SupplierFreeShippingStoreTest extends FeatureTestCase
{
public function test_marks_are_scoped_by_company_and_default_to_false(): void
{
$store = app(SupplierFreeShippingStore::class);
$companyId = (int) $this->company->id;
$store->save(9, true, $companyId, (int) $this->user->id, 'manual');
$store->save(11, false, $companyId, (int) $this->user->id, 'manual');
$rows = $store->decorateRows([
(object) ['Id' => 9],
(object) ['Id' => 11],
(object) ['Id' => 12],
], $companyId);
$this->assertTrue($rows[0]->free_shipping_manufacturer);
$this->assertFalse($rows[1]->free_shipping_manufacturer);
$this->assertFalse($rows[2]->free_shipping_manufacturer);
$otherCompanyRows = $store->decorateRows([(object) ['Id' => 9]], $companyId + 1);
$this->assertFalse($otherCompanyRows[0]->free_shipping_manufacturer);
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace Tests\Feature\Business;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Services\SupplierFreeShippingWorkbook;
use Tests\TestCase;
class SupplierFreeShippingWorkbookTest extends TestCase
{
public function test_csv_reads_package_shipping_value_and_blank_as_false(): void
{
$file = UploadedFile::fake()->createWithContent(
'supplier-free-shipping.csv',
"\xEF\xBB\xBF供应商代码,供应商名称,是否包邮厂家\nN901,一泰,包邮\nY411,七彩,\nS30,万都装饰,-\n",
);
$this->assertSame([
'N901' => true,
'Y411' => false,
'S30' => false,
], app(SupplierFreeShippingWorkbook::class)->read($file));
}
public function test_invalid_package_shipping_value_is_rejected_with_row_number(): void
{
$file = UploadedFile::fake()->createWithContent(
'supplier-free-shipping.csv',
"供应商代码,是否包邮厂家\nN901,待确认\n",
);
$this->expectException(ValidationException::class);
try {
app(SupplierFreeShippingWorkbook::class)->read($file);
} catch (ValidationException $exception) {
$this->assertSame(
['import' => ['第 2 行“是否包邮厂家”只能填写“包邮”,或留空表示否。']],
$exception->errors(),
);
throw $exception;
}
}
public function test_case_insensitive_duplicate_codes_are_rejected(): void
{
$file = UploadedFile::fake()->createWithContent(
'supplier-free-shipping.csv',
"供应商代码,是否包邮厂家\nN901,包邮\nn901,\n",
);
$this->expectException(ValidationException::class);
app(SupplierFreeShippingWorkbook::class)->read($file);
}
}

View File

@ -3,12 +3,17 @@
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Http\UploadedFile;
use Mockery;
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
use Modules\EcommerceFinance\Services\SupplierFreeShippingStore;
use Modules\EcommerceFinance\Services\SupplierFreeShippingWorkbook;
use Tests\Feature\FeatureTestCase;
class SupplierManagementPageTest extends FeatureTestCase
{
private \Mockery\MockInterface $freeShipping;
private function repository(bool $ready): \Mockery\MockInterface
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
@ -16,6 +21,15 @@ class SupplierManagementPageTest extends FeatureTestCase
$repository->shouldReceive('ready')->andReturn($ready);
$repository->shouldReceive('state')->andReturn(null);
$this->app->instance(SupplierManagementMirrorRepository::class, $repository);
$this->freeShipping = Mockery::mock(SupplierFreeShippingStore::class);
$this->freeShipping->shouldReceive('decorateRows')->andReturnUsing(
static fn (array $rows): array => $rows,
);
$this->freeShipping->shouldReceive('decorateRow')->andReturnUsing(
static fn (object $row): object => $row,
);
$this->app->instance(SupplierFreeShippingStore::class, $this->freeShipping);
return $repository;
}
@ -36,7 +50,8 @@ class SupplierManagementPageTest extends FeatureTestCase
{
$repository = $this->repository(true);
$row = (object) ['Id' => 3, 'scmcode' => '001', 'scmname' => '测试供应商', 'coopersgn' => 1,
'region' => '苏北', 'sindou' => '双日', 'capacity' => '120'];
'region' => '苏北', 'sindou' => '双日', 'capacity' => '120',
SupplierFreeShippingStore::FIELD => true];
$repository->shouldReceive('paginate')->once()->with(['supplier_name' => '测试', 'buyer' => '', 'cooperation_status' => '1'], 1, 20)
->andReturn(['rows' => [$row], 'total' => 1]);
$repository->shouldReceive('details')->once()->with($row, 'contacts', 1, 20)
@ -44,7 +59,11 @@ class SupplierManagementPageTest extends FeatureTestCase
$this->loginAs()->get($this->url(['supplier_name' => ' 测试 ', 'cooperation_status' => '1', 'supplier_id' => 3, 'detail_tab' => 'contacts']))
->assertOk()->assertSeeText('测试供应商')->assertSeeText('测试联系人')->assertSee('data-caiwu-replay', false)->assertSeeText('打款信息')
->assertSeeText('工厂产能')->assertSeeText('叫货周期')->assertSeeText('赔付终止日期')
->assertSeeText('苏北')->assertSeeText('双日')->assertSeeText('120');
->assertSeeText('苏北')->assertSeeText('双日')->assertSeeText('120')
->assertSeeText('是否包邮厂家')->assertSeeText('包邮')
->assertSee('data-supplier-free-shipping', false)
->assertSee('>-</option>', false)
->assertSee(route('business.supplier-management.free-shipping.template'), false);
}
public function test_detail_failure_keeps_the_supplier_list_visible(): void
@ -66,10 +85,62 @@ class SupplierManagementPageTest extends FeatureTestCase
$response = $this->loginAs()->get(route('business.supplier-management.export', ['supplier_name' => '测试', 'page' => 2]))->assertOk();
$content = $response->streamedContent();
$this->assertStringStartsWith("\xEF\xBB\xBF", $content);
$this->assertStringStartsWith(
"数据来源,是否合作,供应商代码,供应商名称,是否包邮厂家,供应商类型",
ltrim($content, "\xEF\xBB\xBF"),
);
$this->assertSame(23, substr_count($content, "'=SUM(1,2)"));
$this->assertStringContainsString("'123456789012345678", $content);
}
public function test_free_shipping_flag_can_be_edited_inline(): void
{
$repository = $this->repository(true);
$supplier = (object) ['Id' => 9, 'scmcode' => 'N901', 'scmname' => '一泰'];
$repository->shouldReceive('supplierById')->once()->with(9)->andReturn($supplier);
$this->freeShipping->shouldReceive('save')->once()
->with(9, true, Mockery::type('int'), Mockery::type('int'), 'manual')
->andReturn(['created' => 1, 'updated' => 0]);
$this->loginAs()->patchJson(
route('business.supplier-management.free-shipping.update', ['supplier' => 9]),
['value' => '包邮'],
)->assertOk()->assertJson([
'ok' => true,
'value' => '包邮',
'display' => '包邮',
]);
}
public function test_free_shipping_import_matches_supplier_codes_and_saves_blank_as_false(): void
{
$repository = $this->repository(true);
$suppliers = [
(object) ['Id' => 9, 'scmcode' => 'N901', 'scmname' => '一泰'],
(object) ['Id' => 11, 'scmcode' => 'Y411', 'scmname' => '七彩'],
];
$repository->shouldReceive('suppliersByCodes')->once()->with(['N901', 'Y411'])->andReturn($suppliers);
$workbook = Mockery::mock(SupplierFreeShippingWorkbook::class);
$workbook->shouldReceive('read')->once()->andReturn(['N901' => true, 'Y411' => false]);
$this->app->instance(SupplierFreeShippingWorkbook::class, $workbook);
$this->freeShipping->shouldReceive('saveMany')->once()
->with([9 => true, 11 => false], Mockery::type('int'), Mockery::type('int'), 'import')
->andReturn(['created' => 2, 'updated' => 0]);
$this->loginAs()->post(route('business.supplier-management.free-shipping.import'), [
'import' => UploadedFile::fake()->create('free-shipping.xlsx', 10, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
])->assertRedirect()
->assertSessionHas('supplier_management_message', '导入完成:新增 2 家供应商维护记录,更新 0 家供应商维护记录。');
}
public function test_free_shipping_template_is_downloadable(): void
{
$this->repository(true);
$response = $this->loginAs()->get(route('business.supplier-management.free-shipping.template'))->assertOk();
$this->assertStringStartsWith('PK', $response->streamedContent());
}
public function test_unauthenticated_export_requires_login(): void
{
$this->withExceptionHandling()->get(route('business.supplier-management.export', ['company_id' => $this->company->id]))->assertRedirect();

View File

@ -139,6 +139,20 @@ class SupplierManagementMirrorRepositoryTest extends TestCase
$this->assertNull($this->repository->signingSupplier(999));
}
public function test_supplier_lookup_uses_supplier_codes_for_local_supplements(): void
{
$this->ready(SupplierManagementMirrorRepository::DATASET);
$this->table('js_supplier_management')->insert([
['Id' => 9, 'scmcode' => 'N901', 'scmname' => '一泰'],
['Id' => 11, 'scmcode' => 'Y411', 'scmname' => '七彩'],
]);
$rows = $this->repository->suppliersByCodes(['N901', 'Y411', 'UNKNOWN']);
$this->assertSame([9, 11], array_map(static fn (object $row): int => (int) $row->Id, $rows));
$this->assertSame('一泰', $this->repository->supplierById(9)->scmname);
$this->assertNull($this->repository->supplierById(999));
}
private function ready(string $key): void
{
$this->table('business_mirror_table_states')->insert(['dataset_key' => $key, 'initial_completed_at' => '2026-10-11 00:00:00']);