CaiWuXiTong/modules/EcommerceFinance/Services/FinanceLedgerSnapshotImporter.php

189 lines
6.6 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;
/**
* 将已核对的财务台账快照(ledger_raw.json)导入 fin_transactions。
*
* 该入口用于本地开发和生产首次建账;日常刷新由 rpt 同步命令负责。
* 快照本身不进入 Git,导入前必须确认文件摘要和行数。
*/
class FinanceLedgerSnapshotImporter
{
private const SOURCES = [
'日常费用报销流程' => 'dingtalk',
'种菜报销流程' => 'dingtalk',
'应付申请流程' => 'dingtalk',
'快递面单应付流程' => 'dingtalk',
'线上代发业务报销' => 'dingtalk',
'固定资产报废或出售流程' => 'dingtalk',
'聚工单查账流程' => 'workorder',
'供应商应付' => 'payable',
'店铺开店费用' => 'store',
'店铺提现' => 'shop',
];
public function import(string $path, bool $recalculate = true): array
{
if (! is_file($path)) {
throw new RuntimeException('台账快照文件不存在:' . $path);
}
$payload = json_decode((string) file_get_contents($path), true);
if (! is_array($payload) || $payload === []) {
throw new RuntimeException('台账快照不是有效的 JSON 数组。');
}
$connection = $this->connection();
$now = now();
$rows = [];
$occurrences = [];
foreach ($payload as $index => $item) {
if (! is_array($item)) {
continue;
}
$process = trim((string) ($item['process'] ?? ''));
$source = self::SOURCES[$process] ?? null;
if ($source === null) {
throw new RuntimeException('未知的台账流程:' . $process);
}
$date = $this->date($item['date'] ?? null);
if ($date === null) {
continue;
}
$approvalNo = $this->text($item['process_no'] ?? '');
$rawAccount = $this->text($item['account_short'] ?? '');
$accountName = $this->text($item['acct_name'] ?? '');
$sourceValue = $source === 'dingtalk' ? $accountName : $rawAccount;
$income = round($this->number($item['income'] ?? 0), 2);
$expense = round($this->number($item['expense'] ?? 0), 2);
$detail = $this->text($item['detail'] ?? '', 4000);
$canonical = implode("\0", [
$source,
$this->text($item['pid'] ?? ''),
$this->text($item['seq'] ?? ''),
$approvalNo,
$this->text($item['acct_code'] ?? ''),
$accountName,
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' => $process,
'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): void {
$connection->table('fin_transactions')->delete();
foreach (array_chunk($rows, 500) as $chunk) {
$connection->table('fin_transactions')->insert($chunk);
}
$this->refreshMappingStats($connection, $now);
});
$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): void
{
$stats = $connection->table('fin_transactions')
->selectRaw('source, source_value, COUNT(*) AS txn_count, SUM(income) AS income_sum, SUM(expense) AS expense_sum')
->whereNotNull('source_value')
->where('source_value', '<>', '')
->groupBy('source', 'source_value')
->get();
$rows = [];
foreach ($stats as $stat) {
$rows[] = [
'source' => (string) $stat->source,
'source_value' => mb_substr((string) $stat->source_value, 0, 200),
'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,
];
}
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 connection()
{
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
}
private function text(mixed $value, int $max = 200): string
{
$text = trim((string) ($value ?? ''));
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
return mb_substr($text, 0, $max);
}
private function number(mixed $value): float
{
if (is_int($value) || is_float($value)) {
return (float) $value;
}
$text = str_replace([',', ',', '¥', '¥', ' '], '', trim((string) ($value ?? '')));
return is_numeric($text) ? (float) $text : 0.0;
}
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]);
}
}