CaiWuXiTong/modules/EcommerceFinance/Services/DingPayableWorkflowMirrorSynchronizer.php

762 lines
33 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\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) {
// 忽略重置失败。
}
}
}
}