701 lines
24 KiB
PHP
701 lines
24 KiB
PHP
<?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 : [];
|
||
}
|
||
}
|