perf: 优化OA流程刷新等待与并发 #830
@ -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),
|
||||
];
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}}')"
|
||||
|
||||
@ -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 个审批编号。
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user