This commit is contained in:
zlwu committed 2026-06-26 00:50:04 +08:00
1 parent 0b50eff3d8
commit ee364e24aa
323 files changed
+57731 -35766

No files matched your search

+33
View File
@@ -47,3 +47,36 @@ export function productionTaskTypeForWorkOrder(workOrderType?: string | null): s
if (workOrderType === 'outbound') return 'outbound'
return 'cell_storage'
}
/** 工艺步骤状态 dzx=待执行 jxz=进行中 ywc=已完成 */
export function productionStepStatusTag(status?: string | null): { label: string; type: TagType } {
const map: Record<string, { label: string; type: TagType }> = {
dzx: { label: '待执行', type: 'info' },
jxz: { label: '进行中', type: 'warning' },
ywc: { label: '已完成', type: 'success' }
}
const s = (status || '').trim()
return map[s] || { label: s || '—', type: 'info' }
}
export function extractProductionTaskSteps(detail: Record<string, any> | null | undefined) {
if (!detail || typeof detail !== 'object') return []
const steps = detail.steps ?? detail.taskSteps ?? detail.stepList
return Array.isArray(steps) ? steps : []
}
/** 计划开始时间:展示为 YYYY-MM-DD */
export function formatPlannedStartDate(raw?: string | null): string {
const s = String(raw || '').trim()
if (!s) return ''
const datePart = s.slice(0, 10)
if (/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return datePart
const matched = s.match(/^(\d{4}-\d{2}-\d{2})/)
return matched ? matched[1] : ''
}
/** 计划开始时间:表单绑定为 YYYY-MM-DD 或 null */
export function normalizePlannedStartDate(raw?: string | null): string | null {
const formatted = formatPlannedStartDate(raw)
return formatted || null
}
@@ -0,0 +1,97 @@
import {
fetchMineProductionWorkOrderTabStepCounts,
fetchProductionWorkOrderTabStepCounts,
sumProductionWorkOrderTabStepCounts
} from '@/api/production-work-order'
import {
PRODUCTION_MENU_CODE_MY_TASK,
PRODUCTION_MENU_CODE_WORK_ORDER,
useProductionMenuBadgeStore
} from '@/store/modules/production-menu-badge'
import { useMenuStore } from '@/store/modules/menu'
import type { AppRouteRecord } from '@/types/router'
import { PRODUCTION_WORK_ORDER_VISIBLE_TABS } from '@/utils/production-work-order-tabs'
const VISIBLE_TAB_KEYS = PRODUCTION_WORK_ORDER_VISIBLE_TABS.map((tab) => tab.key)
/** 解析菜单项对应的生产角标 menuCode(兼容后端菜单未带 menuCode) */
export function resolveProductionMenuBadgeCode(item: AppRouteRecord): string | undefined {
const code = (item.meta.menuCode || '').trim()
if (code === PRODUCTION_MENU_CODE_WORK_ORDER || code === PRODUCTION_MENU_CODE_MY_TASK) {
return code
}
const name = String(item.name || '')
if (name === 'ProductionWorkOrder') return PRODUCTION_MENU_CODE_WORK_ORDER
if (name === 'ProductionMyProductionTask') return PRODUCTION_MENU_CODE_MY_TASK
const path = String(item.path || '')
if (path.endsWith('/production-work-order') || path === 'production-work-order') {
return PRODUCTION_MENU_CODE_WORK_ORDER
}
if (path.endsWith('/my-production-task') || path === 'my-production-task') {
return PRODUCTION_MENU_CODE_MY_TASK
}
return undefined
}
/** 从当前侧栏菜单树收集需展示角标的生产菜单 code */
export function collectVisibleProductionMenuBadgeCodes(menuList: AppRouteRecord[]): string[] {
const codes = new Set<string>()
const walk = (items: AppRouteRecord[]) => {
for (const item of items) {
const code = resolveProductionMenuBadgeCode(item)
if (code) codes.add(code)
if (item.children?.length) walk(item.children)
}
}
walk(menuList || [])
return [...codes]
}
/** 刷新侧栏「生产工单 / 我的生产任务」Tab 角标汇总 */
export async function refreshProductionMenuBadges() {
const menuStore = useMenuStore()
const badgeStore = useProductionMenuBadgeStore()
const visibleCodes = collectVisibleProductionMenuBadgeCodes(menuStore.menuList)
const tasks: Promise<void>[] = []
if (visibleCodes.includes(PRODUCTION_MENU_CODE_WORK_ORDER)) {
tasks.push(
fetchProductionWorkOrderTabStepCounts()
.then((data) =>
badgeStore.setBadge(
PRODUCTION_MENU_CODE_WORK_ORDER,
sumProductionWorkOrderTabStepCounts(data, VISIBLE_TAB_KEYS)
)
)
.catch(() =>
badgeStore.setBadge(PRODUCTION_MENU_CODE_WORK_ORDER, {
overdueIncomplete: 0,
unassigned: 0
})
)
)
}
if (visibleCodes.includes(PRODUCTION_MENU_CODE_MY_TASK)) {
tasks.push(
fetchMineProductionWorkOrderTabStepCounts()
.then((data) =>
badgeStore.setBadge(
PRODUCTION_MENU_CODE_MY_TASK,
sumProductionWorkOrderTabStepCounts(data, VISIBLE_TAB_KEYS)
)
)
.catch(() =>
badgeStore.setBadge(PRODUCTION_MENU_CODE_MY_TASK, {
overdueIncomplete: 0,
unassigned: 0
})
)
)
}
if (!tasks.length) return
await Promise.all(tasks)
}
+147
View File
@@ -0,0 +1,147 @@
import request from '@/utils/http'
import type { QcTask } from '@/api/qc-task'
export type QcInspectionBatch = {
id: string
workOrderId?: string
batchNo: string
batchName: string
orderNum?: number
assigneeUserIds: string[]
assigneeUserNames?: string
hasLab?: boolean
hasThirdParty?: boolean
labItemIds?: string[]
thirdPartyItemIds?: string[]
taskCount?: number
completedTaskCount?: number
tasks?: QcTask[]
}
export type QcInspectionBatchConfigureItem = {
id?: string
batchName: string
assigneeUserIds: string[]
hasLab: boolean
hasThirdParty: boolean
labItemIds: string[]
thirdPartyItemIds: string[]
}
export function fetchQcInspectionBatches(workOrderId: string) {
return request.get<QcInspectionBatch[]>({
url: `/web/admin/qc-work-orders/${workOrderId}/inspection-batches`
})
}
/** 优先使用详情内嵌批次,否则独立 GET */
export async function loadQcInspectionBatches(
workOrderId: string,
embedded?: QcInspectionBatch[] | null
): Promise<QcInspectionBatch[]> {
if (embedded?.length) return embedded
if (!workOrderId) return []
try {
return (await fetchQcInspectionBatches(workOrderId)) || []
} catch {
return []
}
}
type QcBatchTaskLike = {
id?: string
batchId?: string
taskStatus?: string
assigneeUserNames?: string
}
export type QcBatchTaskSection<T extends QcBatchTaskLike = QcBatchTaskLike> = {
key: string
batchId: string
batchNo: string
batchName: string
assigneeUserNames: string
taskCount: number
completedTaskCount: number
tasks: T[]
}
export function batchTasksPanelSlot(batchKey: string) {
return `batch-tasks-${batchKey}`
}
export function buildBatchSectionsFromBatches<T extends QcBatchTaskLike>(
batches: QcInspectionBatch[],
fallbackTasks?: T[]
): QcBatchTaskSection<T>[] {
if (batches.length) {
return batches.map((b) => ({
key: b.id || b.batchNo,
batchId: b.id,
batchNo: b.batchNo,
batchName: b.batchName || b.batchNo || '检测批次',
assigneeUserNames: b.assigneeUserNames || '',
taskCount: b.taskCount ?? (b.tasks?.length || 0),
completedTaskCount: b.completedTaskCount ?? 0,
tasks: (b.tasks || []) as T[]
}))
}
if (fallbackTasks?.length) {
return buildBatchSectionsFromTasks(fallbackTasks)
}
return []
}
export function buildBatchSectionsFromTasks<T extends QcBatchTaskLike>(
tasks: T[]
): QcBatchTaskSection<T>[] {
const map = new Map<string, T[]>()
const order: string[] = []
for (const t of tasks) {
const bid = (t.batchId || '').trim() || '__legacy__'
if (!map.has(bid)) {
map.set(bid, [])
order.push(bid)
}
map.get(bid)!.push(t)
}
return order.map((bid) => {
const group = map.get(bid)!
const completed = group.filter((t) => t.taskStatus === 'ywc').length
return {
key: bid,
batchId: bid === '__legacy__' ? '' : bid,
batchNo: '',
batchName: bid === '__legacy__' ? '历史任务' : '检测批次',
assigneeUserNames: group[0]?.assigneeUserNames || '',
taskCount: group.length,
completedTaskCount: completed,
tasks: group
}
})
}
export function resolveBatchSections<T extends QcBatchTaskLike>(
batches: QcInspectionBatch[] | undefined | null,
tasks: T[] | undefined | null
): QcBatchTaskSection<T>[] {
const fromBatches = buildBatchSectionsFromBatches(batches || [], tasks || undefined)
if (fromBatches.length) return fromBatches
if (tasks?.length) return buildBatchSectionsFromTasks(tasks)
return []
}
export function createEmptyInspectionBatch(): QcInspectionBatchConfigureItem {
const today = new Date()
const y = today.getFullYear()
const m = String(today.getMonth() + 1).padStart(2, '0')
const d = String(today.getDate()).padStart(2, '0')
return {
batchName: `${y}${m}${d}检测`,
assigneeUserIds: [],
hasLab: false,
hasThirdParty: false,
labItemIds: [],
thirdPartyItemIds: []
}
}
@@ -0,0 +1,104 @@
import type { QcItemParam } from '@/api/qc-item-param'
/** 解析参数 options 字段(JSON 数组字符串) */
export function parseQcParamOptions(options?: string | null): string[] {
if (!options) return []
try {
const arr = JSON.parse(options)
return Array.isArray(arr)
? arr.map((x) => String(x ?? '').trim()).filter(Boolean)
: []
} catch {
return []
}
}
/** 逗号分隔文本 → 选项列表(支持中英文逗号) */
export function parseQcParamOptionText(text?: string | null): string[] {
if (!text?.trim()) return []
const seen = new Set<string>()
const list: string[] = []
for (const part of text.split(/[,,]/)) {
const val = part.trim()
if (!val || seen.has(val)) continue
seen.add(val)
list.push(val)
}
return list
}
/** 选项列表 → 逗号分隔展示文本(默认项排在最前) */
export function formatQcParamOptionText(options?: string | null, defaultValue?: string | null): string {
const list = parseQcParamOptions(options)
if (!list.length) return ''
const dv = (defaultValue || '').trim()
if (dv && list.includes(dv) && list[0] !== dv) {
return [dv, ...list.filter((x) => x !== dv)].join(',')
}
return list.join(',')
}
/** 表单加载:补充单选选项编辑态 */
export function hydrateQcItemParamsForForm(params: QcItemParam[]): QcItemParam[] {
return (params || []).map((p) => {
if (p.paramType !== 'select') return { ...p }
return {
...p,
_optionText: formatQcParamOptionText(p.options, p.defaultValue)
}
})
}
/** 类型切换时清理或初始化单选字段 */
export function onQcItemParamTypeChange(param: QcItemParam) {
if (param.paramType === 'select') {
if (param._optionText === undefined) {
param._optionText = ''
}
return
}
param.options = undefined
param.defaultValue = undefined
param._optionText = undefined
}
/** 提交前序列化并校验 */
export function serializeQcItemParamsForSave(params: QcItemParam[]): QcItemParam[] {
return (params || []).map((p, idx) => {
const { _optionText, ...rest } = p
const base: QcItemParam = { ...rest, orderNum: p.orderNum ?? idx }
if (p.paramType !== 'select') {
base.options = undefined
base.defaultValue = undefined
return base
}
const list =
_optionText !== undefined
? parseQcParamOptionText(_optionText)
: parseQcParamOptions(p.options)
return {
...base,
options: list.length ? JSON.stringify(list) : undefined,
defaultValue: list[0] || undefined
}
})
}
/** @returns 错误文案;通过时返回 null */
export function validateQcItemParamsBeforeSave(params: QcItemParam[]): string | null {
const serialized = serializeQcItemParamsForSave(params)
for (let i = 0; i < serialized.length; i++) {
const p = serialized[i]
const label = p.paramName?.trim() || `第 ${i + 1} 个参数`
if (p.paramType === 'select') {
const opts = parseQcParamOptions(p.options)
if (!opts.length) {
return `「${label}」为单选类型,请至少配置一个选项`
}
if (new Set(opts).size !== opts.length) {
return `「${label}」的选项不能重复`
}
}
}
return null
}
+73
View File
@@ -0,0 +1,73 @@
import {
fetchMineQcWorkOrderTabUnfinishedCounts,
fetchQcWorkOrderTabUnfinishedCounts
} from '@/api/qc-work-order'
import {
QC_MENU_CODE_MY_TASK,
QC_MENU_CODE_WORK_ORDER,
useQcMenuBadgeStore
} from '@/store/modules/qc-menu-badge'
import { useMenuStore } from '@/store/modules/menu'
import type { AppRouteRecord } from '@/types/router'
/** 解析菜单项对应的质检角标 menuCode(兼容后端菜单未带 menuCode) */
export function resolveQcMenuBadgeCode(item: AppRouteRecord): string | undefined {
const code = (item.meta.menuCode || '').trim()
if (code === QC_MENU_CODE_WORK_ORDER || code === QC_MENU_CODE_MY_TASK) {
return code
}
const name = String(item.name || '')
if (name === 'QualityInspectionQcWorkOrder') return QC_MENU_CODE_WORK_ORDER
if (name === 'QualityInspectionMyQcTask') return QC_MENU_CODE_MY_TASK
const path = String(item.path || '')
if (path.endsWith('/qc-work-order') || path === 'qc-work-order') {
return QC_MENU_CODE_WORK_ORDER
}
if (path.endsWith('/my-qc-task') || path === 'my-qc-task') {
return QC_MENU_CODE_MY_TASK
}
return undefined
}
/** 从当前侧栏菜单树收集需展示角标的质检菜单 code */
export function collectVisibleQcMenuBadgeCodes(menuList: AppRouteRecord[]): string[] {
const codes = new Set<string>()
const walk = (items: AppRouteRecord[]) => {
for (const item of items) {
const code = resolveQcMenuBadgeCode(item)
if (code) codes.add(code)
if (item.children?.length) walk(item.children)
}
}
walk(menuList || [])
return [...codes]
}
/** 刷新侧栏「质检工单 / 我的质检任务」未完成数角标 */
export async function refreshQcMenuBadges() {
const menuStore = useMenuStore()
const badgeStore = useQcMenuBadgeStore()
const visibleCodes = collectVisibleQcMenuBadgeCodes(menuStore.menuList)
const tasks: Promise<void>[] = []
if (visibleCodes.includes(QC_MENU_CODE_WORK_ORDER)) {
tasks.push(
fetchQcWorkOrderTabUnfinishedCounts()
.then((data) => badgeStore.setBadge(QC_MENU_CODE_WORK_ORDER, data?.all ?? 0))
.catch(() => badgeStore.setBadge(QC_MENU_CODE_WORK_ORDER, 0))
)
}
if (visibleCodes.includes(QC_MENU_CODE_MY_TASK)) {
tasks.push(
fetchMineQcWorkOrderTabUnfinishedCounts()
.then((data) => badgeStore.setBadge(QC_MENU_CODE_MY_TASK, data?.all ?? 0))
.catch(() => badgeStore.setBadge(QC_MENU_CODE_MY_TASK, 0))
)
}
if (!tasks.length) return
await Promise.all(tasks)
}
+49 -15
View File
@@ -1,14 +1,23 @@
import type { QcWorkOrder } from '@/api/qc-work-order'
import { fetchQcWorkOrderDetail } from '@/api/qc-work-order'
import { fetchMyQcTaskList, type QcTask } from '@/api/qc-task'
import { groupInboundQcWorkOrders, matchesTemporaryQcSourceFilter } from '@/utils/qc-work-order-groups'
import {
groupInboundQcWorkOrders,
matchesTemporaryQcSourceFilter
} from '@/utils/qc-work-order-groups'
import type { QcWorkOrderTabKey } from '@/utils/qc-work-order-tabs'
import {
resolveTabKeyFromWorkOrder,
tabUsesInfusionTimeFilter,
getQcWorkOrderTab,
toWorkOrderListQuery
toWorkOrderListQuery,
isAllQcWorkOrderTab
} from '@/utils/qc-work-order-tabs'
import {
matchesAllTabTriggerSource,
toAllTabListQuery,
workOrderMatchesCategory
} from '@/utils/qc-work-order-category'
export type QcTaskMineFilters = {
keyword?: string
@@ -21,6 +30,7 @@ export type MineWorkOrderSearchFilters = {
keyword?: string
workOrderStatus?: string
triggerSource?: string
qcCategory?: string
createTimeStart?: string
createTimeEnd?: string
infusionTimeStart?: string
@@ -45,7 +55,11 @@ function dateInRange(value: string | undefined, start?: string, end?: string): b
return true
}
function keywordMatchesWorkOrder(row: QcWorkOrder, keyword: string, tabKey: QcWorkOrderTabKey): boolean {
function keywordMatchesWorkOrder(
row: QcWorkOrder,
keyword: string,
tabKey: QcWorkOrderTabKey
): boolean {
const kw = keyword.trim().toLowerCase()
if (!kw) return true
const tab = getQcWorkOrderTab(tabKey)!
@@ -53,10 +67,16 @@ function keywordMatchesWorkOrder(row: QcWorkOrder, keyword: string, tabKey: QcWo
tabKey === 'temporary'
? [row.workOrderNo, row.productionWorkOrderNo]
: tabKey === 'outbound_basic_experiment'
? [row.workOrderNo, row.applicationNo, row.projectTitle, row.customerName, row.applicationName]
? [
row.workOrderNo,
row.applicationNo,
row.projectTitle,
row.customerName,
row.applicationName
]
: tab.group === 'outbound'
? [row.workOrderNo, row.applicationNo, row.applicationName, row.patientName]
: [row.workOrderNo, row.sampleCode, row.labStorageCode]
: [row.workOrderNo, row.protocolNo, row.sampleCode, row.labStorageCode]
return fields.some((f) => (f || '').toLowerCase().includes(kw))
}
@@ -68,21 +88,29 @@ export function filterMineEntriesByWorkOrderSearch(
const tab = getQcWorkOrderTab(tabKey)!
return entries.filter((entry) => {
const row = entry.workOrder
if (resolveTabKeyFromWorkOrder(row) !== tabKey) return false
if (!isAllQcWorkOrderTab(tabKey) && resolveTabKeyFromWorkOrder(row) !== tabKey) {
return false
}
if (isAllQcWorkOrderTab(tabKey) && !workOrderMatchesCategory(row, filters.qcCategory || '')) {
return false
}
if (filters.workOrderStatus && row.workOrderStatus !== filters.workOrderStatus) return false
if (filters.triggerSource) {
if (tabKey === 'temporary') {
if (isAllQcWorkOrderTab(tabKey)) {
if (!matchesAllTabTriggerSource(row, filters.triggerSource)) return false
} else if (tabKey === 'temporary') {
if (!matchesTemporaryQcSourceFilter(row, filters.triggerSource)) return false
} else if (tab.listLayout !== 'standalone' && row.triggerSource !== filters.triggerSource) {
return false
}
}
if (!keywordMatchesWorkOrder(row, filters.keyword || '', tabKey)) return false
if (tabUsesInfusionTimeFilter(tab)) {
if (!dateInRange(row.infusionTime, filters.infusionTimeStart, filters.infusionTimeEnd)) {
const keywordTabKey = isAllQcWorkOrderTab(tabKey) ? resolveTabKeyFromWorkOrder(row) : tabKey
if (!keywordMatchesWorkOrder(row, filters.keyword || '', keywordTabKey)) return false
if (isAllQcWorkOrderTab(tabKey) || !tabUsesInfusionTimeFilter(tab)) {
if (!dateInRange(row.createTime, filters.createTimeStart, filters.createTimeEnd)) {
return false
}
} else if (!dateInRange(row.createTime, filters.createTimeStart, filters.createTimeEnd)) {
} else if (!dateInRange(row.infusionTime, filters.infusionTimeStart, filters.infusionTimeEnd)) {
return false
}
return true
@@ -91,7 +119,9 @@ export function filterMineEntriesByWorkOrderSearch(
export async function fetchAllMineTasks(filters: QcTaskMineFilters): Promise<QcTask[]> {
const tab = getQcWorkOrderTab(filters.tabKey)!
const listQuery = toWorkOrderListQuery(tab)
const listQuery = isAllQcWorkOrderTab(filters.tabKey)
? toAllTabListQuery('')
: toWorkOrderListQuery(tab)
const pageSize = MINE_TASK_FETCH_PAGE_SIZE
let page = 1
const all: QcTask[] = []
@@ -103,7 +133,7 @@ export async function fetchAllMineTasks(filters: QcTaskMineFilters): Promise<QcT
keyword: filters.keyword || undefined,
taskType: filters.taskType || undefined,
taskStatus: filters.taskStatus || undefined,
qcBizType: tab.workOrderType,
qcBizType: listQuery.workOrderType,
receptionSource: listQuery.receptionSource,
bizType: listQuery.bizType
})
@@ -262,11 +292,15 @@ export function buildMineCardListModel(
tasks: QcTask[],
tabKey: QcWorkOrderTabKey
): MineCardListModel {
const entries = groupMineTasksByWorkOrder(tasks).filter((e) => e.tabKey === tabKey)
const entries = groupMineTasksByWorkOrder(tasks).filter((e) =>
isAllQcWorkOrderTab(tabKey) ? true : e.tabKey === tabKey
)
const presetTasksByWorkOrderId = buildPresetTasksMap(entries)
const tab = getQcWorkOrderTab(tabKey)!
const useInboundGroups = tab.listLayout === 'inbound-grouped'
const inboundGroups = useInboundGroups ? groupInboundQcWorkOrders(entries.map((e) => e.workOrder)) : []
const inboundGroups = useInboundGroups
? groupInboundQcWorkOrders(entries.map((e) => e.workOrder))
: []
const standaloneEntries = useInboundGroups ? [] : entries
return {
inboundGroups,
+85 -4
View File
@@ -1,4 +1,4 @@
import type { QcWorkOrder } from '@/api/qc-work-order'
import type { QcWorkOrder, QcProtocolPaymentSummary } from '@/api/qc-work-order'
import {
donationStatusLabel,
donationStatusTagType,
@@ -7,10 +7,12 @@ import {
} from '@/utils/status-tag-registry'
import type { TagType } from '@/utils/status-tag-registry'
import type { QcWorkOrderTabKey } from '@/utils/qc-work-order-tabs'
import { getQcWorkOrderTab } from '@/utils/qc-work-order-tabs'
import { getQcWorkOrderTab, resolveTabKeyFromWorkOrder } from '@/utils/qc-work-order-tabs'
import { formatMoneyDisplay } from '@/utils/money-format'
import {
isTemporaryQcFromProduction
countConfiguredSamplesInProtocolGroup,
isTemporaryQcFromProduction,
type QcWorkOrderProtocolGroupModel
} from '@/utils/qc-work-order-groups'
export type CardMetaFieldKind = 'text' | 'tag'
@@ -30,6 +32,7 @@ export type CardMetaField = {
export type QcWorkOrderCardMetaConfig = {
tabKey: QcWorkOrderTabKey
listLayout: 'inbound-grouped' | 'outbound-grouped' | 'standalone'
showCategoryTag: boolean
showTriggerTag: boolean
showProtocolFeeSummary: boolean
showOutboundFeeSummary: boolean
@@ -79,7 +82,9 @@ function protocolStatusLabelBySource(status: string, source: string): string {
}
function protocolStatusTagTypeBySource(status: string, source: string): TagType {
return source === 'research_donation' ? donationStatusTagType(status) : protocolStatusTagType(status)
return source === 'research_donation'
? donationStatusTagType(status)
: protocolStatusTagType(status)
}
const commonTailFields: CardMetaField[] = [
@@ -176,6 +181,36 @@ function buildDonationGroupContext(row: QcWorkOrder): string[] {
return parts
}
export function buildProtocolGroupProgressLabel(group: QcWorkOrderProtocolGroupModel): string {
const { configured, total } = countConfiguredSamplesInProtocolGroup(group)
if (total <= 0) return ''
if (configured <= 0) return ''
return `${configured}/${total} 已配置`
}
export function resolveProtocolGroupStatus(row: QcWorkOrder): {
label: string
tagType: TagType
} {
const status = String(row.protocolStatus || '')
const source = row.receptionSource || 'cell_storage'
return {
label: protocolStatusLabelBySource(status, source),
tagType: protocolStatusTagTypeBySource(status, source)
}
}
/** 协议组级费用摘要:取组内首条有 paymentSummary 的 cell_storage 工单 */
export function resolveProtocolGroupPaymentSummary(
group: QcWorkOrderProtocolGroupModel
): QcProtocolPaymentSummary | null {
if (group.receptionSource !== 'cell_storage') return null
for (const wo of group.workOrders) {
if (wo.paymentSummary) return wo.paymentSummary
}
return null
}
const outboundGroupedFields: CardMetaField[] = [
{ label: '质检对象', getValue: formatQcTarget, colSpan: 2 },
{
@@ -205,9 +240,24 @@ function buildOutboundBasicExperimentGroupContext(row: QcWorkOrder): string[] {
}
const CARD_META_BY_TAB: Record<QcWorkOrderTabKey, QcWorkOrderCardMetaConfig> = {
all: {
tabKey: 'all',
listLayout: 'standalone',
showCategoryTag: true,
showTriggerTag: true,
showProtocolFeeSummary: false,
showOutboundFeeSummary: false,
showCellProducts: false,
metaFields: [],
buildGroupContextParts: () => [],
allowEditTemporary: false,
allowEditRemark: false,
allowDelete: false
},
inbound_cell_storage: {
tabKey: 'inbound_cell_storage',
listLayout: 'inbound-grouped',
showCategoryTag: false,
showTriggerTag: true,
showProtocolFeeSummary: true,
showOutboundFeeSummary: false,
@@ -221,6 +271,7 @@ const CARD_META_BY_TAB: Record<QcWorkOrderTabKey, QcWorkOrderCardMetaConfig> = {
inbound_research_donation: {
tabKey: 'inbound_research_donation',
listLayout: 'inbound-grouped',
showCategoryTag: false,
showTriggerTag: true,
showProtocolFeeSummary: false,
showOutboundFeeSummary: false,
@@ -234,6 +285,7 @@ const CARD_META_BY_TAB: Record<QcWorkOrderTabKey, QcWorkOrderCardMetaConfig> = {
outbound_pilot_service: {
tabKey: 'outbound_pilot_service',
listLayout: 'outbound-grouped',
showCategoryTag: false,
showTriggerTag: true,
showProtocolFeeSummary: false,
showOutboundFeeSummary: false,
@@ -247,6 +299,7 @@ const CARD_META_BY_TAB: Record<QcWorkOrderTabKey, QcWorkOrderCardMetaConfig> = {
outbound_basic_experiment: {
tabKey: 'outbound_basic_experiment',
listLayout: 'outbound-grouped',
showCategoryTag: false,
showTriggerTag: true,
showProtocolFeeSummary: false,
showOutboundFeeSummary: true,
@@ -260,6 +313,7 @@ const CARD_META_BY_TAB: Record<QcWorkOrderTabKey, QcWorkOrderCardMetaConfig> = {
outbound_clinical_application: {
tabKey: 'outbound_clinical_application',
listLayout: 'standalone',
showCategoryTag: false,
showTriggerTag: false,
showProtocolFeeSummary: false,
showOutboundFeeSummary: true,
@@ -273,6 +327,7 @@ const CARD_META_BY_TAB: Record<QcWorkOrderTabKey, QcWorkOrderCardMetaConfig> = {
temporary: {
tabKey: 'temporary',
listLayout: 'standalone',
showCategoryTag: false,
showTriggerTag: true,
showProtocolFeeSummary: false,
showOutboundFeeSummary: false,
@@ -289,11 +344,37 @@ export function getCardMetaConfig(tabKey: QcWorkOrderTabKey): QcWorkOrderCardMet
return CARD_META_BY_TAB[tabKey]
}
export function resolveCardMetaForRow(
row: QcWorkOrder,
tabKey: QcWorkOrderTabKey
): QcWorkOrderCardMetaConfig {
if (tabKey !== 'all') return getCardMetaConfig(tabKey)
const rowTabKey = resolveTabKeyFromWorkOrder(row)
const rowMeta = getCardMetaConfig(rowTabKey)
return {
...rowMeta,
tabKey: 'all',
showCategoryTag: true
}
}
export function buildGroupContextSubtitle(row: QcWorkOrder, tabKey: QcWorkOrderTabKey): string {
const parts = getCardMetaConfig(tabKey).buildGroupContextParts(row)
return parts.length ? parts.join(' · ') : '—'
}
/** 入库分组列表 · 工单卡片主标题(按触发来源) */
export function resolveInboundWorkOrderCardTitle(row: QcWorkOrder): string {
const ts = (row.triggerSource || 'reception').trim()
if (ts === 'production') {
return String(row.cellProductCode || '').trim() || '—'
}
if (ts === 'reception') {
return String(row.labStorageCode || '').trim() || '—'
}
return String(row.workOrderNo || '').trim() || '—'
}
export function cardMetaUsesInboundGroups(tabKey: QcWorkOrderTabKey): boolean {
return getQcWorkOrderTab(tabKey)?.listLayout === 'inbound-grouped'
}
@@ -0,0 +1,116 @@
import type { QcWorkOrder } from '@/api/qc-work-order'
import { isTemporaryQcFromProduction } from '@/utils/qc-work-order-groups'
import type { TagType } from '@/utils/status-tag-registry'
import {
resolveTabKeyFromWorkOrder,
type QcWorkOrderTabKey,
getQcWorkOrderTab,
QC_WORK_ORDER_VISIBLE_TABS
} from '@/utils/qc-work-order-tabs'
/** 「所有」Tab 内质检类型子筛选(不含 all / hidden clinical) */
export type QcWorkOrderCategoryKey = Exclude<
QcWorkOrderTabKey,
'all' | 'outbound_clinical_application'
>
export const QC_WORK_ORDER_CATEGORY_OPTIONS: { key: QcWorkOrderCategoryKey; label: string }[] =
QC_WORK_ORDER_VISIBLE_TABS.filter((t) => t.key !== 'all').map((t) => ({
key: t.key as QcWorkOrderCategoryKey,
label: t.label.replace(/质检$/, '')
}))
const CATEGORY_LABELS: Record<QcWorkOrderCategoryKey, string> = {
inbound_cell_storage: '细胞存储',
inbound_research_donation: '科研捐赠',
outbound_pilot_service: '中试服务',
outbound_basic_experiment: '基础实验',
temporary: '临时'
}
const CATEGORY_TAG_TYPES: Record<QcWorkOrderCategoryKey, TagType> = {
inbound_cell_storage: 'primary',
inbound_research_donation: 'primary',
outbound_pilot_service: 'info',
outbound_basic_experiment: 'info',
temporary: 'warning'
}
export function resolveWorkOrderCategoryKey(row: {
workOrderType?: string
receptionSource?: string
bizType?: string
}): QcWorkOrderCategoryKey {
const tabKey = resolveTabKeyFromWorkOrder(row)
if (tabKey === 'outbound_clinical_application') return 'outbound_pilot_service'
return tabKey as QcWorkOrderCategoryKey
}
export function qcWorkOrderCategoryLabel(row: QcWorkOrder): string {
return CATEGORY_LABELS[resolveWorkOrderCategoryKey(row)] || '—'
}
export function qcWorkOrderCategoryTagType(row: QcWorkOrder): TagType {
return CATEGORY_TAG_TYPES[resolveWorkOrderCategoryKey(row)] || 'info'
}
/** 「所有」Tab 工单来源筛选项 value(含临时专用值) */
export const QC_ALL_TAB_TRIGGER_SOURCE_OPTIONS = [
{ label: '样本接收', value: 'reception' },
{ label: '计划质检', value: 'schedule' },
{ label: '生产过程', value: 'production' },
{ label: '生产派发', value: 'temp_production' },
{ label: '手动创建', value: 'temp_manual' }
] as const
export type QcAllTabTriggerSource = (typeof QC_ALL_TAB_TRIGGER_SOURCE_OPTIONS)[number]['value']
export function toAllTabListQuery(categoryKey: string) {
const key = (categoryKey || '').trim()
if (!key) {
return {
workOrderType: undefined as string | undefined,
receptionSource: undefined as string | undefined,
bizType: undefined as string | undefined
}
}
const tab = getQcWorkOrderTab(key)
if (!tab || tab.key === 'all') {
return {
workOrderType: undefined,
receptionSource: undefined,
bizType: undefined
}
}
return {
workOrderType: tab.workOrderType,
receptionSource: tab.receptionSource,
bizType: tab.bizType
}
}
export function workOrderMatchesCategory(row: QcWorkOrder, categoryKey: string): boolean {
const key = (categoryKey || '').trim()
if (!key) return true
return resolveWorkOrderCategoryKey(row) === key
}
export function matchesAllTabTriggerSource(row: QcWorkOrder, value?: string): boolean {
const filterValue = (value || '').trim()
if (!filterValue) return true
const wt = row.workOrderType || 'inbound'
const ts = row.triggerSource || 'reception'
if (filterValue === 'reception' || filterValue === 'schedule') {
return ts === filterValue
}
if (filterValue === 'production') {
return wt !== 'temporary' && ts === 'production'
}
if (filterValue === 'temp_production') {
return wt === 'temporary' && isTemporaryQcFromProduction(row)
}
if (filterValue === 'temp_manual') {
return wt === 'temporary' && !isTemporaryQcFromProduction(row)
}
return ts === filterValue
}
+145
View File
@@ -1,4 +1,5 @@
import type { QcWorkOrder } from '@/api/qc-work-order'
import { getQcWorkOrderTab, resolveTabKeyFromWorkOrder } from '@/utils/qc-work-order-tabs'
export type QcWorkOrderSampleGroupModel = {
groupKey: string
@@ -95,3 +96,147 @@ export function matchesTemporaryQcSourceFilter(row: QcWorkOrder, filterValue?: s
if (filterValue === 'manual') return !isTemporaryQcFromProduction(row)
return (row.triggerSource || '') === filterValue
}
export function resolveInboundProtocolGroupKey(row: QcWorkOrder): string {
const id = (row.collectionProtocolId || '').trim()
if (id) return id
const no = (row.protocolNo || '').trim()
if (no) return `protocol-no:${no}`
return row.id
}
export function resolveInboundSampleGroupKey(row: QcWorkOrder): string {
return (row.sampleCode || '').trim() || row.id
}
/** 样本组内用于组头/Tab 状态的 gate 工单(接收优先,否则首条) */
export function resolveGateWorkOrderForSampleGroup(
workOrders: QcWorkOrder[]
): QcWorkOrder | undefined {
if (!workOrders.length) return undefined
return workOrders.find((w) => isGateTriggerSource(w.triggerSource)) || workOrders[0]
}
export type QcWorkOrderProtocolGroupModel = {
groupKey: string
protocolId: string
protocolNo: string
receptionSource: 'cell_storage' | 'research_donation'
sampleGroups: QcWorkOrderSampleGroupModel[]
workOrders: QcWorkOrder[]
}
function protocolGroupSortTime(group: QcWorkOrderProtocolGroupModel): string {
let max = ''
for (const wo of group.workOrders) {
const t = String(wo.createTime || '')
if (t > max) max = t
}
return max
}
/** 入库 Tab:按协议单分组,组内再按样本分组 */
export function groupInboundQcWorkOrdersByProtocol(
rows: QcWorkOrder[]
): QcWorkOrderProtocolGroupModel[] {
const map = new Map<string, QcWorkOrder[]>()
for (const row of rows) {
const key = resolveInboundProtocolGroupKey(row)
const list = map.get(key)
if (list) list.push(row)
else map.set(key, [row])
}
const groups = Array.from(map.entries()).map(([groupKey, workOrders]) => {
const sampleGroups = groupInboundQcWorkOrders(workOrders)
const head = workOrders[0]
const source =
(head?.receptionSource || 'cell_storage') === 'research_donation'
? 'research_donation'
: 'cell_storage'
return {
groupKey,
protocolId: (head?.collectionProtocolId || '').trim(),
protocolNo: (head?.protocolNo || '').trim(),
receptionSource: source as 'cell_storage' | 'research_donation',
sampleGroups,
workOrders
}
})
return groups.sort((a, b) => protocolGroupSortTime(b).localeCompare(protocolGroupSortTime(a)))
}
/** 协议组进度:已配置样本数 / 样本总数(按 gate 工单 workOrderStatus !== dpz) */
export function countConfiguredSamplesInProtocolGroup(group: QcWorkOrderProtocolGroupModel): {
configured: number
total: number
} {
const total = group.sampleGroups.length
let configured = 0
for (const sg of group.sampleGroups) {
const gate = resolveGateWorkOrderForSampleGroup(sg.workOrders)
if (gate && gate.workOrderStatus !== 'dpz') configured += 1
}
return { configured, total }
}
export type QcWorkOrderAllTabListItem =
| { kind: 'sample-group'; group: QcWorkOrderSampleGroupModel }
| { kind: 'standalone'; row: QcWorkOrder }
type AllTabPendingGroup = {
layout: 'inbound-grouped' | 'outbound-grouped'
workOrders: QcWorkOrder[]
firstIndex: number
}
/** 「所有」Tab:按行类型选用分组/单列布局,同页内合并同样本/同输注计划 */
export function buildAllTabListItems(rows: QcWorkOrder[]): QcWorkOrderAllTabListItem[] {
const pendingInbound = new Map<string, AllTabPendingGroup>()
const pendingOutbound = new Map<string, AllTabPendingGroup>()
const slots: { firstIndex: number; item: QcWorkOrderAllTabListItem }[] = []
rows.forEach((row, index) => {
const rowTabKey = resolveTabKeyFromWorkOrder(row)
const layout = getQcWorkOrderTab(rowTabKey)?.listLayout
if (layout === 'inbound-grouped') {
const key = (row.sampleCode || '').trim() || row.id
const existing = pendingInbound.get(key)
if (existing) {
existing.workOrders.push(row)
} else {
pendingInbound.set(key, { layout: 'inbound-grouped', workOrders: [row], firstIndex: index })
}
return
}
if (layout === 'outbound-grouped') {
const key = (row.infusionScheduleId || '').trim() || row.id
const existing = pendingOutbound.get(key)
if (existing) {
existing.workOrders.push(row)
} else {
pendingOutbound.set(key, {
layout: 'outbound-grouped',
workOrders: [row],
firstIndex: index
})
}
return
}
slots.push({ firstIndex: index, item: { kind: 'standalone', row } })
})
for (const pending of pendingInbound.values()) {
const [group] = groupInboundQcWorkOrders(pending.workOrders)
if (group) {
slots.push({ firstIndex: pending.firstIndex, item: { kind: 'sample-group', group } })
}
}
for (const pending of pendingOutbound.values()) {
const [group] = groupOutboundQcWorkOrders(pending.workOrders)
if (group) {
slots.push({ firstIndex: pending.firstIndex, item: { kind: 'sample-group', group } })
}
}
return slots.sort((a, b) => a.firstIndex - b.firstIndex).map((slot) => slot.item)
}
+42 -6
View File
@@ -1,6 +1,7 @@
import type { QcWorkOrder } from '@/api/qc-work-order'
export type QcWorkOrderTabKey =
| 'all'
| 'inbound_cell_storage'
| 'inbound_research_donation'
| 'outbound_pilot_service'
@@ -8,13 +9,13 @@ export type QcWorkOrderTabKey =
| 'outbound_clinical_application'
| 'temporary'
export type QcWorkOrderTabGroup = 'inbound' | 'outbound' | 'temporary'
export type QcWorkOrderTabGroup = 'all' | 'inbound' | 'outbound' | 'temporary'
export type QcWorkOrderTabConfig = {
key: QcWorkOrderTabKey
label: string
group: QcWorkOrderTabGroup
workOrderType: 'inbound' | 'outbound' | 'temporary'
workOrderType?: 'inbound' | 'outbound' | 'temporary'
receptionSource?: 'cell_storage' | 'research_donation'
bizType?: 'pilot_service' | 'basic_experiment' | 'clinical_application'
listLayout: 'inbound-grouped' | 'outbound-grouped' | 'standalone'
@@ -23,6 +24,12 @@ export type QcWorkOrderTabConfig = {
}
export const QC_WORK_ORDER_TABS: QcWorkOrderTabConfig[] = [
{
key: 'all',
label: '所有',
group: 'all',
listLayout: 'standalone'
},
{
key: 'inbound_cell_storage',
label: '细胞存储质检',
@@ -76,7 +83,7 @@ export const QC_WORK_ORDER_TABS: QcWorkOrderTabConfig[] = [
/** 质检工单 / 我的质检任务 Tab 栏展示项(不含 hidden) */
export const QC_WORK_ORDER_VISIBLE_TABS = QC_WORK_ORDER_TABS.filter((t) => !t.hidden)
export const DEFAULT_QC_WORK_ORDER_TAB: QcWorkOrderTabKey = 'inbound_cell_storage'
export const DEFAULT_QC_WORK_ORDER_TAB: QcWorkOrderTabKey = 'all'
export function getQcWorkOrderTab(key: string): QcWorkOrderTabConfig | undefined {
return QC_WORK_ORDER_TABS.find((t) => t.key === key)
@@ -93,6 +100,13 @@ export function parseQcWorkOrderTabKey(raw?: string | null): QcWorkOrderTabKey {
}
export function toWorkOrderListQuery(tab: QcWorkOrderTabConfig) {
if (tab.key === 'all') {
return {
workOrderType: undefined as string | undefined,
receptionSource: undefined as string | undefined,
bizType: undefined as string | undefined
}
}
return {
workOrderType: tab.workOrderType,
receptionSource: tab.receptionSource,
@@ -123,29 +137,51 @@ export function tabUsesInfusionTimeFilter(tab: QcWorkOrderTabConfig): boolean {
}
export function keywordPlaceholderForTab(tab: QcWorkOrderTabConfig): string {
if (tab.key === 'all') return '工单号 / 样本编码 / 申请单号 / 产品名称等'
if (tab.key === 'temporary') return '工单号'
if (tab.key === 'outbound_basic_experiment') {
return '工单号/申请单号/课题名称/客户'
}
if (tab.group === 'outbound') return '工单号/申请单号/申请单名称/患者'
if (tab.key === 'inbound_research_donation') {
return '工单号/捐赠单号/样本编码/入库编码'
}
if (tab.group === 'inbound') {
return '工单号/协议号/样本编码/入库编码'
}
return '工单号/样本编码/入库编码'
}
export function filterTimeLabelForTab(tab: QcWorkOrderTabConfig): string {
if (tab.key === 'all') return '创建时间'
if (tab.key === 'outbound_basic_experiment') return '交付时间'
if (tab.group === 'outbound') return '输注时间'
return '创建时间'
}
export function filterTimeHintForTab(tab: QcWorkOrderTabConfig): string {
if (tab.key === 'all') {
return '出库工单按输注/交付时间筛选请进入对应分类 Tab。'
}
if (tab.group === 'outbound') {
return tab.key === 'outbound_basic_experiment'
? '支持按交付时间筛选。'
: '支持按输注时间筛选。'
return tab.key === 'outbound_basic_experiment' ? '支持按交付时间筛选。' : '支持按输注时间筛选。'
}
return '支持按创建时间筛选。'
}
export function workOrderMatchesTab(row: QcWorkOrder, tabKey: QcWorkOrderTabKey): boolean {
if (tabKey === 'all') return true
return resolveTabKeyFromWorkOrder(row) === tabKey
}
export function isAllQcWorkOrderTab(tabKey: QcWorkOrderTabKey): boolean {
return tabKey === 'all'
}
/** 列表 Tab 为「所有」时,按工单解析其分类 Tab Key */
export function resolveEffectiveRowTabKey(
listTabKey: QcWorkOrderTabKey,
row: QcWorkOrder
): QcWorkOrderTabKey {
return isAllQcWorkOrderTab(listTabKey) ? resolveTabKeyFromWorkOrder(row) : listTabKey
}
@@ -0,0 +1,65 @@
export type SampleReceptionSourceType = 'cell_storage' | 'research_donation'
const SAMPLE_TYPE_PERINATAL_HSC = '围产期造血干细胞'
const SAMPLE_TYPE_UC_MSC = '脐带间充质干细胞'
function normalizeSampleTypeName(name: string): string {
return String(name || '').trim()
}
function normalizeSourceType(source: string): SampleReceptionSourceType {
return source === 'research_donation' ? 'research_donation' : 'cell_storage'
}
function formatYm(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}${pad(d.getMonth() + 1)}`
}
function formatYear(d: Date): string {
return String(d.getFullYear())
}
/** 从接收时间字符串解析参考日期;无效时回退为当前时刻 */
export function parseLabStorageReferenceDate(receptionTime: string): Date {
const s = String(receptionTime || '').trim()
const dateStr = s.length >= 10 ? s.slice(0, 10) : ''
if (!dateStr || dateStr[4] !== '-' || dateStr[7] !== '-') {
return new Date()
}
const [y, m, d] = dateStr.split('-').map((v) => Number(v))
if (!y || !m || !d) return new Date()
return new Date(y, m - 1, d)
}
/**
* 按来源与样本类型预制实验室入库编码;不匹配规则时返回空字符串。
*/
export function buildPrefilledLabStorageCode(
sourceType: string,
sampleTypeName: string,
referenceDate: Date = new Date()
): string {
const source = normalizeSourceType(sourceType)
const typeName = normalizeSampleTypeName(sampleTypeName)
if (source === 'research_donation') {
if (typeName === SAMPLE_TYPE_PERINATAL_HSC) {
return `UBMC${formatYm(referenceDate)}`
}
if (typeName === SAMPLE_TYPE_UC_MSC) {
return `UC${formatYear(referenceDate)}`
}
}
if (source === 'cell_storage') {
if (typeName === SAMPLE_TYPE_UC_MSC) {
return 'BC'
}
if (typeName === SAMPLE_TYPE_PERINATAL_HSC) {
return 'B'
}
}
return ''
}