708 lines
29 KiB
PHP
708 lines
29 KiB
PHP
<?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);
|
||
}
|
||
}
|