CaiWuXiTong/app/Business/KukaPostingQuotation/KukaPostingQuotationImporter.php
chenxi c32dbe57b2 feat: 接入顾家报价单导入 (#1070)
feat: 接入顾家报价单导入 (#1070)
2026-11-11 13:20:51 +08:00

252 lines
9.0 KiB
PHP

<?php
namespace App\Business\KukaPostingQuotation;
use App\Business\XlsxSheetReader;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Schema;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
class KukaPostingQuotationImporter
{
private const INSERT_CHUNK = 500;
public function __construct(
private readonly DatabaseManager $databases,
private readonly XlsxSheetReader $reader,
) {
}
/**
* @return array{
* dry_run: bool,
* rows: int,
* material_count: int,
* status_counts: array<string, int>,
* file_sha256: string
* }
*/
public function import(
string $path,
?string $sheetName = null,
int $companyId = 1,
bool $dryRun = false,
): array {
if (! is_file($path) || ! is_readable($path)) {
throw new InvalidArgumentException('顾家报价单文件不存在或不可读。');
}
if (! Schema::connection($this->connectionName())->hasTable('kuka_posting_quotation_imports')
|| ! Schema::connection($this->connectionName())->hasTable('kuka_posting_quotation_rows')) {
throw new RuntimeException('顾家报价单表尚未建立,请先执行数据库迁移。');
}
$fields = (array) config('kuka_posting.quotation_fields', []);
$sourceColumns = array_values(array_filter(array_map(
static fn (array $field): string => trim((string) ($field['source'] ?? '')),
$fields,
)));
if ($fields === [] || count($sourceColumns) !== count($fields)) {
throw new RuntimeException('顾家报价单字段配置不完整。');
}
$keyField = (string) config('kuka_posting.quotation_key_field', 'MaterialNumber');
$statusField = (string) config('kuka_posting.quotation_status_field', 'RecordStatus');
if (! in_array($keyField, $sourceColumns, true) || ! in_array($statusField, $sourceColumns, true)) {
throw new RuntimeException('顾家报价单缺少物料编码或状态字段配置。');
}
$sheetName = trim((string) ($sheetName ?: config('kuka_posting.quotation_source_sheet', 'Sheet1')));
$fileHash = hash_file('sha256', $path);
if (! is_string($fileHash)) {
throw new RuntimeException('无法计算顾家报价单文件摘要。');
}
$connection = $this->connection();
$rows = [];
$materials = [];
$statusCounts = [];
$sourceRowNumber = 1;
$now = now();
foreach ($this->reader->rows($path, $sourceColumns, [], $sheetName) as $sourceValues) {
$sourceRowNumber++;
// 前两行分别是系统字段名和中文表名,真实报价记录从第 3 行开始。
if ($sourceRowNumber < 3) {
continue;
}
if (count(array_filter(
$sourceValues,
static fn (mixed $value): bool => trim((string) $value) !== '',
)) === 0) {
continue;
}
$payload = [];
foreach ($fields as $field) {
$source = (string) $field['source'];
$value = trim((string) ($sourceValues[$source] ?? ''));
if (($field['kind'] ?? null) === 'date' && $value !== '') {
$value = $this->normalizeDate($value);
}
$payload[(string) $field['name']] = $value;
}
$materialNumber = trim((string) ($payload['material_number'] ?? ''));
if ($materialNumber === '') {
throw new RuntimeException('顾家报价单第 ' . $sourceRowNumber . ' 行缺少物料编码。');
}
if (isset($materials[$materialNumber])) {
throw new RuntimeException('顾家报价单存在重复物料编码:' . $materialNumber);
}
$materials[$materialNumber] = true;
$status = trim((string) ($payload['record_status'] ?? ''));
if ($status !== '') {
$statusCounts[$status] = ($statusCounts[$status] ?? 0) + 1;
}
$rows[] = [
'company_id' => $companyId,
'material_number' => $materialNumber,
'material_desc' => $this->limit((string) ($payload['material_desc'] ?? ''), 500),
'supplier_account' => $this->limit((string) ($payload['supplier_account'] ?? ''), 191),
'supplier_name' => $this->limit((string) ($payload['supplier_name'] ?? ''), 500),
'record_status' => $this->limit($status, 64),
'price_including_tax' => $this->decimal((string) ($payload['price_including_tax'] ?? '')),
'price_excluding_tax' => $this->decimal((string) ($payload['price_excluding_tax'] ?? '')),
'price_valid_date' => $this->nullableValue((string) ($payload['price_valid_date'] ?? '')),
'price_expiry_date' => $this->nullableValue((string) ($payload['price_expiry_date'] ?? '')),
'row_hash' => hash(
'sha256',
json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
),
'source_values' => json_encode(
$payload,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR,
),
'source_row_number' => $sourceRowNumber,
'imported_at' => $now,
];
}
if ($rows === []) {
throw new RuntimeException('顾家报价单中没有可导入的报价记录。');
}
ksort($statusCounts);
$write = function () use (
$connection,
$companyId,
&$rows,
$statusCounts,
$sheetName,
$path,
$fileHash,
$now,
$dryRun,
): void {
if ($dryRun) {
return;
}
$connection->table('kuka_posting_quotation_rows')
->where('company_id', $companyId)
->delete();
foreach (array_chunk($rows, self::INSERT_CHUNK) as $chunk) {
$connection->table('kuka_posting_quotation_rows')->insert($chunk);
}
$connection->table('kuka_posting_quotation_imports')->upsert(
[[
'company_id' => $companyId,
'row_count' => count($rows),
'material_count' => count($rows),
'status_counts' => json_encode($statusCounts, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'file_name' => basename($path),
'file_sha256' => strtolower($fileHash),
'source_sheet' => $sheetName,
'completed_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]],
['company_id'],
[
'row_count',
'material_count',
'status_counts',
'file_name',
'file_sha256',
'source_sheet',
'completed_at',
'updated_at',
],
);
};
if ($dryRun) {
$write();
} else {
$connection->transaction($write);
}
return [
'dry_run' => $dryRun,
'rows' => count($rows),
'material_count' => count($materials),
'status_counts' => $statusCounts,
'file_sha256' => strtolower($fileHash),
];
}
private function connection(): ConnectionInterface
{
return $this->databases->connection($this->connectionName());
}
private function connectionName(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
private function normalizeDate(string $value): string
{
if (is_numeric($value) && (float) $value > 20000 && (float) $value < 90000) {
return gmdate('Y-m-d H:i:s', (int) round(((float) $value - 25569) * 86400));
}
try {
return CarbonImmutable::parse($value, 'Asia/Shanghai')->format('Y-m-d H:i:s');
} catch (Throwable) {
throw new InvalidArgumentException('顾家报价单日期格式无法识别:' . $value);
}
}
private function decimal(string $value): ?string
{
$value = str_replace(',', '', trim($value));
return $value !== '' && is_numeric($value) ? $value : null;
}
private function nullableValue(string $value): ?string
{
return $value === '' ? null : $value;
}
private function limit(string $value, int $length): ?string
{
$value = trim($value);
return $value === '' ? null : mb_substr($value, 0, $length);
}
}