CaiWuXiTong/app/Http/Controllers/Business/AccountMappingActions.php

636 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Http\Controllers\Business;
use App\Models\Auth\User;
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 Modules\EcommerceFinance\Services\AccountMappingResyncDispatcher;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 财务规则 · 科目映射表的维护接口。
*
* 表里存的是「科目对 + 方向基准」,不存最终借贷方向;方向在记账时按账单实际收支代入。
* 本控制器只负责:增、改、删、导入追加、导出、模板、校验。
*/
class AccountMappingActions extends Controller
{
use Companies;
/** 操作记录保留并展示的条数 */
private const HISTORY_LIMIT = 500;
private const HISTORY_ACTION_LABELS = [
'create' => '新增',
'update' => '修改',
'delete' => '删除',
'import' => '导入',
'reset' => '重置',
];
private const HISTORY_FIELD_LABELS = [
'platform' => '平台',
'item' => '账单项目',
'memo' => '财务备注',
'invoice' => '票据口径',
'category' => '大类',
'feeMemo' => '费用备注',
'dAcct' => '借方科目',
'dSub' => '借方二级',
'cAcct' => '贷方科目',
'cSub' => '贷方二级',
'basis' => '方向基准',
];
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());
$hint = $this->dispatchResync([(string) ($values['platform'] ?? '')]);
return response()->json(['message' => '已新增 1 条规则。' . $hint] + $this->payload());
}
public function update(Request $request, int $id): JsonResponse
{
$before = $this->store->find($id);
if ($before === null) {
throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']);
}
$values = $this->payloadValues($request);
$this->store->update($id, $values, $this->userId());
$hint = $this->dispatchResync([(string) ($before['platform'] ?? ''), (string) ($values['platform'] ?? '')]);
return response()->json(['message' => '已保存。' . $hint] + $this->payload());
}
public function destroy(int $id): JsonResponse
{
$before = $this->store->find($id);
if (! $this->store->destroy($id, $this->userId())) {
throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']);
}
$hint = $this->dispatchResync([(string) ($before['platform'] ?? '')]);
return response()->json(['message' => '已删除 1 条规则。' . $hint] + $this->payload());
}
/**
* 操作记录:最近 500 条(新增 / 修改 / 删除 / 导入 / 重置)。
*
* 每条含改动前、改动后的完整规则内容,所以能查出「谁在什么时候把哪个字段从什么改成了什么」,
* 也能据此还原被删掉的行。操作人只返回名称,不返回账号等敏感字段。
*/
public function history(): JsonResponse
{
$events = $this->historyEvents();
return response()->json([
'events' => $events,
'total' => count($events),
'limit' => self::HISTORY_LIMIT,
]);
}
/**
* 操作记录导出为 CSV,筛选口径与页面上的「操作记录」一致。
*/
public function historyExport(Request $request): StreamedResponse
{
$action = trim((string) $request->query('action', ''));
$keyword = trim((string) $request->query('keyword', ''));
$rows = array_values(array_filter(
$this->historyEvents(),
fn (array $row): bool => $this->historyMatches($row, $action, $keyword)
));
return response()->streamDownload(function () use ($rows): void {
$handle = fopen('php://output', 'w');
fwrite($handle, "\xEF\xBB\xBF");
fputcsv($handle, ['时间', '操作', '平台', '账单项目', '财务备注', '变更内容', '操作人']);
foreach ($rows as $row) {
fputcsv($handle, [
$row['at'], $row['action_label'], $row['platform'], $row['item'], $row['memo'],
implode(';', $row['changes']), $row['actor'],
]);
}
fclose($handle);
}, '科目映射表_操作记录.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/**
* 按操作记录恢复:被删除的规则重新建回来,被改过的规则恢复成改前内容。
*
* 前端传的是「记录在列表里的位置 + 时间 + 操作 + 规则号」,三者都对得上才动手,
* 避免这期间有人新增了记录、位置发生偏移时恢复错行。
*/
public function historyRestore(Request $request): JsonResponse
{
$index = (int) $request->input('index', -1);
$at = trim((string) $request->input('at', ''));
$action = trim((string) $request->input('action', ''));
$ruleId = (int) $request->input('id', 0);
$event = $this->store->events(self::HISTORY_LIMIT)[$index] ?? null;
if ($event === null
|| (string) ($event['at'] ?? '') !== $at
|| (string) ($event['action'] ?? '') !== $action
|| (int) ($event['id'] ?? 0) !== $ruleId) {
throw ValidationException::withMessages(['history' => '操作记录已经变化,请关掉面板重新打开再试。']);
}
$before = is_array($event['before'] ?? null) ? $event['before'] : null;
if ($before === null) {
throw ValidationException::withMessages(['history' => '这条记录没有可恢复的内容。']);
}
if ($action === 'delete') {
foreach ($this->store->all() as $rule) {
if ($this->store->keyOf($rule) === $this->store->keyOf($before)) {
throw ValidationException::withMessages([
'history' => '这条规则已经在表里了(记录 #' . (int) $rule['id'] . '),不用恢复。',
]);
}
}
$restored = $this->store->create($before, $this->userId());
$hint = $this->dispatchResync([(string) ($restored['platform'] ?? '')]);
return response()->json([
'message' => '已恢复「' . $restored['platform'] . ' · ' . $restored['item'] . '」(新记录 #' . (int) $restored['id'] . ')。' . $hint,
] + $this->payload());
}
if ($action === 'update') {
$current = $this->store->find($ruleId);
if ($current === null) {
throw ValidationException::withMessages(['history' => '这条规则已经被删掉了,请用同一条的「删除」记录恢复。']);
}
$this->store->update($ruleId, $before, $this->userId());
$hint = $this->dispatchResync([(string) ($current['platform'] ?? ''), (string) ($before['platform'] ?? '')]);
return response()->json([
'message' => '已恢复成这次修改前的内容(记录 #' . $ruleId . ')。' . $hint,
] + $this->payload());
}
throw ValidationException::withMessages(['history' => '只有「删除」和「修改」的记录可以恢复。']);
}
/**
* 导入预览:只解析、不落盘。
*
* 单独一条路由、且不挂 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']} 条。";
$platforms = array_values(array_unique(array_map(
static fn (array $row): string => (string) ($row['platform'] ?? ''),
$parsed['rows']
)));
$message .= $this->dispatchResync($platforms);
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->dispatchResyncAll()] + $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) {
$value = (string) ($rule[$field] ?? '');
// 商品名称关键词留空时导出成「-」,与页面一致(导回来会按空处理)
if ($field === 'goods' && trim($value) === '') {
$value = '-';
}
$row[] = $this->csvText($value);
}
fputcsv($csv, $row);
}
fclose($csv);
}, '科目映射表_' . $this->store->platformNow()->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;
}
/**
* 保存映射后把受影响的平台排进后台重跑队列。
*
* 映射表本身是实时读取的,但业绩明细的优惠/冲减/退款金额是同步时落库的,
* 所以保存成功后需要自动重跑对应平台和月份。队列创建失败不能把已经保存成功的规则回滚。
*
* @param list<string> $platforms
*/
private function dispatchResync(array $platforms): string
{
try {
$result = app(AccountMappingResyncDispatcher::class)->dispatchFor($platforms);
} catch (\Throwable $exception) {
report($exception);
return ' 业绩明细自动重跑任务创建失败,请稍后重试保存或联系管理员。';
}
return $result['jobs'] > 0
? " 业绩明细已自动排队重跑:{$result['jobs']} 个平台×月份任务,后台依次执行。"
: '';
}
private function dispatchResyncAll(): string
{
try {
$result = app(AccountMappingResyncDispatcher::class)->dispatchAll();
} catch (\Throwable $exception) {
report($exception);
return ' 业绩明细自动重跑任务创建失败,请稍后重试或联系管理员。';
}
return $result['jobs'] > 0
? " 业绩明细已自动排队重跑:{$result['jobs']} 个平台×月份任务,后台依次执行。"
: '';
}
/**
* 原始操作记录 + 页面/CSV 直接可用的字段(变更内容与操作人只在服务端算一次)。
*
* @return array<int,array<string,mixed>>
*/
private function historyEvents(): array
{
$events = $this->store->events(self::HISTORY_LIMIT);
$names = $this->historyUserNames($events);
$rows = [];
foreach ($events as $event) {
$before = is_array($event['before'] ?? null) ? $event['before'] : null;
$after = is_array($event['after'] ?? null) ? $event['after'] : null;
$rule = $before ?? $after ?? [];
$action = (string) ($event['action'] ?? '');
$rows[] = $event + [
'action_label' => self::HISTORY_ACTION_LABELS[$action] ?? $action,
'platform' => trim((string) ($rule['platform'] ?? '')),
'item' => trim((string) ($rule['item'] ?? '')),
'memo' => trim((string) ($rule['memo'] ?? '')),
'changes' => $this->historyChangeParts($event, $before, $after),
'actor' => $this->historyActor($event, $names),
];
}
return $rows;
}
/**
* @param array<int,array<string,mixed>> $events
* @return array<int,string>
*/
private function historyUserNames(array $events): array
{
$ids = [];
foreach ($events as $event) {
$userId = (int) ($event['user_id'] ?? 0);
if ($userId > 0) {
$ids[$userId] = true;
}
}
if ($ids === []) {
return [];
}
return User::query()->whereIn('id', array_keys($ids))->pluck('name', 'id')->all();
}
/**
* @param array<string,mixed> $event
* @param array<int,string> $names
*/
private function historyActor(array $event, array $names): string
{
$userId = (int) ($event['user_id'] ?? 0);
if ($userId <= 0) {
return '脚本 / 系统';
}
return $names[$userId] ?? ('用户 #' . $userId);
}
/**
* 变更内容:修改给出「字段:改前 → 改后」,删除/新增给出科目对摘要,导入/重置给出条数。
*
* @param array<string,mixed> $event
* @param array<string,string>|null $before
* @param array<string,string>|null $after
* @return array<int,string>
*/
private function historyChangeParts(array $event, ?array $before, ?array $after): array
{
$action = (string) ($event['action'] ?? '');
if ($action === 'update') {
$parts = [];
foreach (self::HISTORY_FIELD_LABELS as $field => $label) {
$old = trim((string) ($before[$field] ?? ''));
$new = trim((string) ($after[$field] ?? ''));
if ($old === $new) {
continue;
}
$parts[] = $label . ':' . ($old === '' ? '(空)' : $old) . ' → ' . ($new === '' ? '(空)' : $new);
}
return $parts === [] ? ['未识别到字段变化'] : $parts;
}
if ($action === 'delete') {
return ['删除前:' . $this->historyRuleSummary($before)];
}
if ($action === 'create') {
return ['新增:' . $this->historyRuleSummary($after)];
}
if ($action === 'import') {
$parts = [];
foreach (['created' => '新增', 'updated' => '更新', 'skipped' => '跳过'] as $key => $label) {
if (isset($event[$key])) {
$parts[] = $label . ' ' . (int) $event[$key];
}
}
return ['导入:' . ($parts === [] ? '—' : implode(' · ', $parts))];
}
if ($action === 'reset') {
return ['重置为初始规则' . (isset($event['total']) ? '(' . (int) $event['total'] . ' 条)' : '')];
}
return [$this->historyRuleSummary($before ?? $after)];
}
/** @param array<string,string>|null $rule */
private function historyRuleSummary(?array $rule): string
{
$rule ??= [];
$side = static function (string $acctKey, string $subKey) use ($rule): string {
$acct = trim((string) ($rule[$acctKey] ?? ''));
$sub = trim((string) ($rule[$subKey] ?? ''));
if ($acct === '' && $sub === '') {
return '—';
}
if ($sub === '' || $sub === $acct) {
return $acct !== '' ? $acct : $sub;
}
return $acct . ' / ' . $sub;
};
return '借 ' . $side('dAcct', 'dSub') . ' | 贷 ' . $side('cAcct', 'cSub')
. ' | ' . (trim((string) ($rule['category'] ?? '')) ?: '—')
. ' · ' . (trim((string) ($rule['basis'] ?? '')) ?: '—')
. ' · 票据 ' . (trim((string) ($rule['invoice'] ?? '')) ?: '—');
}
/** @param array<string,mixed> $row */
private function historyMatches(array $row, string $action, string $keyword): bool
{
if ($action !== '' && (string) ($row['action'] ?? '') !== $action) {
return false;
}
if ($keyword === '') {
return true;
}
$haystack = mb_strtolower(implode(' ', [
$row['platform'] ?? '',
$row['item'] ?? '',
$row['memo'] ?? '',
$row['actor'] ?? '',
implode(' ', (array) ($row['changes'] ?? [])),
]));
return str_contains($haystack, mb_strtolower($keyword));
}
private function csvText(mixed $value): string
{
$value = (string) $value;
return preg_match('/^[=+\-@\t\r]/u', $value) === 1 ? "\t" . $value : $value;
}
}