200 lines
7.7 KiB
PHP
200 lines
7.7 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Services;
|
||
|
||
use Illuminate\Support\Facades\DB;
|
||
use RuntimeException;
|
||
|
||
/**
|
||
* 动账明细写入器:全量替换 fin_transactions,刷新映射统计并重算流水。
|
||
*
|
||
* 同一笔业务在相同输入下生成稳定的 row_key;完全重复的行保留出现序号,
|
||
* 以保持规格书 12,037 笔的口径。
|
||
*/
|
||
class FinanceLedgerTransactionWriter
|
||
{
|
||
public const MANUAL_SOURCE = 'manual';
|
||
|
||
public const SHOP_WITHDRAWAL_SOURCE = 'shop_bill';
|
||
|
||
public function replace(array $transactions, bool $recalculate = true): array
|
||
{
|
||
return $this->persist($transactions, $recalculate, true);
|
||
}
|
||
|
||
/** 只更新/插入本次快照,不删除历史明细;用于每日增量。 */
|
||
public function merge(array $transactions, bool $recalculate = true): array
|
||
{
|
||
return $this->persist($transactions, $recalculate, false);
|
||
}
|
||
|
||
private function persist(array $transactions, bool $recalculate, bool $replaceExisting): array
|
||
{
|
||
if ($transactions === []) {
|
||
throw new RuntimeException('没有可写入的动账明细。');
|
||
}
|
||
|
||
$connection = $this->connection();
|
||
$now = now();
|
||
$rows = [];
|
||
$occurrences = [];
|
||
|
||
foreach ($transactions as $item) {
|
||
$date = $this->date($item['txn_date'] ?? null);
|
||
if ($date === null) {
|
||
continue;
|
||
}
|
||
|
||
$source = (string) ($item['source'] ?? '');
|
||
if ($source === '') {
|
||
throw new RuntimeException('动账明细缺少 source。');
|
||
}
|
||
|
||
$income = round((float) ($item['income'] ?? 0), 2);
|
||
$expense = round((float) ($item['expense'] ?? 0), 2);
|
||
$detail = mb_substr($this->normalizeText($item['detail'] ?? ''), 0, 4000);
|
||
$approvalNo = mb_substr(trim((string) ($item['approval_no'] ?? '')), 0, 100);
|
||
$sourceValue = mb_substr($this->normalizeText($item['source_value'] ?? ''), 0, 200);
|
||
$rawAccount = mb_substr($this->normalizeText($item['account_short'] ?? ''), 0, 100);
|
||
$processType = mb_substr(trim((string) ($item['process_type'] ?? '')), 0, 50);
|
||
$pid = trim((string) ($item['pid'] ?? ''));
|
||
$seq = trim((string) ($item['seq'] ?? ''));
|
||
$acctCode = trim((string) ($item['acct_code'] ?? ''));
|
||
$acctName = trim((string) ($item['acct_name'] ?? ''));
|
||
|
||
$canonical = implode("\0", [
|
||
$source, $processType, $pid, $seq, $approvalNo, $acctCode, $acctName,
|
||
number_format($income, 2, '.', ''), number_format($expense, 2, '.', ''), $detail,
|
||
]);
|
||
$occurrences[$canonical] = ($occurrences[$canonical] ?? 0) + 1;
|
||
|
||
$rows[] = [
|
||
'txn_date' => $date,
|
||
'account_short' => $rawAccount,
|
||
'detail' => $detail,
|
||
'income' => $income,
|
||
'expense' => $expense,
|
||
'approval_no' => $approvalNo,
|
||
'source' => $source,
|
||
'source_value' => $sourceValue,
|
||
'process_type' => $processType,
|
||
'row_key' => substr(hash('sha256', $canonical), 0, 56) . ':' . $occurrences[$canonical],
|
||
'synced_at' => $now,
|
||
'created_at' => $now,
|
||
'updated_at' => $now,
|
||
];
|
||
}
|
||
|
||
if ($rows === []) {
|
||
throw new RuntimeException('动账明细没有有效日期。');
|
||
}
|
||
|
||
$connection->transaction(function () use ($connection, $rows, $now, $replaceExisting): void {
|
||
if ($replaceExisting) {
|
||
$connection->table('fin_transactions')
|
||
->whereNotIn('source', [self::MANUAL_SOURCE, self::SHOP_WITHDRAWAL_SOURCE])
|
||
->delete();
|
||
}
|
||
foreach (array_chunk($rows, 500) as $chunk) {
|
||
if ($replaceExisting) {
|
||
$connection->table('fin_transactions')->insert($chunk);
|
||
} else {
|
||
$connection->table('fin_transactions')->upsert(
|
||
$chunk,
|
||
['row_key'],
|
||
['txn_date', 'account_short', 'detail', 'income', 'expense', 'approval_no',
|
||
'source', 'source_value', 'process_type', 'synced_at', 'updated_at'],
|
||
);
|
||
}
|
||
}
|
||
$this->refreshMappingStats($connection, $now, $replaceExisting);
|
||
});
|
||
|
||
$result = [
|
||
'rows' => count($rows),
|
||
'income' => round(array_sum(array_column($rows, 'income')), 2),
|
||
'expense' => round(array_sum(array_column($rows, 'expense')), 2),
|
||
'from' => min(array_column($rows, 'txn_date')),
|
||
'to' => max(array_column($rows, 'txn_date')),
|
||
];
|
||
|
||
if ($recalculate) {
|
||
$result['recalculated'] = app(FinanceLedgerCalculator::class)->recalculate();
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
private function refreshMappingStats($connection, $now, bool $pruneStale = true): void
|
||
{
|
||
$stats = $connection->table('fin_transactions')
|
||
->selectRaw('source, source_value, COUNT(*) AS txn_count, SUM(income) AS income_sum, SUM(expense) AS expense_sum')
|
||
->whereNotIn('source', [self::MANUAL_SOURCE, self::SHOP_WITHDRAWAL_SOURCE])
|
||
->whereNotNull('source_value')
|
||
->where('source_value', '<>', '')
|
||
->groupBy('source', 'source_value')
|
||
->get();
|
||
|
||
$rows = [];
|
||
$keys = [];
|
||
foreach ($stats as $stat) {
|
||
$source = (string) $stat->source;
|
||
$value = mb_substr((string) $stat->source_value, 0, 200);
|
||
$keys[$source . "\0" . $value] = true;
|
||
$rows[] = [
|
||
'source' => $source,
|
||
'source_value' => $value,
|
||
'txn_count' => (int) $stat->txn_count,
|
||
'income_sum' => round((float) $stat->income_sum, 2),
|
||
'expense_sum' => round((float) $stat->expense_sum, 2),
|
||
'created_at' => $now,
|
||
'updated_at' => $now,
|
||
];
|
||
}
|
||
|
||
// 上游删除或改写的来源值不能继续留在映射表里;只清理本次未出现的行,
|
||
// 保留已有 account_short,避免重同步覆盖人工映射。
|
||
if ($pruneStale) {
|
||
$stale = [];
|
||
foreach ($connection->table('fin_account_map')->get(['id', 'source', 'source_value']) as $existing) {
|
||
if (! isset($keys[(string) $existing->source . "\0" . (string) $existing->source_value])) {
|
||
$stale[] = (int) $existing->id;
|
||
}
|
||
}
|
||
foreach (array_chunk($stale, 500) as $ids) {
|
||
$connection->table('fin_account_map')->whereIn('id', $ids)->delete();
|
||
}
|
||
}
|
||
|
||
foreach (array_chunk($rows, 500) as $chunk) {
|
||
$connection->table('fin_account_map')->upsert(
|
||
$chunk,
|
||
['source', 'source_value'],
|
||
['txn_count', 'income_sum', 'expense_sum', 'updated_at'],
|
||
);
|
||
}
|
||
}
|
||
|
||
private function normalizeText(mixed $value): string
|
||
{
|
||
$text = trim((string) ($value ?? ''));
|
||
|
||
return preg_replace('/\s+/u', ' ', $text) ?? $text;
|
||
}
|
||
|
||
private function connection()
|
||
{
|
||
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||
}
|
||
|
||
private function date(mixed $value): ?string
|
||
{
|
||
$text = trim((string) ($value ?? ''));
|
||
if (preg_match('/^(\d{4})[-\/](\d{1,2})[-\/](\d{1,2})/', $text, $matches) !== 1) {
|
||
return null;
|
||
}
|
||
|
||
return sprintf('%04d-%02d-%02d', $matches[1], $matches[2], $matches[3]);
|
||
}
|
||
}
|