762 lines
33 KiB
PHP
762 lines
33 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Services;
|
||
|
||
use Illuminate\Database\ConnectionInterface;
|
||
use Illuminate\Database\Schema\Blueprint;
|
||
use Illuminate\Database\Schema\Builder;
|
||
use Illuminate\Support\Facades\Schema;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Str;
|
||
use RuntimeException;
|
||
use Throwable;
|
||
|
||
/**
|
||
* 将钉钉审批流程(按固定流程编码)同步为按流程聚合的共享镜像。
|
||
*
|
||
* 钉钉表单字段存放在 JSON 中,且历史版本字段不完全一致;镜像保留原始
|
||
* 主表、表单和审批任务 payload,页面只在镜像库中解析和筛选。
|
||
*
|
||
* 同一套同步逻辑通过 configKey 支持多个钉钉流程:默认处理“应付申请流程”,
|
||
* 配置 ding_online_daifa_price 时按列映射抽取宽表列并(可选)按明细表格
|
||
* 展开为多行,便于按商品ID关联商品目录。新增流程只需在
|
||
* config/upstream_mirrors.php 增加一项,无需另造同步链路。
|
||
*/
|
||
class DingPayableWorkflowMirrorSynchronizer
|
||
{
|
||
private readonly ConnectionInterface $connection;
|
||
|
||
/** 当前同步的钉钉流程配置键,默认“应付申请流程”。 */
|
||
private string $configKey = 'ding_payable_workflows';
|
||
|
||
public function __construct(private readonly UpstreamReadOnlyQueryFactory $queries)
|
||
{
|
||
$this->connection = app('db')->connection(
|
||
(string) config('upstream_mirrors.connection', 'business_mirror')
|
||
);
|
||
}
|
||
|
||
/** 切换要同步的钉钉流程配置键(复用同一套同步链路)。 */
|
||
public function forConfigKey(string $configKey): self
|
||
{
|
||
$this->configKey = $configKey;
|
||
|
||
return $this;
|
||
}
|
||
|
||
/** @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.' . $this->configKey, []);
|
||
$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';
|
||
$stateCursor = $this->decodeJson($state['cursor'] ?? null);
|
||
$highWatermark = $this->cursorFromState($this->decodeJson($state['high_watermark'] ?? null));
|
||
$resume = is_array($state)
|
||
&& ($state['run_mode'] ?? null) === $mode
|
||
&& in_array($state['status'] ?? null, ['running', 'failed'], true)
|
||
&& is_string($state['generation'] ?? null)
|
||
&& $this->isCompatibleCursor($stateCursor);
|
||
$generation = $resume ? (string) $state['generation'] : (string) Str::uuid();
|
||
[$windowStart, $windowEnd] = $this->window($state, $mode, $resume);
|
||
$cursor = $resume ? $this->cursorFromState($stateCursor) : null;
|
||
$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, $resume, $windowStart, $windowEnd);
|
||
$batches = 0;
|
||
|
||
try {
|
||
while (true) {
|
||
$workflows = $this->readWorkflows($source, $processCode, $cursor, $windowStart, $windowEnd, $batchSize);
|
||
if ($workflows === []) {
|
||
$this->complete($dataset, $target, $mode, $generation, $rows, $cursor ?? $highWatermark);
|
||
|
||
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,
|
||
)));
|
||
$originatorIds = array_values(array_unique(array_filter(array_map(
|
||
static fn (array $row): string => trim((string) ($row['originatorUserId'] ?? '')),
|
||
$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');
|
||
$originatorNames = $this->readOriginatorNames($source, $originatorIds);
|
||
|
||
$schemeById = $this->groupBy($schemes, 'processInstanceId');
|
||
$taskById = $this->groupBy($tasks, 'processInstanceId');
|
||
$now = now();
|
||
$payloads = [];
|
||
$extendColumns = ! empty($config['extend_columns']);
|
||
$columnMap = $extendColumns ? (array) ($config['column_map'] ?? []) : [];
|
||
$columnTypes = $extendColumns ? (array) ($config['column_types'] ?? []) : [];
|
||
$detailTable = $extendColumns ? trim((string) ($config['detail_table'] ?? '')) : '';
|
||
$resolveProductName = $extendColumns && ! empty($config['resolve_product_name']);
|
||
$rowFilters = $extendColumns ? (array) ($config['row_filters'] ?? []) : [];
|
||
$onlyStatus = strtoupper(trim((string) ($config['only_status'] ?? '')));
|
||
|
||
foreach ($workflows as $workflow) {
|
||
$id = (string) ($workflow['processInstanceId'] ?? '');
|
||
if ($id === '') {
|
||
continue;
|
||
}
|
||
// 工作流级状态过滤:只保留指定审批状态(默认只要“已完成/已通过”),
|
||
// 未通过/已拒绝/撤回等整单不写入镜像。置空 only_status 则不限状态。
|
||
if ($onlyStatus !== '' && DingPayableWorkflowStatus::key($workflow) !== $onlyStatus) {
|
||
continue;
|
||
}
|
||
$originatorId = trim((string) ($workflow['originatorUserId'] ?? ''));
|
||
$originatorName = trim((string) ($originatorNames[$originatorId] ?? ''));
|
||
if ($originatorName !== '') {
|
||
// 保留钉钉原始用户 ID,同时把稳定的中文姓名写入镜像,
|
||
// 页面无需在请求期间再次直连上游或猜测标题内容。
|
||
$workflow['originatorUserName'] = $originatorName;
|
||
}
|
||
$schemeRows = $schemeById[$id] ?? [];
|
||
$detailFieldsList = $this->buildDetailRowFields($schemeRows, $detailTable);
|
||
$detailIndex = 0;
|
||
foreach ($detailFieldsList as $fields) {
|
||
$payload = [
|
||
'process_instance_id' => $id,
|
||
'workflow_payload' => json_encode($workflow, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
|
||
'scheme_payload' => json_encode($schemeRows, 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,
|
||
];
|
||
if ($extendColumns) {
|
||
foreach ($columnMap as $column => $label) {
|
||
$payload[$column] = $this->castColumn($fields[$label] ?? null, (string) ($columnTypes[$column] ?? 'string'));
|
||
}
|
||
// 行级过滤(如“收款人全称”限定):不匹配的明细行直接跳过,
|
||
// 不写入镜像,也不占用 detail_index,保证保留行编号连续。
|
||
if ($rowFilters !== [] && ! $this->matchesRowFilters($payload, $rowFilters)) {
|
||
continue;
|
||
}
|
||
$payload['detail_index'] = $detailIndex;
|
||
}
|
||
$payloads[] = $payload;
|
||
$detailIndex++;
|
||
}
|
||
|
||
}
|
||
|
||
if ($resolveProductName && $payloads !== []) {
|
||
$this->resolveProductNames($payloads);
|
||
}
|
||
|
||
$nextCursor = $this->cursorFromWorkflow($workflows[array_key_last($workflows)]);
|
||
if ($nextCursor === null) {
|
||
throw new RuntimeException('ding_payable_workflow_missing_watermark');
|
||
}
|
||
$cursor = $nextCursor;
|
||
$rows += count($payloads);
|
||
$batches++;
|
||
$upsertKeys = $extendColumns ? ['process_instance_id', 'detail_index'] : ['process_instance_id'];
|
||
$updateColumns = ['workflow_payload', 'scheme_payload', 'task_payload', 'workflow_created_at', 'sync_generation', 'synced_at'];
|
||
if ($extendColumns) {
|
||
$updateColumns = array_merge($updateColumns, array_keys($columnMap));
|
||
if ($resolveProductName) {
|
||
$updateColumns[] = 'product_name';
|
||
}
|
||
}
|
||
$this->connection->transaction(function () use ($dataset, $target, $payloads, $cursor, $generation, $rows, $now, $upsertKeys, $updateColumns, $extendColumns, $ids): void {
|
||
// A changed workflow may have fewer details or no longer match
|
||
// the approved status/payee. Replace its rows atomically.
|
||
if ($extendColumns && $ids !== []) {
|
||
$this->connection->table($target)->whereIn('process_instance_id', $ids)->delete();
|
||
}
|
||
if ($payloads !== []) {
|
||
$this->connection->table($target)->upsert(
|
||
$payloads,
|
||
$upsertKeys,
|
||
$updateColumns,
|
||
);
|
||
}
|
||
$this->connection->table('business_mirror_table_states')
|
||
->where('dataset_key', $dataset)
|
||
->update([
|
||
'cursor' => $cursor === null
|
||
? null
|
||
: json_encode($cursor, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||
'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;
|
||
}
|
||
$config = (array) config('upstream_mirrors.' . $this->configKey, []);
|
||
if (! empty($config['extend_columns'])) {
|
||
$this->createExtendedMirrorTable($schema, $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);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 扩展镜像表:在原始 JSON 载荷之外抽取宽表列并建立索引,便于按商品ID
|
||
* 关联商品目录、按单据号/店铺筛选。结构与对应迁移保持一致。
|
||
*/
|
||
private function createExtendedMirrorTable(Builder $schema, string $target): void
|
||
{
|
||
$schema->create($target, function (Blueprint $table): void {
|
||
$table->string('process_instance_id', 100);
|
||
$table->unsignedTinyInteger('detail_index')->default(0);
|
||
$table->string('product_id', 64)->nullable()->index();
|
||
$table->string('daifa_trade_no', 64)->nullable()->index();
|
||
$table->string('shop_trade_no', 64)->nullable()->index();
|
||
$table->string('shop_name', 120)->nullable()->index();
|
||
$table->string('payee_name', 120)->nullable()->index();
|
||
$table->string('supplier_name', 120)->nullable();
|
||
$table->string('material_no', 120)->nullable();
|
||
$table->string('product_name', 255)->nullable()->comment('按商品ID关联商品目录得到的名称');
|
||
$table->unsignedInteger('ship_qty')->nullable();
|
||
$table->decimal('paid_amount', 14, 2)->nullable();
|
||
$table->decimal('daifa_amount', 14, 2)->nullable();
|
||
$table->decimal('price_diff', 14, 2)->nullable();
|
||
$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);
|
||
$table->primary(['process_instance_id', 'detail_index']);
|
||
});
|
||
}
|
||
|
||
/** @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,
|
||
bool $resume,
|
||
?Carbon $windowStart,
|
||
?Carbon $windowEnd,
|
||
): 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' => $windowStart,
|
||
'window_ends_at' => $windowEnd,
|
||
'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, ?array $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' => $cursor === null
|
||
? null
|
||
: json_encode($cursor, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||
'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,
|
||
?array $cursor,
|
||
?Carbon $windowStart,
|
||
?Carbon $windowEnd,
|
||
int $limit,
|
||
): array
|
||
{
|
||
$watermark = 'COALESCE([updateAt], [createAt])';
|
||
$where = '[processCode] = ?';
|
||
$bindings = [$processCode];
|
||
if ($windowStart !== null && $windowEnd !== null) {
|
||
$where .= ' AND ' . $watermark . ' >= ? AND ' . $watermark . ' < ?';
|
||
$bindings[] = $this->sqlServerDateTime($windowStart);
|
||
$bindings[] = $this->sqlServerDateTime($windowEnd);
|
||
}
|
||
if ($cursor !== null) {
|
||
$where .= ' AND (' . $watermark . ' > ? OR (' . $watermark . ' = ? AND [processInstanceId] > ?))';
|
||
$bindings[] = (string) ($cursor['updateAt'] ?? '');
|
||
$bindings[] = (string) ($cursor['updateAt'] ?? '');
|
||
$bindings[] = (string) ($cursor['processInstanceId'] ?? '');
|
||
}
|
||
return $this->queries->forSource($source)->select(
|
||
'SELECT TOP (' . $limit . ') [finishTime],[businessId],[title],[result],[originatorUserId],[originatorDeptId],[originatorDeptName],[status],[bizAction],[processInstanceId], [joinAt], COALESCE([updateAt], [createAt]) AS [updateAt], [createAt], [processCode], [flowType] FROM [rpt].[ding_Workflow] WHERE ' . $where . ' ORDER BY ' . $watermark . ', [processInstanceId]',
|
||
$bindings,
|
||
);
|
||
}
|
||
|
||
/** @return array{0:?Carbon,1:?Carbon} */
|
||
private function window(?array $state, string $mode, bool $resume): array
|
||
{
|
||
if ($mode === 'full') {
|
||
return [null, null];
|
||
}
|
||
|
||
if ($resume && ($state['window_started_at'] ?? null) !== null && ($state['window_ends_at'] ?? null) !== null) {
|
||
return [
|
||
Carbon::parse((string) $state['window_started_at']),
|
||
Carbon::parse((string) $state['window_ends_at']),
|
||
];
|
||
}
|
||
|
||
$overlap = max((int) config('upstream_mirrors.table_sync.overlap_hours', 48), 1);
|
||
$lastSucceeded = ($state['last_succeeded_at'] ?? null) !== null
|
||
? Carbon::parse((string) $state['last_succeeded_at'])
|
||
: now();
|
||
|
||
return [$lastSucceeded->copy()->subHours($overlap), now()->addSecond()];
|
||
}
|
||
|
||
/** @return array{updateAt:string,processInstanceId:string}|null */
|
||
private function cursorFromState(array $cursor): ?array
|
||
{
|
||
if (($cursor['updateAt'] ?? '') === '' && ($cursor['processInstanceId'] ?? '') === '') {
|
||
return null;
|
||
}
|
||
|
||
if (($cursor['updateAt'] ?? '') === '') {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'updateAt' => (string) $cursor['updateAt'],
|
||
'processInstanceId' => (string) ($cursor['processInstanceId'] ?? ''),
|
||
];
|
||
}
|
||
|
||
/** @param array<string,mixed> $workflow @return array{updateAt:string,processInstanceId:string}|null */
|
||
private function cursorFromWorkflow(array $workflow): ?array
|
||
{
|
||
$id = trim((string) ($workflow['processInstanceId'] ?? ''));
|
||
$updateAt = trim((string) ($workflow['updateAt'] ?? $workflow['createAt'] ?? ''));
|
||
|
||
if ($id === '' || $updateAt === '') {
|
||
return null;
|
||
}
|
||
|
||
return ['updateAt' => $updateAt, 'processInstanceId' => $id];
|
||
}
|
||
|
||
private function isCompatibleCursor(array $cursor): bool
|
||
{
|
||
return $cursor === [] || array_key_exists('updateAt', $cursor);
|
||
}
|
||
|
||
private function sqlServerDateTime(Carbon $value): string
|
||
{
|
||
return $value->format('Y-m-d\TH:i:s.v');
|
||
}
|
||
|
||
/** @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<string> $userIds @return array<string,string> */
|
||
private function readOriginatorNames(string $source, array $userIds): array
|
||
{
|
||
if ($userIds === []) {
|
||
return [];
|
||
}
|
||
|
||
$names = [];
|
||
try {
|
||
$names = $this->readUserNameRows($source, 'hr_employee', ['userid', 'name'], $userIds);
|
||
} catch (Throwable) {
|
||
// 姓名目录短暂不可用时不应阻断应付流程同步;页面会回退显示原始用户 ID。
|
||
}
|
||
|
||
$missing = array_values(array_diff($userIds, array_keys($names)));
|
||
if ($missing !== []) {
|
||
try {
|
||
// 已离职申请人不一定还存在于 hr_employee,使用同一只读来源的
|
||
// 离职视图补齐姓名;两个查询均为固定字段和参数化 IN 条件。
|
||
$retired = $this->readUserNameRows($source, 'hr_employee_dimission', ['userid', 'realName', 'name'], $missing);
|
||
$names += $retired;
|
||
} catch (Throwable) {
|
||
// 离职目录不可用时保留已解析的在职姓名。
|
||
}
|
||
}
|
||
|
||
return $names;
|
||
}
|
||
|
||
/** @param list<string> $columns @param list<string> $userIds @return array<string,string> */
|
||
private function readUserNameRows(string $source, string $table, array $columns, array $userIds): array
|
||
{
|
||
$placeholders = implode(',', array_fill(0, count($userIds), '?'));
|
||
$rows = $this->queries->forSource($source)->select(
|
||
'SELECT ' . implode(', ', array_map(static fn (string $column): string => '[' . $column . ']', $columns))
|
||
. ' FROM [rpt].[' . $table . '] WHERE [userid] IN (' . $placeholders . ')',
|
||
$userIds,
|
||
);
|
||
$names = [];
|
||
|
||
foreach ($rows as $row) {
|
||
$id = trim((string) ($row['userid'] ?? $row['userId'] ?? ''));
|
||
$name = trim((string) ($row['name'] ?? $row['realName'] ?? ''));
|
||
if ($id !== '' && $name !== '') {
|
||
$names[$id] = $name;
|
||
}
|
||
}
|
||
|
||
return $names;
|
||
}
|
||
|
||
/** @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;
|
||
}
|
||
|
||
/**
|
||
* 从表单 scheme 解析每一行的“字段名 -> 值”映射。
|
||
*
|
||
* 若配置了明细表格字段名,则按该表格的每一行展开(并合并顶层字段,
|
||
* 如店铺名称),否则整张表单视为单行。返回数组长度即镜像明细行数。
|
||
*
|
||
* @param array<int,array<string,mixed>> $schemeRows
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
private function buildDetailRowFields(array $schemeRows, string $detailTable): array
|
||
{
|
||
$topLevel = $this->formFieldLookup($schemeRows);
|
||
if ($detailTable === '') {
|
||
return [$topLevel];
|
||
}
|
||
|
||
$grid = null;
|
||
foreach ($schemeRows as $row) {
|
||
if (! is_array($row)) {
|
||
continue;
|
||
}
|
||
if (trim((string) ($row['name'] ?? '')) === $detailTable) {
|
||
$value = $row['value'] ?? null;
|
||
// 钉钉明细表格在上游/镜像中以 JSON 字符串存储,需先解码为数组再按行展开。
|
||
if (is_string($value)) {
|
||
$decoded = json_decode($value, true);
|
||
$value = is_array($decoded) ? $decoded : null;
|
||
}
|
||
$grid = is_array($value) ? $value : null;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if ($grid === null) {
|
||
return [$topLevel];
|
||
}
|
||
|
||
$rows = [];
|
||
foreach ($grid as $gridRow) {
|
||
if (! is_array($gridRow) || ! isset($gridRow['rowValue']) || ! is_array($gridRow['rowValue'])) {
|
||
continue;
|
||
}
|
||
$cellLookup = [];
|
||
foreach ($gridRow['rowValue'] as $cell) {
|
||
if (! is_array($cell) || ! isset($cell['label'])) {
|
||
continue;
|
||
}
|
||
$cellLookup[(string) $cell['label']] = $this->scalarValue($cell['value'] ?? null);
|
||
}
|
||
$rows[] = array_merge($topLevel, $cellLookup);
|
||
}
|
||
|
||
return $rows === [] ? [$topLevel] : $rows;
|
||
}
|
||
|
||
/** @param array<int,array<string,mixed>> $schemeRows @return array<string,mixed> */
|
||
private function formFieldLookup(array $schemeRows): array
|
||
{
|
||
$lookup = [];
|
||
foreach ($schemeRows as $row) {
|
||
if (! is_array($row)) {
|
||
continue;
|
||
}
|
||
$name = trim((string) ($row['name'] ?? ''));
|
||
if ($name === '') {
|
||
continue;
|
||
}
|
||
$lookup[$name] = $this->scalarValue($row['value'] ?? null);
|
||
}
|
||
return $lookup;
|
||
}
|
||
|
||
private function scalarValue(mixed $value): mixed
|
||
{
|
||
if (is_scalar($value) || $value === null) {
|
||
return $value;
|
||
}
|
||
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
}
|
||
|
||
/**
|
||
* 行级过滤:按“镜像列 => 目标值”精确匹配(去首尾空白)。任一条件不满足即
|
||
* 返回 false,该明细行不写入镜像。目标值为空表示不对该列过滤;过滤集合为
|
||
* 空则全部通过。用于把“线上代发业务报销”限定到指定收款人全称等场景。
|
||
*
|
||
* @param array<string,mixed> $payload
|
||
* @param array<string,mixed> $filters
|
||
*/
|
||
private function matchesRowFilters(array $payload, array $filters): bool
|
||
{
|
||
foreach ($filters as $column => $expected) {
|
||
$expected = trim((string) $expected);
|
||
if ($expected === '') {
|
||
continue;
|
||
}
|
||
if (trim((string) ($payload[$column] ?? '')) !== $expected) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private function castColumn(mixed $value, string $type): mixed
|
||
{
|
||
if ($value === null || $value === '') {
|
||
return null;
|
||
}
|
||
return match ($type) {
|
||
'int' => is_numeric($value) ? (int) $value : null,
|
||
'decimal' => is_numeric($value) ? (float) $value : null,
|
||
default => is_scalar($value) ? (string) $value : json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 同步阶段按商品ID(钉钉表单“商品ID”即 ops_jst_sku_map.shop_i_id)关联商品目录,
|
||
* 将已匹配的商品名称写入镜像宽表的 product_name 列。页面只读镜像,不在此处临时
|
||
* JOIN 2500 万行的商品映射表,避免页面查询超时。所有商品ID一次性批量关联,
|
||
* 未命中的再回退到 ops_jst_sku 的 i_id/sku_id。
|
||
*
|
||
* @param list<array<string,mixed>> $payloads
|
||
*/
|
||
private function resolveProductNames(array &$payloads): void
|
||
{
|
||
$ids = [];
|
||
foreach ($payloads as $payload) {
|
||
$id = trim((string) ($payload['product_id'] ?? ''));
|
||
if ($id !== '' && ! isset($ids[$id])) {
|
||
$ids[$id] = true;
|
||
}
|
||
}
|
||
if ($ids === []) {
|
||
return;
|
||
}
|
||
$idList = array_keys($ids);
|
||
|
||
$names = [];
|
||
if ($this->connection->getSchemaBuilder()->hasTable('ops_jst_sku_map')) {
|
||
$rows = $this->guardedQuery(4000, fn () => $this->connection->table('ops_jst_sku_map')
|
||
->whereIn('shop_i_id', $idList)
|
||
->select(['shop_i_id', 'sku_id', 'i_id', 'name'])
|
||
->limit(5000)
|
||
->get());
|
||
foreach ($rows as $row) {
|
||
$id = (string) $row->shop_i_id;
|
||
if (! isset($names[$id])) {
|
||
$name = $this->cleanProductName($row->name ?? null);
|
||
if ($name !== null) {
|
||
$names[$id] = $name;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
$missing = array_values(array_filter($idList, static fn (string $id): bool => ! isset($names[$id])));
|
||
if ($missing !== [] && $this->connection->getSchemaBuilder()->hasTable('ops_jst_sku')) {
|
||
$rows = $this->guardedQuery(4000, fn () => $this->connection->table('ops_jst_sku')
|
||
->whereIn('i_id', $missing)
|
||
->orWhereIn('sku_id', $missing)
|
||
->select(['i_id', 'sku_id', 'name'])
|
||
->limit(5000)
|
||
->get());
|
||
foreach ($rows as $row) {
|
||
foreach ([(string) $row->i_id, (string) $row->sku_id] as $candidate) {
|
||
if ($candidate !== '' && isset($names[$candidate]) === false) {
|
||
$name = $this->cleanProductName($row->name ?? null);
|
||
if ($name !== null) {
|
||
$names[$candidate] = $name;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach ($payloads as &$payload) {
|
||
$id = trim((string) ($payload['product_id'] ?? ''));
|
||
$payload['product_name'] = $id !== '' && isset($names[$id]) ? $names[$id] : null;
|
||
}
|
||
unset($payload);
|
||
}
|
||
|
||
private function cleanProductName(mixed $value): ?string
|
||
{
|
||
if ($value === null) {
|
||
return null;
|
||
}
|
||
$name = trim((string) $value);
|
||
return $name === '' ? null : $name;
|
||
}
|
||
|
||
/**
|
||
* 对可能命中大表的查询加会话级超时,避免索引缺失时整批同步被拖垮。
|
||
* 超时后该批商品名称留空,页面仍可正常展示(仅缺失匹配名),不影响同步完成。
|
||
*
|
||
* @template T
|
||
* @param callable():T $callback
|
||
* @return T
|
||
*/
|
||
private function guardedQuery(int $milliseconds, callable $callback)
|
||
{
|
||
$conn = $this->connection;
|
||
try {
|
||
$conn->statement('SET SESSION MAX_EXECUTION_TIME=' . $milliseconds);
|
||
return $callback();
|
||
} catch (Throwable $exception) {
|
||
// 超时(SQLSTATE HY000/3024)或其它只读异常:放弃匹配该批商品名称,
|
||
// 不让同步链路中断。
|
||
return [];
|
||
} finally {
|
||
try {
|
||
$conn->statement('SET SESSION MAX_EXECUTION_TIME=0');
|
||
} catch (Throwable) {
|
||
// 忽略重置失败。
|
||
}
|
||
}
|
||
}
|
||
}
|