281 lines
9.9 KiB
PHP
281 lines
9.9 KiB
PHP
<?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;
|
||
}
|
||
}
|