fix: 协议子资源单事务保存,并补齐样本编码/OA/质检等近期能力
锁定缴费校验失败时整单回滚,避免样本半成功落库;前端禁止删除锁定缴费样本,改码走更正编码。同步落地样本更正、采集微信通知、OA提及未读、细胞产品套餐与相关迁移文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)) || ''
|
||||
)
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user