feat: 钉钉官方接口补取流程付款明细 #681
@ -10,17 +10,33 @@ class SyncDingProcessDetailMirror extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:sync-ding-process-details
|
||||
{--full : 从配置起始日期完整重建明细和付款事实}
|
||||
{--business-id= : 只同步指定审批编号,多个用逗号分隔(最多 50 个)}
|
||||
{--dry-run : 只读取上游并校验,不写入镜像}';
|
||||
|
||||
protected $description = '从钉钉结构化审批表单同步流程明细和付款事实';
|
||||
protected $description = '从结构化审批镜像同步流程明细,并用钉钉官方接口补取缺失付款';
|
||||
|
||||
public function handle(DingProcessDetailMirrorSynchronizer $synchronizer): int
|
||||
{
|
||||
$businessIds = array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) $this->option('business-id')),
|
||||
)));
|
||||
|
||||
try {
|
||||
$result = $synchronizer->sync(
|
||||
(bool) $this->option('full'),
|
||||
(bool) $this->option('dry-run'),
|
||||
);
|
||||
if ($businessIds !== []) {
|
||||
if ((bool) $this->option('full')) {
|
||||
throw new \InvalidArgumentException('--full 不能和 --business-id 同时使用。');
|
||||
}
|
||||
$result = $synchronizer->syncBusinessIds(
|
||||
$businessIds,
|
||||
(bool) $this->option('dry-run'),
|
||||
);
|
||||
} else {
|
||||
$result = $synchronizer->sync(
|
||||
(bool) $this->option('full'),
|
||||
(bool) $this->option('dry-run'),
|
||||
);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$this->error('同步失败,已保留上一版共享数据:' . $exception->getMessage());
|
||||
|
||||
|
||||
@ -37,6 +37,8 @@
|
||||
|
||||
流程明细另设独立定时任务,避免与每分钟科目映射消费争用同一上游目录时被静默跳过。正式发布后由工作站执行 `scripts/install-ding-process-sync-cron.ps1` 安装 `/etc/cron.d/caiwu-ding-process-details`:每 5 分钟尝试运行 `business-mirror:sync-ding-process-details`,使用独立调度锁并在共用上游锁上最多等待 15 分钟。增量同步除跟进审批主表更新时间外,还以 `ding_WorkflowScheme` 的数字 `id` 高水位发现后补表单行,避免付款明细等新组件写入后主审批 `updateAt` 不变而被漏掉;原地改写同一组件仍由每周全量校准兜底。页面按审批单费用合计和独立付款事实展示“付款明细待同步 / 未付款 / 部分付款 / 已付款 / 不适用”:只有源表单明确存在付款表或付款合计字段但金额为零时才标“未付款”,付款表单缺失时必须显示“付款明细待同步”,不能把上游尚未返回的数据误报为未付款。审批状态不能代替付款状态。
|
||||
|
||||
`operations_replica` 缺失付款组件时,同步器复用现有钉钉应用凭据调用 `https://api.dingtalk.com/v1.0/workflow/processInstances` 补取官方最新表单。付款来源标记为 `api`,付款事实仍进入 `ding_process_payment_mirror`;不会用官方接口覆盖副本已有的明细,只补缺失或空的付款字段。自动增量每 5 分钟按 `process_instance_id` 游标轮转最多 50 个缺失流程,避免单个失败记录阻塞队列或瞬时打满接口;人工核查可执行 `business-mirror:sync-ding-process-details --business-id=审批编号1,审批编号2 --dry-run`,确认后再去掉 `--dry-run`。单次最多 50 个审批编号。
|
||||
|
||||
抖音月中校准可在上述导入命令追加 `--through=YYYY-MM-DD`,文件必须包含该月 1 日至截止日(含当天)的全部店铺和流水。系统仅替换这一日期范围,截止日之后继续读取原始账单镜像;省略参数仍表示完整月份。截止日期不得早于同月已校准范围。每天更新时重新导出月初至昨日的完整结果并核对笔数、收支总额,以覆盖迟到及修改记录,不能把单日增量当作月初至截止日的完整结果导入。
|
||||
|
||||
### 成本价镜像观测
|
||||
|
||||
@ -307,7 +307,7 @@ class DingProcessDetailMirrorRepository
|
||||
< COALESCE(detail_totals.approval_amount, 0) THEN '部分付款'
|
||||
WHEN COALESCE(payment_totals.payment_amount, 0) > 0.005 THEN '已付款'
|
||||
WHEN {$sourceState} = 'not_applicable' THEN '不适用'
|
||||
WHEN {$sourceState} = 'present' THEN '未付款'
|
||||
WHEN {$sourceState} IN ('present', 'api') THEN '未付款'
|
||||
ELSE '付款明细待同步'
|
||||
END";
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@ namespace Modules\EcommerceFinance\Services;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
@ -21,6 +23,8 @@ class DingProcessDetailMirrorSynchronizer
|
||||
|
||||
private const SOURCE = 'operations_replica';
|
||||
|
||||
private const APPROVAL_API_FALLBACK_LIMIT = 50;
|
||||
|
||||
private const STATUS_MAP = [
|
||||
'COMPLETED' => '已完成',
|
||||
'RUNNING' => '审批中',
|
||||
@ -32,9 +36,14 @@ class DingProcessDetailMirrorSynchronizer
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
private int $approvalApiAttempts = 0;
|
||||
|
||||
private bool $approvalApiAvailable = true;
|
||||
|
||||
public function __construct(
|
||||
DatabaseManager $database,
|
||||
private readonly UpstreamReadOnlyQueryFactory $queries,
|
||||
private readonly DingTalkProcessInstanceClient $approvalClient,
|
||||
) {
|
||||
$this->connection = $database->connection(
|
||||
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
||||
@ -54,6 +63,8 @@ class DingProcessDetailMirrorSynchronizer
|
||||
*/
|
||||
public function sync(bool $forceFull = false, bool $dryRun = false): array
|
||||
{
|
||||
$this->approvalApiAttempts = 0;
|
||||
$this->approvalApiAvailable = true;
|
||||
$scope = $this->scope();
|
||||
$state = $this->state();
|
||||
$structuredState = $this->structuredState($state);
|
||||
@ -132,6 +143,108 @@ class DingProcessDetailMirrorSynchronizer
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild selected approvals from the source replica and fill missing
|
||||
* payment components from DingTalk's current process-instance API.
|
||||
*
|
||||
* @param list<string> $businessIds
|
||||
* @return array{
|
||||
* status:string,
|
||||
* run_mode:string,
|
||||
* workflows:int,
|
||||
* details:int,
|
||||
* payments:int,
|
||||
* amount_total:float,
|
||||
* bank_amount_total:float
|
||||
* }
|
||||
*/
|
||||
public function syncBusinessIds(array $businessIds, bool $dryRun = false): array
|
||||
{
|
||||
$businessIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $value): string => trim((string) $value),
|
||||
$businessIds,
|
||||
))));
|
||||
if ($businessIds === []) {
|
||||
throw new RuntimeException('至少需要一个审批编号。');
|
||||
}
|
||||
if (count($businessIds) > self::APPROVAL_API_FALLBACK_LIMIT) {
|
||||
throw new RuntimeException('单次最多按审批编号补取 50 个流程。');
|
||||
}
|
||||
|
||||
$this->approvalApiAttempts = 0;
|
||||
$this->approvalApiAvailable = true;
|
||||
$scope = $this->scope();
|
||||
$state = $this->state();
|
||||
if (! $this->structuredState($state)) {
|
||||
return [
|
||||
'status' => 'requires_full_sync',
|
||||
'run_mode' => 'full',
|
||||
'workflows' => 0,
|
||||
'details' => 0,
|
||||
'payments' => 0,
|
||||
'amount_total' => 0.0,
|
||||
'bank_amount_total' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
$now = now();
|
||||
if (! $dryRun) {
|
||||
$this->markAttempted($state, false, $now);
|
||||
}
|
||||
|
||||
try {
|
||||
$placeholders = implode(',', array_fill(0, count($businessIds), '?'));
|
||||
$extracted = $this->extractWorkflows(
|
||||
"w.businessId IN ({$placeholders})",
|
||||
$businessIds,
|
||||
'w.businessId',
|
||||
'businessId',
|
||||
$now,
|
||||
);
|
||||
$cursor = $this->stateCursor($state);
|
||||
$extracted['metrics']['approval_api_cursor'] = (string) (
|
||||
$cursor['approval_api_cursor'] ?? ''
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
return [
|
||||
'status' => 'validated',
|
||||
'run_mode' => 'business_ids',
|
||||
'workflows' => $extracted['metrics']['workflows'],
|
||||
'details' => $extracted['metrics']['details'],
|
||||
'payments' => $extracted['metrics']['payments'],
|
||||
'amount_total' => $extracted['metrics']['amount_total'],
|
||||
'bank_amount_total' => $extracted['metrics']['bank_amount_total'],
|
||||
];
|
||||
}
|
||||
|
||||
$this->publish(
|
||||
$extracted['details'],
|
||||
$extracted['payments'],
|
||||
$extracted['process_instance_ids'],
|
||||
false,
|
||||
$scope,
|
||||
$extracted['metrics'],
|
||||
$now,
|
||||
);
|
||||
|
||||
return [
|
||||
'status' => 'succeeded',
|
||||
'run_mode' => 'business_ids',
|
||||
'workflows' => $extracted['metrics']['workflows'],
|
||||
'details' => $extracted['metrics']['details'],
|
||||
'payments' => $extracted['metrics']['payments'],
|
||||
'amount_total' => $extracted['metrics']['amount_total'],
|
||||
'bank_amount_total' => $extracted['metrics']['bank_amount_total'],
|
||||
];
|
||||
} catch (Throwable $exception) {
|
||||
if (! $dryRun) {
|
||||
$this->markFailed($state, $exception, $now);
|
||||
}
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* details:list<array<string,mixed>>,
|
||||
@ -283,11 +396,101 @@ class DingProcessDetailMirrorSynchronizer
|
||||
}
|
||||
}
|
||||
|
||||
$apiCursor = (string) ($cursor['approval_api_cursor'] ?? '');
|
||||
$fallbackIds = $this->approvalApiFallbackCandidates(
|
||||
$apiCursor,
|
||||
$result['process_instance_ids'],
|
||||
);
|
||||
if ($fallbackIds !== []) {
|
||||
foreach (array_chunk($fallbackIds, 25) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
$fallbackResult = $this->extractWorkflows(
|
||||
"w.processInstanceId IN ({$placeholders})",
|
||||
$chunk,
|
||||
'w.processInstanceId',
|
||||
'processInstanceId',
|
||||
$now,
|
||||
);
|
||||
$result = $this->mergeExtracted($result, $fallbackResult);
|
||||
}
|
||||
$apiCursor = (string) $fallbackIds[array_key_last($fallbackIds)];
|
||||
}
|
||||
|
||||
$result['metrics']['scheme_high_watermark'] = $schemeHighWatermark;
|
||||
$result['metrics']['approval_api_cursor'] = $apiCursor;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate through approvals whose payment component is absent from the
|
||||
* replica. The cursor prevents one repeatedly failing approval from
|
||||
* blocking the rest of the backlog.
|
||||
*
|
||||
* @param list<string> $excludedProcessInstanceIds
|
||||
* @return list<string>
|
||||
*/
|
||||
private function approvalApiFallbackCandidates(
|
||||
string $afterProcessInstanceId,
|
||||
array $excludedProcessInstanceIds,
|
||||
): array {
|
||||
if (! $this->approvalClient->isConfigured()
|
||||
|| ! Schema::connection($this->connection->getName())->hasColumn(
|
||||
'ding_process_detail_mirror',
|
||||
'payment_source_state',
|
||||
)) {
|
||||
return [];
|
||||
}
|
||||
$remainingAttempts = self::APPROVAL_API_FALLBACK_LIMIT - $this->approvalApiAttempts;
|
||||
if (! $this->approvalApiAvailable || $remainingAttempts <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$base = $this->connection->table('ding_process_detail_mirror')
|
||||
->where('source_scope', $this->scope())
|
||||
->whereIn('status', ['审批中', '已完成'])
|
||||
->where('payment_source_state', 'missing')
|
||||
->whereNotNull('process_instance_id')
|
||||
->where('process_instance_id', '!=', '');
|
||||
$excluded = array_fill_keys($excludedProcessInstanceIds, true);
|
||||
|
||||
$ids = (clone $base)
|
||||
->when(
|
||||
$afterProcessInstanceId !== '',
|
||||
fn ($query) => $query->where('process_instance_id', '>', $afterProcessInstanceId),
|
||||
)
|
||||
->distinct()
|
||||
->orderBy('process_instance_id')
|
||||
->limit($remainingAttempts * 2)
|
||||
->pluck('process_instance_id')
|
||||
->map(static fn (mixed $value): string => trim((string) $value))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
$ids = array_values(array_filter(
|
||||
$ids,
|
||||
static fn (string $id): bool => ! isset($excluded[$id]),
|
||||
));
|
||||
|
||||
if ($ids === [] && $afterProcessInstanceId !== '') {
|
||||
$ids = (clone $base)
|
||||
->distinct()
|
||||
->orderBy('process_instance_id')
|
||||
->limit($remainingAttempts * 2)
|
||||
->pluck('process_instance_id')
|
||||
->map(static fn (mixed $value): string => trim((string) $value))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
$ids = array_values(array_filter(
|
||||
$ids,
|
||||
static fn (string $id): bool => ! isset($excluded[$id]),
|
||||
));
|
||||
}
|
||||
|
||||
return array_slice($ids, 0, $remainingAttempts);
|
||||
}
|
||||
|
||||
/**
|
||||
* New scheme rows can be appended after the workflow itself stopped
|
||||
* changing. Track their numeric ID so late payment/details are discovered
|
||||
@ -643,6 +846,20 @@ class DingProcessDetailMirrorSynchronizer
|
||||
$status = $this->status((string) ($workflow['status'] ?? ''));
|
||||
$businessMonth = $this->businessMonth($businessId, (string) ($workflow['joinAt'] ?? ''));
|
||||
$paymentSourceState = $this->paymentSourceState($process, $fields);
|
||||
if ($this->needsApprovalApiFallback($process, $fields, $paymentSourceState)) {
|
||||
$apiFields = $this->approvalApiFields($workflow);
|
||||
if ($apiFields !== []) {
|
||||
foreach ($apiFields as $fieldName => $fieldValue) {
|
||||
if (! array_key_exists($fieldName, $fields)
|
||||
|| trim((string) ($fields[$fieldName] ?? '')) === '') {
|
||||
$fields[$fieldName] = $fieldValue;
|
||||
}
|
||||
}
|
||||
$paymentSourceState = $this->hasApprovalPaymentSource($process, $apiFields)
|
||||
? 'api'
|
||||
: $this->paymentSourceState($process, $fields);
|
||||
}
|
||||
}
|
||||
|
||||
[$workflowDetails, $workflowAmountCents, $detailTableName] = $this->detailRows(
|
||||
$workflow,
|
||||
@ -921,6 +1138,93 @@ class DingProcessDetailMirrorSynchronizer
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $process
|
||||
* @param array<string,mixed> $fields
|
||||
*/
|
||||
private function needsApprovalApiFallback(
|
||||
array $process,
|
||||
array $fields,
|
||||
string $paymentSourceState,
|
||||
): bool {
|
||||
if ($paymentSourceState === 'missing') {
|
||||
return true;
|
||||
}
|
||||
if ($paymentSourceState !== 'present') {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->paymentSourceFieldNames($process) as $fieldName) {
|
||||
if (trim((string) ($fields[$fieldName] ?? '')) === '') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $workflow
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function approvalApiFields(array $workflow): array
|
||||
{
|
||||
if (! $this->approvalClient->isConfigured()
|
||||
|| ! $this->approvalApiAvailable
|
||||
|| $this->approvalApiAttempts >= self::APPROVAL_API_FALLBACK_LIMIT) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$processInstanceId = trim((string) ($workflow['processInstanceId'] ?? ''));
|
||||
if ($processInstanceId === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->approvalApiAttempts++;
|
||||
try {
|
||||
return $this->approvalClient->formValues($processInstanceId);
|
||||
} catch (Throwable $exception) {
|
||||
$this->approvalApiAvailable = false;
|
||||
Log::warning('钉钉官方审批接口补取流程表单失败。', [
|
||||
'process_instance_id' => $processInstanceId,
|
||||
'exception' => $exception::class,
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $process
|
||||
* @param array<string,mixed> $apiFields
|
||||
*/
|
||||
private function hasApprovalPaymentSource(array $process, array $apiFields): bool
|
||||
{
|
||||
foreach ($this->paymentSourceFieldNames($process) as $fieldName) {
|
||||
if (array_key_exists($fieldName, $apiFields)
|
||||
&& trim((string) ($apiFields[$fieldName] ?? '')) !== '') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $process
|
||||
* @return list<string>
|
||||
*/
|
||||
private function paymentSourceFieldNames(array $process): array
|
||||
{
|
||||
return array_values(array_unique(array_filter([
|
||||
trim((string) ($process['payment_table'] ?? '')),
|
||||
...array_map(
|
||||
static fn (mixed $value): string => trim((string) $value),
|
||||
(array) ($process['payment_total_fields'] ?? []),
|
||||
),
|
||||
])));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $businessId
|
||||
* @param array<string,mixed> $process
|
||||
@ -1343,7 +1647,9 @@ class DingProcessDetailMirrorSynchronizer
|
||||
|
||||
$values = [
|
||||
'source_key' => self::SOURCE,
|
||||
'source_object' => 'ding_Workflow + ding_WorkflowScheme structured details',
|
||||
'source_object' => $this->approvalApiAttempts > 0
|
||||
? 'ding_Workflow + ding_WorkflowScheme + DingTalk processInstance API fallback'
|
||||
: 'ding_Workflow + ding_WorkflowScheme structured details',
|
||||
'target_table' => 'ding_process_detail_mirror',
|
||||
'status' => 'succeeded',
|
||||
'run_mode' => $full ? 'full' : 'incremental',
|
||||
@ -1358,6 +1664,8 @@ class DingProcessDetailMirrorSynchronizer
|
||||
'amount_total' => round($amountTotal, 2),
|
||||
'bank_amount_total' => round($bankAmountTotal, 2),
|
||||
'scheme_high_watermark' => (int) ($metrics['scheme_high_watermark'] ?? 0),
|
||||
'approval_api_cursor' => (string) ($metrics['approval_api_cursor'] ?? ''),
|
||||
'approval_api_attempts' => $this->approvalApiAttempts,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'high_watermark' => null,
|
||||
'window_started_at' => null,
|
||||
|
||||
@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
class DingTalkProcessInstanceClient
|
||||
{
|
||||
private const PROCESS_INSTANCE_URL = 'https://api.dingtalk.com/v1.0/workflow/processInstances';
|
||||
|
||||
private const TOKEN_SAFETY_SECONDS = 60;
|
||||
|
||||
private ?string $accessToken = null;
|
||||
|
||||
private int $accessTokenExpiresAt = 0;
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return trim((string) config('dingtalk.client_id', '')) !== ''
|
||||
&& trim((string) config('dingtalk.client_secret', '')) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return DingTalk's latest form component values by component name.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function formValues(string $processInstanceId): array
|
||||
{
|
||||
$processInstanceId = trim($processInstanceId);
|
||||
if ($processInstanceId === '') {
|
||||
throw new RuntimeException('DingTalk process instance ID is required.');
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::acceptJson()
|
||||
->timeout($this->timeout())
|
||||
->withHeaders([
|
||||
'x-acs-dingtalk-access-token' => $this->appAccessToken(),
|
||||
])
|
||||
->get(self::PROCESS_INSTANCE_URL, [
|
||||
'processInstanceId' => $processInstanceId,
|
||||
]);
|
||||
} catch (RequestException $exception) {
|
||||
throw new RuntimeException(
|
||||
'DingTalk process instance lookup failed.',
|
||||
0,
|
||||
$exception,
|
||||
);
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException(
|
||||
'DingTalk process instance lookup returned HTTP ' . $response->status() . '.',
|
||||
);
|
||||
}
|
||||
|
||||
$result = $response->json('result');
|
||||
if (! is_array($result)) {
|
||||
throw new RuntimeException('DingTalk process instance response was incomplete.');
|
||||
}
|
||||
|
||||
$values = [];
|
||||
foreach ((array) ($result['formComponentValues'] ?? []) as $component) {
|
||||
if (! is_array($component)) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($component['name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$values[$name] = $component['value'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
private function appAccessToken(): string
|
||||
{
|
||||
if ($this->accessToken !== null && time() < $this->accessTokenExpiresAt) {
|
||||
return $this->accessToken;
|
||||
}
|
||||
if (! $this->isConfigured()) {
|
||||
throw new RuntimeException('DingTalk process API credentials are not configured.');
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::acceptJson()
|
||||
->timeout($this->timeout())
|
||||
->get((string) config('dingtalk.app_token_url'), [
|
||||
'appkey' => (string) config('dingtalk.client_id'),
|
||||
'appsecret' => (string) config('dingtalk.client_secret'),
|
||||
])
|
||||
->throw();
|
||||
} catch (RequestException $exception) {
|
||||
throw new RuntimeException('DingTalk application token lookup failed.', 0, $exception);
|
||||
}
|
||||
|
||||
$token = trim((string) $response->json('access_token'));
|
||||
if ((int) $response->json('errcode', -1) !== 0 || $token === '') {
|
||||
throw new RuntimeException('DingTalk application token was unavailable.');
|
||||
}
|
||||
|
||||
$expiresIn = max(300, (int) $response->json('expires_in', 7200));
|
||||
$this->accessToken = $token;
|
||||
$this->accessTokenExpiresAt = time() + max(
|
||||
120,
|
||||
$expiresIn - self::TOKEN_SAFETY_SECONDS,
|
||||
);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function timeout(): int
|
||||
{
|
||||
return max(3, min(30, (int) config('dingtalk.timeout', 10)));
|
||||
}
|
||||
}
|
||||
51
tests/Feature/Console/SyncDingProcessDetailMirrorTest.php
Normal file
51
tests/Feature/Console/SyncDingProcessDetailMirrorTest.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Services\DingProcessDetailMirrorSynchronizer;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class SyncDingProcessDetailMirrorTest extends FeatureTestCase
|
||||
{
|
||||
public function test_business_id_option_runs_targeted_dry_run(): void
|
||||
{
|
||||
$synchronizer = Mockery::mock(DingProcessDetailMirrorSynchronizer::class);
|
||||
$synchronizer->shouldReceive('syncBusinessIds')
|
||||
->once()
|
||||
->with(
|
||||
['202610151743000310225', '202610151743000310226'],
|
||||
true,
|
||||
)
|
||||
->andReturn([
|
||||
'status' => 'validated',
|
||||
'run_mode' => 'business_ids',
|
||||
'workflows' => 1,
|
||||
'details' => 2,
|
||||
'payments' => 1,
|
||||
'amount_total' => 15535.37,
|
||||
'bank_amount_total' => 15535.37,
|
||||
]);
|
||||
$this->app->instance(DingProcessDetailMirrorSynchronizer::class, $synchronizer);
|
||||
|
||||
$this->artisan('business-mirror:sync-ding-process-details', [
|
||||
'--business-id' => '202610151743000310225,202610151743000310226',
|
||||
'--dry-run' => true,
|
||||
])
|
||||
->expectsOutputToContain('校验通过:1 个审批,2 条明细,1 笔付款')
|
||||
->assertSuccessful();
|
||||
}
|
||||
|
||||
public function test_full_and_business_id_cannot_be_combined(): void
|
||||
{
|
||||
$synchronizer = Mockery::mock(DingProcessDetailMirrorSynchronizer::class);
|
||||
$synchronizer->shouldNotReceive('sync');
|
||||
$synchronizer->shouldNotReceive('syncBusinessIds');
|
||||
$this->app->instance(DingProcessDetailMirrorSynchronizer::class, $synchronizer);
|
||||
|
||||
$this->artisan('business-mirror:sync-ding-process-details', [
|
||||
'--full' => true,
|
||||
'--business-id' => '202610151743000310225',
|
||||
])->assertFailed();
|
||||
}
|
||||
}
|
||||
@ -164,6 +164,23 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function test_official_api_payment_source_uses_normal_payment_states(): void
|
||||
{
|
||||
DB::connection('business_mirror')
|
||||
->table('ding_process_payment_mirror')
|
||||
->where('business_id', '202602031200000000002')
|
||||
->delete();
|
||||
DB::connection('business_mirror')
|
||||
->table('ding_process_detail_mirror')
|
||||
->where('business_id', '202602031200000000002')
|
||||
->update(['payment_source_state' => 'api']);
|
||||
|
||||
$result = $this->repository->paginate(['payment_state' => '未付款'], 1, 50);
|
||||
|
||||
$this->assertSame(2, $result['total']);
|
||||
$this->assertSame('api', (string) $result['data'][0]->payment_source_state);
|
||||
}
|
||||
|
||||
public function test_export_rows_returns_every_filtered_row(): void
|
||||
{
|
||||
$rows = iterator_to_array($this->repository->exportRows(['status' => '已完成']));
|
||||
|
||||
@ -7,6 +7,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\DingProcessDetailMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\DingProcessDetailMirrorSynchronizer;
|
||||
use Modules\EcommerceFinance\Services\DingTalkProcessInstanceClient;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
||||
use RuntimeException;
|
||||
@ -16,6 +17,8 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase
|
||||
{
|
||||
private FakeDingProcessDetailQuery $query;
|
||||
|
||||
private FakeDingTalkProcessInstanceClient $approvalClient;
|
||||
|
||||
private DingProcessDetailMirrorSynchronizer $synchronizer;
|
||||
|
||||
protected function setUp(): void
|
||||
@ -64,6 +67,8 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase
|
||||
}
|
||||
};
|
||||
$this->app->instance(UpstreamReadOnlyQueryFactory::class, $factory);
|
||||
$this->approvalClient = new FakeDingTalkProcessInstanceClient();
|
||||
$this->app->instance(DingTalkProcessInstanceClient::class, $this->approvalClient);
|
||||
$this->synchronizer = app(DingProcessDetailMirrorSynchronizer::class);
|
||||
}
|
||||
|
||||
@ -242,6 +247,155 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase
|
||||
$this->assertSame(101, (int) json_decode((string) $state->cursor, true)['scheme_high_watermark']);
|
||||
}
|
||||
|
||||
public function test_official_api_fills_payment_when_replica_omits_payment_table(): void
|
||||
{
|
||||
$workflow = [
|
||||
'processInstanceId' => 'process-api-payment',
|
||||
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
|
||||
'businessId' => '202610151743000310225',
|
||||
'title' => '李斯提交的线上代发业务报销',
|
||||
'status' => 'RUNNING',
|
||||
'originatorUserId' => 'user-1',
|
||||
'joinAt' => '2026-10-15 17:44:06',
|
||||
'updateAt' => '2026-10-15 17:44:06',
|
||||
];
|
||||
$this->query->workflows = [$workflow];
|
||||
$this->query->scheme = [
|
||||
'收款费用明细' => json_encode([[
|
||||
'rowValue' => [
|
||||
['label' => '代发订单金额', 'value' => '15535.37'],
|
||||
['label' => '科目编码', 'value' => '5601.05.03'],
|
||||
['label' => '科目名称', 'value' => '销售费用_营销费用_软件服务费'],
|
||||
],
|
||||
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'收款合计' => 15535.37,
|
||||
];
|
||||
$this->approvalClient->valuesByInstance['process-api-payment'] = [
|
||||
'付款明细' => json_encode([[
|
||||
'rowValue' => [
|
||||
['label' => '对应序号', 'value' => '1'],
|
||||
['label' => '付款方式', 'value' => '银行卡'],
|
||||
['label' => '付款金额', 'value' => '15535.37'],
|
||||
['label' => '科目编码', 'value' => '1002.01.544'],
|
||||
['label' => '科目名称', 'value' => '华夏银行-福尼达'],
|
||||
['label' => '打款时间', 'value' => '2026-10-18 00:00'],
|
||||
],
|
||||
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'付款金额合计' => 15535.37,
|
||||
];
|
||||
|
||||
$result = $this->synchronizer->sync(true);
|
||||
|
||||
$this->assertSame(1, $result['payments']);
|
||||
$row = DB::connection('business_mirror')->table('ding_process_detail_mirror')->first();
|
||||
$this->assertSame('api', $row->payment_source_state);
|
||||
$this->assertSame('银行卡', $row->payment_method);
|
||||
$this->assertSame('2026-10-18 00:00', $row->paid_at);
|
||||
$this->assertSame(['process-api-payment'], $this->approvalClient->calls);
|
||||
}
|
||||
|
||||
public function test_incremental_sync_rotates_missing_payments_through_official_api(): void
|
||||
{
|
||||
$workflow = [
|
||||
'processInstanceId' => 'process-api-queue',
|
||||
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
|
||||
'businessId' => '202610151743000310226',
|
||||
'title' => '李斯提交的线上代发业务报销',
|
||||
'status' => 'RUNNING',
|
||||
'originatorUserId' => 'user-1',
|
||||
'joinAt' => '2026-10-15 17:44:06',
|
||||
'updateAt' => '2026-10-15 17:44:06',
|
||||
];
|
||||
$this->query->workflows = [$workflow];
|
||||
$this->query->scheme = [
|
||||
'收款费用明细' => json_encode([[
|
||||
'rowValue' => [['label' => '代发订单金额', 'value' => '100.00']],
|
||||
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'收款合计' => 100,
|
||||
];
|
||||
$this->synchronizer->sync(true);
|
||||
$this->assertSame('missing', DB::connection('business_mirror')
|
||||
->table('ding_process_detail_mirror')->value('payment_source_state'));
|
||||
|
||||
DB::connection('business_mirror')
|
||||
->table('business_mirror_table_states')
|
||||
->where('dataset_key', 'ding.process_details.personal_permission')
|
||||
->update(['last_succeeded_at' => now()->subMinutes(10)]);
|
||||
$this->query->workflows = [];
|
||||
$this->query->schemeChangedWorkflows = [$workflow];
|
||||
$this->approvalClient->valuesByInstance['process-api-queue'] = [
|
||||
'付款明细' => json_encode([[
|
||||
'rowValue' => [
|
||||
['label' => '对应序号', 'value' => '1'],
|
||||
['label' => '付款方式', 'value' => '银行卡'],
|
||||
['label' => '付款金额', 'value' => '100.00'],
|
||||
['label' => '科目编码', 'value' => '1002.01.544'],
|
||||
['label' => '打款时间', 'value' => '2026-10-18 00:00'],
|
||||
],
|
||||
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'付款金额合计' => 100,
|
||||
];
|
||||
|
||||
$result = $this->synchronizer->sync(false);
|
||||
|
||||
$this->assertSame(1, $result['payments']);
|
||||
$this->assertSame('api', DB::connection('business_mirror')
|
||||
->table('ding_process_detail_mirror')->value('payment_source_state'));
|
||||
$state = DB::connection('business_mirror')
|
||||
->table('business_mirror_table_states')
|
||||
->first();
|
||||
$this->assertSame(
|
||||
'process-api-queue',
|
||||
json_decode((string) $state->cursor, true)['approval_api_cursor'],
|
||||
);
|
||||
}
|
||||
|
||||
public function test_business_id_dry_run_uses_official_api_without_publishing(): void
|
||||
{
|
||||
$workflow = [
|
||||
'processInstanceId' => 'process-target-api',
|
||||
'processCode' => 'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
|
||||
'businessId' => '202610151743000310227',
|
||||
'title' => '李斯提交的线上代发业务报销',
|
||||
'status' => 'RUNNING',
|
||||
'originatorUserId' => 'user-1',
|
||||
'joinAt' => '2026-10-15 17:44:06',
|
||||
'updateAt' => '2026-10-15 17:44:06',
|
||||
];
|
||||
$this->query->workflows = [$workflow];
|
||||
$this->query->scheme = [
|
||||
'收款费用明细' => json_encode([[
|
||||
'rowValue' => [['label' => '代发订单金额', 'value' => '100.00']],
|
||||
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'收款合计' => 100,
|
||||
];
|
||||
$this->synchronizer->sync(true);
|
||||
|
||||
$this->approvalClient->valuesByInstance['process-target-api'] = [
|
||||
'付款明细' => json_encode([[
|
||||
'rowValue' => [
|
||||
['label' => '对应序号', 'value' => '1'],
|
||||
['label' => '付款金额', 'value' => '100.00'],
|
||||
['label' => '付款方式', 'value' => '银行卡'],
|
||||
['label' => '打款时间', 'value' => '2026-10-18 00:00'],
|
||||
],
|
||||
]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'付款金额合计' => 100,
|
||||
];
|
||||
|
||||
$result = $this->synchronizer->syncBusinessIds(
|
||||
['202610151743000310227'],
|
||||
true,
|
||||
);
|
||||
|
||||
$this->assertSame('validated', $result['status']);
|
||||
$this->assertSame(1, $result['payments']);
|
||||
$this->assertSame('missing', DB::connection('business_mirror')
|
||||
->table('ding_process_detail_mirror')->value('payment_source_state'));
|
||||
$this->assertSame(0, DB::connection('business_mirror')
|
||||
->table('ding_process_payment_mirror')->count());
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function daifaScheme(): array
|
||||
{
|
||||
@ -430,3 +584,26 @@ class FakeDingProcessDetailQuery extends UpstreamReadOnlyQuery
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDingTalkProcessInstanceClient extends DingTalkProcessInstanceClient
|
||||
{
|
||||
public bool $configured = true;
|
||||
|
||||
/** @var array<string,array<string,mixed>> */
|
||||
public array $valuesByInstance = [];
|
||||
|
||||
/** @var list<string> */
|
||||
public array $calls = [];
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->configured;
|
||||
}
|
||||
|
||||
public function formValues(string $processInstanceId): array
|
||||
{
|
||||
$this->calls[] = $processInstanceId;
|
||||
|
||||
return $this->valuesByInstance[$processInstanceId] ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user