252 lines
9.0 KiB
PHP
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);
|
|
}
|
|
}
|