CaiWuXiTong/modules/EcommerceFinance/Services/FinanceLedgerRptSynchronizer.php

676 lines
28 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): array
{
$from = trim((string) ($from ?? config('finance_ledger.window_start', '2026-04-18')));
$to = trim((string) ($to ?? config('finance_ledger.window_end', ''))) ?: now()->toDateString();
$transactions = array_merge(
$this->dingTalkRows(),
$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 = $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(): array
{
$processCodes = array_keys((array) config('finance_ledger.dingtalk_process_codes', []));
if ($processCodes === []) {
throw new RuntimeException('未配置钉钉审批流程编码。');
}
$placeholders = implode(',', array_fill(0, count($processCodes), '?'));
$query = $this->queries->forSource(self::SOURCE);
$workflows = $query->select(
'SELECT processInstanceId, processCode, businessId, title, status, originatorUserId, '
. 'originatorDeptName, joinAt, finishTime FROM rpt.ding_Workflow '
. 'WHERE joinAt >= ? AND processCode IN (' . $placeholders . ')',
[self::DING_SINCE, ...$processCodes],
);
if ($workflows === []) {
return [];
}
$schemes = $query->select(
'SELECT s.id, s.processInstanceId, s.name, CAST(s.value AS VARCHAR(MAX)) AS value '
. 'FROM rpt.ding_WorkflowScheme s JOIN rpt.ding_Workflow w ON w.processInstanceId = s.processInstanceId '
. 'WHERE w.joinAt >= ? AND w.processCode IN (' . $placeholders . ') '
. "AND s.name IN ('付款明细','收款费用明细','应付明细','固定资产报废或出售明细','收款合计','付款金额合计') "
. 'ORDER BY s.id',
[self::DING_SINCE, ...$processCodes],
);
$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->queries->forSource(self::SOURCE)->select(
'SELECT Id, woNo, createby, created, completeTime, businessFieldList FROM rpt.jst_workOrderInfo '
. 'WHERE woTypeId = ? AND status = ?',
[self::JST_WORK_ORDER_TYPE, 'CONFIRMED'],
);
$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->queries->forSource(self::SOURCE)->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 a.flowId, a.AccountCode, a.AccountName, a.costType, a.money, a.paymentTime, '
. 'a.paymentAccount, b.erpShopName, b.platform, b.applyUser, b.applyDepartment '
. 'FROM rpt.fys_shopInfo_kingdeeCode_flow a '
. 'LEFT JOIN rpt.fys_shopInfo_flow b ON a.flowId = b.flowId '
. 'WHERE a.flowId IN (SELECT flowId FROM rpt.flow_flow WHERE flowType = 8 AND flowStatus = 3) '
. 'AND a.type = 1 AND a.paymentTime >= ? AND a.isPaymentMade = 1 AND a.money > 0';
$rows = $this->queries->forSource(self::SOURCE)->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
{
$sql = 'WITH d AS ('
. 'SELECT a.Id, a.shop_id, a.channel, a.fee_time, a.fee_name, a.inout_type, a.amount, a.primary_key, '
. 'ROW_NUMBER() OVER (PARTITION BY a.primary_key ORDER BY a.Id) AS rn '
. "FROM rpt.jst_originalBillInfo a WHERE a.fee_name LIKE N'%提现%' AND a.fee_time >= ?"
. ') SELECT d.Id, d.channel, d.fee_time, d.fee_name, d.amount, b.shop_name, d.inout_type '
. 'FROM d LEFT JOIN rpt.jst_shopInfo b ON d.shop_id = b.shop_id WHERE d.rn = 1';
$rows = $this->queries->forSource(self::SOURCE)->select($sql, [$from]);
$result = [];
foreach ($rows as $row) {
if ((string) ($row['inout_type'] ?? '') !== '-1') {
continue;
}
$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;
}
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);
}
}