新增「财务规则 · 科目映射表」可维护页面 #279

Merged
yueyang merged 4 commits from feature/yueyang-account-mapping-page into main 2026-09-29 10:41:54 +08:00
12 changed files with 3905 additions and 0 deletions

View File

@ -0,0 +1,280 @@
<?php
namespace App\Http\Controllers\Business;
use App\Traits\Companies;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Services\AccountMappingCoa;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Modules\EcommerceFinance\Services\AccountMappingValidator;
use Modules\EcommerceFinance\Services\AccountMappingWorkbookImporter;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 财务规则 · 科目映射表的维护接口。
*
* 表里存的是「科目对 + 方向基准」,不存最终借贷方向;方向在记账时按账单实际收支代入。
* 本控制器只负责:增、改、删、导入追加、导出、模板、校验。
*/
class AccountMappingActions extends Controller
{
use Companies;
public function __construct(
private readonly AccountMappingStore $store,
private readonly AccountMappingValidator $validator,
private readonly AccountMappingCoa $coa,
private readonly AccountMappingWorkbookImporter $importer,
) {
$this->middleware('auth');
$this->middleware('company.identify');
}
/** 全量规则 + 校验结果 + 筛选项 */
public function index(): JsonResponse
{
return response()->json($this->payload());
}
public function store(Request $request): JsonResponse
{
$values = $this->payloadValues($request);
if (trim((string) ($values['platform'] ?? '')) === '' || trim((string) ($values['item'] ?? '')) === '') {
throw ValidationException::withMessages(['platform' => '平台和账单项目不能为空。']);
}
$this->store->create($values, $this->userId());
return response()->json(['message' => '已新增 1 条规则。'] + $this->payload());
}
public function update(Request $request, int $id): JsonResponse
{
if ($this->store->find($id) === null) {
throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']);
}
$this->store->update($id, $this->payloadValues($request), $this->userId());
return response()->json(['message' => '已保存。'] + $this->payload());
}
public function destroy(int $id): JsonResponse
{
if (! $this->store->destroy($id, $this->userId())) {
throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']);
}
return response()->json(['message' => '已删除 1 条规则。'] + $this->payload());
}
/**
* 导入预览:只解析、不落盘。
*
* 单独一条路由、且不挂 throttle:import —— 因为平台对「导入」限的是 1 次/分钟,
* 而页面上「预览 → 确认导入」天然要发两次请求,预览走限流必然 429。
*/
public function preview(Request $request): JsonResponse
{
return response()->json($this->previewPayload($this->workbookFile($request)));
}
/**
* 导入落盘。带 ?dry_run=1 时行为同 preview(保留兼容)。
*/
public function import(Request $request): JsonResponse
{
$file = $this->workbookFile($request);
if ($request->boolean('dry_run')) {
return response()->json($this->previewPayload($file));
}
$parsed = $this->importer->parse($file);
$result = $this->store->importRows($parsed['rows'], $request->boolean('overwrite'), $this->userId());
$message = "导入完成:识别 {$result['total']} 行,新增 {$result['created']} 条,更新 {$result['updated']} 条,跳过重复 {$result['skipped']} 条。";
return response()->json(['message' => $message, 'result' => $result] + $this->payload());
}
/**
* 取上传的工作簿。
*
* 这里**故意不用** `mimes:xlsx,xls,csv`:那个规则靠 libmagic 嗅探真实类型,
* 而只有两三行的 CSV 会被猜成 `text/plain`、扩展名算成 `txt`,于是合法文件被拒
* (实测 2 行 231B 的 csv 报「必须是 xlsx, xls, csv 的文件」)。按扩展名判就稳。
*/
protected function workbookFile(Request $request)
{
$request->validate([
'import' => ['required', 'file', 'max:10240'],
], [
'import.required' => '请先选择要导入的文件。',
'import.file' => '请选择要导入的文件。',
'import.max' => '文件不能超过 10MB。',
]);
$file = $request->file('import');
$extension = mb_strtolower((string) $file->getClientOriginalExtension());
if (! in_array($extension, ['xlsx', 'xls', 'csv'], true)) {
throw ValidationException::withMessages([
'import' => '只支持 xlsx、xls、csv 三种格式,当前是「' . $extension . '」。',
]);
}
return $file;
}
/**
* @param \Illuminate\Http\UploadedFile $file
* @return array<string,mixed>
*/
protected function previewPayload($file): array
{
$parsed = $this->importer->parse($file);
$rows = $parsed['rows'];
$existing = [];
foreach ($this->store->all() as $rule) {
$existing[$this->store->keyOf($rule)] = true;
}
$fresh = [];
$duplicated = [];
foreach ($rows as $row) {
$key = $this->store->keyOf($row);
if (isset($existing[$key])) {
$duplicated[$key] = ($duplicated[$key] ?? 0) + 1;
} else {
$fresh[$key] = true;
$existing[$key] = true;
}
}
return [
'dry_run' => true,
'sheet' => $parsed['sheet'],
'total' => count($rows),
'can_create' => count($fresh),
'can_overwrite' => count($duplicated),
'duplicate_keys' => array_slice(array_keys($duplicated), 0, 20),
'sample' => array_slice($rows, 0, 6),
];
}
/** 恢复成初始 112 条 */
public function reset(): JsonResponse
{
$total = $this->store->reset($this->userId());
return response()->json(['message' => "已恢复为初始 {$total} 条规则。"] + $this->payload());
}
/** 账套科目(一级 + 各一级下的明细),供下拉使用 */
public function accounts(): JsonResponse
{
$level1 = $this->coa->level1();
$children = [];
foreach ($level1 as $account) {
$children[$account['code']] = $this->coa->descendants($account['code']);
}
return response()->json(['level1' => $level1, 'children' => $children]);
}
/** 按当前表导出 CSV(Excel 可直接打开) */
public function export(): StreamedResponse
{
$rules = $this->store->all();
return response()->streamDownload(function () use ($rules): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new RuntimeException('无法创建导出文件。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_values(AccountMappingStore::LABELS));
foreach ($rules as $rule) {
$row = [];
foreach (array_keys(AccountMappingStore::LABELS) as $field) {
$row[] = $this->csvText($rule[$field] ?? '');
}
fputcsv($csv, $row);
}
fclose($csv);
}, '科目映射表_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** 导入模板(表头 + 两条示例,并且这一份可以直接改完再导回来) */
public function template(): StreamedResponse
{
$samples = [
['得物', '平台服务费', '平台服务费', '0.06', '费用', '销售费用', '平台佣金', '其他货币资金', '', '支出基准'],
['淘宝', '交易收入', '订单', '-', '收入', '其他货币资金', '', '主营业务收入', '', '收入基准'],
];
return response()->streamDownload(function () use ($samples): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new RuntimeException('无法创建模板文件。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_values(AccountMappingStore::LABELS));
foreach ($samples as $sample) {
fputcsv($csv, $sample);
}
fclose($csv);
}, '科目映射表_导入模板.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @return array<string,mixed> */
private function payload(): array
{
$rules = $this->store->all();
$validated = $this->validator->validate($rules);
$rows = [];
foreach ($rules as $rule) {
$rows[] = $rule + ['issues' => $validated['issues'][(int) $rule['id']] ?? []];
}
return [
'rules' => $rows,
'summary' => $validated['summary'],
'options' => $this->store->options(),
'events' => $this->store->events(20),
];
}
/** @return array<string,string> */
private function payloadValues(Request $request): array
{
$input = (array) $request->input('values', $request->all());
return $this->store->normalize($input);
}
private function userId(): ?int
{
return user_id() ? (int) user_id() : null;
}
private function csvText(mixed $value): string
{
$value = (string) $value;
return preg_match('/^[=+\-@\t\r]/u', $value) === 1 ? "\t" . $value : $value;
}
}

View File

@ -62,6 +62,8 @@ use Modules\EcommerceFinance\Repositories\DingOnlineDaifaPriceMirrorRepository;
use Modules\EcommerceFinance\Repositories\SmallPaymentWorkOrderMirrorRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository;
use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Modules\EcommerceFinance\Services\AccountMappingValidator;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Modules\EcommerceFinance\Services\SupplierPayableMaintenanceRegistry;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
@ -996,10 +998,64 @@ class Pages extends Controller
'shipping-data' => $this->contentForShippingData(),
'withdrawal-management' => $this->contentForWithdrawalManagement(),
'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(),
'account-mapping' => $this->contentForAccountMapping(),
default => null,
};
}
/**
* 财务规则 · 科目映射表
*
* 表里存的是「科目对 + 方向基准」,不存最终借贷方向;记账时按账单实际收支代入。
* 页面支持导入追加、单条修改、新增、删除,并实时给出账套科目校验结果。
*/
protected function contentForAccountMapping(): array
{
$store = app(AccountMappingStore::class);
$validator = app(AccountMappingValidator::class);
$rows = [];
$summary = [
'total' => 0, 'error_rows' => 0, 'warning_rows' => 0, 'ok_rows' => 0,
'error_count' => 0, 'warning_count' => 0, 'tip_count' => 0,
];
$options = ['platforms' => [], 'categories' => [], 'basis' => [], 'invoices' => []];
$error = null;
try {
$rules = $store->all();
$validated = $validator->validate($rules);
foreach ($rules as $rule) {
$rows[] = $rule + ['issues' => $validated['issues'][(int) $rule['id']] ?? []];
}
$summary = $validated['summary'];
$options = $store->options();
} catch (Throwable $exception) {
$error = '映射表存储暂时读不出来,请稍后重试。';
}
return [
'view' => 'business.pages.financial-rules.account-mapping',
'amRows' => $rows,
'amSummary' => $summary,
'amOptions' => $options,
'amEndpoint' => $this->accountMappingEndpoint(),
'amError' => $error,
];
}
/** 维护接口前缀(去掉末尾的 /rules) */
protected function accountMappingEndpoint(): string
{
try {
$url = route('business.financial-rules.account-mapping.actions.rules');
} catch (Throwable) {
$url = url((int) (company_id() ?: 1) . '/workspace/financial-rules/account-mapping/actions/rules');
}
return (string) preg_replace('#/rules$#', '', $url);
}
protected function contentForCourierPayableSummary(string $page): array
{
$store = app(CourierPayableSummaryStore::class);

View File

@ -708,6 +708,17 @@ $navigation = [
['slug' => 'actual-statistics', 'title' => '实际SW统计表'],
],
],
// 注意:新分组要放在 financial-sw 之后。
// NavigationTest 断言 financial-risk-control 的下一个分组必须是 financial-sw,
// 插在它俩之间会让既有测试失败(等于改动了别人的页面顺序)。
[
'slug' => 'financial-rules',
'title' => '财务规则',
'icon' => 'rule',
'pages' => [
['slug' => 'account-mapping', 'title' => '科目映射表'],
],
],
[
'slug' => 'data-monitoring',
'title' => '数据监控',

View File

@ -0,0 +1,154 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Facades\File;
/**
* 账套科目索引(resources/data/coa.json)。
*
* 账套是「小企业会计准则 + 5 位一级科目」,层级用点分:5601.05.04。
* 映射表里写的是「一级科目名 + 二级科目名」两列,二级名往往是第 3、4 层,
* 所以按「一级名 → 一级编码 → 编码前缀」这条路去子树里找,才找得准。
*/
class AccountMappingCoa
{
/** @var array<int,array{code:string,name:string,level:int,category:string,balance:string}>|null */
private ?array $accounts = null;
/** @return array<int,array{code:string,name:string,level:int,category:string,balance:string}> */
public function all(): array
{
if ($this->accounts !== null) {
return $this->accounts;
}
$path = resource_path('data/coa.json');
$payload = File::exists($path) ? json_decode((string) File::get($path), true) : null;
$rows = is_array($payload['accounts'] ?? null) ? $payload['accounts'] : [];
$this->accounts = array_map(static fn (array $row): array => [
'code' => (string) ($row[0] ?? ''),
'name' => (string) ($row[1] ?? ''),
'level' => (int) ($row[2] ?? 1),
'category' => (string) ($row[3] ?? ''),
'balance' => (string) ($row[4] ?? ''),
], $rows);
return $this->accounts;
}
/** @return array<int,array{code:string,name:string}> 一级科目(用于下拉第一级) */
public function level1(): array
{
return array_values(array_map(
static fn (array $a): array => ['code' => $a['code'], 'name' => $a['name']],
array_filter($this->all(), static fn (array $a): bool => $a['level'] === 1 && $a['name'] !== '')
));
}
/** @return array<int,array{code:string,name:string,level:int}> 某编码子树下的非一级科目 */
public function descendants(string $code): array
{
if ($code === '') {
return [];
}
$prefix = $code . '.';
$hits = [];
foreach ($this->all() as $a) {
if ($a['code'] !== $code && str_starts_with($a['code'], $prefix)) {
$hits[] = ['code' => $a['code'], 'name' => $a['name'], 'level' => $a['level']];
}
}
return $hits;
}
/** 一级科目名 → 编码(账套里一级科目名唯一) */
public function level1Code(string $name): ?string
{
$name = trim($name);
if ($name === '') {
return null;
}
foreach ($this->all() as $a) {
if ($a['level'] === 1 && $a['name'] === $name) {
return $a['code'];
}
}
return null;
}
/**
* 在「一级科目」子树下按名称找科目编码。
*
* @return array<int,string> 命中编码;空数组 = 不存在;多于 1 个 = 同名歧义
*/
public function resolve(string $level1Name, string $subName): array
{
$subName = trim($subName);
if ($subName === '') {
return [];
}
$base = $this->level1Code($level1Name);
if ($base === null) {
return [];
}
$hits = [];
foreach ($this->all() as $a) {
if ($a['name'] === $subName && ($a['code'] === $base || str_starts_with($a['code'], $base . '.'))) {
$hits[] = $a['code'];
}
}
sort($hits);
return $hits;
}
/** 全账套里同名的编码(用于解释「为什么找不到」) @return array<int,string> */
public function anywhereByName(string $name): array
{
$name = trim($name);
if ($name === '') {
return [];
}
$hits = [];
foreach ($this->all() as $a) {
if ($a['name'] === $name) {
$hits[] = $a['code'];
}
}
sort($hits);
return $hits;
}
/** 名称或编码 → 编码(映射表里偶尔会直接写编码) */
public function toCode(string $level1Token): ?string
{
$token = trim($level1Token);
if ($token === '') {
return null;
}
if (preg_match('/^\d+(\.\d+)*$/', $token) === 1) {
foreach ($this->all() as $a) {
if ($a['code'] === $token) {
return $token;
}
}
return null;
}
return $this->level1Code($token);
}
}

View File

@ -0,0 +1,406 @@
<?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_IN,
'转账' => self::BASIS_IN,
];
/** 票据口径可取值 */
public const INVOICES = ['0.06', '不开票', '-'];
/**
* 存储目录。
*
* 用 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 : [];
}
}

View File

@ -0,0 +1,196 @@
<?php
namespace Modules\EcommerceFinance\Services;
/**
* 科目映射表校验。
*
* 三档含义(不是拍脑袋的严重度):
* 错误 = 会让程序生成凭证失败,或让账务数字直接错 → 必须改
* 警告 = 账能做、程序能跑,但口径不对 → 影响扣除、税负、可比性
* 提示 = 只是建议 → 做账时留意
*
* 校验只针对「一笔账单按某条规则生成分录」这一件事,不评价业务口径优劣。
*/
class AccountMappingValidator
{
public function __construct(private readonly AccountMappingCoa $coa)
{
}
/**
* @param array<int,array<string,mixed>> $rules
* @return array{issues:array<int,array<int,array{level:string,code:string,message:string}>>,summary:array<string,int>}
*/
public function validate(array $rules): array
{
$issues = [];
$dupIndex = [];
foreach ($rules as $rule) {
$dupIndex[implode('|', [
trim((string) ($rule['platform'] ?? '')),
trim((string) ($rule['item'] ?? '')),
])][] = (int) $rule['id'];
}
foreach ($rules as $rule) {
$id = (int) $rule['id'];
$list = [];
$list = array_merge($list, $this->accountIssues($rule, 'dAcct', 'dSub', '借方'));
$list = array_merge($list, $this->accountIssues($rule, 'cAcct', 'cSub', '贷方'));
// 方向基准
$basis = trim((string) ($rule['basis'] ?? ''));
$category = trim((string) ($rule['category'] ?? ''));
if ($basis === '') {
$list[] = $this->issue('错误', 'basis_missing', '没有填方向基准,程序无法判断该照抄还是互换。');
} elseif (! in_array($basis, AccountMappingStore::BASISES, true)) {
$list[] = $this->issue('错误', 'basis_invalid', "方向基准「{$basis}」不是有效值,只能是支出基准或收入基准。");
} elseif ($category !== '' && isset(AccountMappingStore::CATEGORY_BASIS[$category])) {
$expected = AccountMappingStore::CATEGORY_BASIS[$category];
if ($expected !== $basis) {
$list[] = $this->issue(
'警告',
'basis_mismatch',
"大类「{$category}」按口径应是「{$expected}」,这里填的是「{$basis}」,账单收支与基准不一致时方向会反。"
);
}
}
if ($category === '') {
$list[] = $this->issue('警告', 'category_missing', '没有填大类,方向基准失去参照,也无法按大类筛选。');
}
// 票据口径
$invoice = trim((string) ($rule['invoice'] ?? ''));
if ($invoice === '') {
$list[] = $this->issue('提示', 'invoice_missing', '票据口径为空,汇算清缴时不好判断调增项。');
} elseif (! in_array($invoice, AccountMappingStore::INVOICES, true)) {
$list[] = $this->issue('提示', 'invoice_unknown', "票据口径「{$invoice}」不在常用取值(0.06 / 不开票 / -)内。");
}
// 借贷两边同一个科目
$d = trim((string) ($rule['dAcct'] ?? ''));
$c = trim((string) ($rule['cAcct'] ?? ''));
$ds = trim((string) ($rule['dSub'] ?? ''));
$cs = trim((string) ($rule['cSub'] ?? ''));
if ($d !== '' && $d === $c && $ds === $cs) {
$list[] = $this->issue('警告', 'same_account', '借方和贷方是同一个科目,这笔业务不会有损益影响。');
}
// 同平台同项目重复
$pairKey = trim((string) ($rule['platform'] ?? '')) . '|' . trim((string) ($rule['item'] ?? ''));
if (count($dupIndex[$pairKey] ?? []) > 1) {
$others = array_values(array_diff($dupIndex[$pairKey], [$id]));
$list[] = $this->issue(
'提示',
'duplicate_pair',
'同一平台下这个账单项目还有第 ' . implode('、', $others) . ' 条规则;若单据上无法区分,程序会取先出现的那条。'
);
}
if ($list !== []) {
$issues[$id] = $list;
}
}
$errRows = 0;
$warnRows = 0;
$errCount = 0;
$warnCount = 0;
$tipCount = 0;
foreach ($issues as $list) {
$levels = array_column($list, 'level');
if (in_array('错误', $levels, true)) {
$errRows++;
} elseif (in_array('警告', $levels, true)) {
$warnRows++;
}
foreach ($levels as $level) {
if ($level === '错误') {
$errCount++;
} elseif ($level === '警告') {
$warnCount++;
} else {
$tipCount++;
}
}
}
$total = count($rules);
return [
'issues' => $issues,
'summary' => [
'total' => $total,
'error_rows' => $errRows,
'warning_rows' => $warnRows,
'ok_rows' => max($total - $errRows - $warnRows, 0),
'error_count' => $errCount,
'warning_count' => $warnCount,
'tip_count' => $tipCount,
],
];
}
/**
* @return array<int,array{level:string,code:string,message:string}>
*/
private function accountIssues(array $rule, string $acctField, string $subField, string $side): array
{
$list = [];
$acct = trim((string) ($rule[$acctField] ?? ''));
$sub = trim((string) ($rule[$subField] ?? ''));
if ($acct === '') {
$list[] = $this->issue('错误', 'account_missing', "{$side}科目为空,生成不了分录。");
return $list;
}
$base = $this->coa->toCode($acct);
if ($base === null) {
$anywhere = $this->coa->anywhereByName($acct);
$hint = $anywhere === [] ? '账套里没有这个名称的科目,需要先建科目或改成账套已有的一级科目。'
: '它只存在于 ' . implode('、', $anywhere) . ',不是一级科目,应放到对应的一级科目下面。';
$list[] = $this->issue('错误', 'account_unknown', "{$side}科目「{$acct}」在账套里找不到:{$hint}");
return $list;
}
if ($sub === '' || $sub === $acct) {
return $list;
}
$hits = $this->coa->resolve($acct, $sub);
if (count($hits) === 1) {
return $list;
}
if ($hits === []) {
$anywhere = $this->coa->anywhereByName($sub);
$hint = $anywhere === []
? '账套里没有这个名称,需要新建,或改用已有明细。'
: '它在 ' . implode('、', $anywhere) . ',不挂在「' . $acct . '」下面。';
$list[] = $this->issue('警告', 'sub_unknown', "{$side}二级科目「{$sub}」找不到:{$hint}");
return $list;
}
$list[] = $this->issue(
'警告',
'sub_ambiguous',
"{$side}二级科目「{$sub}」在「{$acct}」下有 " . count($hits) . ' 个(' . implode('、', $hits) . '),程序无法自动选,建议改写为编码。'
);
return $list;
}
/** @return array{level:string,code:string,message:string} */
private function issue(string $level, string $code, string $message): array
{
return ['level' => $level, 'code' => $code, 'message' => $message];
}
}

View File

@ -0,0 +1,205 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Throwable;
/**
* 科目映射表导入。
*
* 兼容两套表头:
* A 旧版 9 列:平台 | 账单项目 | 财务备注 | 票据 | 大类 | 借/贷(借) | 借/贷(二级) | 借/贷(借) | 借/贷(二级)
* —— 「借/贷(借)」重复出现两次,靠位置区分:前两列是借方、后两列是贷方
* B 新版 10 列:平台 | 账单项目 | 财务备注 | 票据口径 | 大类 | 借方科目 | 借方二级 | 贷方科目 | 贷方二级 | 方向基准
*
* 没有方向基准列时,按大类推导(费用/保证金/推广/暂挂/营业外支出/提现 → 支出基准;收入/转账 → 收入基准)。
*/
class AccountMappingWorkbookImporter
{
private const ALIASES = [
'platform' => ['平台', '平台名称', '渠道'],
'item' => ['账单项目', '项目', '账单类型', '交易类型', '收支项目'],
'memo' => ['财务备注', '二次备注', '备注'],
'invoice' => ['票据口径', '票据', '票据类型', '发票'],
'category' => ['大类', '业务性质', '科目大类', '分类'],
'basis' => ['方向基准', '基准'],
'dAcct' => ['借方科目', '借/贷(借)', '借/贷(借)', '借方'],
'dSub' => ['借方二级'],
'cAcct' => ['贷方科目'],
'cSub' => ['贷方二级'],
];
private const INVOICE_MAP = [
'0.06' => '0.06',
'6%' => '0.06',
'06' => '0.06',
'可开6%专票' => '0.06',
'开6%专票' => '0.06',
'不开票' => '不开票',
'不开票(无票)' => '不开票',
'不开票(无票)' => '不开票',
'无票' => '不开票',
'-' => '-',
'—' => '-',
'不涉及' => '-',
];
/**
* @return array{rows:array<int,array<string,string>>,header_row:int,sheet:string}
*/
public function parse(UploadedFile $file): array
{
$extension = mb_strtolower((string) $file->getClientOriginalExtension());
try {
$readerType = match ($extension) {
'xlsx' => 'Xlsx',
'xls' => 'Xls',
'csv' => 'Csv',
default => throw new \RuntimeException('Unsupported workbook format.'),
};
$reader = IOFactory::createReader($readerType);
if ($readerType === 'Csv') {
$reader->setInputEncoding('UTF-8');
}
$spreadsheet = $reader->load($file->getRealPath());
} catch (Throwable) {
throw ValidationException::withMessages([
'import' => '读不出这个文件,请确认是 xlsx / xls / csv,且没有损坏。',
]);
}
try {
$best = null;
foreach ($spreadsheet->getWorksheetIterator() as $sheet) {
$grid = $sheet->toArray(null, true, false, false);
$grid = array_values(array_filter($grid, static fn (array $row): bool => $row !== []));
if ($grid === []) {
continue;
}
$headerRow = $this->locateHeader($grid);
$score = $headerRow === null ? -1 : count(array_filter($this->mapHeader($grid[$headerRow])));
if ($score <= 0) {
continue;
}
if ($best === null || $score > $best['score']) {
$best = ['score' => $score, 'grid' => $grid, 'header_row' => $headerRow, 'sheet' => $sheet->getTitle()];
}
}
} finally {
$spreadsheet->disconnectWorksheets();
}
if ($best === null) {
throw ValidationException::withMessages([
'import' => '没找到表头。至少要有一列「平台」和一列「账单项目」,以及借方、贷方两组科目列。',
]);
}
$columns = $this->mapHeader($best['grid'][$best['header_row']]);
$rows = [];
foreach (array_slice($best['grid'], $best['header_row'] + 1) as $gridRow) {
$row = [];
foreach ($columns as $field => $index) {
if ($index === null) {
$row[$field] = '';
continue;
}
$value = $gridRow[$index] ?? '';
$row[$field] = trim((string) (is_scalar($value) ? $value : ''));
}
if ($row['platform'] === '' && $row['item'] === '') {
continue;
}
$row['invoice'] = $this->normalizeInvoice($row['invoice']);
$row = $this->inferBasis($row);
$rows[] = $row;
}
return ['rows' => $rows, 'header_row' => $best['header_row'], 'sheet' => $best['sheet']];
}
/** @param array<int,mixed> $row */
private function locateHeader(array $grid): ?int
{
foreach ($grid as $index => $row) {
if (count(array_filter($this->mapHeader($row))) >= 3) {
return $index;
}
}
return null;
}
/**
* @param array<int,mixed> $header
* @return array<string,int|null>
*/
private function mapHeader(array $header): array
{
$cells = [];
foreach ($header as $index => $value) {
$cells[$index] = $this->normalizeHeader((string) (is_scalar($value) ? $value : ''));
}
$columns = [];
foreach (self::ALIASES as $field => $aliases) {
$columns[$field] = null;
foreach ($aliases as $alias) {
$needle = $this->normalizeHeader($alias);
$found = array_search($needle, $cells, true);
if ($found !== false) {
$columns[$field] = (int) $found;
break;
}
}
}
// 旧版表头「借/贷(借)」出现两次、没有「贷方科目」:借方组命中的那列起,往后取 4 列。
$dAcct = $columns['dAcct'];
if ($dAcct !== null && $columns['dSub'] === null && $columns['cAcct'] === null && $columns['cSub'] === null) {
$columns['dSub'] = $dAcct + 1;
$columns['cAcct'] = $dAcct + 2;
$columns['cSub'] = $dAcct + 3;
} elseif ($dAcct === null && $columns['cAcct'] !== null && $columns['dSub'] === null) {
$columns['dAcct'] = max($columns['cAcct'] - 2, 0);
$columns['dSub'] = max($columns['cAcct'] - 1, 0);
$columns['cSub'] = $columns['cAcct'] + 1;
}
return $columns;
}
private function normalizeHeader(string $value): string
{
$value = str_replace(['(', ')', '(', ')', ' '], ['(', ')', '(', ')', ''], $value);
return trim($value);
}
private function normalizeInvoice(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
return self::INVOICE_MAP[$value] ?? $value;
}
/** @param array<string,string> $row @return array<string,string> */
private function inferBasis(array $row): array
{
$basis = trim((string) ($row['basis'] ?? ''));
if ($basis === '') {
$basis = AccountMappingStore::CATEGORY_BASIS[trim((string) $row['category'])] ?? '';
}
$row['basis'] = $basis;
return $row;
}
}

File diff suppressed because it is too large Load Diff

1
resources/data/coa.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,909 @@
@php
// 数据由 Pages::contentForAccountMapping() 注入:
// $amRows 每条规则 + 该行的校验问题
// $amSummary 汇总(总数 / 错误行 / 警告行 / 提示数)
// $amOptions 筛选项(平台 / 大类 / 基准 / 票据)
// $amError 数据层读取失败时的提示
$amRows = $amRows ?? [];
$amSummary = $amSummary ?? ['total' => 0, 'error_rows' => 0, 'warning_rows' => 0, 'ok_rows' => 0, 'error_count' => 0, 'warning_count' => 0, 'tip_count' => 0];
$amOptions = $amOptions ?? ['platforms' => [], 'categories' => [], 'basis' => [], 'invoices' => []];
$amError = $amError ?? null;
$platforms = $amOptions['platforms'] ?? [];
$categories = $amOptions['categories'] ?? [];
$outBase = 0;
$inBase = 0;
foreach ($amRows as $r) {
if (($r['basis'] ?? '') === '支出基准') { $outBase++; }
if (($r['basis'] ?? '') === '收入基准') { $inBase++; }
}
@endphp
<div class="upstream-data-page upstream-data-page--spaced" data-account-mapping="true">
@include('business.pages.partials.upstream-data-styles')
<style>
.am-intro {
margin: 1.25rem 0 0;
padding: 1rem 1.25rem;
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 12px;
background: #fffdf8;
line-height: 1.75;
font-size: 0.8125rem;
color: #3d3a35;
}
.am-intro__title { font-weight: 600; font-size: 0.875rem; margin-bottom: 0.5rem; color: #1d1d1f; }
.am-basis {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.am-basis--out { background: #fdecec; color: #a32d2d; border: 1px solid #f3c3c3; }
.am-basis--in { background: #eaf3de; color: #3b6d11; border: 1px solid #c8e0a5; }
.am-flow { display: grid; gap: 0.5rem; margin-top: 0.5rem; }
.am-flow__row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: var(--vip-content-bg, #f8f6f1);
font-size: 0.8125rem;
}
.am-flow__tag { flex: 0 0 auto; padding: 0.1rem 0.45rem; border-radius: 4px; font-size: 0.6875rem; font-weight: 600; }
.am-flow__tag--out { background: #fdecec; color: #a32d2d; }
.am-flow__tag--in { background: #eaf3de; color: #3b6d11; }
.am-flow__entry { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.am-flow__note { margin-left: auto; font-size: 0.6875rem; color: #8a8377; white-space: nowrap; }
.am-item { cursor: pointer; }
.am-item:hover { background: #faf7f0; }
.am-item.is-editing { background: #fffaf0; }
.am-item.is-editing:hover { background: #fffaf0; }
.am-detail > td { padding: 0 !important; background: #fbfaf6; border-top: 0 !important; }
.am-detail__box { padding: 0.75rem 1rem 1rem; border-left: 3px solid var(--vip-content-accent, #b9954f); }
.am-detail__head { font-size: 0.75rem; color: #6b665e; margin-bottom: 0.35rem; }
.am-empty { padding: 2rem; text-align: center; color: #8a8377; font-size: 0.8125rem; }
.am-caret { display: inline-block; transition: transform 0.15s; color: #b0a99c; }
.am-item.is-open .am-caret { transform: rotate(90deg); }
/* 校验标记 */
.am-flag { display: inline-flex; align-items: center; gap: 0.25rem; font-size: 0.6875rem; font-weight: 600; }
.am-flag--err { color: #a32d2d; }
.am-flag--warn { color: #9a6b12; }
.am-flag--tip { color: #4a6a8a; }
.am-flag--ok { color: #6b8e4e; }
.am-dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; display: inline-block; }
.am-dot--err { background: #d05a5a; }
.am-dot--warn { background: #d0a04a; }
.am-dot--tip { background: #7a9ab8; }
.am-dot--ok { background: #8fb26a; }
.am-issue-list { margin-top: 0.6rem; display: grid; gap: 0.3rem; }
.am-issue {
display: flex;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
border-radius: 6px;
font-size: 0.75rem;
line-height: 1.6;
background: #fff;
border: 1px solid #eae4d8;
}
.am-issue__tag { flex: 0 0 auto; font-weight: 600; }
.am-issue--错误 { border-color: #f0cccc; background: #fdf5f5; }
.am-issue--错误 .am-issue__tag { color: #a32d2d; }
.am-issue--警告 { border-color: #f0e0c0; background: #fdfaf2; }
.am-issue--警告 .am-issue__tag { color: #8a5d10; }
.am-issue--提示 { border-color: #dce6ef; background: #f8fbfd; }
.am-issue--提示 .am-issue__tag { color: #40607e; }
/* 表格必须锁列宽(table-layout:fixed)。默认的自动列宽会按内容互相挤压,
「借方/贷方科目」两列会被挤到文字被截断;锁死后按上面 th 的宽度分配,
窗口不够宽时由外层 .upstream-data-table-scroll 横向滚动。
87.5rem = 各列宽度之和,改列宽时要一起改。 */
.am-table { table-layout: fixed; min-width: 87.5rem; }
/* 只读单元格超长就省略号(编辑态那格要放开,否则下拉会被裁) */
.am-table > tbody > tr.am-item:not(.is-editing) > td { overflow: hidden; text-overflow: ellipsis; }
/* 行内编辑 */
/* 注意:td 上有 height:2.5rem + white-space:nowrap(来自 upstream-data-table),
编辑态必须放开,否则一级/二级两个下拉会被压成一行、挤出单元格 */
.am-item.is-editing > td {
height: auto !important;
white-space: normal !important;
vertical-align: top;
padding-top: 0.5rem !important;
padding-bottom: 0.5rem !important;
}
.am-edit-cell input, .am-edit-cell select {
display: block;
box-sizing: border-box;
width: 100%;
max-width: 100%;
padding: 0.2rem 0.35rem;
border: 1px solid #d8d2c7;
border-radius: 6px;
font-size: 0.6875rem;
line-height: 1.5;
background: #fff;
color: #2c2a26;
}
/* 二级科目下拉:跟一级分开一行,颜色淡一点以示从属 */
.am-edit-cell select + select { margin-top: 0.25rem; border-color: #e6dfd0; color: #5c574e; }
.am-edit-cell input:focus, .am-edit-cell select:focus { outline: 2px solid #ddc99a; border-color: #c9ab63; }
.am-row-actions { display: flex; gap: 0.35rem; white-space: nowrap; }
.am-btn-mini {
padding: 0.2rem 0.5rem;
border-radius: 6px;
border: 1px solid #d8d2c7;
background: #fff;
font-size: 0.6875rem;
cursor: pointer;
color: #4a463f;
}
.am-btn-mini:hover { background: #f6f2e9; border-color: #c9ab63; }
.am-btn-mini--danger { color: #a32d2d; border-color: #edcccc; }
.am-btn-mini--danger:hover { background: #fdf3f3; border-color: #d99; }
.am-btn-mini--primary { background: #1d1d1f; color: #f0e4bf; border-color: #1d1d1f; }
.am-btn-mini--primary:hover { background: #333; color: #f7edd2; }
/* 工具栏补充按钮 */
.am-tool-actions { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.am-btn {
padding: 0.4rem 0.85rem;
border-radius: 8px;
border: 1px solid #d8d2c7;
background: #fff;
font-size: 0.78rem;
cursor: pointer;
color: #3d3a35;
white-space: nowrap;
}
.am-btn:hover { border-color: #c9ab63; background: #faf6ec; }
.am-btn--primary { background: #1d1d1f; color: #f0e4bf; border-color: #1d1d1f; }
.am-btn--primary:hover { background: #333; color: #f7edd2; }
.am-btn[disabled] { opacity: 0.5; cursor: not-allowed; }
.am-summary { display: flex; flex-wrap: wrap; gap: 1.25rem; align-items: center; }
.am-summary__sep { color: #d8d2c7; }
/* 弹窗 */
.am-modal {
position: fixed;
inset: 0;
background: rgba(20, 18, 15, 0.42);
display: flex;
align-items: center;
justify-content: center;
z-index: 9998;
padding: 1.5rem;
}
.am-modal[hidden] { display: none; }
.am-modal__box {
width: min(720px, 100%);
max-height: 86vh;
overflow: auto;
background: #fffdf8;
border-radius: 14px;
border: 1px solid #e4dccd;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.22);
}
.am-modal__head {
padding: 1rem 1.25rem;
border-bottom: 1px solid #eee7da;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
}
.am-modal__body { padding: 1.25rem; font-size: 0.8125rem; line-height: 1.8; color: #3d3a35; }
.am-modal__foot { padding: 1rem 1.25rem; border-top: 1px solid #eee7da; display: flex; gap: 0.5rem; justify-content: flex-end; }
.am-modal__close { border: 0; background: transparent; font-size: 1.1rem; cursor: pointer; color: #8a8377; }
.am-kv { display: grid; grid-template-columns: 7rem 1fr; gap: 0.4rem 0.75rem; margin: 0.75rem 0; }
.am-kv__k { color: #8a8377; }
.am-preview-table { width: 100%; border-collapse: collapse; font-size: 0.75rem; margin-top: 0.5rem; }
.am-preview-table th, .am-preview-table td { border: 1px solid #eee7da; padding: 0.3rem 0.45rem; text-align: left; }
.am-preview-table th { background: #f6f2e9; font-weight: 600; }
.am-check { display: inline-flex; align-items: center; gap: 0.4rem; margin-top: 0.75rem; }
/* 轻提示 */
.am-toast {
position: fixed;
right: 1.5rem;
bottom: 1.5rem;
z-index: 9999;
padding: 0.7rem 1rem;
border-radius: 10px;
font-size: 0.8125rem;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18);
max-width: 26rem;
line-height: 1.6;
}
.am-toast[hidden] { display: none; }
.am-toast--ok { background: #1d1d1f; color: #f0e4bf; }
.am-toast--err { background: #8e2f2f; color: #fff; }
.am-alert {
margin-top: 1rem;
padding: 0.75rem 1rem;
border-radius: 10px;
border: 1px solid #f0cccc;
background: #fdf5f5;
color: #8e2f2f;
font-size: 0.8125rem;
}
@media (max-width: 768px) {
.am-flow__note { display: none; }
.am-intro { font-size: 0.78rem; }
}
</style>
<section class="am-intro">
<div class="am-intro__title">这张表的用法:科目对固定,借贷方向随账单收支走</div>
每条规则只锁定「走哪两个科目」和「方向基准」两件事。实际记账时,按这笔账单在平台上的实际收支代入:
<br>
· 基准为 <span class="am-basis am-basis--out">支出基准</span> 的项目 —— 账单<b>支出</b>时<b>照抄</b>表内方向;账单<b>收入</b>(返还 / 退回)时<b>借贷互换</b>。
<br>
· 基准为 <span class="am-basis am-basis--in">收入基准</span> 的项目 —— 账单<b>收入</b>时<b>照抄</b>表内方向;账单<b>支出</b>(退款 / 冲减)时<b>借贷互换</b>。
<br>
<span style="color:#8a8377;">点行展开看两种收支下的最终分录与校验结果;点「编辑」可直接改这一行;有新增账单项目时用「导入」追加。</span>
</section>
@if ($amError)
<div class="am-alert">读取映射表失败:{{ $amError }}</div>
@endif
<div class="upstream-data-summary">
<div class="am-summary" id="amSummaryBar">
<span>共 <b id="amTotal">{{ $amSummary['total'] }}</b> 条规则</span>
<span>支出基准 <b id="amOut">{{ $outBase }}</b></span>
<span>收入基准 <b id="amIn">{{ $inBase }}</b></span>
<span>平台 <b id="amPlatformCount">{{ count($platforms) }}</b> 个</span>
<span class="am-summary__sep">|</span>
<span>校验:<b id="amErrRows" style="color:#a32d2d;">{{ $amSummary['error_rows'] }}</b> 行有错误 ·
<b id="amWarnRows" style="color:#8a5d10;">{{ $amSummary['warning_rows'] }}</b> 行有警告 ·
<b id="amTipCount" style="color:#40607e;">{{ $amSummary['tip_count'] }}</b> 条提示</span>
</div>
</div>
<div class="upstream-data-toolbar">
<label class="upstream-data-field">
<span>搜索</span>
<input type="search" id="amKeyword" placeholder="平台 / 账单项目 / 备注 / 科目">
</label>
<label class="upstream-data-field">
<span>平台</span>
<select id="amPlatform">
<option value="">全部</option>
@foreach ($platforms as $p)
<option value="{{ $p }}">{{ $p }}</option>
@endforeach
</select>
</label>
<label class="upstream-data-field">
<span>大类</span>
<select id="amCategory">
<option value="">全部</option>
@foreach ($categories as $c)
<option value="{{ $c }}">{{ $c }}</option>
@endforeach
</select>
</label>
<label class="upstream-data-field">
<span>方向基准</span>
<select id="amBasis">
<option value="">全部</option>
<option value="支出基准">支出基准</option>
<option value="收入基准">收入基准</option>
</select>
</label>
<label class="upstream-data-field">
<span>只看</span>
<select id="amOnly">
<option value="">全部</option>
<option value="issue">有问题的行</option>
<option value="error">只看错误</option>
</select>
</label>
<div class="upstream-data-actions am-tool-actions">
<button type="button" class="am-btn am-btn--primary" id="amAdd">+ 新增一条</button>
<button type="button" class="am-btn" id="amImportBtn">导入</button>
<button type="button" class="am-btn" id="amExportBtn">导出 CSV</button>
<button type="button" class="am-btn" id="amTemplateBtn">导入模板</button>
<button type="button" class="am-btn" id="amResetFilter">重置筛选</button>
<span id="amCount" style="font-size:0.75rem;color:#8a8377;"></span>
</div>
</div>
<div class="upstream-data-table-panel">
<div class="upstream-data-table-scroll">
<table class="upstream-data-table am-table">
<thead>
<tr>
<th style="width:2.5rem;">#</th>
<th style="width:4.5rem;">平台</th>
<th style="width:17rem;">账单项目</th>
<th style="width:12rem;">财务备注</th>
<th style="width:5rem;">票据</th>
<th style="width:5rem;">大类</th>
<th style="width:11rem;">借方科目</th>
<th style="width:11rem;">贷方科目</th>
<th style="width:6rem;">方向基准</th>
<th style="width:6rem;">校验</th>
<th style="width:8rem;">操作</th>
</tr>
</thead>
<tbody id="amBody"></tbody>
</table>
</div>
<div class="am-empty" id="amEmpty" hidden>没有符合条件的数据。</div>
</div>
</div>
<div class="am-modal" id="amModal" hidden>
<div class="am-modal__box">
<div class="am-modal__head">
<span id="amModalTitle">导入科目映射表</span>
<button type="button" class="am-modal__close" id="amModalClose">✕</button>
</div>
<div class="am-modal__body" id="amModalBody"></div>
<div class="am-modal__foot" id="amModalFoot"></div>
</div>
</div>
<div class="am-toast" id="amToast" hidden></div>
<input type="file" id="amFile" accept=".xlsx,.xls,.csv" hidden>
<script>
(function () {
var CSRF = '{{ csrf_token() }}';
var ENDPOINT = @json($amEndpoint ?? url('workspace/financial-rules/account-mapping/actions'));
var ROWS = @json($amRows, JSON_UNESCAPED_UNICODE);
var OPTIONS = @json($amOptions, JSON_UNESCAPED_UNICODE);
var SUMMARY = @json($amSummary, JSON_UNESCAPED_UNICODE);
var COA = null;
var editingId = null;
var draft = null;
var openId = null;
// 大类 → 预期方向基准:改大类时顺手把基准带过去,避免两者打架
var CATEGORY_BASIS = {
'费用': '支出基准', '保证金': '支出基准', '推广': '支出基准', '暂挂': '支出基准',
'营业外支出': '支出基准', '提现': '支出基准', '收入': '收入基准', '转账': '收入基准'
};
var body = document.getElementById('amBody');
var empty = document.getElementById('amEmpty');
var countEl = document.getElementById('amCount');
var kw = document.getElementById('amKeyword');
var fPlatform = document.getElementById('amPlatform');
var fCategory = document.getElementById('amCategory');
var fBasis = document.getElementById('amBasis');
var fOnly = document.getElementById('amOnly');
var toastEl = document.getElementById('amToast');
/* ---------- 小工具 ---------- */
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function acct(a, b) {
a = String(a || '').trim();
b = String(b || '').trim();
if (!a && !b) { return '—'; }
if (b && b !== a) { return esc(a) + ' / ' + esc(b); }
return esc(a || b);
}
function levels(issues) {
var out = { error: 0, warning: 0, tip: 0 };
(issues || []).forEach(function (i) {
if (i.level === '错误') { out.error++; }
else if (i.level === '警告') { out.warning++; }
else { out.tip++; }
});
return out;
}
function flagHtml(issues) {
var n = levels(issues);
if (n.error) { return '<span class="am-flag am-flag--err"><span class="am-dot am-dot--err"></span>错误 ' + n.error + '</span>'; }
if (n.warning) { return '<span class="am-flag am-flag--warn"><span class="am-dot am-dot--warn"></span>警告 ' + n.warning + '</span>'; }
if (n.tip) { return '<span class="am-flag am-flag--tip"><span class="am-dot am-dot--tip"></span>提示 ' + n.tip + '</span>'; }
return '<span class="am-flag am-flag--ok"><span class="am-dot am-dot--ok"></span>正常</span>';
}
function basisTag(b) {
if (b === '支出基准') { return '<span class="am-basis am-basis--out">支出基准</span>'; }
if (b === '收入基准') { return '<span class="am-basis am-basis--in">收入基准</span>'; }
return '<span style="color:#b04a4a;">未标注</span>';
}
function toast(msg, kind) {
toastEl.className = 'am-toast am-toast--' + (kind === 'err' ? 'err' : 'ok');
toastEl.textContent = msg;
toastEl.hidden = false;
clearTimeout(toastEl._t);
toastEl._t = setTimeout(function () { toastEl.hidden = true; }, 4200);
}
/* ---------- 核心:按账单实际收支 + 本行基准,决定照抄还是互换 ---------- */
function entry(r, flow) {
var natural = (r.basis === '支出基准') ? 'out' : 'in';
var swap = (flow !== natural);
var d = swap ? [r.cAcct, r.cSub] : [r.dAcct, r.dSub];
var c = swap ? [r.dAcct, r.dSub] : [r.cAcct, r.cSub];
return { swap: swap, d: d, c: c };
}
function detailHtml(r) {
var out = entry(r, 'out');
var inn = entry(r, 'in');
function line(tag, label, e) {
return '<div class="am-flow__row">'
+ '<span class="am-flow__tag am-flow__tag--' + tag + '">' + label + '</span>'
+ '<span class="am-flow__entry">借 ' + acct(e.d[0], e.d[1]) + ' / 贷 ' + acct(e.c[0], e.c[1]) + '</span>'
+ '<span class="am-flow__note">' + (e.swap ? '借贷互换' : '照抄表内方向') + '</span>'
+ '</div>';
}
var issues = r.issues || [];
var issuesHtml = '';
if (issues.length) {
issuesHtml = '<div class="am-issue-list">' + issues.map(function (i) {
return '<div class="am-issue am-issue--' + esc(i.level) + '">'
+ '<span class="am-issue__tag">' + esc(i.level) + '</span>'
+ '<span>' + esc(i.message) + '</span></div>';
}).join('') + '</div>';
}
return '<tr class="am-detail"><td colspan="11"><div class="am-detail__box">'
+ '<div class="am-detail__head">' + esc(r.platform) + ' · ' + esc(r.item)
+ ' (基准:' + esc(r.basis) + ',记录 ' + r.id + ')</div>'
+ '<div class="am-flow">'
+ line('out', '账单支出', out)
+ line('in', '账单收入', inn)
+ '</div>' + issuesHtml + '</div></td></tr>';
}
/* ---------- 渲染 ---------- */
function visibleRows() {
var keyword = (kw.value || '').trim().toLowerCase();
return ROWS.filter(function (r) {
if (fPlatform.value && r.platform !== fPlatform.value) { return false; }
if (fCategory.value && r.category !== fCategory.value) { return false; }
if (fBasis.value && r.basis !== fBasis.value) { return false; }
if (fOnly.value) {
var n = levels(r.issues);
if (fOnly.value === 'error' && !n.error) { return false; }
if (fOnly.value === 'issue' && !n.error && !n.warning && !n.tip) { return false; }
}
if (keyword) {
var hay = [r.platform, r.item, r.memo, r.dAcct, r.dSub, r.cAcct, r.cSub, r.category, r.basis]
.join(' ').toLowerCase();
if (hay.indexOf(keyword) < 0) { return false; }
}
return true;
});
}
function rowHtml(r, index) {
var edit = (editingId === r.id && draft);
if (edit) {
return '<tr class="am-item is-editing" data-id="' + r.id + '">'
+ '<td>' + (index + 1) + '</td>'
+ '<td class="am-edit-cell"><input data-f="platform" value="' + esc(draft.platform) + '" list="amPlatformList"></td>'
+ '<td class="am-edit-cell"><input data-f="item" value="' + esc(draft.item) + '"></td>'
+ '<td class="am-edit-cell"><input data-f="memo" value="' + esc(draft.memo) + '"></td>'
+ '<td class="am-edit-cell"><select data-f="invoice">' + optionsHtml(OPTIONS.invoices || [], draft.invoice) + '</select></td>'
+ '<td class="am-edit-cell"><select data-f="category">' + optionsHtml(OPTIONS.categories || [], draft.category) + '</select></td>'
+ '<td class="am-edit-cell">' + acctEditor('dAcct', 'dSub') + '</td>'
+ '<td class="am-edit-cell">' + acctEditor('cAcct', 'cSub') + '</td>'
+ '<td class="am-edit-cell"><select data-f="basis">' + optionsHtml(OPTIONS.basis || [], draft.basis) + '</select></td>'
+ '<td>' + flagHtml(r.issues) + '</td>'
+ '<td><div class="am-row-actions">'
+ '<button type="button" class="am-btn-mini am-btn-mini--primary" data-act="save">保存</button>'
+ '<button type="button" class="am-btn-mini" data-act="cancel">取消</button>'
+ '</div></td></tr>';
}
return '<tr class="am-item" data-id="' + r.id + '" data-index="' + index + '">'
+ '<td>' + (index + 1) + '</td>'
+ '<td>' + esc(r.platform) + '</td>'
+ '<td><span class="am-caret">▶</span> <span title="' + esc(r.item) + '">' + esc(r.item) + '</span></td>'
+ '<td><span title="' + esc(r.memo) + '">' + esc(r.memo) + '</span></td>'
+ '<td>' + esc(r.invoice || '—') + '</td>'
+ '<td>' + esc(r.category) + '</td>'
+ '<td>' + acct(r.dAcct, r.dSub) + '</td>'
+ '<td>' + acct(r.cAcct, r.cSub) + '</td>'
+ '<td>' + basisTag(r.basis) + '</td>'
+ '<td>' + flagHtml(r.issues) + '</td>'
+ '<td><div class="am-row-actions">'
+ '<button type="button" class="am-btn-mini" data-act="edit">编辑</button>'
+ '<button type="button" class="am-btn-mini am-btn-mini--danger" data-act="delete">删除</button>'
+ '</div></td></tr>';
}
// placeholder:第一个空 option 的显示文字。不传就是纯空白 —— 但纯空白会让人以为
// 下拉是坏的(比如「其他货币资金」有 40 个明细,选中项却是空白时看着像个空框)。
function optionsHtml(values, current, placeholder) {
var list = values.slice();
if (current && list.indexOf(current) < 0) { list.push(current); }
var out = list.indexOf('') < 0
? '<option value="">' + esc(placeholder == null ? '' : placeholder) + '</option>'
: '';
return out + list.map(function (v) {
return '<option value="' + esc(v) + '"' + (v === current ? ' selected' : '') + '>' + esc(v) + '</option>';
}).join('');
}
function acctEditor(acctField, subField) {
var acctValue = draft[acctField] || '';
var subValue = draft[subField] || '';
var level1 = (COA && COA.level1) ? COA.level1.map(function (a) { return a.name; }) : [];
var subList = [];
if (COA && COA.level1 && draft[acctField]) {
var hit = COA.level1.filter(function (a) { return a.name === draft[acctField]; })[0];
if (hit && COA.children && COA.children[hit.code]) {
subList = COA.children[hit.code].map(function (a) { return a.name; });
}
}
var acctHint = COA ? '' : '<div style="font-size:0.625rem;color:#b0a99c;margin-top:0.15rem;">科目清单加载中…</div>';
var subPlaceholder = subList.length ? '(不选二级,直接挂一级)' : '(该一级科目下没有明细)';
return '<select data-f="' + acctField + '">' + optionsHtml(level1, acctValue, '(选一级科目)') + '</select>'
+ acctHint
+ '<select data-f="' + subField + '" style="margin-top:0.25rem;">' + optionsHtml(subList, subValue, subPlaceholder) + '</select>';
}
function render() {
var list = visibleRows();
var html = '';
list.forEach(function (r, i) { html += rowHtml(r, i); });
body.innerHTML = html;
if (editingId === 'new' && draft) {
body.insertAdjacentHTML('afterbegin', newRowHtml());
}
empty.hidden = list.length > 0;
countEl.textContent = (editingId === 'new')
? '正在新增一条,填完点「保存」'
: ('显示 ' + list.length + ' / ' + ROWS.length + ' 条');
paintSummary();
}
function paintSummary() {
document.getElementById('amTotal').textContent = ROWS.length;
var out = 0, inn = 0, platforms = {};
ROWS.forEach(function (r) {
if (r.basis === '支出基准') { out++; }
if (r.basis === '收入基准') { inn++; }
if (r.platform) { platforms[r.platform] = true; }
});
document.getElementById('amOut').textContent = out;
document.getElementById('amIn').textContent = inn;
document.getElementById('amPlatformCount').textContent = Object.keys(platforms).length;
document.getElementById('amErrRows').textContent = SUMMARY.error_rows || 0;
document.getElementById('amWarnRows').textContent = SUMMARY.warning_rows || 0;
document.getElementById('amTipCount').textContent = SUMMARY.tip_count || 0;
}
/* ---------- 与服务端交互 ---------- */
function api(method, path, body) {
var headers = { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' };
if (body && !(body instanceof FormData)) { headers['Content-Type'] = 'application/json'; }
return fetch(ENDPOINT + path, {
method: method,
headers: headers,
credentials: 'same-origin',
body: body ? (body instanceof FormData ? body : JSON.stringify(body)) : undefined
}).then(function (res) {
return res.json().catch(function () { return {}; }).then(function (data) {
if (!res.ok) {
var msg = data.message || '';
if (data.errors) {
msg = Object.keys(data.errors).map(function (k) { return data.errors[k][0]; }).join(' ');
}
// 导入接口有 1 次/分钟的限流,撞上了要说清楚,别报「HTTP 429」让人猜
if (res.status === 429) { msg = '导入操作太频繁了(平台限制每分钟 1 次),请等 1 分钟再试。'; }
if (res.status === 419) { msg = '登录状态已过期,请刷新页面重新登录。'; }
if (res.status === 403) { msg = msg || '当前账号没有维护这张表的权限。'; }
throw new Error(msg || ('请求失败(HTTP ' + res.status + ')'));
}
return data;
});
});
}
function applyState(data) {
if (data.rules) {
ROWS = data.rules;
editingId = null;
draft = null;
openId = null;
}
if (data.options) { OPTIONS = data.options; rebuildFilterOptions(); }
if (data.summary) { SUMMARY = data.summary; }
render();
}
function rebuildFilterOptions() {
fill(fPlatform, OPTIONS.platforms || [], fPlatform.value);
var cats = (OPTIONS.categories || []).slice();
(fCategory.value ? [fCategory.value] : []).forEach(function (v) { if (cats.indexOf(v) < 0) { cats.push(v); } });
fill(fCategory, cats, fCategory.value);
}
function fill(select, values, current) {
var keep = current || '';
var out = '<option value="">全部</option>';
values.forEach(function (v) { out += '<option value="' + esc(v) + '"' + (v === keep ? ' selected' : '') + '>' + esc(v) + '</option>'; });
select.innerHTML = out;
}
function loadCoa() {
if (COA) { return Promise.resolve(COA); }
return api('GET', '/accounts').then(function (data) {
COA = data;
return COA;
});
}
/* ---------- 行操作 ---------- */
body.addEventListener('click', function (ev) {
var btn = ev.target.closest('button[data-act]');
var tr = ev.target.closest('tr.am-item');
if (!tr) { return; }
var id = Number(tr.getAttribute('data-id'));
if (btn) {
ev.stopPropagation();
var act = btn.getAttribute('data-act');
var isNew = tr.getAttribute('data-id') === 'new';
if (act === 'edit') { startEdit(id); }
else if (act === 'cancel') { editingId = null; draft = null; render(); }
else if (act === 'save') { if (isNew) { createRow(); } else { saveEdit(id); } }
else if (act === 'delete') { removeRow(id); }
return;
}
if (editingId === id) { return; }
// 点行 → 展开 / 收起分录与校验
var r = ROWS.filter(function (x) { return x.id === id; })[0];
if (!r) { return; }
var isOpen = (openId === id);
collapseDetail();
if (isOpen) { return; }
openId = id;
tr.classList.add('is-open');
tr.insertAdjacentHTML('afterend', detailHtml(r));
});
body.addEventListener('change', function (ev) {
var el = ev.target;
var f = el.getAttribute && el.getAttribute('data-f');
if (!f || !draft) { return; }
draft[f] = el.value;
if (f === 'dAcct' || f === 'cAcct') {
// 换了一级科目就清掉不属于新科目的二级,避免留下「5601 / 5602.45」这种组合
draft[(f === 'dAcct') ? 'dSub' : 'cSub'] = '';
}
if (f === 'category') {
// 大类变了,方向基准跟着走,避免两者打架
draft.basis = CATEGORY_BASIS[draft.category] || draft.basis;
}
render();
});
body.addEventListener('input', function (ev) {
var el = ev.target;
var f = el.getAttribute && el.getAttribute('data-f');
if (!f || !draft) { return; }
draft[f] = el.value;
});
function collapseDetail() {
Array.prototype.forEach.call(body.querySelectorAll('tr.am-item.is-open'), function (el) {
el.classList.remove('is-open');
if (el.nextElementSibling && el.nextElementSibling.classList.contains('am-detail')) {
el.nextElementSibling.remove();
}
});
openId = null;
}
function collectDraft() {
var values = {};
['platform', 'item', 'memo', 'invoice', 'category', 'dAcct', 'dSub', 'cAcct', 'cSub', 'basis'].forEach(function (f) {
values[f] = draft[f] == null ? '' : draft[f];
});
return values;
}
function startEdit(id) {
loadCoa().then(function () {
var r = ROWS.filter(function (x) { return x.id === id; })[0];
if (!r) { return; }
draft = {
platform: r.platform, item: r.item, memo: r.memo, invoice: r.invoice,
category: r.category, dAcct: r.dAcct, dSub: r.dSub, cAcct: r.cAcct, cSub: r.cSub, basis: r.basis
};
editingId = id;
collapseDetail();
render();
}).catch(function (e) { toast('科目清单加载失败:' + e.message, 'err'); });
}
function saveEdit(id) {
api('PATCH', '/rules/' + id, { values: collectDraft() }).then(function (data) {
applyState(data);
toast(data.message || '已保存。');
}).catch(function (e) { toast(e.message, 'err'); });
}
function removeRow(id) {
var r = ROWS.filter(function (x) { return x.id === id; })[0];
if (!r) { return; }
if (!window.confirm('确定删除「' + r.platform + ' · ' + r.item + '」这条规则?\n\n删掉以后,这个账单项目就匹配不到科目了。')) { return; }
api('DELETE', '/rules/' + id).then(function (data) {
applyState(data);
toast(data.message || '已删除。');
}).catch(function (e) { toast(e.message, 'err'); });
}
/* ---------- 新增 ---------- */
document.getElementById('amAdd').addEventListener('click', function () {
if (editingId) { toast('请先保存或取消正在编辑的那一行。', 'err'); return; }
loadCoa().then(function () {
draft = {
platform: '', item: '', memo: '', invoice: '-', category: '费用',
dAcct: '', dSub: '', cAcct: '', cSub: '', basis: '支出基准'
};
editingId = 'new';
collapseDetail();
render();
var focus = body.querySelector('tr[data-id="new"] input[data-f="platform"]');
if (focus) { focus.focus(); }
}).catch(function (e) { toast('科目清单加载失败:' + e.message, 'err'); });
});
function newRowHtml() {
return '<tr class="am-item is-editing" data-id="new">'
+ '<td>新</td>'
+ '<td class="am-edit-cell"><input data-f="platform" value="' + esc(draft.platform) + '" placeholder="平台"></td>'
+ '<td class="am-edit-cell"><input data-f="item" value="' + esc(draft.item) + '" placeholder="账单项目上的名称"></td>'
+ '<td class="am-edit-cell"><input data-f="memo" value="' + esc(draft.memo) + '" placeholder="财务备注"></td>'
+ '<td class="am-edit-cell"><select data-f="invoice">' + optionsHtml(OPTIONS.invoices || [], draft.invoice) + '</select></td>'
+ '<td class="am-edit-cell"><select data-f="category">' + optionsHtml(OPTIONS.categories || [], draft.category) + '</select></td>'
+ '<td class="am-edit-cell">' + acctEditor('dAcct', 'dSub') + '</td>'
+ '<td class="am-edit-cell">' + acctEditor('cAcct', 'cSub') + '</td>'
+ '<td class="am-edit-cell"><select data-f="basis">' + optionsHtml(OPTIONS.basis || [], draft.basis) + '</select></td>'
+ '<td>—</td>'
+ '<td><div class="am-row-actions">'
+ '<button type="button" class="am-btn-mini am-btn-mini--primary" data-act="save">保存</button>'
+ '<button type="button" class="am-btn-mini" data-act="cancel">取消</button>'
+ '</div></td></tr>';
}
function createRow() {
var values = collectDraft();
if (!values.platform || !values.item) {
toast('平台和账单项目不能为空。', 'err');
return;
}
api('POST', '/rules', { values: values }).then(function (data) {
applyState(data);
toast(data.message || '已新增。');
}).catch(function (e) { toast(e.message, 'err'); });
}
/* ---------- 导入 ---------- */
var fileInput = document.getElementById('amFile');
document.getElementById('amImportBtn').addEventListener('click', function () { fileInput.click(); });
fileInput.addEventListener('change', function () {
// 关键:File 对象必须在**同步**阶段就抓住。
// 若在 fetch 回来之后才读 fileInput.files[0],一旦 input.value 被清空
// (或 change 被触发第二次)就会拿到 undefined,抛 "reading 'name'"。
var file = fileInput.files && fileInput.files[0];
if (!file) { return; }
fileInput.value = ''; // 立刻清空:再选同一个文件也能触发 change
var form = new FormData();
form.append('import', file);
openModal('正在识别文件…', '<p>请稍候。</p>', []);
api('POST', '/preview', form).then(function (data) {
showImportPreview(data, file);
}).catch(function (e) {
closeModal();
toast('导入失败:' + e.message, 'err');
});
});
function showImportPreview(data, file) {
var rows = (data.sample || []).map(function (r) {
return '<tr><td>' + esc(r.platform) + '</td><td>' + esc(r.item) + '</td><td>' + esc(r.memo) + '</td>'
+ '<td>' + esc(r.category) + '</td><td>' + acct(r.dAcct, r.dSub) + '</td><td>' + acct(r.cAcct, r.cSub) + '</td>'
+ '<td>' + esc(r.basis) + '</td></tr>';
}).join('');
var dupList = (data.duplicate_keys || []).length
? '<div style="margin-top:0.5rem;color:#8a5d10;">重复的键(平台 | 项目 | 备注)前几个:<br>' + (data.duplicate_keys || []).map(esc).join('<br>') + '</div>'
: '';
var html = '<div>文件:<b>' + esc(file.name) + '</b>(工作表 ' + esc(data.sheet) + ')</div>'
+ '<div class="am-kv">'
+ '<div class="am-kv__k">识别到</div><div><b>' + data.total + '</b> 行</div>'
+ '<div class="am-kv__k">可新增</div><div><b style="color:#3b6d11;">' + data.can_create + '</b> 条</div>'
+ '<div class="am-kv__k">与现有重复</div><div><b style="color:#8a5d10;">' + data.can_overwrite + '</b> 条</div>'
+ '</div>'
+ '<label class="am-check"><input type="checkbox" id="amOverwrite"> 重复的那些用文件里的内容覆盖现有规则(不勾选则跳过)</label>'
+ dupList
+ '<div style="margin-top:0.75rem;">前 ' + (data.sample || []).length + ' 行预览:</div>'
+ '<table class="am-preview-table"><thead><tr><th>平台</th><th>账单项目</th><th>备注</th><th>大类</th><th>借方</th><th>贷方</th><th>基准</th></tr></thead><tbody>' + rows + '</tbody></table>';
var foot = '<button type="button" class="am-btn" id="amImportCancel">取消</button>'
+ '<button type="button" class="am-btn am-btn--primary" id="amImportConfirm">确认导入</button>';
openModal('导入预览', html, foot);
document.getElementById('amImportCancel').addEventListener('click', closeModal);
document.getElementById('amImportConfirm').addEventListener('click', function () {
var overwrite = document.getElementById('amOverwrite').checked;
var form = new FormData();
form.append('import', file);
if (overwrite) { form.append('overwrite', '1'); }
var btn = document.getElementById('amImportConfirm');
btn.disabled = true;
btn.textContent = '导入中…';
api('POST', '/import', form).then(function (res) {
closeModal();
applyState(res);
toast(res.message || '导入完成。');
}).catch(function (e) {
btn.disabled = false;
btn.textContent = '确认导入';
toast('导入失败:' + e.message, 'err');
});
});
}
/* ---------- 弹窗 ---------- */
var modal = document.getElementById('amModal');
function openModal(title, bodyHtml, footButtons) {
document.getElementById('amModalTitle').textContent = title;
document.getElementById('amModalBody').innerHTML = bodyHtml;
// footButtons 既可传数组也可传 HTML 字符串,两种都认
document.getElementById('amModalFoot').innerHTML = Array.isArray(footButtons)
? footButtons.join('')
: String(footButtons || '');
modal.hidden = false;
}
function closeModal() { modal.hidden = true; }
document.getElementById('amModalClose').addEventListener('click', closeModal);
modal.addEventListener('click', function (ev) { if (ev.target === modal) { closeModal(); } });
/* ---------- 导出 / 模板 ---------- */
document.getElementById('amExportBtn').addEventListener('click', function () { window.location.href = ENDPOINT + '/export'; });
document.getElementById('amTemplateBtn').addEventListener('click', function () { window.location.href = ENDPOINT + '/template'; });
/* ---------- 筛选 ---------- */
[kw, fPlatform, fCategory, fBasis, fOnly].forEach(function (el) {
el.addEventListener('input', render);
el.addEventListener('change', render);
});
document.getElementById('amResetFilter').addEventListener('click', function () {
kw.value = ''; fPlatform.value = ''; fCategory.value = ''; fBasis.value = ''; fOnly.value = '';
render();
});
// 有编辑中的行时,离开前提醒一下
window.addEventListener('beforeunload', function (ev) {
if (draft) { ev.preventDefault(); ev.returnValue = ''; }
});
render();
// 后台预取账套科目清单(160KB 左右)。不做这一步的话,第一次点「编辑」要等它下载完
// 才有反应,用户会以为按钮坏了。
loadCoa().catch(function () { /* 失败也没关系,点编辑时会再试并给出提示 */ });
})();
</script>

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Business\AccountMappingActions;
use App\Http\Controllers\Business\CourierQuoteChanges;
use App\Http\Controllers\Business\CourierQuoteSignatures;
use App\Http\Controllers\Business\CourierAgreementSignatures;
@ -202,6 +203,23 @@ foreach ($supplierPayableActionPages as $dataset) {
});
}
Route::prefix('workspace/financial-rules/account-mapping/actions')
->name('business.financial-rules.account-mapping.actions.')
->middleware('business.page:financial-rules,account-mapping')
->group(function () {
Route::get('rules', [AccountMappingActions::class, 'index'])->name('rules');
Route::post('rules', [AccountMappingActions::class, 'store'])->name('rules.store');
Route::patch('rules/{id}', [AccountMappingActions::class, 'update'])->whereNumber('id')->name('rules.update');
Route::delete('rules/{id}', [AccountMappingActions::class, 'destroy'])->whereNumber('id')->name('rules.destroy');
Route::get('accounts', [AccountMappingActions::class, 'accounts'])->name('accounts');
// 预览不落盘,所以不挂 throttle:import(限额 1 次/分钟,会卡住「预览→确认」两次请求)
Route::post('preview', [AccountMappingActions::class, 'preview'])->name('preview');
Route::post('import', [AccountMappingActions::class, 'import'])->middleware('import')->name('import');
Route::post('reset', [AccountMappingActions::class, 'reset'])->name('reset');
Route::get('export', [AccountMappingActions::class, 'export'])->name('export');
Route::get('template', [AccountMappingActions::class, 'template'])->name('template');
});
Route::get('workspace/refunds/non-jingxi-refund-data/export', 'Business\Pages@exportNonJingxiRefunds')
->middleware('business.page:refunds,non-jingxi-refund-data')
->name('business.refunds.non-jingxi-refund-data.export');

View File

@ -0,0 +1,211 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Modules\EcommerceFinance\Services\AccountMappingValidator;
use Tests\Feature\FeatureTestCase;
/**
* 财务规则 · 科目映射表页面。
*
* 注意:这张表的数据落在 storage_path('app/account-mapping'),
* 所以每个用例都把 storage 指到临时目录,绝不污染真实 storage。
* 种子仍取仓库里的 resources/data/account-mapping.json。
*/
class AccountMappingPageTest extends FeatureTestCase
{
private const SEED_TOTAL = 112;
private string $storageRoot = '';
protected function setUp(): void
{
parent::setUp();
$this->storageRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'account-mapping-' . uniqid();
File::makeDirectory($this->storageRoot . '/app', 0777, true, true);
// 只影响 storage_path(),视图编译目录已在启动时解析,不受影响
$this->app->useStoragePath($this->storageRoot);
}
protected function tearDown(): void
{
if ($this->storageRoot !== '' && File::isDirectory($this->storageRoot)) {
File::deleteDirectory($this->storageRoot);
}
parent::tearDown();
}
private function grantAdminPermission(): void
{
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
}
private function pageUrl(): string
{
return route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'financial-rules',
'page' => 'account-mapping',
]);
}
/** .../actions */
private function actionsBase(): string
{
// 取 rules 路由再砍掉尾巴,等价于控制器里给前端下发端点的方式
return (string) preg_replace('#/rules$#', '', route(
'business.financial-rules.account-mapping.actions.rules',
['company_id' => $this->company->id]
));
}
/** .../actions/rules[/{id}] */
private function rulesUrl(?int $id = null): string
{
return $this->actionsBase() . '/rules' . ($id === null ? '' : '/' . $id);
}
/** .../actions/{preview|import} */
private function actionUrl(string $action): string
{
return $this->actionsBase() . '/' . $action;
}
/**
* 页面必须把种子规则和校验汇总**渲染进 HTML**。
* 只断言 200 是不够的:注入数据为空时页面照样 200,之前就踩过这个坑。
*/
public function test_page_renders_seeded_rules_and_validation_summary(): void
{
$this->grantAdminPermission();
$this->loginAs()->get($this->pageUrl())
->assertOk()
->assertSee('data-account-mapping="true"', false)
->assertSee('共 <b id="amTotal">' . self::SEED_TOTAL . '</b> 条规则', false)
->assertSee('var ROWS = [', false)
->assertSee('"platform":"得物"', false)
->assertSeeText('科目映射表')
// ENDPOINT 经 @json 输出,斜杠会被转义成 \/,所以只断言变量名
->assertSee('var ENDPOINT =', false);
}
public function test_rules_endpoint_returns_seed_rules_and_summary(): void
{
$this->grantAdminPermission();
$this->loginAs()->getJson($this->rulesUrl())
->assertOk()
->assertJsonCount(self::SEED_TOTAL, 'rules')
->assertJsonPath('summary.total', self::SEED_TOTAL)
->assertJsonStructure(['rules', 'summary', 'options' => ['platforms', 'categories', 'basis', 'invoices']]);
}
public function test_can_create_update_and_delete_a_rule(): void
{
$this->grantAdminPermission();
$payload = ['values' => [
'platform' => '自测平台',
'item' => '自测账单项目',
'memo' => '自测备注',
'invoice' => '不开票',
'category' => '费用',
'dAcct' => '销售费用',
'dSub' => '运费',
'cAcct' => '其他货币资金',
'cSub' => '',
'basis' => '支出基准',
]];
$created = $this->loginAs()->postJson($this->rulesUrl(), $payload);
$created->assertOk()->assertJsonPath('summary.total', self::SEED_TOTAL + 1);
$id = collect($created->json('rules'))->firstWhere('platform', '自测平台')['id'];
$this->assertIsInt($id);
$this->loginAs()->patchJson($this->rulesUrl($id), [
'values' => array_merge($payload['values'], ['memo' => '改过的备注']),
])->assertOk()->assertJsonPath('summary.total', self::SEED_TOTAL + 1);
$store = app(AccountMappingStore::class);
$this->assertSame('改过的备注', $store->find($id)['memo']);
$this->loginAs()->deleteJson($this->rulesUrl($id))
->assertOk()
->assertJsonPath('summary.total', self::SEED_TOTAL);
$this->assertNull(app(AccountMappingStore::class)->find($id));
}
/** 账套里查不到的一级科目必须是「错误」,否则自动生成分录时会静默失败 */
public function test_validator_flags_account_missing_from_chart_of_accounts(): void
{
$store = app(AccountMappingStore::class);
$validator = app(AccountMappingValidator::class);
$bad = $store->create([
'platform' => '自测平台', 'item' => '坏数据',
'dAcct' => '不存在的科目', 'cAcct' => '其他货币资金',
'category' => '费用', 'basis' => '支出基准', 'invoice' => '-',
]);
$issues = $validator->validate($store->all())['issues'][$bad['id']] ?? [];
$this->assertNotEmpty($issues);
$this->assertContains('错误', array_column($issues, 'level'));
}
/** 导入:先预览(不落盘)再确认;两行都是新增 */
public function test_import_preview_and_confirm_adds_new_rules(): void
{
$this->grantAdminPermission();
$csv = "平台,账单项目,财务备注,票据口径,大类,借方科目,借方二级,贷方科目,贷方二级,方向基准\n"
. "自测平台,自测项目甲,备注甲,未开票,费用,销售费用,运费,其他货币资金,,支出基准\n"
. "自测平台,自测项目乙,备注乙,未开票,收入,其他货币资金,,主营业务收入,,收入基准\n";
$preview = $this->loginAs()->post($this->actionUrl('preview'), [
'import' => UploadedFile::fake()->createWithContent('rules.csv', $csv),
], ['Accept' => 'application/json']);
$preview->assertOk()
->assertJsonPath('total', 2)
->assertJsonPath('can_create', 2)
->assertJsonPath('can_overwrite', 0);
// 预览不落盘
$this->assertCount(self::SEED_TOTAL, app(AccountMappingStore::class)->all());
$this->loginAs()->post($this->actionUrl('import'), [
'import' => UploadedFile::fake()->createWithContent('rules.csv', $csv),
], ['Accept' => 'application/json'])
->assertOk()
->assertJsonPath('result.created', 2)
->assertJsonPath('summary.total', self::SEED_TOTAL + 2);
}
/** 非表格扩展名要被挡下,并给出人话提示 */
public function test_import_rejects_non_spreadsheet_extension(): void
{
$this->grantAdminPermission();
// FeatureTestCase 默认关掉了异常处理,这里要验 422 就必须把它打开
$this->withExceptionHandling();
$this->loginAs()->post($this->actionUrl('preview'), [
'import' => UploadedFile::fake()->createWithContent('rules.txt', "a,b\n1,2\n"),
], ['Accept' => 'application/json'])
->assertStatus(422)
->assertJsonPath('errors.import.0', '只支持 xlsx、xls、csv 三种格式,当前是「txt」。');
}
}