新增「财务规则 · 科目映射表」可维护页面 #279
280
app/Http/Controllers/Business/AccountMappingActions.php
Normal file
280
app/Http/Controllers/Business/AccountMappingActions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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' => '数据监控',
|
||||
|
||||
154
modules/EcommerceFinance/Services/AccountMappingCoa.php
Normal file
154
modules/EcommerceFinance/Services/AccountMappingCoa.php
Normal 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);
|
||||
}
|
||||
}
|
||||
406
modules/EcommerceFinance/Services/AccountMappingStore.php
Normal file
406
modules/EcommerceFinance/Services/AccountMappingStore.php
Normal 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 : [];
|
||||
}
|
||||
}
|
||||
196
modules/EcommerceFinance/Services/AccountMappingValidator.php
Normal file
196
modules/EcommerceFinance/Services/AccountMappingValidator.php
Normal 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];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
1458
resources/data/account-mapping.json
Normal file
1458
resources/data/account-mapping.json
Normal file
File diff suppressed because it is too large
Load Diff
1
resources/data/coa.json
Normal file
1
resources/data/coa.json
Normal file
File diff suppressed because one or more lines are too long
@ -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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
@ -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');
|
||||
|
||||
211
tests/Feature/Business/AccountMappingPageTest.php
Normal file
211
tests/Feature/Business/AccountMappingPageTest.php
Normal 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」。');
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user