CaiWuXiTong/modules/EcommerceFinance/Services/FinanceLedgerRptSynchronizer.php
aaron 93a27725b6 feat(财务台账): 接入 AI 轻量视图并改为分批增量快照 (#411)
本地验证:7 天和 30 天增量合并后,12,037 笔明细、收入 184,308,335.23、支出 155,917,513.13 保持不变。
2026-10-21 10:37:59 +08:00

708 lines
29 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 Modules\EcommerceFinance\Services;
use RuntimeException;
/**
* 从 AI 库 JSReportReplica.rpt 读取财务台账动账明细。
*
* 数据口径以《财务台账模块开发规格.md》第 3 章为准;所有 SQL 都是固定、
* 参数化的 SELECT,不读取 base,也不写上游。同步结果交给
* FinanceLedgerTransactionWriter 全量替换并重算流水。
*/
class FinanceLedgerRptSynchronizer
{
private const SOURCE = 'operations_replica';
private const DING_SINCE = '2025-10-18';
private const JST_WORK_ORDER_TYPE = '9E5A531F48D94AF4B8611FA6918ADA29';
private const FIXED_ASSET_PROCESS = 'PROC-6407E98B-040A-4C96-AA7D-696650FC1A25';
private const DETAIL_FIELDS = [
'PROC-B290696F-EC2A-4F76-87BA-E0A12E10D09F' => '收款费用明细',
'PROC-C5159B8D-F412-4DF8-981D-64EBB8D03EB6' => '收款费用明细',
'PROC-39DD70DE-DAB2-4A5F-B8F1-0B0B68BD0C88' => '应付明细',
'PROC-BBAC7187-9E59-4942-941A-59C7EAF0CEDA' => '收款费用明细',
'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF' => '收款费用明细',
'PROC-6407E98B-040A-4C96-AA7D-696650FC1A25' => '固定资产报废或出售出售明细',
];
private const CHANNEL_NAMES = [
'alipayCSV' => '支付宝',
'JDWallet' => '京东钱包',
'PinduoduoImport' => '拼多多',
'TouTiaoFXGZJ' => '抖音',
'WeChatPay' => '微信',
'CommonBillTGCPay' => '腾讯广告',
'PddMarketingImport' => '拼多多营销',
'XiaohsSeller' => '小红书',
];
public function __construct(
private readonly UpstreamReadOnlyQueryFactory $queries,
private readonly FinanceLedgerTransactionWriter $writer,
) {
}
public function sync(bool $recalculate = true, ?string $from = null, ?string $to = null, bool $merge = false): array
{
$from = trim((string) ($from ?? config('finance_ledger.window_start', '2026-04-18')));
$to = trim((string) ($to ?? config('finance_ledger.window_end', ''))) ?: now()->toDateString();
$lookback = $merge ? $from : self::DING_SINCE;
$transactions = array_merge(
$this->dingTalkRows($lookback, $to),
$this->workOrderRows(),
$this->payableRows($from),
$this->storeOpeningRows($from),
$this->withdrawalRows($from),
);
$transactions = array_values(array_filter(
$transactions,
static fn (array $row): bool => $row['txn_date'] >= $from && $row['txn_date'] <= $to,
));
if ($transactions === []) {
throw new RuntimeException('rpt 没有返回窗口内的动账明细。');
}
$result = $merge
? $this->writer->merge($transactions, $recalculate)
: $this->writer->replace($transactions, $recalculate);
$result['source_counts'] = array_count_values(array_column($transactions, 'process_type'));
return $result;
}
/** @return array<int,array<string,mixed>> */
private function dingTalkRows(string $from, string $to): array
{
$query = $this->query();
$workflows = $query->select(
'SELECT processInstanceId, processCode, businessId, title, status, originatorUserId, '
. 'originatorDeptName, joinAt, finishTime FROM rpt.fin_ledger_dingtalk_workflow '
. 'WHERE joinAt >= ? AND joinAt < ?',
[$from, \Carbon\CarbonImmutable::parse($to)->addDay()->toDateString()],
);
if ($workflows === []) {
return [];
}
$schemes = [];
foreach (array_chunk(array_values(array_unique(array_column($workflows, 'processInstanceId'))), 100) as $ids) {
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$batch = $query->select(
'SELECT s.id, s.processInstanceId, s.name, s.value FROM rpt.fin_ledger_dingtalk_scheme s '
. 'WHERE s.processInstanceId IN (' . $placeholders . ') ORDER BY s.id',
$ids,
);
array_push($schemes, ...$batch);
}
$employees = [];
foreach ($query->select('SELECT userid, name FROM rpt.hr_employee') as $employee) {
$employees[(string) $employee['userid']] = trim((string) ($employee['name'] ?? ''));
}
$byInstance = [];
foreach ($schemes as $scheme) {
$pid = (string) $scheme['processInstanceId'];
$name = (string) $scheme['name'];
$byInstance[$pid][$name] = (string) $scheme['value'];
}
$rows = [];
foreach ($workflows as $workflow) {
$pid = (string) $workflow['processInstanceId'];
$code = (string) $workflow['processCode'];
$processName = (string) (config('finance_ledger.dingtalk_process_codes.' . $code) ?: '钉钉审批');
if ((string) $workflow['status'] !== 'COMPLETED') {
continue;
}
$applicant = $employees[(string) ($workflow['originatorUserId'] ?? '')] ?? '';
if ($applicant === '') {
if (preg_match('/^(?:\d{2}-\d{2}\+?)?(.+?)提交的/u', (string) ($workflow['title'] ?? ''), $matches) === 1) {
$applicant = trim($matches[1]);
}
}
$dept = trim((string) ($workflow['originatorDeptName'] ?? ''));
$fields = $byInstance[$pid] ?? [];
$approval = trim((string) ($workflow['businessId'] ?? ''));
if ($code === self::FIXED_ASSET_PROCESS) {
foreach ($this->detailRows($fields['固定资产报废或出售明细'] ?? '', $code) as $detail) {
$text = $this->joinParts([
$applicant,
$dept,
$detail['use'],
$detail['asset_name'] !== '' ? '资产:' . $detail['asset_name'] : '',
]);
$rows[] = $this->row([
'date' => $detail['pay_time'],
'account_short' => '',
'source_value' => '银行存款_个人账户_招商银行-朱俊祥',
'source' => 'dingtalk',
'process_type' => $processName,
'approval_no' => $approval,
'detail' => $text,
'income' => $detail['sale_price'],
'expense' => 0,
'pid' => $pid,
'seq' => $detail['seq'],
'acct_code' => '1002.02.046',
'acct_name' => '银行存款_个人账户_招商银行-朱俊祥',
]);
}
continue;
}
$payments = $this->paymentRows($fields['付款明细'] ?? '');
$details = $this->detailRows($fields[self::DETAIL_FIELDS[$code] ?? ''] ?? '', $code);
$detailMap = [];
foreach ($details as $detail) {
foreach ($this->seqTokens($detail['seq']) ?: [''] as $token) {
$detailMap[$token][] = $detail;
}
}
if (count($details) === 1 && $detailMap === []) {
$detailMap['_all'] = $details;
}
foreach ($payments as $payment) {
if ($payment['amount'] == 0.0 && $payment['code'] === '') {
continue;
}
$tokens = $this->seqTokens($payment['seq']) ?: ['_all'];
$matched = [];
foreach ($tokens as $token) {
$matched = array_merge($matched, $detailMap[$token] ?? []);
}
if ($matched === [] && isset($detailMap['_all'])) {
$matched = $detailMap['_all'];
}
if ($matched === [] && count($details) === 1) {
$matched = $details;
}
$uses = $payees = $suppliers = $shops = $items = [];
foreach ($matched as $detail) {
if ($detail['use'] !== '') {
$uses[] = $detail['use'];
}
if ($detail['payee'] !== '') {
$payees[] = $detail['payee'];
}
if ($detail['supplier'] !== '') {
$suppliers[] = $detail['supplier'];
}
if ($detail['shop'] !== '') {
$shops[] = $detail['shop'];
}
if ($detail['item'] !== '') {
$items[] = $detail['item'];
}
}
if ($code === 'PROC-39DD70DE-DAB2-4A5F-B8F1-0B0B68BD0C88') {
$express = $projects = [];
foreach ($matched as $detail) {
if ($detail['express'] !== '') {
$express[] = $detail['express'];
}
if ($detail['project'] !== '') {
$projects[] = $detail['project'];
}
}
$payeeText = $this->unique($payees) ?: ($this->unique($projects) ?: $this->unique($express));
$supplierText = $this->unique($suppliers) ?: ($this->unique($express) ?: $this->unique($projects));
$text = $this->joinParts([$applicant, $dept, $payeeText, $supplierText]);
} else {
$extra = '';
if ($uses === [] && ($shops !== [] || $items !== [])) {
$extra = $this->unique($shops) . ($items !== [] ? '/' . $this->unique($items) : '');
$diff = array_sum(array_map(static fn (array $detail): float => (float) $detail['diff'], $matched));
if ($diff != 0.0) {
$extra .= ';差价' . number_format($diff, 2, '.', '');
}
}
$text = $this->joinParts([$applicant, $dept, $this->unique($uses) ?: $extra]);
}
$rows[] = $this->row([
'date' => $payment['date'],
'account_short' => '',
'source_value' => $payment['name'],
'source' => 'dingtalk',
'process_type' => $processName,
'approval_no' => $approval,
'detail' => $text,
'income' => 0,
'expense' => $payment['amount'],
'pid' => $pid,
'seq' => $payment['seq'],
'acct_code' => $payment['code'],
'acct_name' => $payment['name'],
]);
}
}
return $rows;
}
/** @return array<int,array<string,mixed>> */
private function workOrderRows(): array
{
$rows = $this->query()->select(
'SELECT Id, woNo, createby, created, completeTime, businessFieldList FROM rpt.fin_ledger_workorder',
);
$result = [];
foreach ($rows as $row) {
$fields = json_decode((string) ($row['businessFieldList'] ?? ''), true);
if (! is_array($fields)) {
continue;
}
$map = [];
foreach ($fields as $field) {
$id = (string) ($field['fieldId'] ?? '');
$map[substr($id, -6)] = $field['value'] ?? null;
}
$amount = $this->toNumber($map['091551'] ?? null);
$kind = trim((string) ($map['101125'] ?? ''));
$state = trim((string) ($map['101747'] ?? ''));
if ($amount <= 0 || $kind !== '入账' || ! in_array($state, ['已到账', '已收到'], true)) {
continue;
}
$result[] = $this->row([
'date' => $this->date($map['101432'] ?? null),
'account_short' => trim((string) ($map['101730'] ?? '')),
'source_value' => trim((string) ($map['101730'] ?? '')),
'source' => 'workorder',
'process_type' => '聚工单查账流程',
'approval_no' => trim((string) ($row['woNo'] ?? '')),
'detail' => $this->joinParts([trim((string) ($row['createby'] ?? '')), '查账工单', $state]),
'income' => $amount,
'expense' => 0,
'pid' => 'JST-' . (string) ($row['Id'] ?? ''),
]);
}
return $result;
}
/** @return array<int,array<string,mixed>> */
private function payableRows(string $from): array
{
$sql = 'WITH paid AS ('
. 'SELECT r.DetailId, SUM(r.amountLower) AS paid_amount FROM rpt.biz_bank_receipt_detail r '
. "WHERE r.DetailId IS NOT NULL AND r.BindStatus = N'已绑定' AND r.MatchStatus = N'匹配成功' AND r.amountLower > 0 "
. 'GROUP BY r.DetailId'
. '), detail AS ('
. 'SELECT d.id, d.bill_period, d.business_no, d.third_short_name, d.our_subsidiary_name, '
. 'd.third_subsidiary_name, d.deduct_amount, ISNULL(p.paid_amount,0) AS paid_amount, '
. "CASE WHEN ISNULL(p.paid_amount,0) <= 0 THEN N'等待打款' "
. "WHEN ISNULL(p.paid_amount,0) >= d.deduct_amount THEN N'打款完成' ELSE N'打款中' END AS receipt_pay_status "
. 'FROM rpt.biz_quota_deduct_detail d LEFT JOIN paid p ON p.DetailId = d.id WHERE d.is_del = 0'
. ') SELECT d.id, d.bill_period, d.business_no, d.third_short_name, d.our_subsidiary_name, '
. 'd.third_subsidiary_name, d.deduct_amount, d.paid_amount, d.receipt_pay_status, '
. 'r.Id, r.payerAccountName, r.UploadTime, r.amountLower, r.transactionDate, r.bookingDate '
. 'FROM detail d JOIN rpt.biz_bank_receipt_detail r ON r.DetailId = d.id AND r.TaskId = d.business_no '
. "WHERE d.receipt_pay_status IN (N'打款中',N'打款完成')";
$rows = $this->query()->select($sql);
$result = [];
foreach ($rows as $row) {
$amount = $this->toNumber($row['amountLower'] ?? null);
if ($amount <= 0) {
continue;
}
$date = $this->date($row['transactionDate'] ?? null)
?: $this->date($row['bookingDate'] ?? null)
?: $this->date($row['UploadTime'] ?? null);
$result[] = $this->row([
'date' => $date,
'account_short' => trim((string) ($row['payerAccountName'] ?? '')),
'source_value' => trim((string) ($row['payerAccountName'] ?? '')),
'source' => 'payable',
'process_type' => '供应商应付',
'approval_no' => trim((string) ($row['business_no'] ?? '')),
'detail' => $this->joinParts([
trim((string) ($row['third_short_name'] ?? '')),
trim((string) ($row['our_subsidiary_name'] ?? '')),
trim((string) ($row['third_subsidiary_name'] ?? '')),
trim((string) ($row['bill_period'] ?? '')),
]),
'income' => 0,
'expense' => $amount,
'pid' => 'PAY-' . (string) ($row['id'] ?? '') . '-' . (string) ($row['Id'] ?? ''),
]);
}
return $result;
}
/** @return array<int,array<string,mixed>> */
private function storeOpeningRows(string $from): array
{
$sql = 'SELECT flowId, AccountCode, AccountName, costType, money, paymentTime, '
. 'paymentAccount, erpShopName, platform, applyUser, applyDepartment '
. 'FROM rpt.fin_ledger_store_opening WHERE paymentTime >= ?';
$rows = $this->query()->select($sql, [$from]);
$seen = [];
$result = [];
foreach ($rows as $row) {
$key = implode('|', [
(string) ($row['flowId'] ?? ''),
(string) ($row['costType'] ?? ''),
(string) ($row['money'] ?? ''),
(string) ($row['paymentTime'] ?? ''),
trim((string) ($row['paymentAccount'] ?? '')),
]);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$amount = $this->toNumber($row['money'] ?? null);
$note = '';
if ($amount > 10_000_000) {
$note = '原值异常已按申请单修正为 200.00';
$amount = 200.0;
}
$account = trim((string) ($row['paymentAccount'] ?? ''));
if (in_array($account, ['无', '-'], true)) {
$account = '';
}
$result[] = $this->row([
'date' => $this->date($row['paymentTime'] ?? null),
'account_short' => $account,
'source_value' => $account,
'source' => 'store',
'process_type' => '店铺开店费用',
'approval_no' => trim((string) ($row['flowId'] ?? '')),
'detail' => $this->joinParts([
trim((string) ($row['applyUser'] ?? '')),
trim((string) ($row['applyDepartment'] ?? '')),
trim((string) ($row['erpShopName'] ?? '')),
trim((string) ($row['platform'] ?? '')),
trim((string) ($row['costType'] ?? '')),
]),
'income' => 0,
'expense' => $amount,
'pid' => 'SHOP-' . (string) ($row['flowId'] ?? '') . '-' . (string) ($row['costType'] ?? '') . '-' . (string) ($row['money'] ?? ''),
'note' => $note,
]);
}
return $result;
}
/** @return array<int,array<string,mixed>> */
private function withdrawalRows(string $from): array
{
$query = $this->query();
$result = [];
$seen = [];
// 视图已经按 fee_time + inout_type 建了过滤索引;这里按月份拉取,
// 应用侧按 primary_key 去重,避免视图内 ROW_NUMBER 全量排序。
foreach ($this->monthWindows($from, now()->toDateString()) as [$windowStart, $windowEnd]) {
$rows = $query->select(
'SELECT Id, shop_name, channel, fee_time, fee_name, amount, primary_key '
. 'FROM rpt.fin_ledger_withdrawal WHERE fee_time >= ? AND fee_time < ?',
[$windowStart, $windowEnd],
);
foreach ($rows as $row) {
$primaryKey = trim((string) ($row['primary_key'] ?? ''));
$dedupeKey = $primaryKey !== '' ? $primaryKey : 'ID:' . (string) ($row['Id'] ?? '');
if (isset($seen[$dedupeKey])) {
continue;
}
$seen[$dedupeKey] = true;
$shop = trim((string) ($row['shop_name'] ?? ''));
$channel = self::CHANNEL_NAMES[(string) ($row['channel'] ?? '')] ?? (string) ($row['channel'] ?? '');
$result[] = $this->row([
'date' => $this->date($row['fee_time'] ?? null),
'account_short' => $shop,
'source_value' => $shop,
'source' => 'shop',
'process_type' => '店铺提现',
'approval_no' => (string) ($row['Id'] ?? ''),
'detail' => $this->joinParts([$shop, $channel, trim((string) ($row['fee_name'] ?? ''))]),
'income' => $this->toNumber($row['amount'] ?? null),
'expense' => 0,
'pid' => 'BILL-' . (string) ($row['Id'] ?? ''),
]);
}
}
return $result;
}
/** @return array<int,array{0:string,1:string}> */
private function monthWindows(string $from, string $to): array
{
if ($from === '' || $to === '' || $from > $to) {
return [];
}
$cursor = \Carbon\CarbonImmutable::parse($from)->startOfMonth();
$end = \Carbon\CarbonImmutable::parse($to)->addDay()->startOfDay();
$windows = [];
while ($cursor->lessThan($end) && count($windows) < 240) {
$next = $cursor->addMonth();
$upper = $next->lessThan($end) ? $next : $end;
$windows[] = [$cursor->toDateString(), $upper->toDateString()];
$cursor = $next;
}
return $windows;
}
private function query(): UpstreamReadOnlyQuery
{
return $this->queries->forSourceWithTimeout(
self::SOURCE,
(int) config('finance_ledger.query_timeout', 120),
);
}
private function row(array $data): array
{
return [
'txn_date' => $data['date'] ?? '',
'account_short' => (string) ($data['account_short'] ?? ''),
'source' => (string) ($data['source'] ?? ''),
'source_value' => (string) ($data['source_value'] ?? ''),
'process_type' => (string) ($data['process_type'] ?? ''),
'approval_no' => (string) ($data['approval_no'] ?? ''),
'detail' => (string) ($data['detail'] ?? ''),
'income' => round((float) ($data['income'] ?? 0), 2),
'expense' => round((float) ($data['expense'] ?? 0), 2),
'pid' => (string) ($data['pid'] ?? ''),
'seq' => (string) ($data['seq'] ?? ''),
'acct_code' => (string) ($data['acct_code'] ?? ''),
'acct_name' => (string) ($data['acct_name'] ?? ''),
];
}
/** @return array<int,array<string,mixed>> */
private function paymentRows(?string $value): array
{
$rows = $this->parseRows($value);
if ($rows !== null) {
$result = [];
foreach ($rows as $row) {
$result[] = [
'seq' => trim((string) ($row['对应序号'] ?? '')),
'amount' => $this->toNumber($row['付款金额'] ?? $row['付款总金额'] ?? null),
'code' => trim((string) ($row['科目编码'] ?? '')),
'name' => trim((string) ($row['科目名称'] ?? '')),
'date' => $this->date($row['打款时间'] ?? null),
];
}
return $result;
}
$result = [];
foreach ($this->corruptRows((string) $value) as $row) {
$values = $row['values'];
if ($values === []) {
continue;
}
$code = '';
$date = '';
foreach ($values as $candidate) {
if ($code === '' && preg_match('/^(1001|1002|1012)(\.\d{2,3})+$/', $candidate) === 1) {
$code = $candidate;
}
if ($date === '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $candidate) === 1) {
$date = substr($candidate, 0, 10);
}
}
$result[] = [
'seq' => trim((string) ($values[0] ?? '')),
'amount' => $this->toNumber($values[2] ?? null),
'code' => $code,
'name' => trim((string) ($values[4] ?? '')),
'date' => $date,
];
}
return $result;
}
/** @return array<int,array<string,mixed>> */
private function detailRows(?string $value, string $processCode): array
{
$rows = $this->parseRows($value);
if ($rows !== null) {
$result = [];
foreach ($rows as $row) {
$result[] = [
'seq' => trim((string) ($row['序号'] ?? '')),
'use' => trim((string) ($row['用途'] ?? '')),
'payee' => trim((string) ($row['收款人全称'] ?? '')),
'supplier' => trim((string) ($row['供应商'] ?? '')),
'shop' => trim((string) ($row['店铺名称'] ?? '')),
'item' => trim((string) ($row['商品ID'] ?? '')),
'diff' => $this->toNumber($row['差价'] ?? null),
'asset_name' => trim((string) ($row['固定资产名称'] ?? '')),
'express' => trim((string) ($row['快递'] ?? '')),
'project' => trim((string) ($row['核算项目'] ?? '')),
'sale_price' => $this->toNumber($row['出售价格'] ?? null),
'pay_time' => $this->date($row['付款时间'] ?? null),
];
}
return $result;
}
$result = [];
foreach ($this->corruptRows((string) $value) as $row) {
$fields = $row['fields'];
$result[] = [
'seq' => trim((string) ($fields['序号'] ?? ($row['values'][0] ?? ''))),
'use' => trim((string) ($fields['用途'] ?? '')),
'payee' => trim((string) ($fields['收款人全称'] ?? '')),
'supplier' => trim((string) ($fields['供应商'] ?? '')),
'shop' => trim((string) ($fields['店铺名称'] ?? '')),
'item' => trim((string) ($fields['商品ID'] ?? '')),
'diff' => $this->toNumber($fields['差价'] ?? null),
'asset_name' => trim((string) ($fields['固定资产名称'] ?? '')),
'express' => trim((string) ($fields['快递'] ?? '')),
'project' => trim((string) ($fields['核算项目'] ?? '')),
'sale_price' => $this->toNumber($fields['出售价格'] ?? null),
'pay_time' => $this->date($fields['付款时间'] ?? null),
];
}
return $result;
}
private function parseRows(?string $value): ?array
{
$text = trim((string) $value);
if ($text === '' || $text === '[]') {
return [];
}
$data = json_decode($text, true);
if (! is_array($data)) {
return null;
}
$rows = [];
foreach ($data as $object) {
$row = [];
foreach ((array) ($object['rowValue'] ?? []) as $cell) {
$label = trim((string) ($cell['label'] ?? ''));
$val = $cell['value'] ?? null;
if (($val === null || $val === '') && is_array($cell['extendValue'] ?? null)) {
$val = $cell['extendValue']['label'] ?? null;
}
$row[$label] = $val;
}
$rows[] = $row;
}
return $rows;
}
/** @return array<int,array{fields:array<string,string>,values:array<int,string>}> */
private function corruptRows(string $value): array
{
if (trim($value) === '' || trim($value) === '[]') {
return [];
}
$chunks = explode('{"rowValue":', $value);
array_shift($chunks);
$rows = [];
foreach ($chunks as $chunk) {
$fields = [];
if (preg_match_all('/"label":"([^"]{1,40})"/u', $chunk, $labels, PREG_OFFSET_CAPTURE) !== false) {
foreach ($labels[1] as $label) {
$tail = substr($chunk, $label[1] + strlen($label[0]) + 2, 400);
if (preg_match('/"value":"([^",}]*)/u', $tail, $match) === 1) {
$fields[$label[0]] = $match[1];
}
}
}
preg_match_all('/"value":"([^",}]*)/u', $chunk, $values);
$flat = $values[1] ?? [];
if ($flat !== []) {
$fields['序号'] ??= $flat[0];
}
$rows[] = ['fields' => $fields, 'values' => $flat];
}
return $rows;
}
private function date(mixed $value): string
{
$text = trim((string) ($value ?? ''));
if (preg_match('/^(\d{4})[-\/年](\d{1,2})[-\/月](\d{1,2})/u', $text, $matches) !== 1) {
return '';
}
return sprintf('%04d-%02d-%02d', $matches[1], $matches[2], $matches[3]);
}
private function toNumber(mixed $value): float
{
if (is_int($value) || is_float($value)) {
return round((float) $value, 2);
}
$text = str_replace([',', ',', '¥', '¥', ' '], '', trim((string) ($value ?? '')));
if ($text === '' || $text === '-' || $text === '/') {
return 0.0;
}
return is_numeric($text) ? round((float) $text, 2) : 0.0;
}
/** @return array<int,string> */
private function seqTokens(string $seq): array
{
return array_values(array_filter(preg_split('/[-,,、\s]+/u', $seq) ?: []));
}
private function unique(array $values): string
{
$unique = [];
foreach ($values as $value) {
$value = trim((string) $value);
if ($value !== '' && ! in_array($value, $unique, true)) {
$unique[] = $value;
}
}
return implode('、', $unique);
}
private function joinParts(array $parts): string
{
$cleaned = [];
foreach ($parts as $part) {
$part = trim((string) $part);
if ($part !== '' && ($cleaned === [] || end($cleaned) !== $part)) {
$cleaned[] = $part;
}
}
return implode(';', $cleaned);
}
}