CaiWuXiTong/modules/EcommerceFinance/Services/AccountMappingStore.php
qiuxiang f0e8803626 科目映射表票据口径并入9%
常用票据口径新增 0.09(京东短信服务费等 9% 税率),页面票据下拉可选且校验不再报提示;补用例覆盖 0.09 与 options.invoices。另:京东「综合违约金/违约赔付」的借方科目由「销售费用/营业外支出」改为「营业外支出」(数据修正,走 store.update 并记操作日志)。
2026-09-29 16:30:11 +08:00

411 lines
12 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 RuntimeException;
/**
* 科目映射表存储。
*
* 一条规则 = 「走哪两个科目(科目对)」+「方向基准」,**不存最终借贷方向**。
* 记账时按这张账单在平台上的实际收支代入:
* 账单收支 === 基准方向 → 照抄;否则 → 借贷互换。
*
* 当前落盘在 storage/app/account-mapping/rules.json(持久卷,重启不丢),
* 首次访问会用 resources/data/account-mapping.json 作为初始 112 条规则灌入。
* 若要变成正式的数据表,只需替换本类的读写实现,页面与接口不用动。
*/
class AccountMappingStore
{
/** 可维护字段(顺序即导出/模板列顺序) */
public const FIELDS = ['platform', 'item', 'memo', 'invoice', 'category', 'dAcct', 'dSub', 'cAcct', 'cSub', 'basis'];
public const LABELS = [
'platform' => '平台',
'item' => '账单项目',
'memo' => '财务备注',
'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_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;
}
return $out;
}
/** 判重键:平台 + 账单项目 + 财务备注 */
public function keyOf(array $rule): string
{
return implode('|', [
trim((string) ($rule['platform'] ?? '')),
trim((string) ($rule['item'] ?? '')),
trim((string) ($rule['memo'] ?? '')),
]);
}
/** @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);
}
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' => now()->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' => now()->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 : [];
}
}