CaiWuXiTong/modules/EcommerceFinance/Services/AccountMappingStore.php
yueyang 6af12c030e 财务经营报表:新增账单明细页(多选筛选/日历选择/四项汇总/按列导出与进度/收款明细同款分页),科目映射表字段改名财务备注与财务类型
- 账单明细:7 个平台账单分支下推查询,按 active_generation 取当前版本(修正抖音重复计数),跨月大数据量由 73s 降到 1-2s

- 筛选:平台/店铺/账单项目/财务类型多选下拉(打字模糊搜索+滚动),日期改日历选择

- 导出:全部筛选结果 CSV 流式下载并按显示列最小取数,带进度轮询;列设置按账号存本机

- 分页:每页 10/20/50,首页/上下页/末页,第 X-Y 条/共 N 条

- 科目映射表:字段改名财务备注/财务类型(导入兼容旧名)
2026-10-06 15:35:16 +08:00

452 lines
14 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\Carbon;
use RuntimeException;
/**
* 科目映射表存储。
*
* 一条规则 = 「走哪两个科目(科目对)」+「方向基准」,**不存最终借贷方向**。
* 记账时按这张账单在平台上的实际收支代入:
* 账单收支 === 基准方向 → 照抄;否则 → 借贷互换。
*
* 当前落盘在 storage/app/account-mapping/rules.json(持久卷,重启不丢),
* 首次访问会用 resources/data/account-mapping.json 作为初始 112 条规则灌入。
* 若要变成正式的数据表,只需替换本类的读写实现,页面与接口不用动。
*
* 时间统一按 config('app.platform_timezone')(= APP_TIMEZONE)落盘,不能用 now():
* 页面请求会被 App\Utilities\Overrider 按公司设置覆盖 app.timezone,ak_settings 里没有
* localisation.timezone 时回落成 Europe/London,会让记录时间比北京时间晚 7 小时。
*/
class AccountMappingStore
{
/** 可维护字段(顺序即导出/模板列顺序) */
public const FIELDS = ['platform', 'item', 'memo', 'goods', 'invoice', 'category', 'dAcct', 'dSub', 'cAcct', 'cSub', 'basis'];
public const LABELS = [
'platform' => '平台',
'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<int,array<string,mixed>> 按 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<int,array<string,mixed>> $rows
* @return array{created:int,updated:int,skipped:int,duplicates:array<int,string>,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<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
{
$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<int,array<string,mixed>> $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<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 : [];
}
}