189 lines
6.6 KiB
PHP
189 lines
6.6 KiB
PHP
<?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]);
|
||
}
|
||
}
|