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

281 lines
9.9 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\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;
}
}