feature: Backend as Single Source of Truth for Batch-Type Field Requirements

This commit is contained in:
voltsrage
2026-06-27 23:47:56 +08:00
parent 4c28bffcc9
commit 5039dbb979
10 changed files with 245 additions and 44 deletions
@@ -7,6 +7,7 @@ public record BatchDetailResponse(
string Status,
string BatchType,
string Track,
BatchTypeFieldRequirements FieldRequirements,
Guid? PatientId,
string DocumentRef,
string? DocumentUrl,
@@ -34,6 +35,7 @@ public record BatchDetailResponse(
batch.Status.ToDbString(),
batch.BatchType.ToDbString(),
batch.Track.ToDbString(),
BatchTypeFieldRequirements.ForBatchType(batch.BatchType),
batch.PatientId,
batch.DocumentRef,
documentUrl,
@@ -0,0 +1,70 @@
public record BatchTypeFieldRequirements(
bool ShowPatientDemographics,
bool ShowEncounterContext,
bool ShowEncounterSummaryFields,
bool ShowObservations,
bool ShowAllergies,
bool ShowMedications
)
{
public static BatchTypeFieldRequirements ForBatchType(BatchType batchType) => batchType switch
{
BatchType.PatientRegistration => new(
ShowPatientDemographics: true,
ShowEncounterContext: false,
ShowEncounterSummaryFields: false,
ShowObservations: false,
ShowAllergies: false,
ShowMedications: false),
BatchType.VitalsSheet => new(
ShowPatientDemographics: true,
ShowEncounterContext: true,
ShowEncounterSummaryFields: false,
ShowObservations: true,
ShowAllergies: false,
ShowMedications: false),
BatchType.LabResults => new(
ShowPatientDemographics: true,
ShowEncounterContext: true,
ShowEncounterSummaryFields: false,
ShowObservations: true,
ShowAllergies: false,
ShowMedications: false),
BatchType.AllergyUpdate => new(
ShowPatientDemographics: true,
ShowEncounterContext: false,
ShowEncounterSummaryFields: false,
ShowObservations: false,
ShowAllergies: true,
ShowMedications: false),
BatchType.EncounterSummary => new(
ShowPatientDemographics: true,
ShowEncounterContext: true,
ShowEncounterSummaryFields: true,
ShowObservations: false,
ShowAllergies: false,
ShowMedications: false),
BatchType.MedicationList => new(
ShowPatientDemographics: true,
ShowEncounterContext: false,
ShowEncounterSummaryFields: false,
ShowObservations: false,
ShowAllergies: false,
ShowMedications: true),
BatchType.Mixed => new(
ShowPatientDemographics: true,
ShowEncounterContext: true,
ShowEncounterSummaryFields: true,
ShowObservations: true,
ShowAllergies: true,
ShowMedications: true),
_ => throw new ArgumentOutOfRangeException(nameof(batchType))
};
}
@@ -2,6 +2,7 @@ public record DraftPayloadResponse(
Guid BatchId,
string Status,
string BatchType,
BatchTypeFieldRequirements FieldRequirements,
DraftPatientDto? Patient,
DraftEncounterDto? Encounter,
List<DraftObservationDto> Observations
@@ -35,6 +35,7 @@ public class DraftService : IDraftService
batch.Id,
batch.Status.ToDbString(),
batch.BatchType.ToDbString(),
BatchTypeFieldRequirements.ForBatchType(batch.BatchType),
batch.DraftPatient is not null ? MapPatient(batch.DraftPatient) : null,
batch.DraftEncounter is not null ? MapEncounter(batch.DraftEncounter) : null,
batch.DraftObservations.Select(MapObservation).OrderBy(o => o.RecordedAt).ToList()
@@ -4,6 +4,10 @@ import { setActivePinia, createPinia } from 'pinia'
import EntryForm from '@/components/EntryForm.vue'
import { useBatchStore } from '@/stores/batches'
import type { BatchDetailResponse } from '@/types'
import {
emptyDraft,
fieldRequirementsForBatchType,
} from '@/__tests__/helpers/fieldRequirements'
vi.mock('@/api/client', () => ({
get: vi.fn(),
@@ -24,11 +28,13 @@ vi.mock('@/composables/useToast', () => ({
}))
function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailResponse {
const batchType = overrides.batchType ?? 'VITALS'
return {
id: 'b1',
status: 'IN_ENTRY',
batchType: 'VITALS',
batchType,
track: 'TRACK_A',
fieldRequirements: fieldRequirementsForBatchType(batchType),
patientId: null,
documentRef: 'docs/scan.pdf',
documentUrl: null,
@@ -41,12 +47,25 @@ function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailRes
promotionEncounterId: null,
supersedesBatchId: null,
clinicianAttestation: false,
isCorrection: false,
supersession: null,
createdAt: '2026-06-27T10:00:00Z',
updatedAt: '2026-06-27T10:00:00Z',
...overrides,
}
}
function mountWithDraft(
batchOverrides: Partial<BatchDetailResponse> = {},
) {
const batch = makeBatch(batchOverrides)
const store = useBatchStore()
store.currentDraft = emptyDraft(batch.batchType)
return mount(EntryForm, {
props: { batch, batchId: 'b1' },
})
}
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
@@ -103,55 +122,41 @@ describe('EntryForm', () => {
describe('conditional sections by batch type', () => {
it('hides allergies section for VITALS batch', () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch({ batchType: 'VITALS' }), batchId: 'b1' },
})
const wrapper = mountWithDraft({ batchType: 'VITALS' })
expect(wrapper.text()).not.toContain('Allergies')
})
it('shows allergies section for ALLERGY_UPDATE batch', () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch({ batchType: 'ALLERGY_UPDATE' }), batchId: 'b1' },
})
const wrapper = mountWithDraft({ batchType: 'ALLERGY_UPDATE' })
expect(wrapper.text()).toContain('Allergies')
expect(wrapper.text()).toContain('No known allergies')
})
it('hides medications section for VITALS batch', () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch({ batchType: 'VITALS' }), batchId: 'b1' },
})
const wrapper = mountWithDraft({ batchType: 'VITALS' })
expect(wrapper.text()).not.toContain('Medications')
})
it('shows medications section for MEDICATION_LIST batch', () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch({ batchType: 'MEDICATION_LIST' }), batchId: 'b1' },
})
const wrapper = mountWithDraft({ batchType: 'MEDICATION_LIST' })
expect(wrapper.text()).toContain('Medications')
expect(wrapper.text()).toContain('No active medications')
})
it('shows both allergies and medications for MIXED batch', () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch({ batchType: 'MIXED' }), batchId: 'b1' },
})
const wrapper = mountWithDraft({ batchType: 'MIXED' })
expect(wrapper.text()).toContain('Allergies')
expect(wrapper.text()).toContain('Medications')
})
it('hides encounter summary fields for VITALS batch', () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch({ batchType: 'VITALS' }), batchId: 'b1' },
})
const wrapper = mountWithDraft({ batchType: 'VITALS' })
expect(wrapper.text()).not.toContain('Encounter Status')
expect(wrapper.text()).not.toContain('Discharge Diagnosis')
})
it('shows encounter summary fields for ENCOUNTER_SUMMARY batch', () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch({ batchType: 'ENCOUNTER_SUMMARY' }), batchId: 'b1' },
})
const wrapper = mountWithDraft({ batchType: 'ENCOUNTER_SUMMARY' })
expect(wrapper.text()).toContain('Encounter Status')
expect(wrapper.text()).toContain('Discharge Diagnosis')
})
@@ -164,7 +169,7 @@ describe('EntryForm', () => {
})
const store = useBatchStore()
store.currentDraft = {
store.currentDraft = emptyDraft('VITALS', {
patient: {
id: 'dp1',
batchId: 'b1',
@@ -180,7 +185,7 @@ describe('EntryForm', () => {
},
encounter: null,
observations: [],
}
})
await wrapper.vm.$nextTick()
const nameInput = wrapper.find('input[type="text"]')
@@ -4,6 +4,10 @@ import { setActivePinia, createPinia } from 'pinia'
import VerificationForm from '@/components/VerificationForm.vue'
import { useBatchStore } from '@/stores/batches'
import type { BatchDetailResponse } from '@/types'
import {
emptyDraft,
fieldRequirementsForBatchType,
} from '@/__tests__/helpers/fieldRequirements'
vi.mock('@/api/client', () => ({
get: vi.fn(),
@@ -30,11 +34,13 @@ vi.mock('vue-router', () => ({
}))
function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailResponse {
const batchType = overrides.batchType ?? 'VITALS'
return {
id: 'b1',
status: 'PENDING_VERIFICATION',
batchType: 'VITALS',
batchType,
track: 'TRACK_A',
fieldRequirements: fieldRequirementsForBatchType(batchType),
patientId: 'p1',
documentRef: 'docs/scan.pdf',
documentUrl: null,
@@ -47,6 +53,8 @@ function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailRes
promotionEncounterId: null,
supersedesBatchId: null,
clinicianAttestation: false,
isCorrection: false,
supersession: null,
createdAt: '2026-06-27T10:00:00Z',
updatedAt: '2026-06-27T10:00:00Z',
...overrides,
@@ -54,12 +62,13 @@ function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailRes
}
function mountWithDraft(batchOverrides: Partial<BatchDetailResponse> = {}) {
const batch = makeBatch(batchOverrides)
const wrapper = mount(VerificationForm, {
props: { batch: makeBatch(batchOverrides), batchId: 'b1' },
props: { batch, batchId: 'b1' },
})
const store = useBatchStore()
store.currentDraft = {
store.currentDraft = emptyDraft(batch.batchType, {
patient: {
id: 'dp1',
batchId: 'b1',
@@ -103,7 +112,7 @@ function mountWithDraft(batchOverrides: Partial<BatchDetailResponse> = {}) {
note: null,
},
],
}
})
return { wrapper, store }
}
@@ -350,7 +359,7 @@ describe('VerificationForm', () => {
})
const store = useBatchStore()
store.currentDraft = {
store.currentDraft = emptyDraft('ALLERGY_UPDATE', {
patient: {
id: 'dp1',
batchId: 'b1',
@@ -375,7 +384,7 @@ describe('VerificationForm', () => {
status: null,
},
observations: [],
}
})
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('No Known Allergies')
@@ -0,0 +1,87 @@
import type { BatchDraft, BatchTypeFieldRequirements } from '@/types'
export function fieldRequirementsForBatchType(
batchType: string,
): BatchTypeFieldRequirements {
switch (batchType) {
case 'PATIENT_REGISTRATION':
return {
showPatientDemographics: true,
showEncounterContext: false,
showEncounterSummaryFields: false,
showObservations: false,
showAllergies: false,
showMedications: false,
}
case 'ALLERGY_UPDATE':
return {
showPatientDemographics: true,
showEncounterContext: false,
showEncounterSummaryFields: false,
showObservations: false,
showAllergies: true,
showMedications: false,
}
case 'ENCOUNTER_SUMMARY':
return {
showPatientDemographics: true,
showEncounterContext: true,
showEncounterSummaryFields: true,
showObservations: false,
showAllergies: false,
showMedications: false,
}
case 'MEDICATION_LIST':
return {
showPatientDemographics: true,
showEncounterContext: false,
showEncounterSummaryFields: false,
showObservations: false,
showAllergies: false,
showMedications: true,
}
case 'MIXED':
return {
showPatientDemographics: true,
showEncounterContext: true,
showEncounterSummaryFields: true,
showObservations: true,
showAllergies: true,
showMedications: true,
}
case 'LAB_RESULTS':
return {
showPatientDemographics: true,
showEncounterContext: true,
showEncounterSummaryFields: false,
showObservations: true,
showAllergies: false,
showMedications: false,
}
default:
return {
showPatientDemographics: true,
showEncounterContext: true,
showEncounterSummaryFields: false,
showObservations: true,
showAllergies: false,
showMedications: false,
}
}
}
export function emptyDraft(
batchType: string,
overrides: Partial<BatchDraft> = {},
): BatchDraft {
return {
batchId: 'b1',
status: 'IN_ENTRY',
batchType,
fieldRequirements: fieldRequirementsForBatchType(batchType),
patient: null,
encounter: null,
observations: [],
...overrides,
}
}
@@ -270,16 +270,10 @@ const departments = [
'Anesthesiology',
]
const batchType = computed(() => props.batch?.batchType ?? '')
const showAllergies = computed(() =>
['ALLERGY_UPDATE', 'MIXED'].includes(batchType.value)
)
const showMedications = computed(() =>
['MEDICATION_LIST', 'MIXED'].includes(batchType.value)
)
const showEncounterSummary = computed(() =>
['ENCOUNTER_SUMMARY', 'MIXED'].includes(batchType.value)
)
const fieldReqs = computed(() => batchStore.currentDraft?.fieldRequirements)
const showAllergies = computed(() => fieldReqs.value?.showAllergies ?? false)
const showMedications = computed(() => fieldReqs.value?.showMedications ?? false)
const showEncounterSummary = computed(() => fieldReqs.value?.showEncounterSummaryFields ?? false)
const patient = reactive({
fullName: '',
@@ -220,10 +220,10 @@ const allergyFields = ref<FieldInfo[]>([])
const medicationFields = ref<FieldInfo[]>([])
const encounterFields = ref<FieldInfo[]>([])
const batchType = computed(() => props.batch?.batchType ?? '')
const showAllergies = computed(() => ['ALLERGY_UPDATE', 'MIXED'].includes(batchType.value))
const showMedications = computed(() => ['MEDICATION_LIST', 'MIXED'].includes(batchType.value))
const showEncounterSummary = computed(() => ['ENCOUNTER_SUMMARY', 'MIXED'].includes(batchType.value))
const fieldReqs = computed(() => batchStore.currentDraft?.fieldRequirements)
const showAllergies = computed(() => fieldReqs.value?.showAllergies ?? false)
const showMedications = computed(() => fieldReqs.value?.showMedications ?? false)
const showEncounterSummary = computed(() => fieldReqs.value?.showEncounterSummaryFields ?? false)
function parseJsonList(json: string | null): string[] {
if (!json) return []
+32
View File
@@ -36,11 +36,28 @@ export interface ApiError {
code: string
}
export interface BatchTypeFieldRequirements {
showPatientDemographics: boolean
showEncounterContext: boolean
showEncounterSummaryFields: boolean
showObservations: boolean
showAllergies: boolean
showMedications: boolean
}
export interface SupersessionInfo {
originalBatchId: string
originalBatchStatus: string
originalPromotedAt: string
originalObservationCount: number
}
export interface BatchDetailResponse {
id: string
status: string
batchType: string
track: string
fieldRequirements: BatchTypeFieldRequirements
patientId: string | null
documentRef: string
documentUrl: string | null
@@ -53,6 +70,8 @@ export interface BatchDetailResponse {
promotionEncounterId: string | null
supersedesBatchId: string | null
clinicianAttestation: boolean
isCorrection: boolean
supersession: SupersessionInfo | null
createdAt: string
updatedAt: string
}
@@ -108,6 +127,10 @@ export interface DraftObservation {
}
export interface BatchDraft {
batchId: string
status: string
batchType: string
fieldRequirements: BatchTypeFieldRequirements
patient: DraftPatient | null
encounter: DraftEncounter | null
observations: DraftObservation[]
@@ -243,4 +266,13 @@ export interface LiveCaptureResponse {
observations: LiveCaptureObservationResponse[]
criticalAlertCount: number
promotedAt: string
}
export interface BatchTypeFieldRequirements {
showPatientDemographics: boolean
showEncounterContext: boolean
showEncounterSummaryFields: boolean
showObservations: boolean
showAllergies: boolean
showMedications: boolean
}