- 账单明细:7 个平台账单分支下推查询,按 active_generation 取当前版本(修正抖音重复计数),跨月大数据量由 73s 降到 1-2s - 筛选:平台/店铺/账单项目/财务类型多选下拉(打字模糊搜索+滚动),日期改日历选择 - 导出:全部筛选结果 CSV 流式下载并按显示列最小取数,带进度轮询;列设置按账号存本机 - 分页:每页 10/20/50,首页/上下页/末页,第 X-Y 条/共 N 条 - 科目映射表:字段改名财务备注/财务类型(导入兼容旧名)
452 lines
14 KiB
PHP
452 lines
14 KiB
PHP
<?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 : [];
|
||
}
|
||
}
|