perf: 优化OA流程刷新等待与并发 #830

Merged
yueyang merged 1 commits from feature/yueyang-oa-refresh-performance into main 2026-11-02 18:14:12 +08:00
14 changed files with 410 additions and 19 deletions

View File

@ -40,5 +40,6 @@ return [
'user_detail_url' => env('DINGTALK_USER_DETAIL_URL', 'https://oapi.dingtalk.com/topapi/v2/user/get'),
'timeout' => (int) env('DINGTALK_HTTP_TIMEOUT', 10),
'process_concurrency' => (int) env('DINGTALK_PROCESS_CONCURRENCY', 5),
'state_ttl_minutes' => (int) env('DINGTALK_STATE_TTL_MINUTES', 5),
];

View File

@ -231,7 +231,7 @@ CRON_TZ=Asia/Shanghai
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge --quiet >/dev/null 2>&1
30 10 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-shop-data --balance-only --quiet >/dev/null 2>&1
# 财务流程明细每周完整校准一次,修正超过 48 小时重叠窗口才出现的上游改单或晚到付款。
20 2 * * 0 root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh business-mirror:sync-ding-process-details --full --quiet >/dev/null 2>&1
20 2 * * 0 root /usr/bin/flock -w 3600 /run/caiwu-ding-process-details.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh business-mirror:sync-ding-process-details --full --quiet >/dev/null 2>&1
# 科目映射保存后保留自动重跑队列,每分钟在受限容器内串行消费。
* * * * * root /usr/bin/flock -n /run/caiwu-account-mapping-resync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:auto-resync --quiet >/dev/null 2>&1
* * * * * root /usr/bin/flock -n /run/caiwu-account-bills-export.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh account-bills:exports:run --quiet >/dev/null 2>&1

View File

@ -235,7 +235,7 @@ CRON_TZ=Asia/Shanghai
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge --quiet >/dev/null 2>&1
30 10 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-shop-data --balance-only --quiet >/dev/null 2>&1
# 财务流程明细每周完整校准一次,修正超过 48 小时重叠窗口才出现的上游改单或晚到付款。
20 2 * * 0 root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh business-mirror:sync-ding-process-details --full --quiet >/dev/null 2>&1
20 2 * * 0 root /usr/bin/flock -w 3600 /run/caiwu-ding-process-details.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh business-mirror:sync-ding-process-details --full --quiet >/dev/null 2>&1
# 科目映射保存后保留自动重跑队列,每分钟在受限容器内串行消费。
* * * * * root /usr/bin/flock -n /run/caiwu-account-mapping-resync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:auto-resync --quiet >/dev/null 2>&1
* * * * * root /usr/bin/flock -n /run/caiwu-account-bills-export.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh account-bills:exports:run --quiet >/dev/null 2>&1

View File

@ -2,7 +2,8 @@
set -eu
REMOTE_DIR="${CAIWU_REMOTE_DIR:-/opt/caiwuxitong}"
LOCK_FILE="${CAIWU_UPSTREAM_LOCK:-/run/caiwu-upstream-sync.lock}"
GENERAL_LOCK_FILE="${CAIWU_GENERAL_LOCK:-/run/caiwu-upstream-sync.lock}"
OA_LOCK_FILE="${CAIWU_OA_LOCK:-/run/caiwu-oa-process.lock}"
is_import=0
CAIWU_BATCH_CALLER_DIR="$PWD"
export CAIWU_BATCH_CALLER_DIR
@ -11,6 +12,14 @@ case "${1:-}" in
jushuitan:sync-shipping|store-receipt:order-match:sync|store-receipt:product-cost:sync|store-receipt:auto-resync|order-data:warm-shop-candidates|business-mirror:sync-tables|business-mirror:sync-ding-process-details|business-mirror:process-ding-refresh-requests|business-mirror:index-work-order-waybills|business-mirror:refresh-paid-sales|account-bills:exports:run|finance-ledger:recalculate|product:monthly-weighted-average:auto|product:monthly-weighted-average:compute|product:monthly-weighted-average:report|finance:monthly-weighted-product-cost:auto) ;;
*) echo 'Unsupported finance batch command' >&2; exit 64 ;;
esac
case "${1:-}" in
business-mirror:sync-ding-process-details|business-mirror:process-ding-refresh-requests)
LOCK_FILE="${CAIWU_UPSTREAM_LOCK:-$OA_LOCK_FILE}"
;;
*)
LOCK_FILE="${CAIWU_UPSTREAM_LOCK:-$GENERAL_LOCK_FILE}"
;;
esac
cd "$REMOTE_DIR"
if [ "$1" = 'account-bills:exports:run' ]; then
storage_root="$(docker inspect caiwu_app --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/storage"}}{{.Source}}{{end}}{{end}}')"

