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