'收款费用明细', '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> */ 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> */ 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> */ 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> */ 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> */ 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 */ 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> */ 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> */ 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,values:array}> */ 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 */ 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); } }