View File

@ -36,7 +36,7 @@
| 本次接入 | 流程明细读取 `operations_replica/rpt.ding_Workflow + rpt.ding_WorkflowScheme` 的结构化审批主表、费用明细和付款明细,写入 `ding_process_detail_mirror`、`ding_process_payment_mirror` 与付款按明细行分摊的 `ding_process_payment_line_mirror`;主表的 `originatorDeptName` 作为发起人部门单独保留。禁止先把明细表和付款表铺平成 CSV 后逐行求和;付款明细必须按“对应序号”关联到代表明细行,但合计始终读取独立付款事实。管理费用按分摊后的付款月份统计,跨月分笔付款不会把整张审批重复计入多月。每个纳入范围的源审批至少保留一条页面记录,没有费用金额的审批以空金额显示,不静默隐藏。首次全量先运行 `business-mirror:sync-ding-process-details --full --dry-run` 校验,再使用 `business-mirror:sync-ding-process-details --full` 发布;日常通过 `updateAt/createAt` 48 小时重叠增量同步。当前纳入日常报销、应付、线上代发、种菜、快递面单、固定资产、日常物品、样品、一次性采买、板材、工资、外贸费用和种菜充值等 14 个财务流程;发现新的明细流程编码时全量同步报错并保留上一版。 |
| 本次接入 | 财务经营报表 › 管理费用使用 `oa_process_account_mapping` 保存流程科目映射;当前快照包含原工作簿 191 个科目和补充的 `5601.11.01 货代费`、`1221.07 种菜备用金`、`5602.11.03.19 AI阿里云软件费`,共 194 个科目,其中 37 个进费比科目和 126 个分摊规则。页面按打款时间提供整月选择并默认展示上个月 OA 流程中审批状态为“已完成”、涉及付款且出纳已打款的明细,不涉及付款或出纳未打款的数据不进入汇总,按费用大类和二级汇总进费比/不进费比/未映射金额,点击二级查看三级汇总与 9 列明细模板。审批完成时间写入 `ding_process_detail_mirror.approval_finished_at`,页面月份筛选使用 `ding_process_detail_mirror.paid_at`;钉钉副本把费用明细表保存为未命名字段时,按配置中的金额列识别包含该列的未命名表格组件,避免只抓到付款明细而漏掉费用科目和金额。明细行没有科目时,使用同一审批表单表头的 `科目编码 / 科目名称` 回填;行级科目始终优先。 |
流程明细另设独立定时任务,避免与每分钟科目映射消费争用同一上游目录时被静默跳过。正式发布脚本会覆盖安装 `/etc/cron.d/caiwu-ding-process-details`:每 5 分钟尝试运行 `business-mirror:sync-ding-process-details`,使用独立调度锁并在共用上游锁上最多等待 15 分钟;`scripts/install-ding-process-sync-cron.ps1` 仅作为手工修复入口。任务时间和镜像状态统一使用平台固定时区,审批等待时长按实际小时展示。OA 页面每小时最多把一次当前页“审批中”的审批编号写入定向刷新队列,由每分钟检查一次的批处理命令消费;其他时间直接展示最近镜像,已完成流程不进入即时刷新链路,仍由常规增量和每周全量校准处理付款等后续变化。增量同步除跟进审批主表更新时间外,还以 `ding_WorkflowScheme` 的数字 `id` 高水位发现后补表单行,避免付款明细等新组件写入后主审批 `updateAt` 不变而被漏掉;原地改写同一组件仍由每周全量校准兜底。付款状态只按钉钉“出纳打款”节点判断,分为“出纳状态待同步 / 出纳未开始 / 出纳待打款 / 出纳退回 / 出纳已打款 / 流程终止 / 不涉及付款”。金额差异不参与付款状态判断,另以“付款差额 = 费用合计 − 已付款合计”展示。不能因为金额存在少量差异就覆盖出纳节点事实,也不能因为出纳已通过就隐藏金额差异。
流程明细另设独立定时任务,避免与每分钟科目映射消费争用同一上游目录时被静默跳过。正式发布脚本会覆盖安装 `/etc/cron.d/caiwu-ding-process-details`:每 5 分钟尝试运行 `business-mirror:sync-ding-process-details`,页面定向刷新和一周一次的全量校准与增量同步共用 `/run/caiwu-oa-process.lock`,只在这些 OA 镜像任务之间排队,不再被店铺对账、商品同步等无关任务占用的全局上游锁阻塞;`scripts/install-ding-process-sync-cron.ps1` 仅作为手工修复入口。任务时间和镜像状态统一使用平台固定时区,审批等待时长按实际小时展示。OA 页面每小时最多把一次当前页“审批中”的审批编号写入定向刷新队列,由每分钟检查一次的批处理命令消费;其他时间直接展示最近镜像,已完成流程不进入即时刷新链路,仍由常规增量和每周全量校准处理付款等后续变化。进行中审批的钉钉接口按配置并发读取(默认 5 个、上限 10 个),单个审批失败不会丢弃同批成功结果。增量同步除跟进审批主表更新时间外,还以 `ding_WorkflowScheme` 的数字 `id` 高水位发现后补表单行,避免付款明细等新组件写入后主审批 `updateAt` 不变而被漏掉;原地改写同一组件仍由每周全量校准兜底。付款状态只按钉钉“出纳打款”节点判断,分为“出纳状态待同步 / 出纳未开始 / 出纳待打款 / 出纳退回 / 出纳已打款 / 流程终止 / 不涉及付款”。金额差异不参与付款状态判断,另以“付款差额 = 费用合计 − 已付款合计”展示。不能因为金额存在少量差异就覆盖出纳节点事实,也不能因为出纳已通过就隐藏金额差异。
`operations_replica` 缺失付款组件或进行中流程需要刷新出纳节点时,同步器复用现有钉钉应用凭据调用 `https://api.dingtalk.com/v1.0/workflow/processInstances`。官方接口同时返回表单、`tasks.taskGroupName` 和 `operationRecords.showName`,用于识别“出纳打款”节点及退回/待处理/已通过状态。付款来源标记为 `api`,付款事实仍进入 `ding_process_payment_mirror`;不会用官方接口覆盖副本已有的业务明细,只补缺失或空的付款字段,并独立保存“出纳状态待同步 / 出纳未开始 / 出纳待打款 / 出纳退回 / 出纳已打款 / 流程终止 / 不涉及付款”,`cashier_checked_at` 用于记录近期已检查时间。自动增量每 5 分钟按 `process_instance_id` 游标轮转最多 50 个缺失或需要刷新出纳状态的流程,避免单个失败记录阻塞队列或瞬时打满接口;人工核查可执行 `business-mirror:sync-ding-process-details --business-id=审批编号1,审批编号2 --dry-run`,确认后再去掉 `--dry-run`。单次最多 50 个审批编号。

