CaiWuXiTong/modules/EcommerceFinance/Services/SupplierStatementTemplate.php
renshen 3b4db0e273 供应商对账:单价/金额留空自动补齐 (#367)
单价留空按金额÷数量补齐、金额留空按数量×单价补齐、都空按 0(数量必填),未定价行厂家可直接上传。
2026-10-17 09:16:51 +08:00

220 lines
8.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
use Throwable;
/**
* 厂家对账单模板:列头、留空规则与严格校验。
* 按业务要求:不按模板形式上传的一律整份失败(表头列名、顺序、列数必须完全一致)。
*/
class SupplierStatementTemplate
{
/** 模板列(顺序固定):前 3 列允许留空,其余必填。 */
public const COLUMNS = [
'expressName' => '快递名称',
'expressNo' => '快递单号',
'purchaseNo' => '单据编号(采购单号)',
'createdAt' => '创建时间',
'supplier' => '供应商',
'barcode' => '条码',
'skuName' => '规格名称',
'qty' => '数量',
'unitPrice' => '单价',
'amount' => '金额',
];
private const MAX_ROWS = 200000;
/** 允许留空的列:快递信息可缺;单价/金额留空时按「金额÷数量」或「数量×单价」补齐,都空按 0。 */
private const OPTIONAL_COLUMNS = ['expressName', 'expressNo', 'purchaseNo', 'unitPrice', 'amount'];
/** 必须是数字的列。 */
private const NUMERIC_COLUMNS = ['qty', 'unitPrice', 'amount'];
public function path(): string
{
return resource_path('templates/supplier-reconciliation/statement-template.xlsx');
}
public function filename(): string
{
return '供应商对账单模板.xlsx';
}
/**
* 解析并严格校验厂家上传的对账单。
*
* @return list<array<string,mixed>> 模块内部键的记录(条码/单价/数量/金额等)
*/
public function parse(UploadedFile $file, ?string $expectedSupplier = null): array
{
$extension = strtolower((string) $file->getClientOriginalExtension());
if (! in_array($extension, ['xlsx', 'xls', 'csv'], true)) {
throw ValidationException::withMessages(['import' => '只能上传 Excel(xlsx / xls)或 CSV 文件。']);
}
try {
$reader = PayablesWorkbookStreamReader::forFile((string) $file->getRealPath(), $extension);
$header = $this->locateHeader($reader);
} catch (ValidationException $exception) {
throw $exception;
} catch (Throwable) {
throw ValidationException::withMessages(['import' => '无法读取文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。']);
}
$records = [];
$fields = array_keys(self::COLUMNS);
foreach ($reader->rows($header['sheet'], $header['row']) as $rowNumber => $cells) {
$values = [];
foreach ($fields as $index => $field) {
$value = trim((string) ($cells[$index] ?? ''));
$values[$field] = $value === '' ? null : $value;
}
if (collect($values)->every(static fn (mixed $value): bool => $value === null)) {
continue;
}
foreach (self::COLUMNS as $field => $label) {
if ($values[$field] === null) {
if (in_array($field, self::OPTIONAL_COLUMNS, true)) {
continue;
}
throw ValidationException::withMessages(['import' => "第 {$rowNumber} 行:「{$label}」不能为空。"]);
}
if (in_array($field, self::NUMERIC_COLUMNS, true)) {
$normalized = str_replace([' ', ',', ','], '', (string) $values[$field]);
if (preg_match('/^-?\d+(?:\.\d+)?$/', $normalized) !== 1) {
throw ValidationException::withMessages(['import' => "第 {$rowNumber} 行:「{$label}」必须是数字。"]);
}
$values[$field] = $normalized;
}
}
$values = $this->completeAmounts($values);
if ($expectedSupplier !== null && $values['supplier'] !== $expectedSupplier) {
throw ValidationException::withMessages([
'import' => "第 {$rowNumber} 行:供应商必须填写「{$expectedSupplier}」,不能提交其他厂家的数据。",
]);
}
$records[] = $values;
if (count($records) > self::MAX_ROWS) {
throw ValidationException::withMessages(['import' => '单次最多上传 200,000 行,请拆分文件后重试。']);
}
}
if ($records === []) {
throw ValidationException::withMessages(['import' => '表格里没有数据行,请按模板填写后重新上传。']);
}
return $records;
}
/**
* 找出模板表头:列名、顺序、起始列必须与模板完全一致。
*
* @return array{sheet:int,row:int}
*/
private function locateHeader(PayablesWorkbookStreamReader $reader): array
{
$expected = array_values(self::COLUMNS);
$expectedNormalized = array_map($this->normalizeLabel(...), $expected);
$bestLabels = null;
$bestScore = -1;
foreach (array_keys($reader->sheetNames()) as $sheetIndex) {
foreach ($reader->rows($sheetIndex, null, 20) as $rowNumber => $row) {
$labels = [];
foreach ($row as $columnIndex => $value) {
$label = $this->normalizeLabel((string) $value);
if ($label !== '') {
$labels[$columnIndex] = $label;
}
}
ksort($labels);
if (array_values($labels) === $expectedNormalized && array_keys($labels) === range(0, count($expected) - 1)) {
return ['sheet' => $sheetIndex, 'row' => $rowNumber];
}
$score = count(array_intersect(array_values($labels), $expectedNormalized));
if ($score > $bestScore) {
$bestScore = $score;
$bestLabels = array_values($labels);
}
}
}
throw ValidationException::withMessages([
'import' => '表头与模板不一致:' . $this->describeHeader($bestLabels, $expectedNormalized) . '请下载最新模板填写,不要增删或修改列(列名、顺序必须与模板完全相同)。',
]);
}
/** @param list<string>|null $actual 标准化后的实际表头;@param list<string> $expected 标准化后的模板表头 */
private function describeHeader(?array $actual, array $expected): string
{
if ($actual === null) {
return '';
}
$display = [];
foreach (self::COLUMNS as $label) {
$display[$this->normalizeLabel($label)] = $label;
}
$missing = array_map(static fn (string $key): string => $display[$key] ?? $key, array_values(array_diff($expected, $actual)));
$extra = array_map(static fn (string $key): string => $display[$key] ?? $key, array_values(array_diff($actual, $expected)));
$parts = [];
if ($missing !== []) {
$parts[] = '缺少「' . implode('」「', $missing) . '」';
}
if ($extra !== []) {
$parts[] = '多出「' . implode('」「', $extra) . '」';
}
$commonActual = array_values(array_filter($actual, static fn (string $key): bool => in_array($key, $expected, true)));
$commonExpected = array_values(array_filter($expected, static fn (string $key): bool => in_array($key, $actual, true)));
if ($commonActual !== $commonExpected) {
$parts[] = '列顺序与模板不同';
}
return $parts === [] ? '' : implode(',', $parts) . ';';
}
/** 比较表头时忽略空白与全半角括号差异。 */
private function normalizeLabel(string $label): string
{
$label = preg_replace('/\s+/u', '', trim($label)) ?? '';
$label = str_replace(['(', ')'], ['(', ')'], $label);
return mb_strtolower($label);
}
/** 单价/金额留空时互相补算;两者都空按 0(未定价行厂家直接导出时会出现)。 */
private function completeAmounts(array $values): array
{
$qty = (float) ($values['qty'] ?? 0);
$unitPrice = $values['unitPrice'] === null ? null : (float) $values['unitPrice'];
$amount = $values['amount'] === null ? null : (float) $values['amount'];
if ($unitPrice === null && $amount !== null && $qty != 0.0) {
$values['unitPrice'] = (string) round($amount / $qty, 4);
} elseif ($unitPrice === null) {
$values['unitPrice'] = '0';
}
if ($amount === null) {
$values['amount'] = (string) round($qty * (float) $values['unitPrice'], 2);
}
return $values;
}
}