CaiWuXiTong/modules/EcommerceFinance/Services/DouyinAuthoritativeBillImporter.php

136 lines
7.9 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
/** Imports a reconciled month-to-date range without changing upstream bills. */
class DouyinAuthoritativeBillImporter
{
public function __construct(private readonly DatabaseManager $databases) {}
public function import(string $path, string $month, int $expectedCount, string $expectedIncome, string $expectedExpense, ?string $through = null): array
{
if (! preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) || $expectedCount < 1) {
throw new InvalidArgumentException('必须指定完整月份和核对后的笔数。');
}
$start = $month . '-01';
$end = Carbon::parse($start)->addMonth()->format('Y-m-d');
if ($through !== null) {
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $through)
|| ! checkdate((int) substr($through, 5, 2), (int) substr($through, 8, 2), (int) substr($through, 0, 4))
|| $through < $start || $through >= $end) {
throw new InvalidArgumentException('截止日期必须是指定月份内的有效日期。');
}
$end = Carbon::parse($through)->addDay()->format('Y-m-d');
}
$expectedIncomeCents = $this->cents($expectedIncome);
$expectedExpenseCents = $this->cents($expectedExpense);
$file = fopen($path, 'rb');
if ($file === false) throw new RuntimeException('无法读取胜算明细文件。');
$db = $this->databases->connection(config('upstream_mirrors.connection', 'business_mirror'));
$generation = (string) Str::uuid();
$count = $income = $expense = 0;
$seen = [];
$batch = [];
try {
$headers = fgetcsv($file);
if (! is_array($headers)) throw new InvalidArgumentException('胜算明细为空。');
$headers[0] = preg_replace('/^\xEF\xBB\xBF/', '', $headers[0]);
foreach (['记录编码', '店铺', '商户主体名称', '账单项目', '动账流水号', '订单号', '结算时间', '收入', '支出'] as $header) {
if (! in_array($header, $headers, true)) throw new InvalidArgumentException('缺少胜算原始明细字段:' . $header);
}
while (($values = fgetcsv($file)) !== false) {
if ($values === [null]) continue;
if (count($values) !== count($headers)) throw new InvalidArgumentException('胜算明细列数不一致。');
$r = array_combine($headers, $values);
$code = trim($r['记录编码']);
if (! preg_match('/^\d{1,64}$/', $code) || isset($seen[$code])) throw new InvalidArgumentException('记录编码为空、失真或重复。');
$seen[$code] = true;
$time = $this->dateTime($r['结算时间']);
if ($time === null || $time < $start || $time >= $end || trim($r['店铺']) === '') {
throw new InvalidArgumentException('明细月份或店铺不符合导入范围。');
}
$in = $this->cents($r['收入']);
$out = $this->cents($r['支出']);
$payload = [];
foreach ([
'merchant_name' => '商户主体名称', 'move_serial_no' => '动账流水号', 'order_no_2' => '订单号',
'online_order_no' => '线上单号', 'sub_order_no' => '子订单号', 'goods_id' => '商品ID',
'goods_name' => '商品名称', 'account_type' => '账务类型', 'move_summary' => '动账摘要',
'business_type' => '业务类型', 'settle_account' => '结算账户', 'move_scene' => '动账场景',
'talent_id' => '达人ID', 'talent_name' => '达人名称',
] as $field => $label) $payload[$field] = ($r[$label] ?? '') !== '' ? $r[$label] : null;
$payload['order_placed_at'] = $this->dateTime($r['下单时间'] ?? '');
$row = [
'id' => 'shengsuan:' . $code, 'record_code' => $code, 'store_name' => trim($r['店铺']),
'finance_remark' => null, 'order_no' => $r['订单号'], 'amount' => ($in ?: $out) / 100,
'custom_remark' => $r['自定义备注'] ?? null, 'bill_item' => $r['账单项目'],
'occurred_at' => $time, 'income' => $in / 100, 'expense' => $out / 100,
'balance' => ($in - $out) / 100, 'remark' => $r['备注'] ?? null,
'modifier' => $r['修改人'] ?? null, 'created_at' => $this->dateTime($r['创建时间'] ?? ''),
'updated_at' => $this->dateTime($r['修改时间'] ?? ''), 'payload' => $payload,
];
$batch[] = [
'generation' => $generation, 'record_code' => $code, 'store_name' => $row['store_name'],
'order_no' => $row['order_no'], 'bill_item' => $row['bill_item'], 'occurred_at' => $time,
'income' => $this->decimal($in), 'expense' => $this->decimal($out), 'balance' => $this->decimal($in - $out),
'row_data' => json_encode($row, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
];
$count++; $income += $in; $expense += $out;
if (count($batch) === 500) {
$db->table('douyin_authoritative_bills')->insert($batch);
$batch = [];
}
}
if ($count !== $expectedCount || $income !== $expectedIncomeCents || $expense !== $expectedExpenseCents) {
throw new InvalidArgumentException('明细笔数或收支合计与核对值不一致,未启用。');
}
if ($batch !== []) $db->table('douyin_authoritative_bills')->insert($batch);
// Publish only a fully validated generation; retain previous generations for rollback.
$state = [
'period_start' => $start, 'period_end' => $end, 'active_generation' => $generation,
'source_filename' => basename($path), 'source_sha256' => hash_file('sha256', $path),
'row_count' => $count, 'income' => $this->decimal($income), 'expense' => $this->decimal($expense),
'imported_at' => now()->format('Y-m-d H:i:s'),
];
$db->transaction(function () use ($db, $start, $end, $state): void {
$previous = $db->table('douyin_authoritative_periods')->where('period_start', $start)->lockForUpdate()->first();
if ($previous !== null && $previous->period_end > $end) {
throw new InvalidArgumentException('截止日期早于已校准范围,请导出包含已校准日期的完整结果。');
}
$db->table('douyin_authoritative_periods')->updateOrInsert(['period_start' => $start], $state);
});
return $state;
} catch (Throwable $e) {
$db->table('douyin_authoritative_bills')->where('generation', $generation)->delete();
throw $e;
} finally {
fclose($file);
}
}
private function cents(string $value): int
{
if (! preg_match('/^\d{1,14}(?:\.\d{1,2})?$/', trim($value))) throw new InvalidArgumentException('收入或支出不是有效的非负金额。');
[$whole, $fraction] = array_pad(explode('.', trim($value), 2), 2, '');
return ((int) $whole) * 100 + (int) str_pad($fraction, 2, '0');
}
private function decimal(int $cents): string
{
return ($cents < 0 ? '-' : '') . intdiv(abs($cents), 100) . '.' . str_pad((string) (abs($cents) % 100), 2, '0', STR_PAD_LEFT);
}
private function dateTime(string $value): ?string
{
if (trim($value) === '') return null;
return Carbon::parse($value)->format('Y-m-d H:i:s');
}
}