'平台', '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> 按 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> $rows * @return array{created:int,updated:int,skipped:int,duplicates:array,total:int,resync_platforms:list} */ 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> */ 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 */ 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 */ 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> 最近的操作记录(倒序;共享数据库优先) */ 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> $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> $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 */ 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 $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 */ private function read(string $path): array { if (! File::exists($path)) { return []; } $payload = json_decode((string) File::get($path), true); return is_array($payload) ? $payload : []; } }