CaiWuXiTong/app/Http/Controllers/Business/PayablesFinalActions.php
renshen 656c655c91 应付款终版导入:支持 20 万行(流式读取 + 分片导入 + 进度显示) (#361)
新增流式 xlsx/csv 读取器与分片导入:上传建行缓存,按片处理(2 万行/25 秒预算),批量 upsert + 批量审计事件,前端进度条自动续跑;上限 20 万行,上传 50MB。
2026-10-16 23:54:03 +08:00

186 lines
6.9 KiB
PHP

<?php
namespace App\Http\Controllers\Business;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\PayablesFinalImporter;
use Modules\EcommerceFinance\Services\PayablesFinalImportRunner;
use Modules\EcommerceFinance\Services\PayablesFinalRegistry;
use Modules\EcommerceFinance\Services\PayablesFieldNormalizer;
use Modules\EcommerceFinance\Services\SupplierPayableImportTemplate;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 「终版」目录的按表数据:界面与基础页面一致,数据独立存放
* (数据集键 final:<目录>:<页面>),初始为空,由页面上的导入、导出、查询、编辑维护。
*/
class PayablesFinalActions extends Controller
{
public const DATASET_PREFIX = 'final:';
public function __construct(
private readonly PayablesFinalRegistry $registry,
private readonly SupplierPayableImportTemplate $template,
private readonly SupplierPayableOverrideStore $overrides,
private readonly PayablesFinalImporter $importer,
private readonly PayablesFieldNormalizer $normalizer,
) {
$this->middleware('auth');
$this->middleware('company.identify');
}
public function definition(Request $request): JsonResponse
{
return response()->json([
'dataset' => $this->dataset($request),
'fields' => collect($this->fields($request))
->map(static fn (array $definition, string $name): array => ['name' => $name] + $definition)
->values()
->all(),
]);
}
public function template(Request $request): StreamedResponse
{
return $this->template->download(
$this->registry->title($this->section($request), $this->dataset($request)),
$this->fields($request),
true,
);
}
public function import(Request $request): JsonResponse|RedirectResponse
{
$request->validate(['import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240']]);
$result = $this->importer->import(
$this->fields($request),
$this->storeDataset($request),
$request->file('import'),
(int) (company_id() ?: 1),
user_id() ? (int) user_id() : null,
);
$message = "导入完成:新增 {$result['created']} 条,更新 {$result['updated']} 条。";
return $request->expectsJson()
? response()->json(['message' => $message] + $result)
: back()->with('success', $message);
}
/** 大文件导入第 1 步:上传文件并建立行缓存(前端随后按片拉取处理)。 */
public function importUpload(Request $request, PayablesFinalImportRunner $runner): JsonResponse
{
$request->validate(['import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:51200']]);
$prepared = $runner->prepare(
$request->file('import'),
$this->fields($request),
$this->storeDataset($request),
(int) (company_id() ?: 1),
user_id() ? (int) user_id() : null,
);
return response()->json($prepared + ['offset' => 0, 'created' => 0, 'updated' => 0, 'done' => false]);
}
/** 大文件导入第 2 步起:每次处理一片,前端拿到进度后继续。 */
public function importChunk(Request $request, PayablesFinalImportRunner $runner): JsonResponse
{
$data = $request->validate([
'token' => ['required', 'string', 'regex:/^[a-f0-9]{32}$/'],
'offset' => ['required', 'integer', 'min:0'],
]);
return response()->json($runner->process(
$data['token'],
(int) $data['offset'],
null,
(int) (company_id() ?: 1),
));
}
public function update(Request $request, string $recordKey): JsonResponse|RedirectResponse
{
$values = $this->normalizer->normalize($this->fields($request), (array) $request->input('values', []));
$result = $this->overrides->save(
$this->storeDataset($request),
$recordKey,
$values,
(int) (company_id() ?: 1),
user_id() ? (int) user_id() : null,
);
return $request->expectsJson()
? response()->json(['message' => '保存成功。'] + $result)
: back()->with('success', '保存成功。');
}
public function export(Request $request): StreamedResponse
{
$fields = $this->fields($request);
$title = $this->registry->title($this->section($request), $this->dataset($request));
$rows = $this->overrides->listRows($this->storeDataset($request), (int) (company_id() ?: 1));
return response()->streamDownload(function () use ($rows, $fields): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new RuntimeException('无法创建导出文件。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_merge(['记录标识'], array_column($fields, 'label')));
foreach ($rows as $row) {
$values = [$this->csvText($row['_action_key'] ?? '')];
foreach ($fields as $field => $definition) {
$value = $row[$field] ?? '';
$values[] = $definition['type'] === 'number' && is_numeric($value)
? $value
: $this->csvText($value);
}
fputcsv($csv, $values);
}
fclose($csv);
}, $title . '_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
private function section(Request $request): string
{
return (string) $request->route('finalSection');
}
private function dataset(Request $request): string
{
$dataset = (string) $request->route('dataset');
$this->registry->title($this->section($request), $dataset);
return $dataset;
}
/** @return array<string,array{label:string,type:string}> */
private function fields(Request $request): array
{
return $this->registry->fields($this->section($request), $this->dataset($request));
}
private function storeDataset(Request $request): string
{
$section = $this->section($request) === PayablesFinalRegistry::SECTION_COURIER ? 'courier' : 'supplier';
return self::DATASET_PREFIX . $section . ':' . $this->dataset($request);
}
private function csvText(mixed $value): string
{
$value = (string) $value;
return preg_match('/^[=+\-@\t\r]/u', $value) === 1 ? "\t" . $value : $value;
}
}