286 lines
10 KiB
PHP
286 lines
10 KiB
PHP
<?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;
|
||
}
|
||
}
|