636 lines
23 KiB
PHP
636 lines
23 KiB
PHP
<?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;
|
||
}
|
||
}
|