CaiWuXiTong/modules/EcommerceFinance/Services/AccountMappingStore.php

701 lines
24 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\File;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Carbon;
use RuntimeException;
use Throwable;
/**
* 科目映射表存储(共享数据库)。
*
* 一条规则 = 「走哪两个科目(科目对)」+「方向基准」,**不存最终借贷方向**。
* 记账时按这张账单在平台上的实际收支代入:
* 账单收支 === 基准方向 → 照抄;否则 → 借贷互换。
*
* 规则存共享系统数据库(默认连接)的 ec_account_mapping_rules,操作记录存
* ec_account_mapping_events:本地与生产读同一份,改完即对两边同时生效。
* 旧的 storage/app/account-mapping/rules.json 仅作为只读备份镜像继续写出;
* 迁移前(表还不存在)或回滚场景自动退回读该文件;初始种子在
* resources/data/account-mapping.json。
*
* 时间统一按 config('app.platform_timezone')(= APP_TIMEZONE)落库,不能用 now():
* 页面请求会被 App\Utilities\Overrider 按公司设置覆盖 app.timezone,ak_settings 里没有
* localisation.timezone 时回落成 Europe/London,会让记录时间比北京时间晚 7 小时。
*/
class AccountMappingStore
{
/** 共享系统数据库里的规则表 / 操作记录表 */
public const RULES_TABLE = 'ec_account_mapping_rules';
public const EVENTS_TABLE = 'ec_account_mapping_events';
/** 可维护字段(顺序即导出/模板列顺序) */
public const FIELDS = ['platform', 'item', 'memo', 'goods', 'invoice', 'category', 'feeMemo', 'dAcct', 'dSub', 'cAcct', 'cSub', 'basis'];
public const LABELS = [
'platform' => '平台',
'item' => '账单项目',
'memo' => '财务备注',
'goods' => '商品名称关键词',
'invoice' => '票据口径',
'category' => '财务类型',
'feeMemo' => '费用备注',
'dAcct' => '借方科目',
'dSub' => '借方二级',
'cAcct' => '贷方科目',
'cSub' => '贷方二级',
'basis' => '方向基准',
];
public const BASIS_OUT = '支出基准';
public const BASIS_IN = '收入基准';
public const BASISES = [self::BASIS_OUT, self::BASIS_IN];
/** 进程内规则缓存(写入时清空;CLI 批量任务可避免逐行重查规则表)。 */
private static ?array $allMemo = null;
private static function forgetMemo(): void
{
self::$allMemo = null;
}
/** 大类 → 预期方向基准(用于校验「基准与大类是否一致」) */
public const CATEGORY_BASIS = [
'费用' => self::BASIS_OUT,
'售后费用' => self::BASIS_OUT,
'平台服务费' => self::BASIS_OUT,
'平台佣金' => self::BASIS_OUT,
'推广' => self::BASIS_OUT,
'保证金' => self::BASIS_OUT,
'暂挂' => self::BASIS_OUT,
'待处理财产损益' => self::BASIS_OUT,
'营业外支出' => self::BASIS_OUT,
'税费' => self::BASIS_OUT,
'提现' => self::BASIS_OUT,
'收入' => self::BASIS_IN,
'往来' => self::BASIS_IN,
'转账' => self::BASIS_IN,
];
/** 票据口径可取值 */
public const INVOICES = ['0.06', '0.09', '不开票', '-'];
/**
* 存储目录。
*
* 用 storage 而不是 runtime:预览/生产容器都只挂了仓库的若干子目录,
* runtime/ 不在挂载范围内(写了会落在容器自己那层,重建就丢);
* storage/app 是 Laravel 规范可写目录,且挂的是持久卷。
*/
public function dir(): string
{
return storage_path('app/account-mapping');
}
public function path(): string
{
return $this->dir() . DIRECTORY_SEPARATOR . 'rules.json';
}
public function eventPath(): string
{
return $this->dir() . DIRECTORY_SEPARATOR . 'events.json';
}
/** @return array<int,array<string,mixed>> 按 id 升序的全部规则(共享数据库优先,表不存在时退回旧文件) */
public function all(): array
{
// 进程内缓存:业绩明细等批量任务会逐行调用匹配器,而匹配器每次都走 all(),
// 不缓存时每个账单行都要重查一次规则表(36k 行会拖到几十分钟)。写入方法会清空缓存。
if (self::$allMemo !== null) {
return self::$allMemo;
}
if ($this->rulesTableReady()) {
$out = [];
foreach (DB::table(self::RULES_TABLE)->orderBy('id')->get() as $row) {
$out[] = $this->fromRow($row);
}
return self::$allMemo = $out;
}
// 兼容:迁移前的环境(或回滚场景)继续读文件
$this->ensureSeeded();
$payload = $this->read($this->path());
$rules = is_array($payload['rules'] ?? null) ? $payload['rules'] : [];
$rules = array_values(array_filter($rules, static fn (mixed $r): bool => is_array($r) && isset($r['id'])));
usort($rules, static fn (array $a, array $b): int => ((int) $a['id']) <=> ((int) $b['id']));
return self::$allMemo = $rules;
}
public function find(int $id): ?array
{
foreach ($this->all() as $rule) {
if ((int) $rule['id'] === $id) {
return $rule;
}
}
return null;
}
/** 新增一条,返回新规则 */
public function create(array $values, ?int $userId = null): array
{
self::forgetMemo();
$rules = $this->all();
$id = 1;
foreach ($rules as $rule) {
$id = max($id, ((int) $rule['id']) + 1);
}
$rule = ['id' => $id] + $this->normalize($values);
$rules[] = $rule;
$this->persist($rules);
$this->logEvent('create', $id, null, $rule, $userId);
return $rule;
}
/** 修改一条,返回改后的规则 */
public function update(int $id, array $values, ?int $userId = null): array
{
self::forgetMemo();
$rules = $this->all();
$updated = null;
$before = null;
foreach ($rules as $index => $rule) {
if ((int) $rule['id'] !== $id) {
continue;
}
$before = $rule;
$updated = ['id' => $id] + $this->normalize(array_replace($rule, $values));
$rules[$index] = $updated;
break;
}
if ($updated === null) {
throw new RuntimeException('未找到要修改的规则。');
}
if ($before !== $updated) {
$this->persist($rules);
$this->logEvent('update', $id, $before, $updated, $userId);
}
return $updated;
}
/** Fee memo is display-only; all other fields retain conservative invalidation. */
public static function requiresReceiptResync(?array $before, ?array $after): bool
{
foreach (self::FIELDS as $field) {
if ($field === 'feeMemo') continue;
if (trim((string) ($before[$field] ?? '')) !== trim((string) ($after[$field] ?? ''))) return true;
}
return false;
}
public function destroy(int $id, ?int $userId = null): bool
{
$rules = $this->all();
$before = null;
$kept = [];
foreach ($rules as $rule) {
if ((int) $rule['id'] === $id) {
$before = $rule;
continue;
}
$kept[] = $rule;
}
if ($before === null) {
return false;
}
$this->persist($kept);
$this->logEvent('delete', $id, $before, null, $userId);
return true;
}
/**
* 导入:按「平台 + 账单项目 + 财务备注」判重。
*
* @param array<int,array<string,mixed>> $rows
* @return array{created:int,updated:int,skipped:int,duplicates:array<int,string>,total:int,resync_platforms:list<string>}
*/
public function importRows(array $rows, bool $overwrite, ?int $userId = null): array
{
self::forgetMemo();
$rules = $this->all();
$byKey = [];
foreach ($rules as $index => $rule) {
$byKey[$this->keyOf($rule)] = $index;
}
$nextId = 1;
foreach ($rules as $rule) {
$nextId = max($nextId, ((int) $rule['id']) + 1);
}
$created = 0;
$updated = 0;
$skipped = 0;
$duplicates = [];
$resyncPlatforms = [];
foreach ($rows as $row) {
$values = $this->normalize($row);
if ($this->isEmptyRow($values)) {
continue;
}
$key = $this->keyOf($values);
if (isset($byKey[$key])) {
if (! $overwrite) {
$skipped++;
$duplicates[] = $key;
continue;
}
$index = $byKey[$key];
$replacement = ['id' => $rules[$index]['id']] + $values;
if ($rules[$index] === $replacement) {
$skipped++;
continue;
}
if (self::requiresReceiptResync($rules[$index], $replacement)) {
$resyncPlatforms[$rules[$index]['platform']] = true;
$resyncPlatforms[$replacement['platform']] = true;
}
$rules[$index] = $replacement;
$updated++;
continue;
}
$rules[] = ['id' => $nextId] + $values;
$byKey[$key] = count($rules) - 1;
$nextId++;
$created++;
$resyncPlatforms[$values['platform']] = true;
}
if ($created + $updated > 0) {
$this->persist($rules);
$this->logEvent('import', null, null, null, $userId, [
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
]);
}
return [
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'duplicates' => array_slice($duplicates, 0, 20),
'total' => count($rows),
'resync_platforms' => array_keys($resyncPlatforms),
];
}
/** 用 resources/data/account-mapping.json 覆盖回初始状态 */
public function reset(?int $userId = null): int
{
$seed = $this->seed();
$this->persist($seed);
$this->logEvent('reset', null, null, null, $userId, ['total' => count($seed)]);
return count($seed);
}
/** @return array<int,array<string,mixed>> */
public function seed(): array
{
$path = resource_path('data/account-mapping.json');
$payload = File::exists($path) ? json_decode((string) File::get($path), true) : null;
// 种子文件既可能是顶层数组,也可能是 {"rules": [...]},两种都认
$rows = [];
if (is_array($payload)) {
if (is_array($payload['rules'] ?? null)) {
$rows = $payload['rules'];
} elseif (array_is_list($payload)) {
$rows = $payload;
}
}
$out = [];
foreach ($rows as $row) {
if (! is_array($row)) {
continue;
}
$out[] = ['id' => (int) ($row['no'] ?? (count($out) + 1))] + $this->normalize($row);
}
usort($out, static fn (array $a, array $b): int => ((int) $a['id']) <=> ((int) $b['id']));
return $out;
}
/** @return array<string,string> */
public function normalize(array $values): array
{
$out = [];
foreach (self::FIELDS as $field) {
$value = $values[$field] ?? '';
if (is_array($value) || is_object($value)) {
$value = '';
}
$out[$field] = trim((string) $value);
}
if (! in_array($out['basis'], self::BASISES, true)) {
$out['basis'] = self::CATEGORY_BASIS[$out['category']] ?? self::BASIS_OUT;
}
// 区分条件里的「-」「—」表示“不限”,按空处理:否则匹配时会拿它去找关键词,规则永远命中不了
foreach (['memo', 'goods'] as $field) {
if (in_array($out[$field], ['-', '—', '-'], true)) {
$out[$field] = '';
}
}
return $out;
}
/**
* 判重键:平台 + 账单项目 + 业务备注关键词 + 商品名称关键词。
*
* 业务备注/商品名称是「区分条件」:同一个账单项目在业务上不同时,靠它们区分;
* 条件完全相同的两条规则才是真重复。
*/
public function keyOf(array $rule): string
{
return implode('|', [
trim((string) ($rule['platform'] ?? '')),
trim((string) ($rule['item'] ?? '')),
trim((string) ($rule['memo'] ?? '')),
trim((string) ($rule['goods'] ?? '')),
]);
}
/** @return array<string,mixed> */
public function options(): array
{
$rules = $this->all();
$collect = static function (string $field) use ($rules): array {
$seen = [];
foreach ($rules as $rule) {
$value = trim((string) ($rule[$field] ?? ''));
if ($value !== '') {
$seen[$value] = true;
}
}
$values = array_keys($seen);
sort($values);
return $values;
};
return [
'platforms' => $collect('platform'),
'categories' => array_keys(self::CATEGORY_BASIS),
'basis' => self::BASISES,
'invoices' => self::INVOICES,
];
}
/** @return array<int,array<string,mixed>> 最近的操作记录(倒序;共享数据库优先) */
public function events(int $limit = 50): array
{
if ($this->eventsTableReady()) {
$out = [];
foreach (DB::table(self::EVENTS_TABLE)->orderByDesc('id')->limit(max(1, $limit))->get() as $row) {
$event = [
'at' => (string) $row->at,
'action' => (string) $row->action,
'id' => $row->rule_id === null ? null : (int) $row->rule_id,
'before' => $row->before_json ? json_decode((string) $row->before_json, true) : null,
'after' => $row->after_json ? json_decode((string) $row->after_json, true) : null,
'user_id' => $row->user_id === null ? null : (int) $row->user_id,
];
$extra = $row->extra_json ? json_decode((string) $row->extra_json, true) : null;
if (is_array($extra)) {
$event = array_merge($event, $extra);
}
$out[] = $event;
}
return $out;
}
$payload = $this->read($this->eventPath());
$events = is_array($payload['events'] ?? null) ? $payload['events'] : [];
return array_slice(array_reverse($events), 0, $limit);
}
/**
* 本模块统一用的“现在”:固定按平台时区,不受页面请求里公司设置覆盖的影响。
*
* 直接用 now() 的话,页面操作写进日志的时间会差 7 小时(Europe/London)。
*/
public function platformNow(): Carbon
{
$timezone = trim((string) config('app.platform_timezone', ''));
if ($timezone === '') {
$timezone = (string) config('app.timezone', 'UTC');
}
try {
return Carbon::now($timezone);
} catch (\Throwable) {
return Carbon::now(config('app.timezone', 'UTC'));
}
}
private function isEmptyRow(array $values): bool
{
return trim((string) ($values['platform'] ?? '')) === ''
&& trim((string) ($values['item'] ?? '')) === '';
}
/**
* 把旧文件存储(rules.json / events.json)导入共享数据库;幂等,以文件为准覆盖。
*
* @return array{rules:int,events:int}
*/
public function syncFromFiles(bool $withEvents = false): array
{
if (! $this->rulesTableReady()) {
throw new RuntimeException('共享数据库里还没有映射表,请先执行数据库迁移。');
}
$payload = $this->read($this->path());
$rules = is_array($payload['rules'] ?? null) ? $payload['rules'] : [];
$rules = array_values(array_filter($rules, static fn (mixed $r): bool => is_array($r) && isset($r['id'])));
usort($rules, static fn (array $a, array $b): int => ((int) $a['id']) <=> ((int) $b['id']));
$this->persist($rules);
$events = 0;
if ($withEvents && $this->eventsTableReady()) {
$payload = $this->read($this->eventPath());
$list = is_array($payload['events'] ?? null) ? $payload['events'] : [];
if ($list !== []) {
DB::table(self::EVENTS_TABLE)->delete();
$rows = [];
foreach ($list as $event) {
if (! is_array($event)) {
continue;
}
$at = (string) ($event['at'] ?? $this->platformNow()->toDateTimeString());
$rows[] = [
'at' => $at,
'action' => (string) ($event['action'] ?? 'unknown'),
'rule_id' => isset($event['id']) ? (int) $event['id'] : null,
'before_json' => array_key_exists('before', $event) && $event['before'] !== null ? json_encode($event['before'], JSON_UNESCAPED_UNICODE) : null,
'after_json' => array_key_exists('after', $event) && $event['after'] !== null ? json_encode($event['after'], JSON_UNESCAPED_UNICODE) : null,
'user_id' => isset($event['user_id']) ? (int) $event['user_id'] : null,
'extra_json' => null,
'created_at' => $at,
'updated_at' => $at,
];
}
foreach (array_chunk($rows, 200) as $chunk) {
DB::table(self::EVENTS_TABLE)->insert($chunk);
}
$events = count($rows);
}
}
return ['rules' => count($rules), 'events' => $events];
}
private function ensureSeeded(): void
{
if (File::exists($this->path())) {
return;
}
$this->persistFile($this->seed());
}
/** @param array<int,array<string,mixed>> $rules */
private function persist(array $rules): void
{
self::forgetMemo();
if (! $this->rulesTableReady()) {
$this->persistFile($rules);
return;
}
$now = $this->platformNow()->toDateTimeString();
DB::transaction(function () use ($rules, $now): void {
DB::table(self::RULES_TABLE)->delete();
$rows = [];
foreach ($rules as $rule) {
$rows[] = $this->toRow($rule, $now);
}
foreach (array_chunk($rows, 200) as $chunk) {
DB::table(self::RULES_TABLE)->insert($chunk);
}
});
// 文件只作为只读备份镜像:写失败不影响主存储
try {
$this->persistFile($rules);
} catch (Throwable) {
}
}
/** @param array<int,array<string,mixed>> $rules */
private function persistFile(array $rules): void
{
if (! File::isDirectory($this->dir()) && ! File::makeDirectory($this->dir(), 0775, true) && ! File::isDirectory($this->dir())) {
throw new RuntimeException('无法创建映射表存储目录。');
}
$json = json_encode(
['updated_at' => $this->platformNow()->toDateTimeString(), 'rules' => array_values($rules)],
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
);
if ($json === false) {
throw new RuntimeException('映射表序列化失败。');
}
File::put($this->path(), $json, true);
}
private function logEvent(string $action, ?int $id, ?array $before, ?array $after, ?int $userId, array $extra = []): void
{
$at = $this->platformNow()->toDateTimeString();
if ($this->eventsTableReady()) {
DB::table(self::EVENTS_TABLE)->insert([
'at' => $at,
'action' => $action,
'rule_id' => $id,
'before_json' => $before === null ? null : json_encode($before, JSON_UNESCAPED_UNICODE),
'after_json' => $after === null ? null : json_encode($after, JSON_UNESCAPED_UNICODE),
'user_id' => $userId,
'extra_json' => $extra === [] ? null : json_encode($extra, JSON_UNESCAPED_UNICODE),
'created_at' => $at,
'updated_at' => $at,
]);
$maxId = (int) DB::table(self::EVENTS_TABLE)->max('id');
if ($maxId > 500) {
DB::table(self::EVENTS_TABLE)->where('id', '<=', $maxId - 500)->delete();
}
return;
}
$payload = $this->read($this->eventPath());
$events = is_array($payload['events'] ?? null) ? $payload['events'] : [];
$events[] = array_merge([
'at' => $at,
'action' => $action,
'id' => $id,
'before' => $before,
'after' => $after,
'user_id' => $userId,
], $extra);
if (count($events) > 500) {
$events = array_slice($events, -500);
}
File::put($this->eventPath(), (string) json_encode(['events' => $events], JSON_UNESCAPED_UNICODE), true);
}
private function rulesTableReady(): bool
{
try {
return Schema::hasTable(self::RULES_TABLE);
} catch (Throwable) {
return false;
}
}
private function eventsTableReady(): bool
{
try {
return Schema::hasTable(self::EVENTS_TABLE);
} catch (Throwable) {
return false;
}
}
/** @return array<string,mixed> */
private function fromRow(object $row): array
{
return [
'id' => (int) $row->id,
'platform' => (string) $row->platform,
'item' => (string) $row->item,
'memo' => (string) $row->memo,
'goods' => (string) $row->goods,
'invoice' => (string) $row->invoice,
'category' => (string) $row->category,
'feeMemo' => (string) ($row->fee_memo ?? ''),
'dAcct' => (string) $row->d_acct,
'dSub' => (string) $row->d_sub,
'cAcct' => (string) $row->c_acct,
'cSub' => (string) $row->c_sub,
'basis' => (string) $row->basis,
];
}
/** @param array<string,mixed> $rule */
private function toRow(array $rule, string $now): array
{
$id = (int) ($rule['id'] ?? 0);
$rule = $this->normalize($rule);
return [
'id' => $id,
'platform' => $rule['platform'],
'item' => $rule['item'],
'memo' => $rule['memo'],
'goods' => $rule['goods'],
'invoice' => $rule['invoice'],
'category' => $rule['category'],
'fee_memo' => $rule['feeMemo'],
'd_acct' => $rule['dAcct'],
'd_sub' => $rule['dSub'],
'c_acct' => $rule['cAcct'],
'c_sub' => $rule['cSub'],
'basis' => $rule['basis'],
'created_at' => $now,
'updated_at' => $now,
];
}
/** @return array<string,mixed> */
private function read(string $path): array
{
if (! File::exists($path)) {
return [];
}
$payload = json_decode((string) File::get($path), true);
return is_array($payload) ? $payload : [];
}
}