CaiWuXiTong/modules/EcommerceFinance/Services/FinanceLedgerTransactionWriter.php

200 lines
7.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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]);
}
}