CaiWuXiTong/modules/EcommerceFinance/Services/DingProcessDetailMirrorImporter.php
yueyang 7ea2d9c7ef 修复钉钉流程明细漏抓与付款金额重复 (#651)
改为按审批主表、费用明细、付款明细结构化同步;补全14类财务流程,付款按对应序号只计一次,合计读取独立付款事实。
2026-10-26 20:46:13 +08:00

286 lines
10 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 Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Str;
use RuntimeException;
class DingProcessDetailMirrorImporter
{
public const DATASET_PREFIX = 'ding.process_details.';
public const DEFAULT_SCOPE = 'personal_permission';
public const HEADERS = [
'序号',
'审批编号',
'发起人',
'状态',
'单据类型',
'用途/摘要',
'单据内行号',
'科目编码',
'科目名称',
'金额(元)',
'银行科目编码',
'银行科目名称',
'银行付款金额',
'付款方式',
'打款时间',
];
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror')
);
}
/**
* @return array{status:string,rows:int,invalid:int,placeholders:int,duplicates:int,scope:string,dataset_key:string}
*/
public function import(
string $path,
string $scope = self::DEFAULT_SCOPE,
bool $replace = false,
): array {
$scope = trim($scope) !== '' ? trim($scope) : self::DEFAULT_SCOPE;
if (! is_file($path)) {
throw new RuntimeException('流程明细 CSV 文件不存在:' . $path);
}
[$rows, $invalid, $placeholders, $duplicates] = $this->readCsv($path, $scope);
if ($rows === []) {
throw new RuntimeException('流程明细 CSV 没有可导入的数据。');
}
$now = now();
$datasetKey = self::DATASET_PREFIX . $scope;
$generation = (string) Str::uuid();
$upserted = 0;
$this->connection->transaction(function () use (
$rows,
$replace,
$scope,
$now,
$generation,
&$upserted,
): void {
if ($replace) {
$this->connection->table('ding_process_detail_mirror')
->where('source_scope', $scope)
->delete();
}
foreach (array_chunk($rows, 500) as $chunk) {
$this->connection->table('ding_process_detail_mirror')->upsert(
$chunk,
['business_id', 'line_no'],
[
'business_month',
'originator_name',
'status',
'document_type',
'purpose',
'account_code',
'account_name',
'amount',
'bank_account_code',
'bank_account_name',
'bank_amount',
'payment_method',
'paid_at',
'source_scope',
'synced_at',
'updated_at',
],
);
$upserted += count($chunk);
}
});
$rowCount = (int) $this->connection->table('ding_process_detail_mirror')
->where('source_scope', $scope)
->count();
$values = [
'source_key' => 'dingtalk_oa',
'source_object' => $scope === self::DEFAULT_SCOPE
? 'oa approval detail (personal permission scope)'
: 'oa approval detail (' . $scope . ')',
'target_table' => 'ding_process_detail_mirror',
'status' => 'succeeded',
'run_mode' => 'full',
'generation' => $generation,
'cursor' => json_encode([
'source' => 'legacy_csv',
'mirror_details' => $rowCount,
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'high_watermark' => null,
'window_started_at' => null,
'window_ends_at' => null,
'initial_completed_at' => $now,
'last_full_succeeded_at' => $now,
'last_attempted_at' => $now,
'last_succeeded_at' => $now,
'last_upserted_rows' => $upserted,
'mirror_row_count' => $rowCount,
'last_error_code' => null,
'updated_at' => $now,
];
$this->connection->table('business_mirror_table_states')->updateOrInsert(
['dataset_key' => $datasetKey],
$values + ['created_at' => $now],
);
return [
'status' => 'succeeded',
'rows' => $upserted,
'invalid' => $invalid,
'placeholders' => $placeholders,
'duplicates' => $duplicates,
'scope' => $scope,
'dataset_key' => $datasetKey,
];
}
/**
* @return array{0:list<array<string,mixed>>,1:int,2:int,3:int}
*/
private function readCsv(string $path, string $scope): array
{
$handle = fopen($path, 'rb');
if ($handle === false) {
throw new RuntimeException('流程明细 CSV 无法打开:' . $path);
}
try {
$header = fgetcsv($handle);
if ($header === false) {
throw new RuntimeException('流程明细 CSV 缺少表头。');
}
$header[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $header[0]);
$header = array_map(static fn (mixed $value): string => trim((string) $value), $header);
$missing = array_values(array_diff(self::HEADERS, $header));
if ($missing !== []) {
throw new RuntimeException('流程明细 CSV 缺少列:' . implode('、', $missing));
}
$index = array_flip($header);
$rows = [];
$qualities = [];
$invalid = 0;
$placeholders = 0;
$duplicates = 0;
$now = now();
while (($data = fgetcsv($handle)) !== false) {
if ($data === [null] || count(array_filter(
$data,
static fn (mixed $value): bool => trim((string) $value) !== '',
)) === 0) {
continue;
}
$businessId = trim((string) ($data[$index['审批编号']] ?? ''));
$lineNo = trim((string) ($data[$index['单据内行号']] ?? ''));
if ($businessId === '' || preg_match('/^\d+$/', $lineNo) !== 1) {
$invalid++;
continue;
}
$accountCode = trim((string) ($data[$index['科目编码']] ?? ''));
$accountName = trim((string) ($data[$index['科目名称']] ?? ''));
$amountRaw = trim((string) ($data[$index['金额(元)']] ?? ''));
// 历史导入遗留的占位行:用途/科目/银行字段全部是 251,金额为空。
if ($accountCode === '251' && $accountName === '251' && $amountRaw === '') {
$placeholders++;
continue;
}
$key = $businessId . '|' . (int) $lineNo;
$candidate = [
'business_id' => mb_substr($businessId, 0, 32),
'line_no' => (int) $lineNo,
'business_month' => preg_match('/^\d{6}/', $businessId) === 1
? substr($businessId, 0, 6)
: '',
'originator_name' => mb_substr(trim((string) ($data[$index['发起人']] ?? '')), 0, 100),
'status' => mb_substr(trim((string) ($data[$index['状态']] ?? '')), 0, 20),
'document_type' => mb_substr(trim((string) ($data[$index['单据类型']] ?? '')), 0, 100),
'purpose' => trim((string) ($data[$index['用途/摘要']] ?? '')),
'account_code' => mb_substr($accountCode, 0, 64),
'account_name' => mb_substr($accountName, 0, 255),
'amount' => $this->decimal($data[$index['金额(元)']] ?? null),
'bank_account_code' => mb_substr(trim((string) ($data[$index['银行科目编码']] ?? '')), 0, 255),
'bank_account_name' => mb_substr(trim((string) ($data[$index['银行科目名称']] ?? '')), 0, 255),
'bank_amount' => $this->decimal($data[$index['银行付款金额']] ?? null),
'payment_method' => mb_substr(trim((string) ($data[$index['付款方式']] ?? '')), 0, 50),
'paid_at' => mb_substr(trim((string) ($data[$index['打款时间']] ?? '')), 0, 50),
'source_scope' => $scope,
'synced_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
$quality = $this->quality($candidate);
if (isset($qualities[$key])) {
$duplicates++;
if ($quality < $qualities[$key]) {
continue;
}
}
$rows[$key] = $candidate;
$qualities[$key] = $quality;
}
return [array_values($rows), $invalid, $placeholders, $duplicates];
} finally {
fclose($handle);
}
}
private function decimal(mixed $value): ?string
{
$value = trim((string) $value);
if ($value === '' || ! is_numeric($value)) {
return null;
}
return number_format((float) $value, 2, '.', '');
}
/** @param array<string,mixed> $row */
private function quality(array $row): int
{
$score = 0;
$score += trim((string) ($row['purpose'] ?? '')) !== ''
&& trim((string) ($row['purpose'] ?? '')) !== '251' ? 1 : 0;
$score += trim((string) ($row['account_name'] ?? '')) !== ''
&& trim((string) ($row['account_name'] ?? '')) !== '251' ? 1 : 0;
$score += is_numeric($row['amount'] ?? null) && (float) $row['amount'] !== 0.0 ? 4 : 0;
$score += trim((string) ($row['bank_account_code'] ?? '')) !== ''
&& trim((string) ($row['bank_account_code'] ?? '')) !== '251' ? 2 : 0;
$score += is_numeric($row['bank_amount'] ?? null) && (float) $row['bank_amount'] !== 0.0 ? 2 : 0;
$score += trim((string) ($row['paid_at'] ?? '')) !== ''
&& trim((string) ($row['paid_at'] ?? '')) !== '251' ? 1 : 0;
return $score;
}
}