feat: sync DingTalk workflows for ongoing metric
This commit is contained in:
parent
d196033b13
commit
eb10f8246b
@ -81,7 +81,8 @@ RUN test -s "/app/public/brand/header-logo.png" && \
|
||||
test -f "/app/prisma/migrations/20260817_add_material_is_incomplete_shell/migration.sql" && \
|
||||
test -f "/app/prisma/migrations/20260819_add_user_permission_inherit/migration.sql" && \
|
||||
test -f "/app/prisma/migrations/20260820_add_permission_group_members/migration.sql" && \
|
||||
test -f "/app/prisma/migrations/20260820_unique_permission_group_department/migration.sql"
|
||||
test -f "/app/prisma/migrations/20260820_unique_permission_group_department/migration.sql" && \
|
||||
test -f "/app/prisma/migrations/20260820_add_ding_workflow_snapshots/migration.sql"
|
||||
|
||||
# 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失,
|
||||
# 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。
|
||||
@ -194,6 +195,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260817_add_materi
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20260819_add_user_permission_inherit ./prisma/migrations/20260819_add_user_permission_inherit
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20260820_add_permission_group_members ./prisma/migrations/20260820_add_permission_group_members
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20260820_unique_permission_group_department ./prisma/migrations/20260820_unique_permission_group_department
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20260820_add_ding_workflow_snapshots ./prisma/migrations/20260820_add_ding_workflow_snapshots
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import {
|
||||
DingWorkflowMetricApiError,
|
||||
getOngoingDingWorkflowSummary,
|
||||
} from '@/lib/ding-workflow-sync'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
const PRIVATE_HEADERS = {
|
||||
'Cache-Control': 'private, no-store, max-age=0',
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
const data = await getOngoingDingWorkflowSummary(session)
|
||||
return NextResponse.json({ success: true, data }, { headers: PRIVATE_HEADERS })
|
||||
} catch (error) {
|
||||
if (error instanceof DingWorkflowMetricApiError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: error.status, headers: PRIVATE_HEADERS },
|
||||
)
|
||||
}
|
||||
|
||||
console.error('[department-dashboard] 进行中流程指标读取失败:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '进行中流程指标读取失败,请稍后重试' },
|
||||
{ status: 500, headers: PRIVATE_HEADERS },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -4,9 +4,9 @@ import { notFound } from 'next/navigation'
|
||||
import CoreMetricDetail from '@/components/department-dashboard/CoreMetricDetail'
|
||||
import { getCoreMetric } from '@/components/department-dashboard/core-metrics'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { getOngoingDingWorkflowProjects } from '@/lib/ding-workflow-sync'
|
||||
import {
|
||||
getFirstPassReviewProjects,
|
||||
getOngoingWorkReviewProjects,
|
||||
} from '@/lib/work-review-service'
|
||||
|
||||
type CoreMetricPageProps = {
|
||||
@ -34,7 +34,7 @@ export default async function CoreMetricPage({ params, searchParams }: CoreMetri
|
||||
|
||||
if (metric.key === 'ongoing-projects') {
|
||||
const session = await getServerSession(authOptions)
|
||||
const projectPage = await getOngoingWorkReviewProjects(session, { page, pageSize: 20, query })
|
||||
const projectPage = await getOngoingDingWorkflowProjects(session, { page, pageSize: 20, query })
|
||||
return <CoreMetricDetail metric={metric} query={query} projectPage={projectPage} />
|
||||
}
|
||||
|
||||
|
||||
@ -16,25 +16,28 @@ describe('CoreMetricDetail', () => {
|
||||
projectPage={{
|
||||
items: [{
|
||||
id: 'project-1',
|
||||
businessId: 'BUSINESS-1',
|
||||
title: '夏季主视觉过稿',
|
||||
description: null,
|
||||
status: 'pending_review',
|
||||
workDate: '2026-08-10T00:00:00.000Z',
|
||||
latestVersion: 2,
|
||||
submitterName: '林一',
|
||||
managerName: '陈主管',
|
||||
finalReviewerName: '元泽',
|
||||
updatedAt: '2026-08-14T02:30:00.000Z',
|
||||
status: 'RUNNING',
|
||||
originatorUserId: 'ding-1',
|
||||
originatorDeptName: '品牌中心',
|
||||
sourceCreatedAt: '2026-08-10T00:00:00.000Z',
|
||||
sourceUpdatedAt: '2026-08-14T02:30:00.000Z',
|
||||
activeTaskCount: 1,
|
||||
taskCount: 3,
|
||||
schemeFieldCount: 12,
|
||||
latestTaskStatus: 'RUNNING',
|
||||
}],
|
||||
pagination: { page: 1, pageSize: 20, total: 1, totalPages: 1 },
|
||||
sync: { total: 1, lastSyncedAt: '2026-08-14T02:30:00.000Z', syncStatus: 'ready' },
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: '进行中项目' })).toBeInTheDocument()
|
||||
expect(screen.getAllByText('夏季主视觉过稿')).toHaveLength(2)
|
||||
expect(screen.getAllByText('评审中').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText(/数据来自作品过稿项目库/)).toBeInTheDocument()
|
||||
expect(screen.getAllByText('流转中').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText(/数据来自钉钉审批流程本地快照/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /返回核心指标/ })).toHaveAttribute('href', '/workspaces/dashboard/core-metrics')
|
||||
})
|
||||
|
||||
|
||||
@ -13,9 +13,11 @@ import {
|
||||
import type {
|
||||
FirstPassReviewProjectListItem,
|
||||
FirstPassReviewProjectPage,
|
||||
OngoingWorkReviewProjectListItem,
|
||||
OngoingWorkReviewProjectPage,
|
||||
} from '@/lib/work-review-service'
|
||||
import type {
|
||||
OngoingDingWorkflowListItem,
|
||||
OngoingDingWorkflowPage,
|
||||
} from '@/lib/ding-workflow-sync'
|
||||
import DepartmentShell from './DepartmentShell'
|
||||
import OnTimeDeliveryRate from './OnTimeDeliveryRate'
|
||||
import type { CoreMetric } from './core-metrics'
|
||||
@ -23,16 +25,19 @@ import type { CoreMetric } from './core-metrics'
|
||||
type CoreMetricDetailProps = {
|
||||
metric: CoreMetric
|
||||
query?: string
|
||||
projectPage?: OngoingWorkReviewProjectPage
|
||||
projectPage?: OngoingDingWorkflowPage
|
||||
firstPassPage?: FirstPassReviewProjectPage
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<OngoingWorkReviewProjectListItem['status'], string> = {
|
||||
pending_review: '评审中',
|
||||
revision_required: '待修改',
|
||||
const WORKFLOW_STATUS_LABELS: Record<string, string> = {
|
||||
RUNNING: '流转中',
|
||||
NEW: '待处理',
|
||||
PENDING: '待处理',
|
||||
PROCESSING: '处理中',
|
||||
}
|
||||
|
||||
function formatDate(value: string, includeTime = false) {
|
||||
function formatDate(value: string | null, includeTime = false) {
|
||||
if (!value) return '—'
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
@ -55,11 +60,10 @@ function formatRate(rate: number | null) {
|
||||
return `${Number.isInteger(rate) ? rate.toFixed(0) : rate.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: OngoingWorkReviewProjectListItem['status'] }) {
|
||||
const isRevision = status === 'revision_required'
|
||||
function StatusBadge({ status }: { status: OngoingDingWorkflowListItem['status'] }) {
|
||||
return (
|
||||
<span className={`inline-flex min-h-7 items-center rounded-lg px-2.5 text-xs font-bold ${isRevision ? 'bg-amber-50 text-amber-800' : 'bg-blue-50 text-blue-700'}`}>
|
||||
{STATUS_LABELS[status]}
|
||||
<span className="inline-flex min-h-7 items-center rounded-lg bg-blue-50 px-2.5 text-xs font-bold text-blue-700">
|
||||
{WORKFLOW_STATUS_LABELS[status] || '未完结'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -73,14 +77,14 @@ function ProjectList({ metric, query, projectPage }: Required<Pick<CoreMetricDet
|
||||
<div>
|
||||
<h2 id="project-list-title" className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">进行中项目明细</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-600">
|
||||
数据来自作品过稿项目库;“评审中”和“待修改”均计入进行中。
|
||||
数据来自钉钉审批流程本地快照;所有未完结流程均计入进行中,每小时增量同步一次。
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold tabular-nums text-slate-600">共 {pagination.total} 个项目</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-5 flex max-w-xl gap-2" action={`/workspaces/dashboard/core-metrics/${metric.key}`} method="get" role="search">
|
||||
<label htmlFor="project-query" className="sr-only">搜索项目、提交人或负责人</label>
|
||||
<label htmlFor="project-query" className="sr-only">搜索流程、编号、发起部门或发起人</label>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" aria-hidden="true" />
|
||||
<input
|
||||
@ -88,7 +92,7 @@ function ProjectList({ metric, query, projectPage }: Required<Pick<CoreMetricDet
|
||||
name="q"
|
||||
type="search"
|
||||
defaultValue={query}
|
||||
placeholder="搜索项目、提交人或负责人"
|
||||
placeholder="搜索流程、编号、发起部门或发起人"
|
||||
className="min-h-11 w-full rounded-xl border border-slate-300 bg-white py-2 pl-9 pr-3 text-sm text-slate-950 outline-none transition-colors placeholder:text-slate-400 focus:border-slate-500 focus:ring-2 focus:ring-slate-300"
|
||||
/>
|
||||
</div>
|
||||
@ -106,7 +110,9 @@ function ProjectList({ metric, query, projectPage }: Required<Pick<CoreMetricDet
|
||||
<p className="mx-auto mt-2 max-w-xl text-sm leading-6 text-slate-600">
|
||||
{query
|
||||
? '请调整关键词后重新查询。'
|
||||
: '数据库中暂时没有“评审中”或“待修改”的作品过稿项目;有项目进入这些状态后会自动显示在这里。'}
|
||||
: projectPage.sync.syncStatus === 'pending'
|
||||
? '本地快照正在建立首轮同步基线,完成后会自动显示未完结流程。'
|
||||
: '当前本地快照中没有未完结的钉钉流程。'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@ -116,12 +122,12 @@ function ProjectList({ metric, query, projectPage }: Required<Pick<CoreMetricDet
|
||||
<caption className="sr-only">进行中项目数据表</caption>
|
||||
<thead className="bg-slate-50 text-xs font-bold text-slate-500">
|
||||
<tr>
|
||||
<th scope="col" className="w-[30%] px-5 py-3.5">项目</th>
|
||||
<th scope="col" className="w-[12%] px-4 py-3.5">状态</th>
|
||||
<th scope="col" className="w-[13%] px-4 py-3.5">提交人</th>
|
||||
<th scope="col" className="w-[13%] px-4 py-3.5">负责人</th>
|
||||
<th scope="col" className="w-[14%] px-4 py-3.5">作品日期</th>
|
||||
<th scope="col" className="w-[18%] px-5 py-3.5">最近更新</th>
|
||||
<th scope="col" className="w-[30%] px-5 py-3.5">流程</th>
|
||||
<th scope="col" className="w-[11%] px-4 py-3.5">状态</th>
|
||||
<th scope="col" className="w-[18%] px-4 py-3.5">发起部门</th>
|
||||
<th scope="col" className="w-[12%] px-4 py-3.5">待办任务</th>
|
||||
<th scope="col" className="w-[14%] px-4 py-3.5">发起时间</th>
|
||||
<th scope="col" className="w-[15%] px-5 py-3.5">最近更新</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
@ -129,13 +135,13 @@ function ProjectList({ metric, query, projectPage }: Required<Pick<CoreMetricDet
|
||||
<tr key={project.id} className="align-top transition-colors hover:bg-slate-50">
|
||||
<td className="px-5 py-4">
|
||||
<p className="truncate font-bold text-slate-950">{project.title}</p>
|
||||
<p className="mt-1 text-xs text-slate-500">第 {project.latestVersion} 版 · 终审 {project.finalReviewerName}</p>
|
||||
<p className="mt-1 truncate text-xs text-slate-500">流程编号 {project.businessId || project.id}</p>
|
||||
</td>
|
||||
<td className="px-4 py-4"><StatusBadge status={project.status} /></td>
|
||||
<td className="truncate px-4 py-4 font-medium text-slate-700">{project.submitterName}</td>
|
||||
<td className="truncate px-4 py-4 font-medium text-slate-700">{project.managerName}</td>
|
||||
<td className="px-4 py-4 tabular-nums text-slate-600">{formatDate(project.workDate)}</td>
|
||||
<td className="px-5 py-4 tabular-nums text-slate-600">{formatDate(project.updatedAt, true)}</td>
|
||||
<td className="truncate px-4 py-4 font-medium text-slate-700" title={project.originatorDeptName || undefined}>{project.originatorDeptName || '未记录'}</td>
|
||||
<td className="px-4 py-4 tabular-nums text-slate-700">{project.activeTaskCount} / {project.taskCount}</td>
|
||||
<td className="px-4 py-4 tabular-nums text-slate-600">{formatDate(project.sourceCreatedAt, true)}</td>
|
||||
<td className="px-5 py-4 tabular-nums text-slate-600">{formatDate(project.sourceUpdatedAt, true)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -148,15 +154,15 @@ function ProjectList({ metric, query, projectPage }: Required<Pick<CoreMetricDet
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate font-bold text-slate-950">{project.title}</h3>
|
||||
<p className="mt-1 text-xs text-slate-500">第 {project.latestVersion} 版 · 终审 {project.finalReviewerName}</p>
|
||||
<p className="mt-1 truncate text-xs text-slate-500">流程编号 {project.businessId || project.id}</p>
|
||||
</div>
|
||||
<StatusBadge status={project.status} />
|
||||
</div>
|
||||
<dl className="mt-4 grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div><dt className="text-xs text-slate-500">提交人</dt><dd className="mt-1 font-semibold text-slate-800">{project.submitterName}</dd></div>
|
||||
<div><dt className="text-xs text-slate-500">负责人</dt><dd className="mt-1 font-semibold text-slate-800">{project.managerName}</dd></div>
|
||||
<div><dt className="text-xs text-slate-500">作品日期</dt><dd className="mt-1 tabular-nums text-slate-700">{formatDate(project.workDate)}</dd></div>
|
||||
<div><dt className="text-xs text-slate-500">最近更新</dt><dd className="mt-1 tabular-nums text-slate-700">{formatDate(project.updatedAt, true)}</dd></div>
|
||||
<div><dt className="text-xs text-slate-500">发起部门</dt><dd className="mt-1 font-semibold text-slate-800">{project.originatorDeptName || '未记录'}</dd></div>
|
||||
<div><dt className="text-xs text-slate-500">待办 / 任务</dt><dd className="mt-1 font-semibold tabular-nums text-slate-800">{project.activeTaskCount} / {project.taskCount}</dd></div>
|
||||
<div><dt className="text-xs text-slate-500">发起时间</dt><dd className="mt-1 tabular-nums text-slate-700">{formatDate(project.sourceCreatedAt, true)}</dd></div>
|
||||
<div><dt className="text-xs text-slate-500">最近更新</dt><dd className="mt-1 tabular-nums text-slate-700">{formatDate(project.sourceUpdatedAt, true)}</dd></div>
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
@ -371,33 +377,40 @@ function PendingDataSource({ metric }: { metric: CoreMetric }) {
|
||||
}
|
||||
|
||||
export default function CoreMetricDetail({ metric, query, projectPage, firstPassPage }: CoreMetricDetailProps) {
|
||||
const Icon = metric.icon
|
||||
const hasRealProjectSource = metric.key === 'ongoing-projects' && projectPage
|
||||
const hasFirstPassSource = metric.key === 'first-pass-rate' && firstPassPage
|
||||
const isOnTimeDelivery = metric.key === 'on-time-delivery'
|
||||
const hasRealSource = hasRealProjectSource || hasFirstPassSource
|
||||
|
||||
return (
|
||||
<DepartmentShell>
|
||||
<main className="mx-auto max-w-[1600px] px-4 pb-16 pt-7 sm:px-6 lg:px-8">
|
||||
<header className="border-b border-[#dfe4ea] pb-6">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-[#edf5ff] text-[#176fc1]"><Icon className="h-6 w-6" aria-hidden="true" /></div>
|
||||
<h1 className="mt-6 text-balance text-3xl font-bold tracking-[-0.03em] sm:text-4xl">{metric.label}</h1>
|
||||
<h1 className="text-balance text-3xl font-bold tracking-[-0.03em] sm:text-4xl">{metric.label}</h1>
|
||||
<p className="mt-3 max-w-3xl text-base leading-7 text-[#66758b] sm:text-lg">{metric.detailDescription}</p>
|
||||
</header>
|
||||
|
||||
<section aria-label="指标口径" className="mt-6 grid overflow-hidden rounded-lg border border-[#e2e8f0] bg-white sm:grid-cols-3">
|
||||
<div className="border-b border-slate-200 p-5 sm:border-b-0 sm:border-r">
|
||||
<p className="text-xs font-bold text-slate-500">{hasFirstPassSource ? '当前首轮通过率' : hasRealProjectSource ? '当前项目数' : '总览示例值'}</p>
|
||||
<p className="text-xs font-bold text-slate-500">{hasFirstPassSource ? '当前首轮通过率' : hasRealProjectSource ? '当前未完结流程' : '总览示例值'}</p>
|
||||
{isOnTimeDelivery && metric.exampleNumericValue !== undefined
|
||||
? <OnTimeDeliveryRate rate={metric.exampleNumericValue} contextLabel="按时交付示例值" size="summary" />
|
||||
: <p className="mt-2 text-2xl font-bold tabular-nums">{hasFirstPassSource ? formatRate(firstPassPage.summary.rate) : hasRealProjectSource ? projectPage.pagination.total : metric.exampleValue}</p>}
|
||||
</div>
|
||||
<div className="border-b border-slate-200 p-5 sm:border-b-0 sm:border-r">
|
||||
<p className="text-xs font-bold text-slate-500">明细数据</p>
|
||||
<p className="mt-2 text-base font-bold">{hasFirstPassSource ? `V1通过 ${firstPassPage.summary.firstPassApproved} / 全部 ${firstPassPage.summary.totalUploads}` : hasRealProjectSource ? `${projectPage.pagination.total} 条真实记录` : '等待业务数据源'}</p>
|
||||
<p className="mt-2 text-base font-bold">{hasFirstPassSource ? `V1通过 ${firstPassPage.summary.firstPassApproved} / 全部 ${firstPassPage.summary.totalUploads}` : hasRealProjectSource ? `${projectPage.pagination.total} 条流程快照` : '等待业务数据源'}</p>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<p className="text-xs font-bold text-slate-500">同步状态</p>
|
||||
<p className="mt-2 inline-flex items-center gap-2 text-base font-bold">
|
||||
<CalendarDays className="h-4 w-4 text-slate-500" aria-hidden="true" />
|
||||
{hasRealProjectSource
|
||||
? projectPage.sync.lastSyncedAt
|
||||
? `每小时增量同步 · ${formatDate(projectPage.sync.lastSyncedAt, true)}`
|
||||
: projectPage.sync.syncStatus === 'error' ? '首轮同步失败' : '等待首轮同步'
|
||||
: hasFirstPassSource ? '打开页面时读取' : '每日同步待接入'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5"><p className="text-xs font-bold text-slate-500">同步状态</p><p className="mt-2 inline-flex items-center gap-2 text-base font-bold"><CalendarDays className="h-4 w-4 text-slate-500" aria-hidden="true" />{hasRealSource ? '打开页面时读取' : '每日同步待接入'}</p></div>
|
||||
</section>
|
||||
|
||||
{hasRealProjectSource
|
||||
@ -408,7 +421,6 @@ export default function CoreMetricDetail({ metric, query, projectPage, firstPass
|
||||
|
||||
<div className="mt-10 flex flex-wrap gap-3">
|
||||
<Link href="/workspaces/dashboard/core-metrics" className="inline-flex min-h-11 items-center gap-2 rounded-xl bg-[#247fd3] px-4 text-sm font-bold text-white transition-colors hover:bg-[#176fc1] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#78aee9] focus-visible:ring-offset-2"><ArrowLeft className="h-4 w-4" aria-hidden="true" />返回核心指标</Link>
|
||||
<Link href="/" className="inline-flex min-h-11 items-center gap-2 rounded-xl bg-white px-4 text-sm font-bold text-[#4f6077] shadow-[0_8px_22px_rgba(67,93,126,0.08)] transition-colors hover:bg-[#f7f9fc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#78aee9]">返回部门看板<ArrowRight className="h-4 w-4" aria-hidden="true" /></Link>
|
||||
</div>
|
||||
</main>
|
||||
</DepartmentShell>
|
||||
|
||||
@ -8,26 +8,35 @@ jest.mock('next-auth/react', () => ({
|
||||
|
||||
describe('CoreMetricsDashboard', () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
global.fetch = jest.fn().mockImplementation(async (url: string) => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
totalUploads: 30,
|
||||
firstPassApproved: 11,
|
||||
rate: 36.7,
|
||||
statusBreakdown: { approved: 11, revisionRequired: 7, pendingReview: 10, unknown: 2 },
|
||||
readAt: '2026-08-14T02:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
}) as jest.Mock
|
||||
json: async () => url.includes('ongoing-projects')
|
||||
? {
|
||||
success: true,
|
||||
data: {
|
||||
total: 2338,
|
||||
lastSyncedAt: '2026-08-14T02:00:00.000Z',
|
||||
syncStatus: 'ready',
|
||||
},
|
||||
}
|
||||
: {
|
||||
success: true,
|
||||
data: {
|
||||
totalUploads: 30,
|
||||
firstPassApproved: 11,
|
||||
rate: 36.7,
|
||||
statusBreakdown: { approved: 11, revisionRequired: 7, pendingReview: 10, unknown: 2 },
|
||||
readAt: '2026-08-14T02:00:00.000Z',
|
||||
},
|
||||
},
|
||||
})) as jest.Mock
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('展示真实首轮通过率与其余五项诚实的示例状态', async () => {
|
||||
it('展示真实进行中流程、首轮通过率与其余四项诚实的示例状态', async () => {
|
||||
render(<CoreMetricsDashboard />)
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: '核心指标' })).toBeInTheDocument()
|
||||
@ -35,17 +44,19 @@ describe('CoreMetricsDashboard', () => {
|
||||
expect(await screen.findByText('36.7%')).toBeInTheDocument()
|
||||
expect(screen.getByText('V1通过 11 / 全部 30')).toBeInTheDocument()
|
||||
expect(screen.getByText('实时读取')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('示例值')).toHaveLength(5)
|
||||
expect(screen.getByText('每小时同步')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('示例值')).toHaveLength(4)
|
||||
|
||||
for (const label of ['进行中项目', '本月上新', '按时交付', '首轮通过', '团队负荷', '风险']) {
|
||||
expect(screen.getByRole('heading', { level: 3, name: label })).toBeInTheDocument()
|
||||
}
|
||||
|
||||
for (const value of ['42', '18/24', '94.6%', '36.7%', '87%', '4项']) {
|
||||
for (const value of ['2338', '18/24', '94.6%', '36.7%', '87%', '4项']) {
|
||||
expect(screen.getByText(value)).toBeInTheDocument()
|
||||
}
|
||||
|
||||
expect(screen.getAllByText('公式待接入')).toHaveLength(5)
|
||||
expect(screen.getAllByText('公式待接入')).toHaveLength(4)
|
||||
expect(screen.getByText('钉钉流程快照')).toBeInTheDocument()
|
||||
expect(screen.getByText('口径已定义')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('查看明细')).toHaveLength(6)
|
||||
const onTimeDelivery = screen.getByRole('link', { name: '查看按时交付明细' })
|
||||
@ -66,8 +77,8 @@ describe('CoreMetricsDashboard', () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({ ok: false, json: async () => ({ success: false }) }) as jest.Mock
|
||||
render(<CoreMetricsDashboard />)
|
||||
|
||||
expect(await screen.findByText('读取失败')).toBeInTheDocument()
|
||||
expect(await screen.findAllByText('读取失败')).toHaveLength(2)
|
||||
expect(screen.queryByText('82.3%')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('数据未加载')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('数据未加载')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
CalendarClock,
|
||||
} from 'lucide-react'
|
||||
import type { FirstPassReviewSummary } from '@/lib/work-review-metrics'
|
||||
import type { OngoingDingWorkflowSummary } from '@/lib/ding-workflow-sync'
|
||||
import DepartmentShell from './DepartmentShell'
|
||||
import { CORE_METRICS, getCoreMetricHref } from './core-metrics'
|
||||
import OnTimeDeliveryRate from './OnTimeDeliveryRate'
|
||||
@ -16,13 +17,31 @@ type FirstPassState =
|
||||
| { status: 'success'; data: FirstPassReviewSummary }
|
||||
| { status: 'error'; data: null }
|
||||
|
||||
type OngoingWorkflowState =
|
||||
| { status: 'loading'; data: null }
|
||||
| { status: 'success'; data: OngoingDingWorkflowSummary }
|
||||
| { status: 'error'; data: null }
|
||||
|
||||
function formatRate(rate: number | null) {
|
||||
if (rate === null) return '—'
|
||||
return `${Number.isInteger(rate) ? rate.toFixed(0) : rate.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatSyncTime(value: string | null) {
|
||||
if (!value) return '等待首轮同步'
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
export default function CoreMetricsDashboard() {
|
||||
const [firstPass, setFirstPass] = useState<FirstPassState>({ status: 'loading', data: null })
|
||||
const [ongoingWorkflows, setOngoingWorkflows] = useState<OngoingWorkflowState>({ status: 'loading', data: null })
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
@ -41,7 +60,22 @@ export default function CoreMetricsDashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOngoingWorkflowSummary() {
|
||||
try {
|
||||
const response = await fetch('/api/department-dashboard/core-metrics/ongoing-projects', {
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
})
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok || !payload?.success || !payload.data) throw new Error('指标读取失败')
|
||||
setOngoingWorkflows({ status: 'success', data: payload.data })
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'AbortError') setOngoingWorkflows({ status: 'error', data: null })
|
||||
}
|
||||
}
|
||||
|
||||
void loadFirstPassSummary()
|
||||
void loadOngoingWorkflowSummary()
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
@ -73,12 +107,25 @@ export default function CoreMetricsDashboard() {
|
||||
<span className="absolute inset-x-0 top-0 h-1 bg-[#5a9ce6]" aria-hidden="true" />
|
||||
{CORE_METRICS.map((metric) => {
|
||||
const Icon = metric.icon
|
||||
const isOngoingWorkflow = metric.key === 'ongoing-projects'
|
||||
const isFirstPass = metric.key === 'first-pass-rate'
|
||||
const isOnTimeDelivery = metric.key === 'on-time-delivery'
|
||||
const badge = isFirstPass
|
||||
const badge = isOngoingWorkflow
|
||||
? ongoingWorkflows.status === 'success'
|
||||
? ongoingWorkflows.data.syncStatus === 'ready'
|
||||
? '每小时同步'
|
||||
: ongoingWorkflows.data.syncStatus === 'syncing'
|
||||
? '正在同步'
|
||||
: ongoingWorkflows.data.syncStatus === 'error'
|
||||
? '同步异常'
|
||||
: '等待首轮同步'
|
||||
: ongoingWorkflows.status === 'error' ? '读取失败' : '正在读取'
|
||||
: isFirstPass
|
||||
? firstPass.status === 'success' ? '实时读取' : firstPass.status === 'error' ? '读取失败' : '正在读取'
|
||||
: '示例值'
|
||||
const value = isFirstPass
|
||||
const value = isOngoingWorkflow
|
||||
? ongoingWorkflows.status === 'success' ? String(ongoingWorkflows.data.total) : '—'
|
||||
: isFirstPass
|
||||
? firstPass.status === 'success' ? formatRate(firstPass.data.rate) : '—'
|
||||
: metric.exampleValue
|
||||
|
||||
@ -99,11 +146,11 @@ export default function CoreMetricsDashboard() {
|
||||
</div>
|
||||
|
||||
<h3 className="mt-6 text-base font-bold text-[#4f6077]">{metric.label}</h3>
|
||||
<div aria-live={isFirstPass ? 'polite' : undefined} aria-atomic={isFirstPass ? 'true' : undefined}>
|
||||
<div aria-live={isFirstPass || isOngoingWorkflow ? 'polite' : undefined} aria-atomic={isFirstPass || isOngoingWorkflow ? 'true' : undefined}>
|
||||
{isOnTimeDelivery && metric.exampleNumericValue !== undefined
|
||||
? <OnTimeDeliveryRate rate={metric.exampleNumericValue} contextLabel={`${metric.label}示例值`} />
|
||||
: (
|
||||
<p className="mt-2 text-3xl font-bold tracking-[-0.03em] text-[#101828] sm:text-4xl" aria-label={`${metric.label}${isFirstPass ? '当前值' : '示例值'}${value}`}>
|
||||
<p className="mt-2 text-3xl font-bold tracking-[-0.03em] text-[#101828] sm:text-4xl" aria-label={`${metric.label}${isFirstPass || isOngoingWorkflow ? '当前值' : '示例值'}${value}`}>
|
||||
{value}
|
||||
</p>
|
||||
)}
|
||||
@ -115,6 +162,14 @@ export default function CoreMetricsDashboard() {
|
||||
{isFirstPass && firstPass.status === 'loading' && (
|
||||
<p className="mt-2 text-xs font-semibold text-slate-500">正在连接作品过稿</p>
|
||||
)}
|
||||
{isOngoingWorkflow && ongoingWorkflows.status === 'success' && (
|
||||
<p className="mt-2 text-xs font-semibold tabular-nums text-slate-500">
|
||||
最近同步 {formatSyncTime(ongoingWorkflows.data.lastSyncedAt)}
|
||||
</p>
|
||||
)}
|
||||
{isOngoingWorkflow && ongoingWorkflows.status === 'loading' && (
|
||||
<p className="mt-2 text-xs font-semibold text-slate-500">正在读取本地流程快照</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 text-[#66758b]">{metric.description}</p>
|
||||
|
||||
@ -122,10 +177,14 @@ export default function CoreMetricsDashboard() {
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-slate-500">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CalendarClock className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{isFirstPass ? (firstPass.status === 'error' ? '刷新页面重试' : '打开页面时读取') : '每日同步'}
|
||||
{isOngoingWorkflow
|
||||
? ongoingWorkflows.status === 'error' ? '刷新页面重试' : '每小时增量同步'
|
||||
: isFirstPass ? (firstPass.status === 'error' ? '刷新页面重试' : '打开页面时读取') : '每日同步'}
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{isFirstPass ? (firstPass.status === 'error' ? '数据未加载' : '口径已定义') : '公式待接入'}</span>
|
||||
<span>{isOngoingWorkflow
|
||||
? ongoingWorkflows.status === 'error' ? '数据未加载' : '钉钉流程快照'
|
||||
: isFirstPass ? (firstPass.status === 'error' ? '数据未加载' : '口径已定义') : '公式待接入'}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-[#176fc1]">
|
||||
查看明细
|
||||
@ -137,7 +196,7 @@ export default function CoreMetricsDashboard() {
|
||||
})}
|
||||
</div>
|
||||
<p id="metrics-disclaimer" className="mt-3 text-xs leading-5 text-[#66758b]">
|
||||
“首轮通过”已读取作品过稿 V1 审核结果;其余示例值将在对应数据源与公式确认后替换。
|
||||
“进行中项目”每小时读取钉钉审批流程快照,“首轮通过”读取作品过稿 V1 审核结果;其余示例值将在对应数据源与公式确认后替换。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@ -32,10 +32,10 @@ export const CORE_METRICS: CoreMetric[] = [
|
||||
key: 'ongoing-projects',
|
||||
label: '进行中项目',
|
||||
exampleValue: '42',
|
||||
description: '当前处于进行中状态的项目总量。',
|
||||
description: '当前未完结的钉钉流程总量。',
|
||||
icon: BriefcaseBusiness,
|
||||
detailDescription: '展示当前仍在流转中的项目,并按最近更新时间排序。',
|
||||
plannedFields: ['项目名称', '当前状态', '提交人', '负责人', '作品日期', '最近更新'],
|
||||
detailDescription: '展示当前仍在流转中的钉钉审批流程,并按最近更新时间排序。',
|
||||
plannedFields: ['流程名称', '当前状态', '发起部门', '待办任务', '发起时间', '最近更新'],
|
||||
},
|
||||
{
|
||||
key: 'monthly-launches',
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## 结论
|
||||
|
||||
本次应用发布依赖七份增量 migration。普通蓝绿部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。
|
||||
当前应用发布工具验证八份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。
|
||||
|
||||
目标 migration:
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
- `20260813_add_material_custody_department`
|
||||
- `20260813_add_organization_units_and_employment_state`
|
||||
- `20260813_add_user_employee_binding`
|
||||
- `20260820_add_ding_workflow_snapshots`
|
||||
|
||||
它们新增可空租约字段/索引、修改未来 Logo 默认值、精确把 `system_settings` 品牌两列中的退役 `s3.1080p.hk` 值改为本地品牌图,并把 `materials_uploadId_key` 重建为“`uploadId` 非空且素材未软删除”范围内的唯一 btree 索引;同时新增人员组织树、素材托管字段和账号—人员绑定表,并在同一个事务中按钉钉唯一值做确定性回填。索引迁移会先检测未删除素材中的重复 `uploadId`;人员绑定迁移会先检测一个钉钉身份对应多个账号的冲突。任一预检失败都会直接停止,不会猜测性合并、删除素材或留下一半回填。迁移没有自动 down;执行失败时停止发布,恢复依赖执行前的数据库备份。
|
||||
|
||||
@ -29,7 +30,7 @@
|
||||
1. 构建并固定同一 exact SHA/digest 的 runner 与 migrator;
|
||||
2. legacy bootstrap `Audit`;
|
||||
3. legacy bootstrap `Apply`,外部验收 503,确认旧容器停止且保留;
|
||||
4. 在同一维护窗内运行本文的真实备份、隔离恢复、七条 migration 和迁移后审计;
|
||||
4. 在同一维护窗内运行本文的真实备份、隔离恢复、八条目标 migration 和迁移后审计;
|
||||
5. 仅在所有门禁通过后,运行普通同 SHA 应用部署。
|
||||
|
||||
顺序不得改为“先 migration,后 bootstrap”。本文备份工具的生产前后摘要若有变化,说明维护窗内仍可能存在写入,会直接失败且不生成 receipt。
|
||||
@ -353,7 +354,7 @@ docker run --rm \
|
||||
"$MIGRATOR_IMAGE" --apply
|
||||
```
|
||||
|
||||
工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对七条 migration 的数据库 checksum、完成状态、关键人员组织表/字段类型与可空性、租约索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及每条现存 Logo 地址均为本地品牌图或当前可信桶地址,并原子写审计 JSON。
|
||||
工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对八条目标 migration 的数据库 checksum、完成状态、关键人员组织表、钉钉流程快照表、字段类型与可空性、租约索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及每条现存 Logo 地址均为本地品牌图或当前可信桶地址,并原子写审计 JSON。
|
||||
|
||||
apply 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写:
|
||||
|
||||
@ -369,7 +370,7 @@ test -n "$MIGRATION_AUDIT_SHA256"
|
||||
只有以下全部成立,才进入应用首次安全上线步骤:
|
||||
|
||||
- `applied.json` 的 `verified=true`;
|
||||
- 七条 target migration 均 `completed=true`,source/database checksum 一致;
|
||||
- 八条 target migration 均 `completed=true`,source/database checksum 一致;
|
||||
- `_prisma_migrations.finished_at` 非空、`rolled_back_at` 为空;
|
||||
- 视频租约列/索引、任务详情作业租约列/索引、active-only `uploadId` 唯一索引、品牌默认值精确匹配;
|
||||
- 再次运行 `--audit` 仍通过;
|
||||
|
||||
@ -80,5 +80,15 @@ export async function register() {
|
||||
} catch (error) {
|
||||
console.error('[Instrumentation] 调度组织架构每日同步任务失败:', error)
|
||||
}
|
||||
|
||||
// 钉钉审批流程快照:启动后建立基线,之后每小时增量同步。
|
||||
// 看板只读本地 PostgreSQL 快照,避免用户请求直接扫描上游无索引大表。
|
||||
try {
|
||||
const { ensureHourlyDingWorkflowSyncStarted } = await import('./lib/ding-workflow-sync')
|
||||
ensureHourlyDingWorkflowSyncStarted()
|
||||
console.log('[Instrumentation] 钉钉流程每小时增量同步任务已调度')
|
||||
} catch (error) {
|
||||
console.error('[Instrumentation] 调度钉钉流程每小时同步任务失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
32
lib/__tests__/ding-workflow-sync.test.ts
Normal file
32
lib/__tests__/ding-workflow-sync.test.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import {
|
||||
isDingWorkflowOngoing,
|
||||
sourceWorkflowTimestampToIso,
|
||||
} from '@/lib/ding-workflow-sync'
|
||||
|
||||
jest.mock('mssql', () => ({
|
||||
__esModule: true,
|
||||
default: {},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
|
||||
jest.mock('@/lib/brand-center-access', () => ({ resolveBrandCenterAccessForUser: jest.fn() }))
|
||||
|
||||
describe('ding workflow snapshot sync semantics', () => {
|
||||
test.each([
|
||||
['RUNNING', null, true],
|
||||
['NEW', '', true],
|
||||
['COMPLETED', null, false],
|
||||
['TERMINATED', null, false],
|
||||
['CANCELED', null, false],
|
||||
['RUNNING', '2026-08-20 10:30:00', false],
|
||||
['', null, false],
|
||||
])('status %s and finish time %s resolves ongoing=%s', (status, finishTime, expected) => {
|
||||
expect(isDingWorkflowOngoing(status, finishTime)).toBe(expected)
|
||||
})
|
||||
|
||||
it('interprets upstream timestamps as Asia/Shanghai when no zone is present', () => {
|
||||
expect(sourceWorkflowTimestampToIso('2026-08-20 10:30:00')).toBe('2026-08-20T02:30:00.000Z')
|
||||
expect(sourceWorkflowTimestampToIso('2026-08-20T10:30:00Z')).toBe('2026-08-20T10:30:00.000Z')
|
||||
expect(sourceWorkflowTimestampToIso('invalid')).toBeNull()
|
||||
})
|
||||
})
|
||||
617
lib/ding-workflow-sync.ts
Normal file
617
lib/ding-workflow-sync.ts
Normal file
@ -0,0 +1,617 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { hostname } from 'node:os'
|
||||
import type { Session } from 'next-auth'
|
||||
import mssql, { type ConnectionPool } from 'mssql'
|
||||
import { resolveBrandCenterAccessForUser } from '@/lib/brand-center-access'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const SYNC_STATE_ID = 'ding-workflow-hourly'
|
||||
const TERMINAL_WORKFLOW_STATUSES = new Set(['COMPLETED', 'TERMINATED', 'CANCELED'])
|
||||
const LEASE_DURATION_MS = 20 * 60 * 1000
|
||||
const DEFAULT_SYNC_INTERVAL_MS = 60 * 60 * 1000
|
||||
const INITIAL_SYNC_DELAY_MS = 5_000
|
||||
const UPSERT_BATCH_SIZE = 750
|
||||
|
||||
const WORKFLOW_TABLE = {
|
||||
schema: process.env.REPORT_DB_WORKFLOW_SCHEMA || 'rpt',
|
||||
table: process.env.REPORT_DB_WORKFLOW_TABLE || 'ding_Workflow',
|
||||
}
|
||||
const WORKFLOW_SCHEME_TABLE = {
|
||||
schema: process.env.REPORT_DB_WORKFLOW_SCHEMA || 'rpt',
|
||||
table: process.env.REPORT_DB_WORKFLOW_SCHEME_TABLE || 'ding_WorkflowScheme',
|
||||
}
|
||||
const WORKFLOW_TASK_TABLE = {
|
||||
schema: process.env.REPORT_DB_WORKFLOW_SCHEMA || 'rpt',
|
||||
table: process.env.REPORT_DB_WORKFLOW_TASK_TABLE || 'ding_WorkflowTask',
|
||||
}
|
||||
|
||||
type SourceWorkflowRow = {
|
||||
processInstanceId: unknown
|
||||
businessId: unknown
|
||||
title: unknown
|
||||
result: unknown
|
||||
originatorUserId: unknown
|
||||
originatorDeptId: unknown
|
||||
originatorDeptName: unknown
|
||||
status: unknown
|
||||
bizAction: unknown
|
||||
processCode: unknown
|
||||
flowType: unknown
|
||||
joinedAtRaw: unknown
|
||||
sourceCreatedAtRaw: unknown
|
||||
sourceUpdatedAtRaw: unknown
|
||||
sourceFinishedAtRaw: unknown
|
||||
}
|
||||
|
||||
type SourceTaskAggregate = {
|
||||
processInstanceId: unknown
|
||||
taskCount: unknown
|
||||
activeTaskCount: unknown
|
||||
latestTaskStatus: unknown
|
||||
latestTaskAtRaw: unknown
|
||||
}
|
||||
|
||||
type SourceSchemeAggregate = {
|
||||
processInstanceId: unknown
|
||||
schemeFieldCount: unknown
|
||||
}
|
||||
|
||||
type WorkflowSnapshotPayload = {
|
||||
processInstanceId: string
|
||||
businessId: string | null
|
||||
title: string | null
|
||||
result: string | null
|
||||
originatorUserId: string | null
|
||||
originatorDeptId: string | null
|
||||
originatorDeptName: string | null
|
||||
status: string
|
||||
bizAction: string | null
|
||||
processCode: string | null
|
||||
flowType: string | null
|
||||
joinedAtRaw: string | null
|
||||
sourceCreatedAtRaw: string | null
|
||||
sourceUpdatedAtRaw: string | null
|
||||
sourceFinishedAtRaw: string | null
|
||||
isOngoing: boolean
|
||||
schemeFieldCount: number
|
||||
taskCount: number
|
||||
activeTaskCount: number
|
||||
latestTaskStatus: string | null
|
||||
latestTaskAtRaw: string | null
|
||||
}
|
||||
|
||||
export type DingWorkflowSyncStatus = 'pending' | 'syncing' | 'ready' | 'error'
|
||||
|
||||
export type OngoingDingWorkflowSummary = {
|
||||
total: number
|
||||
lastSyncedAt: string | null
|
||||
syncStatus: DingWorkflowSyncStatus
|
||||
}
|
||||
|
||||
export type OngoingDingWorkflowListItem = {
|
||||
id: string
|
||||
businessId: string | null
|
||||
title: string
|
||||
status: string
|
||||
originatorUserId: string | null
|
||||
originatorDeptName: string | null
|
||||
sourceCreatedAt: string | null
|
||||
sourceUpdatedAt: string | null
|
||||
activeTaskCount: number
|
||||
taskCount: number
|
||||
schemeFieldCount: number
|
||||
latestTaskStatus: string | null
|
||||
}
|
||||
|
||||
export type OngoingDingWorkflowPage = {
|
||||
items: OngoingDingWorkflowListItem[]
|
||||
pagination: {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
sync: OngoingDingWorkflowSummary
|
||||
}
|
||||
|
||||
export class DingWorkflowMetricApiError extends Error {
|
||||
status: number
|
||||
|
||||
constructor(message: string, status = 400) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
function clean(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null
|
||||
const normalized = String(value).trim()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown): number {
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) && number > 0 ? Math.floor(number) : 0
|
||||
}
|
||||
|
||||
function quoteIdentifier(value: string) {
|
||||
return '[' + String(value).replace(/]/g, ']]') + ']'
|
||||
}
|
||||
|
||||
function tableRef(table: { schema: string; table: string }) {
|
||||
return `${quoteIdentifier(table.schema)}.${quoteIdentifier(table.table)}`
|
||||
}
|
||||
|
||||
export function isDingWorkflowOngoing(status: unknown, finishedAt: unknown): boolean {
|
||||
const normalizedStatus = String(status || '').trim().toUpperCase()
|
||||
return Boolean(normalizedStatus)
|
||||
&& !TERMINAL_WORKFLOW_STATUSES.has(normalizedStatus)
|
||||
&& !clean(finishedAt)
|
||||
}
|
||||
|
||||
export function sourceWorkflowTimestampToIso(value: unknown): string | null {
|
||||
const normalized = clean(value)
|
||||
if (!normalized) return null
|
||||
const hasTimeZone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)
|
||||
const candidate = hasTimeZone
|
||||
? normalized
|
||||
: `${normalized.replace(' ', 'T')}+08:00`
|
||||
const parsed = new Date(candidate)
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString()
|
||||
}
|
||||
|
||||
function maxSourceCursor(rows: WorkflowSnapshotPayload[], current: string | null) {
|
||||
return rows.reduce<string | null>((latest, row) => {
|
||||
const candidate = row.sourceUpdatedAtRaw
|
||||
if (!candidate) return latest
|
||||
return !latest || candidate > latest ? candidate : latest
|
||||
}, current)
|
||||
}
|
||||
|
||||
export function isReportWorkflowDbConfigured() {
|
||||
return Boolean(
|
||||
process.env.REPORT_DB_SERVER
|
||||
&& process.env.REPORT_DB_DATABASE
|
||||
&& process.env.REPORT_DB_USER
|
||||
&& process.env.REPORT_DB_PASSWORD,
|
||||
)
|
||||
}
|
||||
|
||||
function buildSourceConfig() {
|
||||
return {
|
||||
server: process.env.REPORT_DB_SERVER as string,
|
||||
database: process.env.REPORT_DB_DATABASE as string,
|
||||
user: process.env.REPORT_DB_USER as string,
|
||||
password: process.env.REPORT_DB_PASSWORD as string,
|
||||
port: Number(process.env.REPORT_DB_PORT || 1433),
|
||||
options: {
|
||||
encrypt: process.env.REPORT_DB_ENCRYPT === 'true',
|
||||
trustServerCertificate: process.env.REPORT_DB_TRUST_SERVER_CERTIFICATE !== 'false',
|
||||
},
|
||||
connectionTimeout: 20_000,
|
||||
requestTimeout: 300_000,
|
||||
pool: { max: 3, min: 0, idleTimeoutMillis: 30_000 },
|
||||
}
|
||||
}
|
||||
|
||||
async function assertDashboardAccess(session: Session | null) {
|
||||
const userId = session?.user?.id
|
||||
if (!userId) throw new DingWorkflowMetricApiError('请先登录', 401)
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { role: true, isSuperAdmin: true, isActive: true, status: true },
|
||||
})
|
||||
if (!user || !user.isActive || user.status === 'archived' || user.status === 'suspended') {
|
||||
throw new DingWorkflowMetricApiError('账号不可用,请重新登录', 401)
|
||||
}
|
||||
|
||||
const allowed = await resolveBrandCenterAccessForUser(userId, {
|
||||
role: user.role,
|
||||
isSuperAdmin: user.isSuperAdmin,
|
||||
})
|
||||
if (!allowed) throw new DingWorkflowMetricApiError('无权查看品牌中心流程指标', 403)
|
||||
}
|
||||
|
||||
async function fetchSourceWorkflows(pool: ConnectionPool, cursor: string | null) {
|
||||
const request = pool.request()
|
||||
request.input('cursor', mssql.NVarChar(64), cursor)
|
||||
const result = await request.query<SourceWorkflowRow>(`
|
||||
SELECT
|
||||
CONVERT(nvarchar(255), processInstanceId) AS processInstanceId,
|
||||
CONVERT(nvarchar(255), businessId) AS businessId,
|
||||
CONVERT(nvarchar(max), title) AS title,
|
||||
CONVERT(nvarchar(100), result) AS result,
|
||||
CONVERT(nvarchar(255), originatorUserId) AS originatorUserId,
|
||||
CONVERT(nvarchar(255), originatorDeptId) AS originatorDeptId,
|
||||
CONVERT(nvarchar(500), originatorDeptName) AS originatorDeptName,
|
||||
CONVERT(nvarchar(100), status) AS status,
|
||||
CONVERT(nvarchar(100), bizAction) AS bizAction,
|
||||
CONVERT(nvarchar(255), processCode) AS processCode,
|
||||
CONVERT(nvarchar(100), flowType) AS flowType,
|
||||
CONVERT(nvarchar(64), joinAt) AS joinedAtRaw,
|
||||
CONVERT(nvarchar(64), createAt) AS sourceCreatedAtRaw,
|
||||
CONVERT(nvarchar(64), updateAt) AS sourceUpdatedAtRaw,
|
||||
CONVERT(nvarchar(64), finishTime) AS sourceFinishedAtRaw
|
||||
FROM ${tableRef(WORKFLOW_TABLE)}
|
||||
WHERE
|
||||
@cursor IS NULL
|
||||
OR UPPER(LTRIM(RTRIM(COALESCE(CONVERT(nvarchar(100), status), '')))) NOT IN ('COMPLETED', 'TERMINATED', 'CANCELED')
|
||||
OR TRY_CONVERT(datetime2, NULLIF(CONVERT(nvarchar(64), updateAt), '')) >=
|
||||
DATEADD(minute, -5, TRY_CONVERT(datetime2, @cursor))
|
||||
`)
|
||||
return result.recordset
|
||||
}
|
||||
|
||||
async function fetchOngoingAggregates(pool: ConnectionPool, processInstanceIds: string[]) {
|
||||
if (processInstanceIds.length === 0) {
|
||||
return {
|
||||
taskByWorkflow: new Map<string, SourceTaskAggregate>(),
|
||||
schemeByWorkflow: new Map<string, SourceSchemeAggregate>(),
|
||||
}
|
||||
}
|
||||
|
||||
const request = pool.request()
|
||||
request.multiple = true
|
||||
request.input('workflowIds', mssql.NVarChar(mssql.MAX), JSON.stringify(processInstanceIds))
|
||||
const result = await request.query(`
|
||||
;WITH workflow_ids AS (
|
||||
SELECT CONVERT(nvarchar(255), [value]) AS processInstanceId
|
||||
FROM OPENJSON(@workflowIds)
|
||||
), task_rows AS (
|
||||
SELECT
|
||||
CONVERT(nvarchar(255), task.processInstanceId) AS processInstanceId,
|
||||
CONVERT(nvarchar(100), task.status) AS status,
|
||||
CONVERT(nvarchar(64), task.createTime) AS createTime,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY task.processInstanceId
|
||||
ORDER BY TRY_CONVERT(datetime2, NULLIF(CONVERT(nvarchar(64), task.createTime), '')) DESC, task.id DESC
|
||||
) AS rowNumber
|
||||
FROM ${tableRef(WORKFLOW_TASK_TABLE)} task
|
||||
INNER JOIN workflow_ids ids
|
||||
ON ids.processInstanceId = CONVERT(nvarchar(255), task.processInstanceId)
|
||||
)
|
||||
SELECT
|
||||
processInstanceId,
|
||||
COUNT_BIG(*) AS taskCount,
|
||||
SUM(CASE WHEN UPPER(LTRIM(RTRIM(COALESCE(status, '')))) NOT IN ('COMPLETED', 'TERMINATED', 'CANCELED') THEN 1 ELSE 0 END) AS activeTaskCount,
|
||||
MAX(CASE WHEN rowNumber = 1 THEN status END) AS latestTaskStatus,
|
||||
MAX(CASE WHEN rowNumber = 1 THEN createTime END) AS latestTaskAtRaw
|
||||
FROM task_rows
|
||||
GROUP BY processInstanceId;
|
||||
|
||||
;WITH workflow_ids AS (
|
||||
SELECT CONVERT(nvarchar(255), [value]) AS processInstanceId
|
||||
FROM OPENJSON(@workflowIds)
|
||||
)
|
||||
SELECT
|
||||
CONVERT(nvarchar(255), scheme.processInstanceId) AS processInstanceId,
|
||||
COUNT_BIG(*) AS schemeFieldCount
|
||||
FROM ${tableRef(WORKFLOW_SCHEME_TABLE)} scheme
|
||||
INNER JOIN workflow_ids ids
|
||||
ON ids.processInstanceId = CONVERT(nvarchar(255), scheme.processInstanceId)
|
||||
GROUP BY scheme.processInstanceId;
|
||||
`)
|
||||
|
||||
const taskRows = (result.recordsets?.[0] || []) as SourceTaskAggregate[]
|
||||
const schemeRows = (result.recordsets?.[1] || []) as SourceSchemeAggregate[]
|
||||
return {
|
||||
taskByWorkflow: new Map(taskRows.map((row) => [String(row.processInstanceId), row])),
|
||||
schemeByWorkflow: new Map(schemeRows.map((row) => [String(row.processInstanceId), row])),
|
||||
}
|
||||
}
|
||||
|
||||
function buildSnapshotPayload(
|
||||
sourceRows: SourceWorkflowRow[],
|
||||
taskByWorkflow: Map<string, SourceTaskAggregate>,
|
||||
schemeByWorkflow: Map<string, SourceSchemeAggregate>,
|
||||
): WorkflowSnapshotPayload[] {
|
||||
return sourceRows.flatMap((row) => {
|
||||
const processInstanceId = clean(row.processInstanceId)
|
||||
const status = clean(row.status)
|
||||
if (!processInstanceId || !status) return []
|
||||
const ongoing = isDingWorkflowOngoing(status, row.sourceFinishedAtRaw)
|
||||
const task = taskByWorkflow.get(processInstanceId)
|
||||
const scheme = schemeByWorkflow.get(processInstanceId)
|
||||
|
||||
return [{
|
||||
processInstanceId,
|
||||
businessId: clean(row.businessId),
|
||||
title: clean(row.title),
|
||||
result: clean(row.result),
|
||||
originatorUserId: clean(row.originatorUserId),
|
||||
originatorDeptId: clean(row.originatorDeptId),
|
||||
originatorDeptName: clean(row.originatorDeptName),
|
||||
status: status.toUpperCase(),
|
||||
bizAction: clean(row.bizAction),
|
||||
processCode: clean(row.processCode),
|
||||
flowType: clean(row.flowType),
|
||||
joinedAtRaw: clean(row.joinedAtRaw),
|
||||
sourceCreatedAtRaw: clean(row.sourceCreatedAtRaw),
|
||||
sourceUpdatedAtRaw: clean(row.sourceUpdatedAtRaw),
|
||||
sourceFinishedAtRaw: clean(row.sourceFinishedAtRaw),
|
||||
isOngoing: ongoing,
|
||||
schemeFieldCount: ongoing ? nonNegativeInteger(scheme?.schemeFieldCount) : 0,
|
||||
taskCount: ongoing ? nonNegativeInteger(task?.taskCount) : 0,
|
||||
activeTaskCount: ongoing ? nonNegativeInteger(task?.activeTaskCount) : 0,
|
||||
latestTaskStatus: ongoing ? clean(task?.latestTaskStatus)?.toUpperCase() || null : null,
|
||||
latestTaskAtRaw: ongoing ? clean(task?.latestTaskAtRaw) : null,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
async function upsertSnapshotBatch(rows: WorkflowSnapshotPayload[], syncedAt: Date) {
|
||||
if (rows.length === 0) return
|
||||
await prisma.$executeRawUnsafe(`
|
||||
INSERT INTO "ding_workflow_snapshots" (
|
||||
"process_instance_id", "business_id", "title", "result",
|
||||
"originator_user_id", "originator_dept_id", "originator_dept_name",
|
||||
"status", "biz_action", "process_code", "flow_type", "joined_at_raw",
|
||||
"source_created_at_raw", "source_updated_at_raw", "source_finished_at_raw",
|
||||
"is_ongoing", "scheme_field_count", "task_count", "active_task_count",
|
||||
"latest_task_status", "latest_task_at_raw", "synced_at", "updated_at"
|
||||
)
|
||||
SELECT
|
||||
incoming."processInstanceId", incoming."businessId", incoming."title", incoming."result",
|
||||
incoming."originatorUserId", incoming."originatorDeptId", incoming."originatorDeptName",
|
||||
incoming."status", incoming."bizAction", incoming."processCode", incoming."flowType", incoming."joinedAtRaw",
|
||||
incoming."sourceCreatedAtRaw", incoming."sourceUpdatedAtRaw", incoming."sourceFinishedAtRaw",
|
||||
incoming."isOngoing", incoming."schemeFieldCount", incoming."taskCount", incoming."activeTaskCount",
|
||||
incoming."latestTaskStatus", incoming."latestTaskAtRaw", $2::timestamp, $2::timestamp
|
||||
FROM jsonb_to_recordset($1::jsonb) AS incoming(
|
||||
"processInstanceId" text, "businessId" text, "title" text, "result" text,
|
||||
"originatorUserId" text, "originatorDeptId" text, "originatorDeptName" text,
|
||||
"status" text, "bizAction" text, "processCode" text, "flowType" text, "joinedAtRaw" text,
|
||||
"sourceCreatedAtRaw" text, "sourceUpdatedAtRaw" text, "sourceFinishedAtRaw" text,
|
||||
"isOngoing" boolean, "schemeFieldCount" integer, "taskCount" integer, "activeTaskCount" integer,
|
||||
"latestTaskStatus" text, "latestTaskAtRaw" text
|
||||
)
|
||||
ON CONFLICT ("process_instance_id") DO UPDATE SET
|
||||
"business_id" = EXCLUDED."business_id",
|
||||
"title" = EXCLUDED."title",
|
||||
"result" = EXCLUDED."result",
|
||||
"originator_user_id" = EXCLUDED."originator_user_id",
|
||||
"originator_dept_id" = EXCLUDED."originator_dept_id",
|
||||
"originator_dept_name" = EXCLUDED."originator_dept_name",
|
||||
"status" = EXCLUDED."status",
|
||||
"biz_action" = EXCLUDED."biz_action",
|
||||
"process_code" = EXCLUDED."process_code",
|
||||
"flow_type" = EXCLUDED."flow_type",
|
||||
"joined_at_raw" = EXCLUDED."joined_at_raw",
|
||||
"source_created_at_raw" = EXCLUDED."source_created_at_raw",
|
||||
"source_updated_at_raw" = EXCLUDED."source_updated_at_raw",
|
||||
"source_finished_at_raw" = EXCLUDED."source_finished_at_raw",
|
||||
"is_ongoing" = EXCLUDED."is_ongoing",
|
||||
"scheme_field_count" = EXCLUDED."scheme_field_count",
|
||||
"task_count" = EXCLUDED."task_count",
|
||||
"active_task_count" = EXCLUDED."active_task_count",
|
||||
"latest_task_status" = EXCLUDED."latest_task_status",
|
||||
"latest_task_at_raw" = EXCLUDED."latest_task_at_raw",
|
||||
"synced_at" = EXCLUDED."synced_at",
|
||||
"updated_at" = EXCLUDED."updated_at"
|
||||
`, JSON.stringify(rows), syncedAt)
|
||||
}
|
||||
|
||||
async function acquireSyncLease(leaseOwner: string, startedAt: Date) {
|
||||
await prisma.dingWorkflowSyncState.upsert({
|
||||
where: { id: SYNC_STATE_ID },
|
||||
create: { id: SYNC_STATE_ID },
|
||||
update: {},
|
||||
})
|
||||
const claimed = await prisma.dingWorkflowSyncState.updateMany({
|
||||
where: {
|
||||
id: SYNC_STATE_ID,
|
||||
OR: [
|
||||
{ leaseExpiresAt: null },
|
||||
{ leaseExpiresAt: { lt: startedAt } },
|
||||
{ leaseOwner },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
status: 'syncing',
|
||||
lastStartedAt: startedAt,
|
||||
lastError: null,
|
||||
leaseOwner,
|
||||
leaseExpiresAt: new Date(startedAt.getTime() + LEASE_DURATION_MS),
|
||||
},
|
||||
})
|
||||
if (claimed.count !== 1) return null
|
||||
return prisma.dingWorkflowSyncState.findUniqueOrThrow({ where: { id: SYNC_STATE_ID } })
|
||||
}
|
||||
|
||||
export async function syncDingWorkflowSnapshots() {
|
||||
if (!isReportWorkflowDbConfigured()) {
|
||||
throw new Error('未配置上游报表库(REPORT_DB_*),无法同步钉钉流程')
|
||||
}
|
||||
|
||||
const startedAt = new Date()
|
||||
const leaseOwner = `${hostname()}:${process.pid}:${randomUUID()}`
|
||||
const state = await acquireSyncLease(leaseOwner, startedAt)
|
||||
if (!state) return { skipped: true, rowsUpserted: 0, ongoingCount: null }
|
||||
|
||||
const pool = new mssql.ConnectionPool(buildSourceConfig())
|
||||
try {
|
||||
await pool.connect()
|
||||
const sourceRows = await fetchSourceWorkflows(pool, state.sourceCursorRaw)
|
||||
const ongoingIds = sourceRows.flatMap((row) => {
|
||||
const id = clean(row.processInstanceId)
|
||||
return id && isDingWorkflowOngoing(row.status, row.sourceFinishedAtRaw) ? [id] : []
|
||||
})
|
||||
const { taskByWorkflow, schemeByWorkflow } = await fetchOngoingAggregates(pool, ongoingIds)
|
||||
const rows = buildSnapshotPayload(sourceRows, taskByWorkflow, schemeByWorkflow)
|
||||
const syncedAt = new Date()
|
||||
|
||||
for (let index = 0; index < rows.length; index += UPSERT_BATCH_SIZE) {
|
||||
await upsertSnapshotBatch(rows.slice(index, index + UPSERT_BATCH_SIZE), syncedAt)
|
||||
}
|
||||
|
||||
const ongoingCount = await prisma.dingWorkflowSnapshot.count({ where: { isOngoing: true } })
|
||||
const sourceCursorRaw = maxSourceCursor(rows, state.sourceCursorRaw)
|
||||
await prisma.dingWorkflowSyncState.updateMany({
|
||||
where: { id: SYNC_STATE_ID, leaseOwner },
|
||||
data: {
|
||||
status: 'ready',
|
||||
sourceCursorRaw,
|
||||
lastCompletedAt: syncedAt,
|
||||
lastError: null,
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
rowsUpserted: rows.length,
|
||||
currentOngoingCount: ongoingCount,
|
||||
},
|
||||
})
|
||||
return { skipped: false, rowsUpserted: rows.length, ongoingCount }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
await prisma.dingWorkflowSyncState.updateMany({
|
||||
where: { id: SYNC_STATE_ID, leaseOwner },
|
||||
data: {
|
||||
status: 'error',
|
||||
lastError: message.slice(0, 2_000),
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
},
|
||||
}).catch(() => undefined)
|
||||
throw error
|
||||
} finally {
|
||||
await pool.close().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSyncStatus(state: { status: string; lastCompletedAt: Date | null } | null): DingWorkflowSyncStatus {
|
||||
if (!state?.lastCompletedAt) return state?.status === 'syncing' ? 'syncing' : state?.status === 'error' ? 'error' : 'pending'
|
||||
if (state.status === 'syncing') return 'syncing'
|
||||
if (state.status === 'error') return 'error'
|
||||
return 'ready'
|
||||
}
|
||||
|
||||
async function readSummaryWithoutAccessCheck(): Promise<OngoingDingWorkflowSummary> {
|
||||
const [total, state] = await prisma.$transaction([
|
||||
prisma.dingWorkflowSnapshot.count({ where: { isOngoing: true } }),
|
||||
prisma.dingWorkflowSyncState.findUnique({
|
||||
where: { id: SYNC_STATE_ID },
|
||||
select: { status: true, lastCompletedAt: true },
|
||||
}),
|
||||
])
|
||||
return {
|
||||
total,
|
||||
lastSyncedAt: state?.lastCompletedAt?.toISOString() || null,
|
||||
syncStatus: normalizeSyncStatus(state),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOngoingDingWorkflowSummary(session: Session | null) {
|
||||
await assertDashboardAccess(session)
|
||||
return readSummaryWithoutAccessCheck()
|
||||
}
|
||||
|
||||
export async function getOngoingDingWorkflowProjects(
|
||||
session: Session | null,
|
||||
params?: { page?: number; pageSize?: number; query?: string },
|
||||
): Promise<OngoingDingWorkflowPage> {
|
||||
await assertDashboardAccess(session)
|
||||
const page = Math.max(1, Number(params?.page || 1) || 1)
|
||||
const pageSize = Math.min(50, Math.max(1, Number(params?.pageSize || 20) || 20))
|
||||
const query = clean(params?.query)
|
||||
const where = {
|
||||
isOngoing: true,
|
||||
...(query ? {
|
||||
OR: [
|
||||
{ title: { contains: query, mode: 'insensitive' as const } },
|
||||
{ businessId: { contains: query, mode: 'insensitive' as const } },
|
||||
{ processInstanceId: { contains: query, mode: 'insensitive' as const } },
|
||||
{ originatorDeptName: { contains: query, mode: 'insensitive' as const } },
|
||||
{ originatorUserId: { contains: query, mode: 'insensitive' as const } },
|
||||
],
|
||||
} : {}),
|
||||
}
|
||||
|
||||
const [total, rows, sync] = await Promise.all([
|
||||
prisma.dingWorkflowSnapshot.count({ where }),
|
||||
prisma.dingWorkflowSnapshot.findMany({
|
||||
where,
|
||||
select: {
|
||||
processInstanceId: true,
|
||||
businessId: true,
|
||||
title: true,
|
||||
status: true,
|
||||
originatorUserId: true,
|
||||
originatorDeptName: true,
|
||||
sourceCreatedAtRaw: true,
|
||||
sourceUpdatedAtRaw: true,
|
||||
activeTaskCount: true,
|
||||
taskCount: true,
|
||||
schemeFieldCount: true,
|
||||
latestTaskStatus: true,
|
||||
},
|
||||
orderBy: [
|
||||
{ sourceUpdatedAtRaw: { sort: 'desc', nulls: 'last' } },
|
||||
{ processInstanceId: 'desc' },
|
||||
],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
readSummaryWithoutAccessCheck(),
|
||||
])
|
||||
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
id: row.processInstanceId,
|
||||
businessId: row.businessId,
|
||||
title: row.title || '未命名流程',
|
||||
status: row.status,
|
||||
originatorUserId: row.originatorUserId,
|
||||
originatorDeptName: row.originatorDeptName,
|
||||
sourceCreatedAt: sourceWorkflowTimestampToIso(row.sourceCreatedAtRaw),
|
||||
sourceUpdatedAt: sourceWorkflowTimestampToIso(row.sourceUpdatedAtRaw),
|
||||
activeTaskCount: row.activeTaskCount,
|
||||
taskCount: row.taskCount,
|
||||
schemeFieldCount: row.schemeFieldCount,
|
||||
latestTaskStatus: row.latestTaskStatus,
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
},
|
||||
sync,
|
||||
}
|
||||
}
|
||||
|
||||
type DingWorkflowSchedulerGlobal = typeof globalThis & {
|
||||
__dingWorkflowHourlySyncStarted?: boolean
|
||||
}
|
||||
|
||||
export function ensureHourlyDingWorkflowSyncStarted() {
|
||||
const schedulerGlobal = globalThis as DingWorkflowSchedulerGlobal
|
||||
if (schedulerGlobal.__dingWorkflowHourlySyncStarted) return
|
||||
schedulerGlobal.__dingWorkflowHourlySyncStarted = true
|
||||
|
||||
if (!isReportWorkflowDbConfigured()) {
|
||||
console.info('[ding-workflow-sync] 未配置上游报表库,跳过每小时同步')
|
||||
return
|
||||
}
|
||||
|
||||
const configuredHours = Number(process.env.DING_WORKFLOW_SYNC_INTERVAL_HOURS || 1)
|
||||
const intervalMs = Number.isFinite(configuredHours) && configuredHours > 0
|
||||
? Math.max(5 * 60 * 1000, configuredHours * 60 * 60 * 1000)
|
||||
: DEFAULT_SYNC_INTERVAL_MS
|
||||
const runOnce = () => {
|
||||
void syncDingWorkflowSnapshots()
|
||||
.then((result) => {
|
||||
if (result.skipped) {
|
||||
console.info('[ding-workflow-sync] 另一实例正在同步,本轮跳过')
|
||||
return
|
||||
}
|
||||
console.info(`[ding-workflow-sync] 同步完成:更新 ${result.rowsUpserted} 条,进行中 ${result.ongoingCount} 条`)
|
||||
})
|
||||
.catch((error) => console.warn('[ding-workflow-sync] 同步失败:', error))
|
||||
}
|
||||
|
||||
const initialTimer = setTimeout(runOnce, INITIAL_SYNC_DELAY_MS)
|
||||
initialTimer.unref?.()
|
||||
const interval = setInterval(runOnce, intervalMs)
|
||||
interval.unref?.()
|
||||
console.info(`[ding-workflow-sync] 已调度每小时增量同步,周期 ${Math.round(intervalMs / 60_000)} 分钟`)
|
||||
}
|
||||
@ -94,6 +94,11 @@ const OPTIONAL_ENV_VARS = [
|
||||
'REPORT_DB_REGISTER_TABLE',
|
||||
'REPORT_DB_FLOWER_NAME_SCHEMA',
|
||||
'REPORT_DB_FLOWER_NAME_TABLE',
|
||||
'REPORT_DB_WORKFLOW_SCHEMA',
|
||||
'REPORT_DB_WORKFLOW_TABLE',
|
||||
'REPORT_DB_WORKFLOW_SCHEME_TABLE',
|
||||
'REPORT_DB_WORKFLOW_TASK_TABLE',
|
||||
'DING_WORKFLOW_SYNC_INTERVAL_HOURS',
|
||||
'PERFORMANCE_SURVEY_PROFILE_SYNC_HOURS',
|
||||
'PERFORMANCE_SURVEY_DAILY_SYNC_INTERVAL_HOURS',
|
||||
'PERFORMANCE_SURVEY_DAILY_SYNC_INITIAL_DELAY_SECONDS',
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
CREATE TABLE "ding_workflow_snapshots" (
|
||||
"process_instance_id" TEXT NOT NULL,
|
||||
"business_id" TEXT,
|
||||
"title" TEXT,
|
||||
"result" TEXT,
|
||||
"originator_user_id" TEXT,
|
||||
"originator_dept_id" TEXT,
|
||||
"originator_dept_name" TEXT,
|
||||
"status" TEXT NOT NULL,
|
||||
"biz_action" TEXT,
|
||||
"process_code" TEXT,
|
||||
"flow_type" TEXT,
|
||||
"joined_at_raw" TEXT,
|
||||
"source_created_at_raw" TEXT,
|
||||
"source_updated_at_raw" TEXT,
|
||||
"source_finished_at_raw" TEXT,
|
||||
"is_ongoing" BOOLEAN NOT NULL DEFAULT false,
|
||||
"scheme_field_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"task_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"active_task_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"latest_task_status" TEXT,
|
||||
"latest_task_at_raw" TEXT,
|
||||
"synced_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ding_workflow_snapshots_pkey" PRIMARY KEY ("process_instance_id")
|
||||
);
|
||||
|
||||
CREATE TABLE "ding_workflow_sync_states" (
|
||||
"id" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'idle',
|
||||
"source_cursor_raw" TEXT,
|
||||
"last_started_at" TIMESTAMP(3),
|
||||
"last_completed_at" TIMESTAMP(3),
|
||||
"last_error" TEXT,
|
||||
"lease_owner" TEXT,
|
||||
"lease_expires_at" TIMESTAMP(3),
|
||||
"rows_upserted" INTEGER NOT NULL DEFAULT 0,
|
||||
"current_ongoing_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ding_workflow_sync_states_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "ding_workflow_snapshots_ongoing_updated_idx"
|
||||
ON "ding_workflow_snapshots"("is_ongoing", "source_updated_at_raw");
|
||||
|
||||
CREATE INDEX "ding_workflow_snapshots_status_idx"
|
||||
ON "ding_workflow_snapshots"("status");
|
||||
|
||||
CREATE INDEX "ding_workflow_snapshots_department_idx"
|
||||
ON "ding_workflow_snapshots"("originator_dept_name");
|
||||
|
||||
CREATE INDEX "ding_workflow_sync_states_lease_idx"
|
||||
ON "ding_workflow_sync_states"("lease_expires_at");
|
||||
@ -1277,6 +1277,63 @@ model WorkReviewNotification {
|
||||
@@map("work_review_notifications")
|
||||
}
|
||||
|
||||
|
||||
/// 钉钉审批流程的本地只读快照。
|
||||
///
|
||||
/// 上游 rpt.ding_Workflow / ding_WorkflowTask / ding_WorkflowScheme 均为无索引大表,
|
||||
/// 部门看板不得在用户请求链路中直连扫描。后台任务首次建立全量基线,之后每小时
|
||||
/// 增量同步主流程,并为全部未完结流程刷新任务与表单字段汇总。
|
||||
model DingWorkflowSnapshot {
|
||||
processInstanceId String @id @map("process_instance_id")
|
||||
businessId String? @map("business_id")
|
||||
title String?
|
||||
result String?
|
||||
originatorUserId String? @map("originator_user_id")
|
||||
originatorDeptId String? @map("originator_dept_id")
|
||||
originatorDeptName String? @map("originator_dept_name")
|
||||
status String
|
||||
bizAction String? @map("biz_action")
|
||||
processCode String? @map("process_code")
|
||||
flowType String? @map("flow_type")
|
||||
joinedAtRaw String? @map("joined_at_raw")
|
||||
sourceCreatedAtRaw String? @map("source_created_at_raw")
|
||||
sourceUpdatedAtRaw String? @map("source_updated_at_raw")
|
||||
sourceFinishedAtRaw String? @map("source_finished_at_raw")
|
||||
isOngoing Boolean @default(false) @map("is_ongoing")
|
||||
schemeFieldCount Int @default(0) @map("scheme_field_count")
|
||||
taskCount Int @default(0) @map("task_count")
|
||||
activeTaskCount Int @default(0) @map("active_task_count")
|
||||
latestTaskStatus String? @map("latest_task_status")
|
||||
latestTaskAtRaw String? @map("latest_task_at_raw")
|
||||
syncedAt DateTime @map("synced_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([isOngoing, sourceUpdatedAtRaw], map: "ding_workflow_snapshots_ongoing_updated_idx")
|
||||
@@index([status], map: "ding_workflow_snapshots_status_idx")
|
||||
@@index([originatorDeptName], map: "ding_workflow_snapshots_department_idx")
|
||||
@@map("ding_workflow_snapshots")
|
||||
}
|
||||
|
||||
/// 钉钉审批流程同步游标、租约与可观测状态。
|
||||
model DingWorkflowSyncState {
|
||||
id String @id
|
||||
status String @default("idle")
|
||||
sourceCursorRaw String? @map("source_cursor_raw")
|
||||
lastStartedAt DateTime? @map("last_started_at")
|
||||
lastCompletedAt DateTime? @map("last_completed_at")
|
||||
lastError String? @map("last_error")
|
||||
leaseOwner String? @map("lease_owner")
|
||||
leaseExpiresAt DateTime? @map("lease_expires_at")
|
||||
rowsUpserted Int @default(0) @map("rows_upserted")
|
||||
currentOngoingCount Int @default(0) @map("current_ongoing_count")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([leaseExpiresAt], map: "ding_workflow_sync_states_lease_idx")
|
||||
@@map("ding_workflow_sync_states")
|
||||
}
|
||||
|
||||
model VideoTranscodeProgress {
|
||||
id String @id @default(uuid())
|
||||
materialId String @map("material_id")
|
||||
|
||||
@ -1498,6 +1498,7 @@ const expectedMigrationNames = new Set([
|
||||
'20260813_add_material_custody_department',
|
||||
'20260813_add_organization_units_and_employment_state',
|
||||
'20260813_add_user_employee_binding',
|
||||
'20260820_add_ding_workflow_snapshots',
|
||||
]);
|
||||
if (migrations.length !== expectedMigrationNames.size
|
||||
|| migrations.some((item) => !expectedMigrationNames.has(item && item.name)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { createReadStream, realpathSync } from 'node:fs'
|
||||
import { lstat, mkdir, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@ -16,6 +16,7 @@ const TARGET_MIGRATIONS = Object.freeze([
|
||||
'20260813_add_material_custody_department',
|
||||
'20260813_add_organization_units_and_employment_state',
|
||||
'20260813_add_user_employee_binding',
|
||||
'20260820_add_ding_workflow_snapshots',
|
||||
])
|
||||
const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs'
|
||||
const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT'
|
||||
@ -256,7 +257,9 @@ async function queryEvidence(prisma, localMigrations) {
|
||||
OR (table_name = 'system_settings' AND column_name IN ('headerLogoUrl', 'loginLogoUrl', 'logoUrl'))
|
||||
OR (table_name = 'organization_units' AND column_name IN ('id', 'source_dept_id', 'parent_id', 'path', 'level', 'is_active'))
|
||||
OR (table_name = 'performance_survey_employee_profiles' AND column_name IN ('employment_state', 'resigned_at', 'handover_dingtalk_user_id'))
|
||||
OR (table_name = 'user_employee_bindings' AND column_name IN ('id', 'user_id', 'employee_profile_id', 'binding_source', 'status')))
|
||||
OR (table_name = 'user_employee_bindings' AND column_name IN ('id', 'user_id', 'employee_profile_id', 'binding_source', 'status'))
|
||||
OR (table_name = 'ding_workflow_snapshots' AND column_name IN ('process_instance_id', 'status', 'is_ongoing', 'source_updated_at_raw', 'scheme_field_count', 'task_count', 'active_task_count', 'synced_at'))
|
||||
OR (table_name = 'ding_workflow_sync_states' AND column_name IN ('id', 'status', 'source_cursor_raw', 'last_completed_at', 'lease_owner', 'lease_expires_at', 'current_ongoing_count')))
|
||||
ORDER BY table_name, column_name`,
|
||||
)
|
||||
const indexes = await prisma.$queryRawUnsafe(
|
||||
@ -283,7 +286,9 @@ async function queryEvidence(prisma, localMigrations) {
|
||||
AND idx.relname IN (
|
||||
'materials_transcode_lease_idx',
|
||||
'materials_uploadId_key',
|
||||
'product_package_upload_jobs_lease_idx'
|
||||
'product_package_upload_jobs_lease_idx',
|
||||
'ding_workflow_snapshots_ongoing_updated_idx',
|
||||
'ding_workflow_sync_states_lease_idx'
|
||||
)
|
||||
ORDER BY idx.relname`,
|
||||
)
|
||||
@ -349,6 +354,21 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) {
|
||||
requireColumn('user_employee_bindings', 'employee_profile_id', 'text', 'NO')
|
||||
requireColumn('user_employee_bindings', 'binding_source', 'text', 'NO')
|
||||
requireColumn('user_employee_bindings', 'status', 'text', 'NO')
|
||||
requireColumn('ding_workflow_snapshots', 'process_instance_id', 'text', 'NO')
|
||||
requireColumn('ding_workflow_snapshots', 'status', 'text', 'NO')
|
||||
requireColumn('ding_workflow_snapshots', 'is_ongoing', 'boolean', 'NO')
|
||||
requireColumn('ding_workflow_snapshots', 'source_updated_at_raw', 'text', 'YES')
|
||||
requireColumn('ding_workflow_snapshots', 'scheme_field_count', 'integer', 'NO')
|
||||
requireColumn('ding_workflow_snapshots', 'task_count', 'integer', 'NO')
|
||||
requireColumn('ding_workflow_snapshots', 'active_task_count', 'integer', 'NO')
|
||||
requireColumn('ding_workflow_snapshots', 'synced_at', 'timestamp without time zone', 'NO')
|
||||
requireColumn('ding_workflow_sync_states', 'id', 'text', 'NO')
|
||||
requireColumn('ding_workflow_sync_states', 'status', 'text', 'NO')
|
||||
requireColumn('ding_workflow_sync_states', 'source_cursor_raw', 'text', 'YES')
|
||||
requireColumn('ding_workflow_sync_states', 'last_completed_at', 'timestamp without time zone', 'YES')
|
||||
requireColumn('ding_workflow_sync_states', 'lease_owner', 'text', 'YES')
|
||||
requireColumn('ding_workflow_sync_states', 'lease_expires_at', 'timestamp without time zone', 'YES')
|
||||
requireColumn('ding_workflow_sync_states', 'current_ongoing_count', 'integer', 'NO')
|
||||
requireColumn('system_settings', 'headerLogoUrl', 'text', 'NO')
|
||||
requireColumn('system_settings', 'loginLogoUrl', 'text', 'NO')
|
||||
|
||||
@ -382,6 +402,16 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) {
|
||||
assertExactActiveUploadIdIndex(
|
||||
evidence.indexes.find((row) => String(row.indexname) === 'materials_uploadId_key'),
|
||||
)
|
||||
assertExactBtreeIndex(
|
||||
indexByName.get('ding_workflow_snapshots_ongoing_updated_idx'),
|
||||
'ding_workflow_snapshots',
|
||||
['is_ongoing', 'source_updated_at_raw'],
|
||||
)
|
||||
assertExactBtreeIndex(
|
||||
indexByName.get('ding_workflow_sync_states_lease_idx'),
|
||||
'ding_workflow_sync_states',
|
||||
['lease_expires_at'],
|
||||
)
|
||||
|
||||
const isTrustedBrandValue = (value, localDefault) => {
|
||||
const normalized = String(value || '').trim()
|
||||
@ -756,7 +786,10 @@ async function main() {
|
||||
else process.stdout.write('Read-only production migration audit completed.\n')
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
if (
|
||||
process.argv[1]
|
||||
&& realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
|
||||
) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`[SAFE_MIGRATION_ERROR] ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
process.exitCode = 1
|
||||
|
||||
@ -35,18 +35,22 @@ test('entrypoint validates only user arguments and still rejects unknown flags',
|
||||
cpSync(new URL('./safe-production-migrate.mjs', import.meta.url), path.join(scripts, 'safe-production-migrate.mjs'))
|
||||
const commit = 'f'.repeat(40)
|
||||
writeFileSync(path.join(fixture, 'DEPLOYMENT_COMMIT'), `${commit}\n`)
|
||||
// Node 24 会把内建 test runner 的子进程标记继续传给 spawn;迁移入口必须按普通脚本执行。
|
||||
const childEnv = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([name]) => !name.startsWith('NODE_TEST_') && name !== 'NODE_V8_COVERAGE'),
|
||||
)
|
||||
const run = (args) => spawnSync(
|
||||
process.execPath,
|
||||
[path.join(scripts, 'safe-production-migrate.mjs'), ...args],
|
||||
{
|
||||
cwd: fixture,
|
||||
env: { ...process.env, EXPECTED_COMMIT_SHA: commit, DATABASE_URL: '' },
|
||||
env: { ...childEnv, EXPECTED_COMMIT_SHA: commit, DATABASE_URL: '' },
|
||||
encoding: 'utf8',
|
||||
},
|
||||
)
|
||||
for (const args of [['--audit'], ['--apply']]) {
|
||||
const result = run(args)
|
||||
assert.notEqual(result.status, 0)
|
||||
assert.notEqual(result.status, 0, `stdout=${result.stdout}\nstderr=${result.stderr}`)
|
||||
assert.match(result.stderr, /DATABASE_URL is required/)
|
||||
assert.doesNotMatch(result.stderr, /Only --audit/)
|
||||
}
|
||||
@ -328,6 +332,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
|
||||
['20260813_add_material_custody_department', { checksum: 'e' }],
|
||||
['20260813_add_organization_units_and_employment_state', { checksum: 'f' }],
|
||||
['20260813_add_user_employee_binding', { checksum: 'g' }],
|
||||
['20260820_add_ding_workflow_snapshots', { checksum: 'h' }],
|
||||
])
|
||||
const evidence = {
|
||||
migrationRows: [
|
||||
@ -338,6 +343,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
|
||||
{ migration_name: '20260813_add_material_custody_department', checksum: 'e', finished_at: 'now', rolled_back_at: null },
|
||||
{ migration_name: '20260813_add_organization_units_and_employment_state', checksum: 'f', finished_at: 'now', rolled_back_at: null },
|
||||
{ migration_name: '20260813_add_user_employee_binding', checksum: 'g', finished_at: 'now', rolled_back_at: null },
|
||||
{ migration_name: '20260820_add_ding_workflow_snapshots', checksum: 'h', finished_at: 'now', rolled_back_at: null },
|
||||
],
|
||||
columns: [
|
||||
{ table_name: 'materials', column_name: 'transcode_lease_token', data_type: 'text', is_nullable: 'YES' },
|
||||
@ -363,6 +369,21 @@ test('target evidence rejects nullable logo columns and structurally wrong index
|
||||
{ table_name: 'user_employee_bindings', column_name: 'employee_profile_id', data_type: 'text', is_nullable: 'NO' },
|
||||
{ table_name: 'user_employee_bindings', column_name: 'binding_source', data_type: 'text', is_nullable: 'NO' },
|
||||
{ table_name: 'user_employee_bindings', column_name: 'status', data_type: 'text', is_nullable: 'NO' },
|
||||
{ table_name: 'ding_workflow_snapshots', column_name: 'process_instance_id', data_type: 'text', is_nullable: 'NO' },
|
||||
{ table_name: 'ding_workflow_snapshots', column_name: 'status', data_type: 'text', is_nullable: 'NO' },
|
||||
{ table_name: 'ding_workflow_snapshots', column_name: 'is_ongoing', data_type: 'boolean', is_nullable: 'NO' },
|
||||
{ table_name: 'ding_workflow_snapshots', column_name: 'source_updated_at_raw', data_type: 'text', is_nullable: 'YES' },
|
||||
{ table_name: 'ding_workflow_snapshots', column_name: 'scheme_field_count', data_type: 'integer', is_nullable: 'NO' },
|
||||
{ table_name: 'ding_workflow_snapshots', column_name: 'task_count', data_type: 'integer', is_nullable: 'NO' },
|
||||
{ table_name: 'ding_workflow_snapshots', column_name: 'active_task_count', data_type: 'integer', is_nullable: 'NO' },
|
||||
{ table_name: 'ding_workflow_snapshots', column_name: 'synced_at', data_type: 'timestamp without time zone', is_nullable: 'NO' },
|
||||
{ table_name: 'ding_workflow_sync_states', column_name: 'id', data_type: 'text', is_nullable: 'NO' },
|
||||
{ table_name: 'ding_workflow_sync_states', column_name: 'status', data_type: 'text', is_nullable: 'NO' },
|
||||
{ table_name: 'ding_workflow_sync_states', column_name: 'source_cursor_raw', data_type: 'text', is_nullable: 'YES' },
|
||||
{ table_name: 'ding_workflow_sync_states', column_name: 'last_completed_at', data_type: 'timestamp without time zone', is_nullable: 'YES' },
|
||||
{ table_name: 'ding_workflow_sync_states', column_name: 'lease_owner', data_type: 'text', is_nullable: 'YES' },
|
||||
{ table_name: 'ding_workflow_sync_states', column_name: 'lease_expires_at', data_type: 'timestamp without time zone', is_nullable: 'YES' },
|
||||
{ table_name: 'ding_workflow_sync_states', column_name: 'current_ongoing_count', data_type: 'integer', is_nullable: 'NO' },
|
||||
{ table_name: 'system_settings', column_name: 'headerLogoUrl', data_type: 'text', is_nullable: 'NO', column_default: "'/brand/header-logo.png'::text" },
|
||||
{ table_name: 'system_settings', column_name: 'loginLogoUrl', data_type: 'text', is_nullable: 'NO', column_default: "'/brand/login-logo.png'::text" },
|
||||
{ table_name: 'system_settings', column_name: 'logoUrl', data_type: 'text', is_nullable: 'NO', column_default: "'/brand/header-logo.png'::text" },
|
||||
@ -382,6 +403,8 @@ test('target evidence rejects nullable logo columns and structurally wrong index
|
||||
predicate: '(("uploadId" IS NOT NULL) AND ("deletedAt" IS NULL))',
|
||||
},
|
||||
{ indexname: 'product_package_upload_jobs_lease_idx', indexdef: 'CREATE INDEX product_package_upload_jobs_lease_idx ON public.product_package_upload_jobs USING btree (status, lease_expires_at)' },
|
||||
{ indexname: 'ding_workflow_snapshots_ongoing_updated_idx', indexdef: 'CREATE INDEX ding_workflow_snapshots_ongoing_updated_idx ON public.ding_workflow_snapshots USING btree (is_ongoing, source_updated_at_raw)' },
|
||||
{ indexname: 'ding_workflow_sync_states_lease_idx', indexdef: 'CREATE INDEX ding_workflow_sync_states_lease_idx ON public.ding_workflow_sync_states USING btree (lease_expires_at)' },
|
||||
],
|
||||
brandRows: [
|
||||
{ id: 'local', headerLogoUrl: '/brand/header-logo.png', loginLogoUrl: '/brand/login-logo.png' },
|
||||
@ -486,6 +509,7 @@ test('container supply chain and migrator image are immutable while SOP keeps co
|
||||
'20260813_add_material_custody_department',
|
||||
'20260813_add_organization_units_and_employment_state',
|
||||
'20260813_add_user_employee_binding',
|
||||
'20260820_add_ding_workflow_snapshots',
|
||||
]) {
|
||||
assert.match(dockerfile, new RegExp(migrationName))
|
||||
assert.match(migratorSource, new RegExp(`'${migrationName}'`))
|
||||
|
||||
10
types/mssql.d.ts
vendored
10
types/mssql.d.ts
vendored
@ -1,22 +1,28 @@
|
||||
declare module 'mssql' {
|
||||
export interface Request {
|
||||
multiple: boolean
|
||||
input(name: string, type: unknown, value: unknown): Request
|
||||
query<T = any>(sql: string): Promise<{ recordset: T[] }>
|
||||
query<T = any>(sql: string): Promise<{ recordset: T[]; recordsets?: any[][] }>
|
||||
}
|
||||
|
||||
export interface ConnectionPool {
|
||||
export class ConnectionPool {
|
||||
constructor(config: unknown)
|
||||
connect(): Promise<ConnectionPool>
|
||||
request(): Request
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
export const Int: unknown
|
||||
export const MAX: number
|
||||
export function NVarChar(length?: number): unknown
|
||||
export function connect(config: unknown): Promise<ConnectionPool>
|
||||
|
||||
const sql: {
|
||||
Int: typeof Int
|
||||
MAX: typeof MAX
|
||||
NVarChar: typeof NVarChar
|
||||
connect: typeof connect
|
||||
ConnectionPool: typeof ConnectionPool
|
||||
}
|
||||
|
||||
export default sql
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user