fix: 协议子资源单事务保存,并补齐样本编码/OA/质检等近期能力

锁定缴费校验失败时整单回滚,避免样本半成功落库;前端禁止删除锁定缴费样本,改码走更正编码。同步落地样本更正、采集微信通知、OA提及未读、细胞产品套餐与相关迁移文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-26 11:02:25 +08:00
1 parent 19bc66ba45
commit 5dad1bb0e3
142 files changed
+95584 -65717

No files matched your search

@@ -0,0 +1,33 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
DEFAULT_DONATION_PROTOCOL_STATUS_FILTER,
DONATION_PROTOCOL_STATUS_FILTER_OPTIONS,
joinProtocolStatusFilter,
matchesProtocolStatusFilter
} from './donation-protocol-status-filter'
describe('donation protocol status filter helpers', () => {
it('default filter is 待生效 + 生效中', () => {
assert.deepEqual(DEFAULT_DONATION_PROTOCOL_STATUS_FILTER, ['dsx', 'sxz'])
})
it('options cover donation statuses including void', () => {
assert.equal(DONATION_PROTOCOL_STATUS_FILTER_OPTIONS.length, 6)
assert.ok(DONATION_PROTOCOL_STATUS_FILTER_OPTIONS.some((o) => o.value === 'yzf'))
assert.ok(DONATION_PROTOCOL_STATUS_FILTER_OPTIONS.some((o) => o.value === 'sdfq'))
})
it('joinProtocolStatusFilter: empty → empty string', () => {
assert.equal(joinProtocolStatusFilter([]), '')
assert.equal(joinProtocolStatusFilter(['dsx', 'sxz']), 'dsx,sxz')
})
it('matchesProtocolStatusFilter semantics', () => {
assert.equal(matchesProtocolStatusFilter('yzf', undefined), true)
assert.equal(matchesProtocolStatusFilter('yzf', []), false)
assert.equal(matchesProtocolStatusFilter('sxz', ['dsx', 'sxz']), true)
assert.equal(matchesProtocolStatusFilter('yzf', ['dsx', 'sxz']), false)
assert.equal(matchesProtocolStatusFilter('', ['dsx']), false)
})
})
@@ -0,0 +1,39 @@
/**
* 科研捐赠质检 · 协议状态筛选(与 donationStatus / INACTIVE_PROTOCOL_STATUSES 口径一致)。
* 独立模块,便于 node:test 无依赖 API/静态资源。
*/
export const DONATION_PROTOCOL_STATUS_FILTER_OPTIONS = [
{ value: 'dsx', label: '待生效' },
{ value: 'sxz', label: '生效中' },
{ value: 'ygq', label: '已过期' },
{ value: 'yzf', label: '已作废' },
{ value: 'sdfq', label: '属地废弃' },
{ value: 'zckc', label: '转细胞存储' }
] as const
/** 默认勾选:待生效、生效中 */
export const DEFAULT_DONATION_PROTOCOL_STATUS_FILTER = ['dsx', 'sxz']
/**
* 拼协议状态查询参数。
* - 有选中:逗号串
* - 空数组:返回 ''(显式空;勿当 undefined)
*/
export function joinProtocolStatusFilter(statuses: string[]): string {
return statuses.map((s) => s.trim()).filter(Boolean).join(',')
}
/**
* 协议状态是否命中筛选。
* - filter === undefined:不限制
* - filter 为空数组:无匹配
*/
export function matchesProtocolStatusFilter(
status: string,
filter: string[] | undefined
): boolean {
if (filter === undefined) return true
if (!filter.length) return false
return filter.includes((status || '').trim())
}
@@ -0,0 +1,60 @@
import { describe, it, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { register } from 'node:module'
import { pathToFileURL } from 'node:url'
// 隔离 refresh* 的 Pinia/HTTP/静态资源导入链,避免 node --import tsx --test 加载失败。
const stubUrl =
'data:text/javascript,' +
[
'export async function refreshOaMenuBadges() {}',
'export async function refreshQcMenuBadges() {}',
'export async function refreshProductionMenuBadges() {}'
].join('\n')
const hookSource = `
const stubUrl = ${JSON.stringify(stubUrl)}
const names = ['oa-menu-badge', 'qc-menu-badge', 'production-menu-badge']
export async function resolve(specifier, context, nextResolve) {
const base = String(specifier).split('?')[0]
if (names.some((name) => base === './' + name || base.endsWith('/' + name))) {
return { shortCircuit: true, url: stubUrl }
}
return nextResolve(specifier, context)
}
`
register('data:text/javascript,' + encodeURIComponent(hookSource), pathToFileURL('./'))
const {
startMenuBadgePolling,
stopMenuBadgePolling,
isMenuBadgePollingActive,
MENU_BADGE_POLL_INTERVAL_MS
} = await import('./menu-badge-polling')
describe('menu-badge-polling', () => {
beforeEach(() => {
stopMenuBadgePolling()
})
afterEach(() => {
stopMenuBadgePolling()
})
it('exports 60s interval', () => {
assert.equal(MENU_BADGE_POLL_INTERVAL_MS, 60_000)
})
it('start is idempotent (single active flag)', () => {
startMenuBadgePolling()
startMenuBadgePolling()
assert.equal(isMenuBadgePollingActive(), true)
stopMenuBadgePolling()
assert.equal(isMenuBadgePollingActive(), false)
})
it('stop is safe when not started', () => {
stopMenuBadgePolling()
assert.equal(isMenuBadgePollingActive(), false)
})
})
+83
View File
@@ -0,0 +1,83 @@
import { refreshOaMenuBadges } from './oa-menu-badge'
import { refreshQcMenuBadges } from './qc-menu-badge'
import { refreshProductionMenuBadges } from './production-menu-badge'
export const MENU_BADGE_POLL_INTERVAL_MS = 60_000
let timerId: ReturnType<typeof setInterval> | null = null
let visibilityBound = false
let inFlight = false
let started = false
export function isMenuBadgePollingActive(): boolean {
return started
}
export async function refreshAllMenuBadges(): Promise<void> {
if (inFlight) return
inFlight = true
try {
await Promise.allSettled([
refreshOaMenuBadges(),
refreshQcMenuBadges(),
refreshProductionMenuBadges()
])
} finally {
inFlight = false
}
}
function onVisibilityChange(): void {
if (typeof document === 'undefined') return
if (document.hidden) {
if (timerId != null) {
clearInterval(timerId)
timerId = null
}
return
}
// 恢复可见:立即补刷,再重启 interval
void refreshAllMenuBadges()
if (started && timerId == null) {
timerId = setInterval(() => {
void refreshAllMenuBadges()
}, MENU_BADGE_POLL_INTERVAL_MS)
}
}
function bindVisibility(): void {
if (visibilityBound || typeof document === 'undefined') return
document.addEventListener('visibilitychange', onVisibilityChange)
visibilityBound = true
}
function unbindVisibility(): void {
if (!visibilityBound || typeof document === 'undefined') return
document.removeEventListener('visibilitychange', onVisibilityChange)
visibilityBound = false
}
/** 幂等:已启动则忽略。启动时立即刷一轮,再按 60s 轮询。 */
export function startMenuBadgePolling(): void {
if (started) return
started = true
bindVisibility()
void refreshAllMenuBadges()
if (typeof document !== 'undefined' && document.hidden) {
// 隐藏态不启 timer,等可见时 onVisibilityChange 重启
return
}
timerId = setInterval(() => {
void refreshAllMenuBadges()
}, MENU_BADGE_POLL_INTERVAL_MS)
}
export function stopMenuBadgePolling(): void {
started = false
if (timerId != null) {
clearInterval(timerId)
timerId = null
}
unbindVisibility()
inFlight = false
}
+17 -1
View File
@@ -1,18 +1,25 @@
import {
fetchOaCcUnreadCount,
fetchOaInstanceDraftCount,
fetchOaMentionUnreadCount,
fetchOaPendingTaskCount
} from '@/api/oaApproval'
import {
OA_MENU_CODE_CC,
OA_MENU_CODE_DRAFTS,
OA_MENU_CODE_MENTION,
OA_MENU_CODE_PENDING,
useOaMenuBadgeStore
} from '@/store/modules/oa-menu-badge'
import { useMenuStore } from '@/store/modules/menu'
import type { AppRouteRecord } from '@/types/router'
const OA_BADGE_CODES = new Set([OA_MENU_CODE_CC, OA_MENU_CODE_DRAFTS, OA_MENU_CODE_PENDING])
const OA_BADGE_CODES = new Set([
OA_MENU_CODE_CC,
OA_MENU_CODE_DRAFTS,
OA_MENU_CODE_PENDING,
OA_MENU_CODE_MENTION
])
export function resolveOaMenuBadgeCode(item: AppRouteRecord): string | undefined {
const code = (item.meta.menuCode || '').trim()
@@ -21,12 +28,14 @@ export function resolveOaMenuBadgeCode(item: AppRouteRecord): string | undefined
if (name === 'OaApprovalCc') return OA_MENU_CODE_CC
if (name === 'OaApprovalDrafts') return OA_MENU_CODE_DRAFTS
if (name === 'OaApprovalPending') return OA_MENU_CODE_PENDING
if (name === 'OaApprovalMention') return OA_MENU_CODE_MENTION
// path 兜底仅限 oa: 菜单,避免命中旧工作流 pending
if (!code.startsWith('oa:')) return undefined
const path = String(item.path || '')
if (path.endsWith('/cc') || path === 'cc') return OA_MENU_CODE_CC
if (path.endsWith('/drafts') || path === 'drafts') return OA_MENU_CODE_DRAFTS
if (path.endsWith('/pending') || path === 'pending') return OA_MENU_CODE_PENDING
if (path.endsWith('/mentions') || path === 'mentions') return OA_MENU_CODE_MENTION
return undefined
}
@@ -72,6 +81,13 @@ export async function refreshOaMenuBadges() {
.catch(() => badgeStore.setBadge(OA_MENU_CODE_PENDING, 0))
)
}
if (visibleCodes.includes(OA_MENU_CODE_MENTION)) {
jobs.push(
fetchOaMentionUnreadCount()
.then((data) => badgeStore.setBadge(OA_MENU_CODE_MENTION, data?.count ?? 0))
.catch(() => badgeStore.setBadge(OA_MENU_CODE_MENTION, 0))
)
}
await Promise.all(jobs)
}
@@ -16,6 +16,8 @@ import {
applyExpandRemainingToInstallment,
availableSamplePayModes,
samplePaymentHasConfirmedTransfer,
isSamplePaymentTransferLocked,
samplePaymentHasLockedTransfer,
calcLastPeriodDueAmount,
shouldAutoFillLastPeriodDue,
applyLumpSumDeadlineToLastDueDate
@@ -231,6 +233,30 @@ describe('availableSamplePayModes', () => {
})
})
describe('samplePaymentHasLockedTransfer', () => {
it('locks jfqrz / yjf / tfqrz / ytf like backend', () => {
for (const payStatus of ['jfqrz', 'yjf', 'tfqrz', 'ytf']) {
assert.equal(isSamplePaymentTransferLocked(payStatus), true)
assert.equal(
samplePaymentHasLockedTransfer({
schedules: [{ transfers: [{ payStatus }] }]
}),
true
)
}
})
it('dfj / empty are not locked', () => {
assert.equal(isSamplePaymentTransferLocked('dfj'), false)
assert.equal(isSamplePaymentTransferLocked(''), false)
assert.equal(
samplePaymentHasLockedTransfer({
schedules: [{ transfers: [{ payStatus: 'dfj' }] }]
}),
false
)
})
})
describe('applyConsolidateRemainingToPayment', () => {
it('fq→dj keeps paid and consolidates remaining', () => {
const next = applyConsolidateRemainingToPayment(
@@ -271,6 +271,24 @@ export function samplePaymentHasConfirmedTransfer(pay: {
)
}
/**
* 与后端 `_locked_schedule_ids` 对齐:转账状态不是待缴费 `dfj` 即锁定
*(含 jfqrz / yjf / tfqrz / ytf 等)。
*/
export function isSamplePaymentTransferLocked(payStatus: unknown): boolean {
const status = String(payStatus || '').trim()
return Boolean(status) && status !== 'dfj'
}
/** 样本缴费方案是否含锁定转账(不可省略/删除) */
export function samplePaymentHasLockedTransfer(pay: {
schedules?: FgdScheduleLike[]
}): boolean {
return (pay.schedules || []).some((s) =>
(s.transfers || []).some((t) => isSamplePaymentTransferLocked(t.payStatus))
)
}
/**
* 下拉可选缴费模式(趸交 ↔ 分改趸 互斥,不可直转):
* - 分期:可改趸交 / 分改趸
@@ -17,8 +17,10 @@ import {
toAllTabListQuery,
workOrderMatchesCategory
} from '@/utils/qc-work-order-category'
import { matchesProtocolStatusFilter } from '@/utils/donation-protocol-status-filter'
import { matchesWorkOrderStatusFilter } from '@/utils/status-tag-registry'
import { compareLabStorageThenCreateTimeDesc } from '@/utils/qc-inbound-lab-storage-sort'
import { sortByTemporaryProductionCluster } from '@/utils/qc-temporary-production-cluster-sort'
export type QcTaskMineFilters = {
keyword?: string
@@ -30,6 +32,8 @@ export type QcTaskMineFilters = {
export type MineWorkOrderSearchFilters = {
keyword?: string
workOrderStatus?: string | string[]
/** 科研捐赠 Tab:协议/捐赠状态多选;undefined=不限制;[]=无匹配 */
protocolStatus?: string[]
triggerSource?: string
qcCategory?: string
createTimeStart?: string
@@ -104,6 +108,11 @@ export function filterMineEntriesByWorkOrderSearch(
return false
}
if (!matchesWorkOrderStatusFilter(row.workOrderStatus, filters.workOrderStatus)) return false
if (tabKey === 'inbound_research_donation' && filters.protocolStatus !== undefined) {
if (!matchesProtocolStatusFilter(row.protocolStatus || '', filters.protocolStatus)) {
return false
}
}
if (filters.triggerSource) {
if (isAllQcWorkOrderTab(tabKey)) {
if (!matchesAllTabTriggerSource(row, filters.triggerSource)) return false
@@ -187,6 +196,21 @@ export function sortMineEntriesForInboundLabStorage(
)
}
/** 临时质检:同生产工单成组,组位置按组内最新工单创建时间倒序 */
export function sortMineEntriesForTemporary(
entries: MineWorkOrderEntry[]
): MineWorkOrderEntry[] {
const sortedRows = sortByTemporaryProductionCluster(
entries.map((e) => ({
id: e.workOrder.id,
productionWorkOrderId: e.workOrder.productionWorkOrderId,
createTime: e.workOrder.createTime,
entry: e
}))
)
return sortedRows.map((row) => row.entry)
}
export function buildMineTableRows(pageEntries: MineWorkOrderEntry[]): {
rows: QcWorkOrder[]
presetTasksByWorkOrderId: Record<string, QcTask[]>
@@ -0,0 +1,65 @@
/** 临时质检:同生产工单成组;组位置 = 组内最新 createTime DESC;组内 createTime DESC */
export function temporaryProductionClusterKey(
productionWorkOrderId: string | null | undefined,
rowId: string
): string {
const pwo = String(productionWorkOrderId || '').trim()
if (pwo) return `pwo:${pwo}`
return `id:${String(rowId || '').trim()}`
}
export function compareTemporaryProductionClusterDesc(
idA: string,
pwoA: string | null | undefined,
timeA: string | null | undefined,
groupMaxA: string,
idB: string,
pwoB: string | null | undefined,
timeB: string | null | undefined,
groupMaxB: string
): number {
const ga = String(groupMaxA || '').trim()
const gb = String(groupMaxB || '').trim()
if (ga !== gb) return ga > gb ? -1 : 1
const keyA = temporaryProductionClusterKey(pwoA, idA)
const keyB = temporaryProductionClusterKey(pwoB, idB)
if (keyA !== keyB) return keyA < keyB ? -1 : 1
const ta = String(timeA || '').trim()
const tb = String(timeB || '').trim()
if (ta !== tb) return ta > tb ? -1 : 1
const ia = String(idA || '').trim()
const ib = String(idB || '').trim()
if (ia !== ib) return ia > ib ? -1 : 1
return 0
}
export type TemporaryClusterSortable = {
id: string
productionWorkOrderId?: string | null
createTime?: string | null
}
export function sortByTemporaryProductionCluster<T extends TemporaryClusterSortable>(
items: T[]
): T[] {
const groupMax = new Map<string, string>()
for (const item of items) {
const key = temporaryProductionClusterKey(item.productionWorkOrderId, item.id)
const t = String(item.createTime || '').trim()
const prev = groupMax.get(key) || ''
if (t > prev) groupMax.set(key, t)
}
return [...items].sort((a, b) =>
compareTemporaryProductionClusterDesc(
a.id,
a.productionWorkOrderId,
a.createTime,
groupMax.get(temporaryProductionClusterKey(a.productionWorkOrderId, a.id)) || '',
b.id,
b.productionWorkOrderId,
b.createTime,
groupMax.get(temporaryProductionClusterKey(b.productionWorkOrderId, b.id)) || ''
)
)
}