- 新增配置表 business_supplier_bookkeeping_configs(+ 操作日志表),迁移时导入财务提供的 209 家存量配置 - 记账方式:正常记账 / 仅代发仓发错货记账 / 不记账,支持生效期;配置页支持新增、编辑、删除恢复、Excel 导入导出 - 扣款判定:厂家记账按供应商配置,仅正常记账扣款;仓库记账仅订单发货仓库为代发仓且二级原因含“仓库发错货”时作为厂家扣款项,其余不扣 - 未配置/生效期外显示待配置,多供应商判定不一致显示待确认,均不计入扣款金额 - 明细页新增厂家扣款判定筛选、扣款工单数与扣款金额指标、导出列 涉及:数据库迁移(新建配置表并写入存量配置)、金额汇总展示;不涉及上游写入和权限模型。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JouFR6TJ7vGscRJanYScEo
122 lines
6.0 KiB
PHP
122 lines
6.0 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Services;
|
||
|
||
use Illuminate\Validation\ValidationException;
|
||
use InvalidArgumentException;
|
||
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule;
|
||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||
|
||
/**
|
||
* 读取厂家记账配置 Excel:
|
||
* - 财务原表:供应商、厂家是否记账(原文如“代发仓发货错需记账,其他原因均不记账”“不记账(2025.3月-2027.12.30)”);
|
||
* - 本系统导出表:供应商、记账方式、生效开始日期、生效结束日期、原始说明。
|
||
*/
|
||
class SupplierBookkeepingConfigImporter
|
||
{
|
||
/** @return list<array{supplier_name:string,mode:string,effective_from:?string,effective_to:?string,source_text:string}> */
|
||
public function read(string $path): array
|
||
{
|
||
$reader = IOFactory::createReader('Xlsx');
|
||
$info = $reader->listWorksheetInfo($path);
|
||
if ($info === [] || $info[0]['totalRows'] > 5001 || $info[0]['totalColumns'] > 20) {
|
||
throw ValidationException::withMessages(['file' => '请使用第一个工作表存放配置,最多5000条。']);
|
||
}
|
||
$reader->setLoadSheetsOnly($info[0]['worksheetName']);
|
||
$book = $reader->load($path);
|
||
try {
|
||
$sheet = $book->getSheet(0);
|
||
$headers = [];
|
||
for ($column = 1; $column <= $info[0]['totalColumns']; $column++) {
|
||
$headers[$this->label($sheet->getCell([$column, 1])->getValue())] = $column;
|
||
}
|
||
$supplierColumn = $headers['供应商'] ?? $headers['供应商名称'] ?? null;
|
||
$ruleColumn = $headers['厂家是否记账'] ?? null;
|
||
$modeColumn = $headers['记账方式'] ?? null;
|
||
if ($supplierColumn === null || ($ruleColumn === null && $modeColumn === null)) {
|
||
throw ValidationException::withMessages(['file' => '缺少列:需要“供应商”和“厂家是否记账”(或“记账方式”)。']);
|
||
}
|
||
|
||
$rows = [];
|
||
$seen = [];
|
||
for ($number = 2; $number <= $sheet->getHighestDataRow(); $number++) {
|
||
$supplier = $this->text($sheet, $supplierColumn, $number);
|
||
$ruleText = $ruleColumn !== null ? $this->text($sheet, $ruleColumn, $number) : '';
|
||
$modeText = $modeColumn !== null ? $this->text($sheet, $modeColumn, $number) : '';
|
||
// 原表中间重复出现的表头行、空行跳过。
|
||
if (($supplier === '' && $ruleText === '' && $modeText === '') || $this->label($supplier) === '供应商') {
|
||
continue;
|
||
}
|
||
if ($supplier === '' || mb_strlen($supplier) > 191) {
|
||
throw ValidationException::withMessages(['file' => "第{$number}行供应商为空或超长。"]);
|
||
}
|
||
$key = mb_strtolower($supplier);
|
||
if (isset($seen[$key])) {
|
||
throw ValidationException::withMessages(['file' => "第{$number}行供应商“{$supplier}”与第{$seen[$key]}行重复。"]);
|
||
}
|
||
$seen[$key] = $number;
|
||
|
||
try {
|
||
$parsed = Rule::parseRuleText($ruleText !== '' ? $ruleText : $modeText);
|
||
} catch (InvalidArgumentException $exception) {
|
||
throw ValidationException::withMessages(['file' => "第{$number}行:" . $exception->getMessage()]);
|
||
}
|
||
foreach (['effective_from' => '生效开始日期', 'effective_to' => '生效结束日期'] as $field => $label) {
|
||
if (isset($headers[$label])) {
|
||
$date = $this->date($sheet, $headers[$label], $number);
|
||
if ($date !== null) {
|
||
$parsed[$field] = $date;
|
||
}
|
||
}
|
||
}
|
||
if ($parsed['effective_from'] !== null && $parsed['effective_to'] !== null && $parsed['effective_from'] > $parsed['effective_to']) {
|
||
throw ValidationException::withMessages(['file' => "第{$number}行生效结束日期早于开始日期。"]);
|
||
}
|
||
$source = isset($headers['原始说明']) ? $this->text($sheet, $headers['原始说明'], $number) : '';
|
||
$rows[] = ['supplier_name' => $supplier] + $parsed + ['source_text' => $ruleText !== '' ? $ruleText : ($source !== '' ? $source : $modeText)];
|
||
}
|
||
if ($rows === []) {
|
||
throw ValidationException::withMessages(['file' => '配置表没有有效记录。']);
|
||
}
|
||
|
||
return $rows;
|
||
} finally {
|
||
$book->disconnectWorksheets();
|
||
}
|
||
}
|
||
|
||
private function label(mixed $value): string
|
||
{
|
||
return str_replace([' ', ' ', "\n"], '', trim((string) $value));
|
||
}
|
||
|
||
private function text($sheet, int $column, int $row): string
|
||
{
|
||
$cell = $sheet->getCell([$column, $row]);
|
||
if ($cell->getDataType() === 'f') {
|
||
throw ValidationException::withMessages(['file' => "第{$row}行含公式,请使用实际配置值。"]);
|
||
}
|
||
|
||
return trim((string) $cell->getFormattedValue());
|
||
}
|
||
|
||
private function date($sheet, int $column, int $row): ?string
|
||
{
|
||
$cell = $sheet->getCell([$column, $row]);
|
||
$value = $cell->getValue();
|
||
if ($value === null || trim((string) $value) === '') {
|
||
return null;
|
||
}
|
||
if (is_numeric($value) && ExcelDate::isDateTime($cell)) {
|
||
return ExcelDate::excelToDateTimeObject($value)->format('Y-m-d');
|
||
}
|
||
$text = str_replace(['/', '.'], '-', trim((string) $cell->getFormattedValue()));
|
||
if (! preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $text, $m) || ! checkdate((int) $m[2], (int) $m[3], (int) $m[1])) {
|
||
throw ValidationException::withMessages(['file' => "第{$row}行日期无效,请使用 2026-01-31 格式。"]);
|
||
}
|
||
|
||
return sprintf('%04d-%02d-%02d', $m[1], $m[2], $m[3]);
|
||
}
|
||
}
|