CaiWuXiTong/modules/EcommerceFinance/Services/DingPayableWorkflowMirrorSynchronizer.php

278 lines
13 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\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
/**
* 将钉钉应付申请流程的固定流程编码同步为按流程聚合的共享镜像。
*
* 钉钉表单字段存放在 JSON 中,且历史版本字段不完全一致;镜像保留原始
* 主表、表单和审批任务 payload,页面只在镜像库中解析和筛选。
*/
class DingPayableWorkflowMirrorSynchronizer
{
private readonly ConnectionInterface $connection;
public function __construct(private readonly UpstreamReadOnlyQueryFactory $queries)
{
$this->connection = app('db')->connection(
(string) config('upstream_mirrors.connection', 'business_mirror')
);
}
/** @return array{status:string,mode:string,batches:int,rows:int,dataset:string} */
public function sync(bool $full = false, ?int $batchSize = null, ?int $maxBatches = null): array
{
$config = (array) config('upstream_mirrors.ding_payable_workflows', []);
$dataset = (string) ($config['dataset_key'] ?? 'operations_replica.rpt.ding_payable_workflows');
$target = (string) ($config['target_table'] ?? 'ding_payable_workflow_mirror');
$source = (string) ($config['source_key'] ?? 'operations_replica');
$processCode = (string) ($config['process_code'] ?? '');
if ($processCode === '') {
throw new RuntimeException('ding_payable_workflow_mirror_not_initialized');
}
$this->ensureMirrorTable($target);
UpstreamOdbcConnection::reset($source);
$state = $this->state($dataset);
if (! $full && ($state['initial_completed_at'] ?? null) === null) {
return ['status' => 'requires_full_sync', 'mode' => 'incremental', 'batches' => 0, 'rows' => 0, 'dataset' => $dataset];
}
$mode = $full ? 'full' : 'incremental';
$resume = is_array($state)
&& ($state['run_mode'] ?? null) === $mode
&& in_array($state['status'] ?? null, ['running', 'failed'], true)
&& is_string($state['generation'] ?? null)
&& is_array($state['cursor'] ?? null);
$generation = $resume ? (string) $state['generation'] : (string) Str::uuid();
$cursor = $resume
? (string) ($state['cursor']['processInstanceId'] ?? '')
: (string) ($this->decodeJson($state['high_watermark'] ?? null)['processInstanceId'] ?? '');
$rows = $resume ? (int) ($state['last_upserted_rows'] ?? 0) : 0;
$batchSize = min(max($batchSize ?? (int) ($config['batch_size'] ?? 100), 1), 500);
$maxBatches = max($maxBatches ?? (int) ($config['max_batches_per_run'] ?? 2), 0);
$this->start($dataset, $target, $source, $mode, $generation, $cursor, $resume);
$batches = 0;
try {
while (true) {
$workflows = $this->readWorkflows($source, $processCode, $cursor, $batchSize);
if ($workflows === []) {
$this->complete($dataset, $target, $mode, $generation, $rows, $cursor);
return ['status' => 'succeeded', 'mode' => $mode, 'batches' => $batches, 'rows' => $rows, 'dataset' => $dataset];
}
$ids = array_values(array_filter(array_map(
static fn (array $row): string => (string) ($row['processInstanceId'] ?? ''),
$workflows,
)));
$schemes = $this->readRelated($source, 'ding_WorkflowScheme', ['id', 'processInstanceId', 'componentType', 'name', 'value', 'extValue'], $ids, 'id');
$tasks = $this->readRelated($source, 'ding_WorkflowTask', ['processInstanceId', 'result', 'activityId', 'finishTime', 'createTime', 'userId', 'taskId', 'status', 'id'], $ids, 'id');
$schemeById = $this->groupBy($schemes, 'processInstanceId');
$taskById = $this->groupBy($tasks, 'processInstanceId');
$now = now();
$payloads = [];
foreach ($workflows as $workflow) {
$id = (string) ($workflow['processInstanceId'] ?? '');
if ($id === '') {
continue;
}
$payloads[] = [
'process_instance_id' => $id,
'workflow_payload' => json_encode($workflow, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
'scheme_payload' => json_encode($schemeById[$id] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
'task_payload' => json_encode($taskById[$id] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
'workflow_created_at' => isset($workflow['createAt']) ? (string) $workflow['createAt'] : null,
'sync_generation' => $generation,
'synced_at' => $now,
];
}
$cursor = (string) ($workflows[array_key_last($workflows)]['processInstanceId'] ?? $cursor);
$rows += count($payloads);
$batches++;
$this->connection->transaction(function () use ($dataset, $target, $payloads, $cursor, $generation, $rows, $now): void {
if ($payloads !== []) {
$this->connection->table($target)->upsert(
$payloads,
['process_instance_id'],
['workflow_payload', 'scheme_payload', 'task_payload', 'workflow_created_at', 'sync_generation', 'synced_at'],
);
}
$this->connection->table('business_mirror_table_states')
->where('dataset_key', $dataset)
->update([
'cursor' => json_encode(['processInstanceId' => $cursor], JSON_UNESCAPED_UNICODE),
'last_upserted_rows' => $rows,
'updated_at' => $now,
]);
});
if (count($workflows) < $batchSize) {
$this->complete($dataset, $target, $mode, $generation, $rows, $cursor);
return ['status' => 'succeeded', 'mode' => $mode, 'batches' => $batches, 'rows' => $rows, 'dataset' => $dataset];
}
if ($maxBatches > 0 && $batches >= $maxBatches) {
return ['status' => 'running', 'mode' => $mode, 'batches' => $batches, 'rows' => $rows, 'dataset' => $dataset];
}
}
} catch (Throwable $exception) {
$this->connection->table('business_mirror_table_states')
->where('dataset_key', $dataset)
->update(['status' => 'failed', 'last_error_code' => mb_substr($exception::class, 0, 120), 'updated_at' => now()]);
throw $exception;
}
}
private function ensureMirrorTable(string $target): void
{
$schema = Schema::connection($this->connection->getName());
if ($schema->hasTable($target)) {
return;
}
$schema->create($target, function (Blueprint $table): void {
$table->string('process_instance_id', 100)->primary();
$table->json('workflow_payload');
$table->json('scheme_payload');
$table->json('task_payload')->nullable();
$table->string('workflow_created_at', 32)->nullable()->index();
$table->uuid('sync_generation')->nullable()->index();
$table->dateTime('synced_at', 6);
});
}
/** @return array<string,mixed>|null */
private function state(string $dataset): ?array
{
$row = $this->connection->table('business_mirror_table_states')->where('dataset_key', $dataset)->first();
if ($row === null) {
return null;
}
$state = (array) $row;
$state['cursor'] = is_string($state['cursor'] ?? null) ? (json_decode($state['cursor'], true) ?: []) : ($state['cursor'] ?? []);
return $state;
}
private function start(string $dataset, string $target, string $source, string $mode, string $generation, string $cursor, bool $resume): void
{
$now = now();
$values = [
'source_key' => $source,
'source_object' => 'rpt.ding_Workflow + rpt.ding_WorkflowScheme + rpt.ding_WorkflowTask',
'target_table' => $target,
'status' => 'running',
'run_mode' => $mode,
'generation' => $generation,
'last_attempted_at' => $now,
'last_error_code' => null,
'updated_at' => $now,
];
if (! $resume) {
$values += ['cursor' => null, 'window_started_at' => null, 'window_ends_at' => null, 'last_upserted_rows' => 0];
}
$query = $this->connection->table('business_mirror_table_states')->where('dataset_key', $dataset);
$query->exists()
? $query->update($values)
: $this->connection->table('business_mirror_table_states')->insert(['dataset_key' => $dataset, 'created_at' => $now] + $values);
}
private function complete(string $dataset, string $target, string $mode, string $generation, int $rows, string $cursor): void
{
$now = now();
$this->connection->transaction(function () use ($dataset, $target, $mode, $generation, $rows, $cursor, $now): void {
if ($mode === 'full') {
$this->connection->table($target)->where(function ($query) use ($generation): void {
$query->whereNull('sync_generation')->orWhere('sync_generation', '!=', $generation);
})->delete();
}
$values = [
'status' => 'succeeded',
'cursor' => null,
'high_watermark' => json_encode(['processInstanceId' => $cursor], JSON_UNESCAPED_UNICODE),
'last_succeeded_at' => $now,
'last_upserted_rows' => $rows,
'mirror_row_count' => $this->connection->table($target)->count(),
'last_error_code' => null,
'updated_at' => $now,
];
if ($mode === 'full') {
$values['initial_completed_at'] = $now;
$values['last_full_succeeded_at'] = $now;
}
$this->connection->table('business_mirror_table_states')->where('dataset_key', $dataset)->update($values);
});
}
/** @return array<string,mixed> */
private function decodeJson(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (! is_string($value) || $value === '') {
return [];
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
/** @return list<array<string,mixed>> */
private function readWorkflows(string $source, string $processCode, string $cursor, int $limit): array
{
$where = '[processCode] = ?';
$bindings = [$processCode];
if ($cursor !== '') {
$where .= ' AND [processInstanceId] > ?';
$bindings[] = $cursor;
}
return $this->queries->forSource($source)->select(
'SELECT TOP (' . $limit . ') [finishTime],[businessId],[title],[result],[originatorUserId],[originatorDeptId],[originatorDeptName],[status],[bizAction],[processInstanceId],[joinAt],[updateAt],[createAt],[processCode],[flowType] FROM [rpt].[ding_Workflow] WHERE ' . $where . ' ORDER BY [processInstanceId]',
$bindings,
);
}
/** @param list<string> $ids @param list<string> $columns @return list<array<string,mixed>> */
private function readRelated(string $source, string $table, array $columns, array $ids, string $order): array
{
if ($ids === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
return $this->queries->forSource($source)->select(
'SELECT ' . implode(', ', array_map(static fn (string $column): string => '[' . $column . ']', $columns))
. ' FROM [rpt].[' . $table . '] WHERE [processInstanceId] IN (' . $placeholders . ') ORDER BY [' . $order . ']',
$ids,
);
}
/** @param list<array<string,mixed>> $rows @return array<string,list<array<string,mixed>>> */
private function groupBy(array $rows, string $column): array
{
$grouped = [];
foreach ($rows as $row) {
$key = (string) ($row[$column] ?? '');
if ($key !== '') {
$grouped[$key][] = $row;
}
}
return $grouped;
}
}