View File

@ -1010,6 +1010,7 @@ class DingProcessDetailMirrorSynchronizer
$processInstanceIds = [];
$processes = (array) config('upstream_mirrors.ding_process_details.processes', []);
$scope = $this->scope();
$this->prefetchApprovalSnapshots($workflows, $scheme, $processes);
foreach ($workflows as $workflow) {
$processCode = trim((string) ($workflow['processCode'] ?? ''));
@ -1142,6 +1143,88 @@ class DingProcessDetailMirrorSynchronizer
];
}
/**
* Warm the current-run snapshot cache in bounded parallel batches. The
* later normalization pass reuses these snapshots without extra API calls.
*
* @param list<array<string,mixed>> $workflows
* @param array<string,array<string,mixed>> $scheme
* @param array<string,array<string,mixed>> $processes
*/
private function prefetchApprovalSnapshots(
array $workflows,
array $scheme,
array $processes,
): void {
if (! $this->approvalClient->isConfigured() || ! $this->approvalApiAvailable) {
return;
}
$remainingAttempts = self::APPROVAL_API_DRAIN_LIMIT - $this->approvalApiAttempts;
if ($remainingAttempts <= 0) {
return;
}
$processInstanceIds = [];
foreach ($workflows as $workflow) {
$processInstanceId = trim((string) ($workflow['processInstanceId'] ?? ''));
$process = (array) ($processes[$workflow['processCode'] ?? ''] ?? []);
if ($processInstanceId === ''
|| isset($this->approvalSnapshots[$processInstanceId])
|| $process === []) {
continue;
}
$fields = (array) ($scheme[$processInstanceId] ?? []);
$paymentSourceState = $this->paymentSourceState($process, $fields);
if ($this->needsApprovalApiFallback($process, $fields, $paymentSourceState)
|| $this->needsCashierApi($process, $workflow)
|| $this->needsDetailAccountFallback($process, $fields)
|| $this->status($workflow) === '审批中') {
$processInstanceIds[] = $processInstanceId;
}
}
$processInstanceIds = array_slice(
array_values(array_unique($processInstanceIds)),
0,
$remainingAttempts,
);
if ($processInstanceIds === []) {
return;
}
try {
$result = $this->approvalClient->processSnapshots($processInstanceIds);
} catch (Throwable $exception) {
$this->approvalApiAvailable = false;
Log::warning('钉钉官方审批接口批量读取失败。', [
'attempted' => count($processInstanceIds),
'exception' => $exception::class,
]);
return;
}
$this->approvalApiAttempts += (int) ($result['attempted'] ?? 0);
foreach ((array) ($result['snapshots'] ?? []) as $processInstanceId => $snapshot) {
if (is_array($snapshot)) {
$this->approvalSnapshots[(string) $processInstanceId] = $snapshot;
}
}
foreach ((array) ($result['errors'] ?? []) as $processInstanceId => $error) {
// 标记本轮已尝试,避免后续主流程对同一失败审批再次串行请求。
$this->approvalSnapshots[(string) $processInstanceId] = [];
Log::warning('钉钉官方审批接口读取流程状态失败。', [
'process_instance_id' => (string) $processInstanceId,
'error' => (string) $error,
]);
}
if ((bool) ($result['fatal'] ?? false)) {
$this->approvalApiAvailable = false;
}
}
/**
* @param array<string,mixed> $workflow
* @param array<string,mixed> $process

View File

@ -3,9 +3,12 @@
namespace Modules\EcommerceFinance\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Pool;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Modules\EcommerceFinance\Exceptions\DingTalkProcessInstanceException;
use Throwable;
class DingTalkProcessInstanceClient
{
@ -83,13 +86,129 @@ class DingTalkProcessInstanceClient
);
}
$result = $response->json('result');
if (! is_array($result)) {
$snapshot = $this->normalizeSnapshot($response->json('result'));
if ($snapshot === null) {
throw new DingTalkProcessInstanceException(
'DingTalk process instance response was incomplete.',
);
}
return $this->snapshots[$processInstanceId] = $snapshot;
}
/**
* Fetch a bounded batch concurrently while preserving one request per
* process instance. Individual HTTP errors do not hide successful rows.
*
* @param list<string> $processInstanceIds
* @return array{
* snapshots:array<string,array<string,mixed>>,
* errors:array<string,string>,
* fatal:bool,
* attempted:int
* }
*/
public function processSnapshots(array $processInstanceIds): array
{
$ids = array_values(array_unique(array_filter(array_map(
static fn (mixed $value): string => trim((string) $value),
$processInstanceIds,
))));
$snapshots = [];
$pending = [];
foreach ($ids as $processInstanceId) {
if (isset($this->snapshots[$processInstanceId])) {
$snapshots[$processInstanceId] = $this->snapshots[$processInstanceId];
} else {
$pending[] = $processInstanceId;
}
}
if ($pending === []) {
return [
'snapshots' => $snapshots,
'errors' => [],
'fatal' => false,
'attempted' => 0,
];
}
$accessToken = $this->appAccessToken();
$errors = [];
$fatal = false;
$attempted = 0;
$concurrency = max(1, min(10, (int) config('dingtalk.process_concurrency', 5)));
foreach (array_chunk($pending, $concurrency) as $chunk) {
$attempted += count($chunk);
try {
$responses = Http::acceptJson()
->timeout($this->timeout())
->withHeaders([
'x-acs-dingtalk-access-token' => $accessToken,
])
->pool(function (Pool $pool) use ($chunk, $accessToken): void {
foreach ($chunk as $index => $processInstanceId) {
$pool->as((string) $index)
->acceptJson()
->timeout($this->timeout())
->withHeaders([
'x-acs-dingtalk-access-token' => $accessToken,
])
->get(self::PROCESS_INSTANCE_URL, [
'processInstanceId' => $processInstanceId,
]);
}
});
} catch (Throwable $exception) {
foreach ($chunk as $processInstanceId) {
$errors[$processInstanceId] = 'DingTalk process instance lookup failed.';
}
$fatal = true;
break;
}
foreach ($responses as $index => $response) {
$processInstanceId = $chunk[(int) $index] ?? '';
if ($processInstanceId === '' || ! $response instanceof Response) {
continue;
}
if (! $response->successful()) {
$status = $response->status();
$errors[$processInstanceId] = 'DingTalk process instance lookup returned HTTP '
. $status . '.';
$fatal = $fatal
|| in_array($status, [401, 403, 429], true)
|| $status >= 500;
continue;
}
$snapshot = $this->normalizeSnapshot($response->json('result'));
if ($snapshot === null) {
$errors[$processInstanceId] = 'DingTalk process instance response was incomplete.';
continue;
}
$snapshots[$processInstanceId] = $this->snapshots[$processInstanceId] = $snapshot;
}
if ($fatal) {
break;
}
}
return [
'snapshots' => $snapshots,
'errors' => $errors,
'fatal' => $fatal,
'attempted' => $attempted,
];
}
/** @return array<string,mixed>|null */
private function normalizeSnapshot(mixed $result): ?array
{
if (! is_array($result)) {
return null;
}
$values = [];
foreach ((array) ($result['formComponentValues'] ?? []) as $component) {
if (! is_array($component)) {
@ -101,7 +220,7 @@ class DingTalkProcessInstanceClient
}
}
return $this->snapshots[$processInstanceId] = [
return [
'form_values' => $values,
'tasks' => array_values(array_filter(
(array) ($result['tasks'] ?? []),
@ -136,7 +255,7 @@ class DingTalkProcessInstanceClient
'appsecret' => (string) config('dingtalk.client_secret'),
])
->throw();
} catch (RequestException $exception) {
} catch (ConnectionException|RequestException $exception) {
throw new DingTalkProcessInstanceException(
'DingTalk application token lookup failed.',
true,

View File

@ -1523,7 +1523,7 @@
const buttonDefaultTitle = button?.title || '';
const refreshIntervalMs = 60 * 60 * 1000;
const pollIntervalMs = 2000;
const pollLimit = 90;
const pollLimit = 480;
const storage = window.localStorage;
const storageScope = [
window.location.pathname,
@ -1552,6 +1552,32 @@
}
};
const refreshProgressMessage = (refresh, manual) => {
const state = String(refresh?.status || '');
const referenceAt = Date.parse(
state === 'processing'
? String(refresh?.started_at || refresh?.created_at || '')
: String(refresh?.created_at || ''),
);
const elapsedSeconds = Number.isFinite(referenceAt)
? Math.max(0, Math.floor((Date.now() - referenceAt) / 1000))
: null;
const elapsedText = elapsedSeconds === null
? ''
: `(${elapsedSeconds < 60 ? `${elapsedSeconds}秒` : `${Math.floor(elapsedSeconds / 60)}分${elapsedSeconds % 60}秒`})`;
const prefix = manual ? '手动刷新' : '自动刷新';
if (state === 'pending') {
return `${prefix}请求已入队,等待 OA 同步任务…${elapsedText}`;
}
if (state === 'processing') {
return `OA 同步任务正在读取钉钉最新状态…${elapsedText}`;
}
return manual
? '正在手动刷新当前页审批中的流程…'
: '正在按小时刷新当前页审批中的流程…';
};
const statusUrl = (token) => statusUrlTemplate.replace(
'__TOKEN__',
encodeURIComponent(token),
@ -1601,7 +1627,8 @@
return response.json();
})
.then((payload) => {
const state = payload.refresh?.status;
const refresh = payload.refresh || {};
const state = refresh.status;
if (state === 'completed') {
const completedAt = String(Date.now());
storage.setItem(attemptKey, completedAt);
@ -1625,11 +1652,7 @@
);
return;
}
setStatus(
manual
? '正在手动刷新当前页审批中的流程…'
: '正在按小时刷新当前页审批中的流程…',
);
setStatus(refreshProgressMessage(refresh, manual));
window.setTimeout(
() => poll(token, attempt + 1, manual),
pollIntervalMs,

View File

@ -15,8 +15,10 @@ if ($RemotePath -notmatch '^/[A-Za-z0-9._/-]+$') {
$cron = @(
'CRON_TZ=Asia/Shanghai'
'# Process details use a dedicated schedule and wait for the shared upstream lock.'
'# Process details use a dedicated schedule and OA-only mirror lock.'
"2-59/5 * * * * root CAIWU_BATCH_LOCK_WAIT=900 /usr/bin/flock -n /run/caiwu-ding-process-details.lock /bin/sh $RemotePath/run-finance-batch.sh business-mirror:sync-ding-process-details --quiet >/dev/null 2>&1"
'# Consume one queued current-page refresh per minute; idle runs do not start a batch container.'
"* * * * * root CAIWU_BATCH_LOCK_WAIT=900 /usr/bin/flock -n /run/caiwu-ding-process-refresh.lock /bin/sh $RemotePath/run-finance-batch.sh business-mirror:process-ding-refresh-requests --quiet >/dev/null 2>&1"
) -join "`n"
if ($DryRun) {

View File

@ -146,6 +146,11 @@ class BusinessMirrorScheduleTest extends TestCase
$contents,
$script,
);
$this->assertStringContainsString(
'20 2 * * 0 root /usr/bin/flock -w 3600 /run/caiwu-ding-process-details.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh business-mirror:sync-ding-process-details --full --quiet',
$contents,
$script,
);
$this->assertStringContainsString(
"cat > /etc/cron.d/caiwu-ding-process-details <<'EOF'\n"
. "CRON_TZ=Asia/Shanghai\n"

View File

@ -35,18 +35,32 @@ class FinanceBatchIsolationTest extends TestCase
}
}
private function runBatch(string $directory, array $arguments): Process
/** @param array<string,string|null> $environment */
private function runBatch(
string $directory,
array $arguments,
array $environment = [],
): Process
{
file_put_contents($directory . '/docker', '#!/bin/sh' . "\n"
. 'if [ "$1" = inspect ]; then case "$4" in *Mounts*) echo "$CAIWU_STORAGE_ROOT" ;; *) echo caiwu_akaunting:123456789abc ;; esac; exit 0; fi' . "\n"
. 'printf "%s\n" "image=$IMAGE_TAG" "$@" > "$CAIWU_CAPTURE"' . "\n");
chmod($directory . '/docker', 0700);
$process = new Process(array_merge(['sh', dirname(__DIR__, 3) . '/deploy/run-finance-batch.sh'], $arguments), $directory, [
$processEnvironment = [
'PATH' => $directory . ':' . getenv('PATH'), 'CAIWU_REMOTE_DIR' => $directory,
'CAIWU_UPSTREAM_LOCK' => $directory . '/sync.lock', 'CAIWU_BATCH_LOCK_WAIT' => '0.1',
'CAIWU_CAPTURE' => $directory . '/called',
'CAIWU_STORAGE_ROOT' => $directory . '/storage',
]);
...$environment,
];
if ($processEnvironment['CAIWU_UPSTREAM_LOCK'] === null) {
unset($processEnvironment['CAIWU_UPSTREAM_LOCK']);
}
$process = new Process(
array_merge(['sh', dirname(__DIR__, 3) . '/deploy/run-finance-batch.sh'], $arguments),
$directory,
$processEnvironment,
);
$process->run();
return $process;
@ -120,4 +134,35 @@ class FinanceBatchIsolationTest extends TestCase
rmdir($directory);
}
}
public function test_oa_sync_uses_its_own_lock_when_general_upstream_job_is_busy(): void
{
$directory = sys_get_temp_dir() . '/caiwu-oa-lock-' . bin2hex(random_bytes(6));
mkdir($directory);
$generalLockPath = $directory . '/general.lock';
$oaLockPath = $directory . '/oa.lock';
$generalLock = fopen($generalLockPath, 'c');
flock($generalLock, LOCK_EX);
try {
$process = $this->runBatch(
$directory,
['business-mirror:sync-ding-process-details'],
[
'CAIWU_UPSTREAM_LOCK' => null,
'CAIWU_GENERAL_LOCK' => $generalLockPath,
'CAIWU_OA_LOCK' => $oaLockPath,
],
);
$this->assertTrue($process->isSuccessful(), $process->getErrorOutput());
$this->assertFileExists($directory . '/called');
} finally {
flock($generalLock, LOCK_UN);
fclose($generalLock);
foreach (glob($directory . '/*') as $path) {
unlink($path);
}
rmdir($directory);
}
}
}

View File

@ -1275,6 +1275,11 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase
$backlog,
);
$this->assertCount(90, $this->approvalClient->calls);
$this->assertNotEmpty($this->approvalClient->bulkCalls);
$this->assertContains(
'backlog-000',
array_merge(...$this->approvalClient->bulkCalls),
);
$this->assertSame($expectedBacklog, array_slice($this->approvalClient->calls, 0, 30));
$this->assertSame(
'backlog-029',
@ -1697,6 +1702,9 @@ class FakeDingTalkProcessInstanceClient extends DingTalkProcessInstanceClient
/** @var list<string> */
public array $calls = [];
/** @var list<list<string>> */
public array $bulkCalls = [];
/** @var list<string> */
public array $failingInstances = [];
@ -1752,4 +1760,30 @@ class FakeDingTalkProcessInstanceClient extends DingTalkProcessInstanceClient
'result' => $this->resultByInstance[$processInstanceId] ?? null,
];
}
public function processSnapshots(array $processInstanceIds): array
{
$this->bulkCalls[] = array_values($processInstanceIds);
$snapshots = [];
$errors = [];
$fatal = false;
foreach ($processInstanceIds as $processInstanceId) {
try {
$snapshots[$processInstanceId] = $this->processSnapshot($processInstanceId);
} catch (DingTalkProcessInstanceException $exception) {
$errors[$processInstanceId] = $exception->getMessage();
$fatal = $fatal || $exception->stopsRun();
if ($fatal) {
break;
}
}
}
return [
'snapshots' => $snapshots,
'errors' => $errors,
'fatal' => $fatal,
'attempted' => count($processInstanceIds),
];
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Modules\EcommerceFinance\Services\DingTalkProcessInstanceClient;
use Tests\TestCase;
class DingTalkProcessInstanceClientTest extends TestCase
{
public function test_process_snapshots_fetches_and_parses_a_batch(): void
{
config([
'dingtalk.client_id' => 'test-client',
'dingtalk.client_secret' => 'test-secret',
'dingtalk.app_token_url' => 'https://example.test/token',
'dingtalk.timeout' => 3,
'dingtalk.process_concurrency' => 2,
]);
Http::fake([
'https://example.test/token*' => Http::response([
'errcode' => 0,
'access_token' => 'test-token',
'expires_in' => 7200,
]),
'https://api.dingtalk.com/v1.0/workflow/processInstances*' => function (Request $request) {
$processInstanceId = (string) ($request->data()['processInstanceId'] ?? '');
return Http::response([
'result' => [
'status' => 'RUNNING',
'result' => 'NONE',
'formComponentValues' => [[
'name' => '用途',
'value' => '批量读取-' . $processInstanceId,
]],
'tasks' => [[
'taskGroupName' => '出纳打款',
'status' => 'RUNNING',
'result' => 'NONE',
]],
'operationRecords' => [],
],
]);
},
]);
$result = app(DingTalkProcessInstanceClient::class)
->processSnapshots(['process-a', 'process-b']);
$this->assertFalse($result['fatal']);
$this->assertSame(2, $result['attempted']);
$this->assertSame([], $result['errors']);
$this->assertSame(
'批量读取-process-a',
$result['snapshots']['process-a']['form_values']['用途'],
);
$this->assertSame(
'批量读取-process-b',
$result['snapshots']['process-b']['form_values']['用途'],
);
Http::assertSentCount(3);
}
}

View File

@ -36,6 +36,10 @@ class ProcessDetailsPageDataTest extends TestCase
$this->assertStringContainsString('$statusClass ??=', $content);
$this->assertStringNotContainsString('$processRows->links()', $content);
$this->assertStringContainsString('const refreshIntervalMs = 60 * 60 * 1000;', $content);
$this->assertStringContainsString('const pollLimit = 480;', $content);
$this->assertStringContainsString('refreshProgressMessage', $content);
$this->assertStringContainsString('请求已入队,等待 OA 同步任务', $content);
$this->assertStringContainsString('OA 同步任务正在读取钉钉最新状态', $content);
$this->assertStringContainsString('oa-refresh-attempt-at:', $content);
$this->assertStringContainsString('window.localStorage', $content);
$this->assertStringContainsString('scheduleNextReload', $content);