CaiWuXiTong/app/Business/KukaPostingBase/KukaPostingBaseImporter.php
chenxi bde661a119 feat: 接入顾家过账基础数据 (#1049)
feat: 接入顾家过账基础数据 (#1049)
2026-11-10 17:08:41 +08:00

277 lines
9.6 KiB
PHP

<?php
namespace App\Business\KukaPostingBase;
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 KukaPostingBaseImporter
{
private const INSERT_CHUNK = 500;
public function __construct(
private readonly DatabaseManager $databases,
private readonly XlsxSheetReader $reader,
) {
}
/**
* @return array{
* period: string,
* dry_run: bool,
* rows: int,
* stores: array<string, int>,
* store_count: int,
* order_count: int,
* skipped_store_rows: int,
* file_sha256: string
* }
*/
public function import(
string $path,
string $period,
?string $sheetName = null,
int $companyId = 1,
bool $dryRun = false,
): array {
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $period) !== 1) {
throw new InvalidArgumentException('账期格式必须为 YYYY-MM。');
}
if (! is_file($path) || ! is_readable($path)) {
throw new InvalidArgumentException('聚水潭导出文件不存在或不可读。');
}
$connection = $this->connection();
if (! Schema::connection($this->connectionName())->hasTable('kuka_posting_base_imports')
|| ! Schema::connection($this->connectionName())->hasTable('kuka_posting_base_rows')) {
throw new RuntimeException('顾家过账基础数据表尚未建立,请先执行数据库迁移。');
}
$fields = (array) config('kuka_posting.fields', []);
$allowedStores = array_values((array) config('kuka_posting.stores', []));
if ($fields === [] || $allowedStores === []) {
throw new RuntimeException('顾家过账基础数据字段或店铺范围未配置。');
}
$sourceColumns = array_values(array_filter(array_map(
static fn (array $field): string => trim((string) ($field['source'] ?? '')),
$fields,
)));
if (count($sourceColumns) !== count($fields)) {
throw new RuntimeException('顾家过账基础数据字段配置不完整。');
}
$sheetName = trim((string) ($sheetName ?: config('kuka_posting.source_sheet', '发货明细表')));
$fileHash = hash_file('sha256', $path);
if (! is_string($fileHash)) {
throw new RuntimeException('无法计算聚水潭导出文件摘要。');
}
$storeCounts = array_fill_keys($allowedStores, 0);
$orders = [];
$rowCount = 0;
$skippedStoreRows = 0;
$sourceRowNumber = 1;
$now = now();
$write = function () use (
$connection,
$path,
$period,
$sheetName,
$companyId,
$fields,
$sourceColumns,
$allowedStores,
&$storeCounts,
&$orders,
&$rowCount,
&$skippedStoreRows,
&$sourceRowNumber,
$fileHash,
$now,
$dryRun,
): void {
if (! $dryRun) {
$connection->table('kuka_posting_base_rows')
->where('company_id', $companyId)
->where('period', $period)
->delete();
}
$batch = [];
$flush = static function () use (&$batch, $connection): void {
if ($batch === []) {
return;
}
$connection->table('kuka_posting_base_rows')->insert($batch);
$batch = [];
};
foreach ($this->reader->rows($path, $sourceColumns, [], $sheetName) as $sourceValues) {
$sourceRowNumber++;
$storeName = trim((string) ($sourceValues['店铺'] ?? ''));
if ($storeName === '' || ! in_array($storeName, $allowedStores, true)) {
$skippedStoreRows++;
continue;
}
$payload = [];
foreach ($fields as $field) {
$source = (string) $field['source'];
$value = trim((string) ($sourceValues[$source] ?? ''));
if (($field['kind'] ?? null) === 'date') {
$value = $this->normalizeDate($value);
}
$payload[$source] = $value;
}
$rowCount++;
$storeCounts[$storeName]++;
$internalOrderNo = trim((string) ($payload['内部订单号'] ?? ''));
if ($internalOrderNo !== '') {
$orders[$internalOrderNo] = true;
}
if ($dryRun) {
continue;
}
$batch[] = [
'company_id' => $companyId,
'period' => $period,
'store_name' => $storeName,
'internal_order_no' => $this->limit($internalOrderNo, 191),
'online_order_no' => $this->limit((string) ($payload['线上订单号'] ?? ''), 191),
'product_code' => $this->limit((string) ($payload['商品编码'] ?? ''), 191),
'order_status' => $this->limit((string) ($payload['订单状态'] ?? ''), 64),
'order_date' => $this->nullableValue($payload['订单日期'] ?? ''),
'send_date' => $this->nullableValue($payload['发货日期'] ?? ''),
'pay_date' => $this->nullableValue($payload['付款日期'] ?? ''),
'confirm_date' => $this->nullableValue($payload['确认收货日期'] ?? ''),
'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 (count($batch) >= self::INSERT_CHUNK) {
$flush();
}
}
$flush();
if ($rowCount === 0) {
throw new RuntimeException('导出文件中没有指定店铺的数据,已保留原有基础数据。');
}
$missingStores = array_keys(array_filter(
$storeCounts,
static fn (int $count): bool => $count === 0,
));
if ($missingStores !== []) {
throw new RuntimeException('导出文件缺少指定店铺:' . implode('、', $missingStores));
}
if ($dryRun) {
return;
}
$import = [
'company_id' => $companyId,
'period' => $period,
'row_count' => $rowCount,
'store_count' => count(array_filter($storeCounts)),
'order_count' => count($orders),
'store_counts' => json_encode($storeCounts, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'file_name' => basename($path),
'file_sha256' => $fileHash,
'source_sheet' => $sheetName,
'completed_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
$connection->table('kuka_posting_base_imports')->upsert(
[$import],
['company_id', 'period'],
[
'row_count',
'store_count',
'order_count',
'store_counts',
'file_name',
'file_sha256',
'source_sheet',
'completed_at',
'updated_at',
],
);
};
if ($dryRun) {
$write();
} else {
$connection->transaction($write);
}
return [
'period' => $period,
'dry_run' => $dryRun,
'rows' => $rowCount,
'stores' => $storeCounts,
'store_count' => count(array_filter($storeCounts)),
'order_count' => count($orders),
'skipped_store_rows' => $skippedStoreRows,
'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 ($value === '') {
return '';
}
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 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);
}
}