'平台', 'item' => '账单项目', 'memo' => '财务备注', 'goods' => '商品名称关键词', 'invoice' => '票据口径', 'category' => '财务类型', 'dAcct' => '借方科目', 'dSub' => '借方二级', 'cAcct' => '贷方科目', 'cSub' => '贷方二级', 'basis' => '方向基准', ]; public const BASIS_OUT = '支出基准'; public const BASIS_IN = '收入基准'; public const BASISES = [self::BASIS_OUT, self::BASIS_IN]; /** 大类 → 预期方向基准(用于校验「基准与大类是否一致」) */ 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 { $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 $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 { $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 { $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('未找到要修改的规则。'); } $this->persist($rules); $this->logEvent('update', $id, $before, $updated, $userId); return $updated; } 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} */ public function importRows(array $rows, bool $overwrite, ?int $userId = null): array { $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 = []; 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]; $rules[$index] = ['id' => $rules[$index]['id']] + $values; $updated++; continue; } $rules[] = ['id' => $nextId] + $values; $byKey[$key] = count($rules) - 1; $nextId++; $created++; } 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), ]; } /** 用 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 { $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'] ?? '')) === ''; } private function ensureSeeded(): void { if (File::exists($this->path())) { return; } $this->persist($this->seed()); } /** @param array> $rules */ private function persist(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 { $payload = $this->read($this->eventPath()); $events = is_array($payload['events'] ?? null) ? $payload['events'] : []; $events[] = array_merge([ 'at' => $this->platformNow()->toDateTimeString(), '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); } /** @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 : []; } }