patient-service-desk-v3.state.mjs 67 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315
  1. export function createInitialState({
  2. activePatientId = "patient-zhang-san",
  3. demoNow = "2026-07-03T09:00:00+08:00",
  4. } = {}) {
  5. return {
  6. activeTab: "home",
  7. activePatientId,
  8. demoNow,
  9. appliedProjectionFixtureIds: [],
  10. activeAgentTaskProjection: null,
  11. patientWorkItems: [],
  12. journeyProjections: [],
  13. reportSourceProjections: {},
  14. reportFeedback: [],
  15. reportFilters: { family: "ALL", status: "ALL" },
  16. resultSnapshotProjections: {},
  17. commandProjections: {},
  18. messages: [],
  19. consents: {},
  20. consentScopeBindings: {},
  21. overlays: {
  22. commandId: null,
  23. consentId: null,
  24. consentScope: null,
  25. reportId: null,
  26. reportView: null,
  27. reportUnavailableReason: null,
  28. reportExpanded: false,
  29. reportFeedbackOpen: false,
  30. reportFeedbackSubmitted: false,
  31. reportServiceNotice: null,
  32. appointmentId: null,
  33. journeyView: null,
  34. scenarioPanelOpen: false,
  35. },
  36. };
  37. }
  38. const deepFreeze = (value) => {
  39. if (value && typeof value === "object" && !Object.isFrozen(value)) {
  40. Object.freeze(value);
  41. Object.values(value).forEach(deepFreeze);
  42. }
  43. return value;
  44. };
  45. const isRecord = (value) => (
  46. value !== null && typeof value === "object" && !Array.isArray(value)
  47. );
  48. const reportIdPattern = /^[^\s\u0000-\u001F\u007F]{1,128}$/;
  49. const reportHashPattern = /^[^\s\u0000-\u001F\u007F]{1,256}$/;
  50. const reportSourceStatuses = new Set(["FINAL", "PRELIMINARY"]);
  51. const reportFamilies = new Set(["LAB", "EXAM"]);
  52. const reportTypes = new Set(["BLOOD_ROUTINE", "IMAGING"]);
  53. const reportVerificationStatuses = new Set(["VERIFIED", "NEED_REVIEW"]);
  54. export const bloodRoutineReportFixture = deepFreeze({
  55. reportId: "report-blood-20260702",
  56. family: "LAB",
  57. reportType: "BLOOD_ROUTINE",
  58. title: "血常规",
  59. hospitalName: "空海医院",
  60. departmentName: "检验科",
  61. issuedAt: "2026-07-03 09:30",
  62. sourceStatus: "FINAL",
  63. interpretationStatus: "NOT_REQUESTED",
  64. verificationStatus: "VERIFIED",
  65. sourceVersion: 1,
  66. sourceHash: "sha256:blood-v1",
  67. readStatus: "UNREAD",
  68. metrics: [
  69. {
  70. code: "WBC",
  71. name: "白细胞计数",
  72. value: 11.2,
  73. unit: "10^9/L",
  74. referenceRange: "3.5–9.5",
  75. sourceFlag: "HIGH",
  76. },
  77. ],
  78. });
  79. const registrationDemoText = "我想挂明天神经内科李明主任的号";
  80. const registrationResultRef = "result-slot-001";
  81. const registrationResultVersion = 1;
  82. const registrationItems = deepFreeze([
  83. {
  84. candidateId: "candidate-slot-0930",
  85. visitDate: "2026-07-03",
  86. timePeriod: "上午",
  87. visitTime: "09:30",
  88. departmentName: "神经内科",
  89. doctorName: "李明",
  90. doctorTitle: "主任医师",
  91. remainingSlots: 3,
  92. fee: "30元",
  93. },
  94. {
  95. candidateId: "candidate-slot-1030",
  96. visitDate: "2026-07-03",
  97. timePeriod: "上午",
  98. visitTime: "10:30",
  99. departmentName: "神经内科",
  100. doctorName: "李明",
  101. doctorTitle: "主任医师",
  102. remainingSlots: 1,
  103. fee: "30元",
  104. },
  105. {
  106. candidateId: "candidate-slot-1430",
  107. visitDate: "2026-07-03",
  108. timePeriod: "下午",
  109. visitTime: "14:30",
  110. departmentName: "神经内科",
  111. doctorName: "李明",
  112. doctorTitle: "主任医师",
  113. remainingSlots: 5,
  114. fee: "30元",
  115. },
  116. ]);
  117. const registrationResultText = [
  118. `已为你找到 ${registrationItems[0].visitDate} ${registrationItems[0].departmentName}${registrationItems[0].doctorName}${registrationItems[0].doctorTitle}的 ${registrationItems.length} 个号源:`,
  119. ...registrationItems.map((item) => (
  120. `${item.visitDate} ${item.timePeriod} ${item.visitTime},`
  121. + `${item.departmentName},${item.doctorName}${item.doctorTitle},`
  122. + `余号 ${item.remainingSlots},挂号费 ${item.fee};`
  123. )),
  124. "请选择一个号源。",
  125. ].join("");
  126. const nextMessageId = (messages, role) => {
  127. const usedIds = new Set(
  128. messages
  129. .filter((message) => message && typeof message === "object")
  130. .map(({ id }) => id),
  131. );
  132. let sequence = messages.length + 1;
  133. while (usedIds.has(`message-${role}-${sequence}`)) {
  134. sequence += 1;
  135. }
  136. return `message-${role}-${sequence}`;
  137. };
  138. const simulateRegistrationQueryBackendFixture = (interaction) => {
  139. if (interaction?.text !== registrationDemoText) {
  140. return {
  141. type: "NO_RESULT",
  142. text: interaction?.text
  143. ? "暂时没有找到匹配结果。你可以换个说法,试试智能导诊,或转人工服务。"
  144. : "请输入想办理的就医服务。你也可以试试智能导诊或人工服务。",
  145. };
  146. }
  147. return {
  148. type: "REGISTRATION_CANDIDATES",
  149. task: {
  150. taskId: "agent-task-registration-001",
  151. type: "REGISTRATION",
  152. stage: "CANDIDATE_SELECTION",
  153. constraints: {
  154. departmentName: "神经内科",
  155. doctorName: "李明",
  156. doctorTitle: "主任医师",
  157. visitDate: "2026-07-03",
  158. },
  159. missingFields: [],
  160. activeResultRef: registrationResultRef,
  161. },
  162. result: {
  163. resultRef: registrationResultRef,
  164. resultVersion: registrationResultVersion,
  165. expiresAt: "2026-07-03T10:00:00+08:00",
  166. text: registrationResultText,
  167. items: registrationItems.map((item) => ({ ...item })),
  168. },
  169. };
  170. };
  171. const applyRegistrationQueryResponseFixture = (
  172. state,
  173. interaction,
  174. fixture,
  175. ) => {
  176. const messages = Array.isArray(state.messages) ? state.messages : [];
  177. const userMessage = interaction.text ? [{
  178. id: nextMessageId(messages, "user"),
  179. role: "user",
  180. text: interaction.text,
  181. }] : [];
  182. const assistantMessage = {
  183. id: nextMessageId([...messages, ...userMessage], "assistant"),
  184. role: "assistant",
  185. text: fixture.text ?? fixture.result?.text,
  186. ...(fixture.result?.resultRef
  187. ? { resultRef: fixture.result.resultRef }
  188. : {}),
  189. };
  190. if (fixture.type !== "REGISTRATION_CANDIDATES") {
  191. return {
  192. ...state,
  193. activeTab: "assistant",
  194. messages: [...messages, ...userMessage, assistantMessage],
  195. };
  196. }
  197. return {
  198. ...state,
  199. activeTab: "assistant",
  200. activeAgentTaskProjection: fixture.task,
  201. resultSnapshotProjections: {
  202. ...(state.resultSnapshotProjections ?? {}),
  203. [fixture.result.resultRef]: fixture.result,
  204. },
  205. messages: [...messages, ...userMessage, assistantMessage],
  206. };
  207. };
  208. export function submitInteraction(state, text) {
  209. if (!state || typeof state !== "object" || Array.isArray(state)) {
  210. return state;
  211. }
  212. const normalizedText = typeof text === "string" ? text.trim() : "";
  213. const interaction = {
  214. interactionType: "USER_MESSAGE",
  215. text: normalizedText,
  216. };
  217. const responseFixture = simulateRegistrationQueryBackendFixture(
  218. interaction,
  219. );
  220. return applyRegistrationQueryResponseFixture(
  221. state,
  222. interaction,
  223. responseFixture,
  224. );
  225. }
  226. export function buildCandidateSelectionInteraction(
  227. state,
  228. candidateSelection,
  229. now,
  230. ) {
  231. const task = state?.activeAgentTaskProjection;
  232. const resultRef = task?.activeResultRef;
  233. const result = typeof resultRef === "string"
  234. ? state?.resultSnapshotProjections?.[resultRef]
  235. : null;
  236. const normalizedSelection = typeof candidateSelection === "string"
  237. ? candidateSelection.trim()
  238. : candidateSelection;
  239. const candidateId = normalizedSelection === "选第一个"
  240. ? result?.items?.[0]?.candidateId
  241. : normalizedSelection;
  242. const expiresAt = Date.parse(result?.expiresAt);
  243. const evaluatedAt = Date.parse(now ?? state?.demoNow);
  244. const isExpired = !Number.isFinite(expiresAt)
  245. || !Number.isFinite(evaluatedAt)
  246. || expiresAt <= evaluatedAt;
  247. const hasCandidate = Array.isArray(result?.items) && result.items.some(
  248. (item) => item?.candidateId === candidateId,
  249. );
  250. if (
  251. task?.type !== "REGISTRATION"
  252. || task.stage !== "CANDIDATE_SELECTION"
  253. || result?.resultRef !== resultRef
  254. || !Number.isInteger(result?.resultVersion)
  255. || result.resultVersion <= 0
  256. || isExpired
  257. || !hasCandidate
  258. ) {
  259. return null;
  260. }
  261. return {
  262. interactionType: "SELECT_CANDIDATE",
  263. resultRef,
  264. resultVersion: result.resultVersion,
  265. candidateId,
  266. };
  267. }
  268. export function applyCandidateSelectionResponseFixture(state, fixture) {
  269. if (!state || typeof state !== "object" || !fixture?.id) {
  270. return state;
  271. }
  272. const appliedIds = Array.isArray(state.appliedProjectionFixtureIds)
  273. ? state.appliedProjectionFixtureIds
  274. : [];
  275. if (appliedIds.includes(fixture.id)) {
  276. return state;
  277. }
  278. const task = state?.activeAgentTaskProjection;
  279. const result = task?.activeResultRef
  280. ? state?.resultSnapshotProjections?.[task.activeResultRef]
  281. : null;
  282. const isActiveSelection = task?.type === "REGISTRATION"
  283. && task.stage === "CANDIDATE_SELECTION"
  284. && result?.resultRef === task.activeResultRef;
  285. if (!isActiveSelection) {
  286. return state;
  287. }
  288. const canonicalErrors = new Set([
  289. "RESULT_VERSION_CONFLICT",
  290. "RESULT_EXPIRED",
  291. "SLOT_UNAVAILABLE",
  292. ]);
  293. const validation = fixture.validation ?? {};
  294. const derivedError = validation.versionMatched === false
  295. ? "RESULT_VERSION_CONFLICT"
  296. : validation.notExpired === false
  297. ? "RESULT_EXPIRED"
  298. : validation.realtimeAvailable === false
  299. || validation.l1LockSucceeded === false
  300. ? "SLOT_UNAVAILABLE"
  301. : null;
  302. const baseState = {
  303. ...state,
  304. appliedProjectionFixtureIds: [...appliedIds, fixture.id],
  305. };
  306. const evaluatedAt = Date.parse(state.demoNow);
  307. const snapshotExpiresAt = Date.parse(result.expiresAt);
  308. const snapshotNotExpired = Number.isFinite(evaluatedAt)
  309. && Number.isFinite(snapshotExpiresAt)
  310. && snapshotExpiresAt > evaluatedAt;
  311. const lockExpiresAt = Date.parse(fixture.lock?.expiresAt);
  312. const lockIdValid = typeof fixture.lock?.lockId === "string"
  313. && fixture.lock.lockId.trim().length > 0;
  314. const lockExpiryValid = Number.isFinite(evaluatedAt)
  315. && Number.isFinite(lockExpiresAt)
  316. && lockExpiresAt > evaluatedAt;
  317. const candidateExists = Array.isArray(result?.items) && result.items.some(
  318. (item) => item?.candidateId === fixture.candidateId,
  319. );
  320. const validationPassed = [
  321. "tenantMatched",
  322. "patientMatched",
  323. "sessionMatched",
  324. "versionMatched",
  325. "notExpired",
  326. "realtimeAvailable",
  327. "l1LockSucceeded",
  328. ].every((key) => validation[key] === true);
  329. const successIsValid = fixture.outcome === "SUCCESS"
  330. && fixture.resultRef === result.resultRef
  331. && fixture.resultVersion === result.resultVersion
  332. && candidateExists
  333. && validationPassed
  334. && snapshotNotExpired
  335. && lockIdValid
  336. && lockExpiryValid;
  337. if (!successIsValid) {
  338. const errorCode = canonicalErrors.has(fixture.errorCode)
  339. ? fixture.errorCode
  340. : derivedError
  341. ?? (!snapshotNotExpired
  342. ? "RESULT_EXPIRED"
  343. : !lockIdValid && fixture.outcome === "SUCCESS"
  344. ? "SLOT_UNAVAILABLE"
  345. : !lockExpiryValid && fixture.outcome === "SUCCESS"
  346. ? "RESULT_EXPIRED"
  347. : "UNKNOWN_SELECTION_ERROR");
  348. const unavailableAdvice = errorCode === "SLOT_UNAVAILABLE"
  349. ? " 当前号源已无号,可查看其他时段或医生。"
  350. : "";
  351. const messages = Array.isArray(state.messages) ? state.messages : [];
  352. return {
  353. ...baseState,
  354. activeAgentTaskProjection: {
  355. ...task,
  356. stage: "REQUERY_REQUIRED",
  357. activeResultRef: null,
  358. },
  359. messages: [
  360. ...messages,
  361. {
  362. id: nextMessageId(messages, "assistant"),
  363. role: "assistant",
  364. text: `结果已更新,请重新查询。${unavailableAdvice}`,
  365. },
  366. ],
  367. };
  368. }
  369. const messages = Array.isArray(state.messages) ? state.messages : [];
  370. return {
  371. ...baseState,
  372. activeAgentTaskProjection: {
  373. ...task,
  374. stage: "AWAITING_REGISTRATION_CONFIRMATION",
  375. selectedCandidateId: fixture.candidateId,
  376. lock: { ...fixture.lock },
  377. },
  378. messages: [
  379. ...messages,
  380. {
  381. id: nextMessageId(messages, "assistant"),
  382. role: "assistant",
  383. text: "锁号成功,请在锁定时间内完成二次确认后挂号。",
  384. },
  385. ],
  386. };
  387. }
  388. const commandStatuses = new Set([
  389. "PREPARED",
  390. "AWAITING_CONFIRMATION",
  391. "EXECUTING",
  392. "SUCCEEDED",
  393. "FAILED",
  394. "EXPIRED",
  395. "UNKNOWN",
  396. "REJECTED",
  397. ]);
  398. const commandTransitions = {
  399. L1: {
  400. PREPARED: new Set(["EXECUTING"]),
  401. EXECUTING: new Set(["SUCCEEDED", "FAILED", "UNKNOWN"]),
  402. UNKNOWN: new Set(["SUCCEEDED", "FAILED"]),
  403. },
  404. L2: {
  405. PREPARED: new Set(["AWAITING_CONFIRMATION"]),
  406. AWAITING_CONFIRMATION: new Set([
  407. "EXECUTING",
  408. "REJECTED",
  409. "EXPIRED",
  410. ]),
  411. EXECUTING: new Set(["SUCCEEDED", "FAILED", "UNKNOWN"]),
  412. UNKNOWN: new Set(["SUCCEEDED", "FAILED"]),
  413. },
  414. };
  415. const initialCommandStatuses = {
  416. L1: new Set(["PREPARED", "UNKNOWN"]),
  417. L2: new Set(["PREPARED", "AWAITING_CONFIRMATION", "UNKNOWN"]),
  418. };
  419. const commandFixtureError = (message) => {
  420. throw new Error(`Command projection fixture invalid: ${message}`);
  421. };
  422. const legacyAppointmentResult = {
  423. type: "APPOINTMENT_CREATED",
  424. eventId: "appointment-created-001",
  425. appointmentId: "appointment-001",
  426. patientId: "patient-zhang-san",
  427. summary: {
  428. visitDate: "2026-07-03",
  429. visitTime: "09:30",
  430. departmentName: "神经内科",
  431. doctorName: "李明",
  432. doctorTitle: "主任医师",
  433. fee: "30元",
  434. },
  435. };
  436. const applyAppointmentCreatedProjection = (state, fixture) => {
  437. const result = fixture.result ?? (
  438. fixture.resultFixtureId === "appointment-created-001"
  439. ? legacyAppointmentResult
  440. : null
  441. );
  442. if (!result || result.type !== "APPOINTMENT_CREATED") {
  443. return state;
  444. }
  445. const eventId = result.eventId ?? fixture.resultFixtureId;
  446. if (
  447. typeof eventId !== "string"
  448. || !eventId
  449. || typeof result.appointmentId !== "string"
  450. || !result.appointmentId
  451. ) {
  452. commandFixtureError("appointment result identifiers are required");
  453. }
  454. if (state.appliedProjectionFixtureIds.includes(eventId)) {
  455. return state;
  456. }
  457. const summary = result.summary ?? {};
  458. const doctor = `${summary.doctorName ?? ""}${summary.doctorTitle ?? ""}`;
  459. return {
  460. ...state,
  461. appliedProjectionFixtureIds: [
  462. ...state.appliedProjectionFixtureIds,
  463. eventId,
  464. ],
  465. patientWorkItems: [
  466. ...state.patientWorkItems,
  467. {
  468. id: `work-item-${result.appointmentId}`,
  469. appointmentId: result.appointmentId,
  470. patientId: result.patientId,
  471. type: "APPOINTMENT",
  472. status: "UPCOMING",
  473. stage: "WAITING_ARRIVAL",
  474. priority: 80,
  475. title: `${summary.departmentName ?? "门诊"}预约成功`,
  476. description: `${summary.visitDate ?? ""} ${summary.visitTime ?? ""} · ${doctor} · ${summary.fee ?? ""}`,
  477. fee: summary.fee,
  478. primaryAction: null,
  479. secondaryAction: null,
  480. expiresAt: null,
  481. },
  482. ],
  483. journeyProjections: [
  484. ...state.journeyProjections,
  485. {
  486. id: `journey-${result.appointmentId}`,
  487. stage: "WAITING_ARRIVAL",
  488. title: "待到院",
  489. appointmentId: result.appointmentId,
  490. patientId: result.patientId,
  491. },
  492. ],
  493. };
  494. };
  495. export function applyCommandProjectionFixture(state, fixture) {
  496. if (!state || typeof state !== "object" || Array.isArray(state)) {
  497. commandFixtureError("state is required");
  498. }
  499. if (!fixture || typeof fixture !== "object" || Array.isArray(fixture)) {
  500. commandFixtureError("fixture is required");
  501. }
  502. const { id, commandId, status } = fixture;
  503. if (typeof id !== "string" || !id.trim()) {
  504. commandFixtureError("id is required");
  505. }
  506. if (typeof commandId !== "string" || !commandId.trim()) {
  507. commandFixtureError("commandId is required");
  508. }
  509. if (status === "COMPLETED") {
  510. commandFixtureError("COMPLETED is not a Command status");
  511. }
  512. if (!commandStatuses.has(status)) {
  513. commandFixtureError(`unknown status ${String(status)}`);
  514. }
  515. const appliedIds = Array.isArray(state.appliedProjectionFixtureIds)
  516. ? state.appliedProjectionFixtureIds
  517. : [];
  518. if (appliedIds.includes(id)) {
  519. return state;
  520. }
  521. const current = state.commandProjections?.[commandId];
  522. const riskLevel = fixture.riskLevel ?? current?.riskLevel ?? "L2";
  523. if (riskLevel !== "L1" && riskLevel !== "L2") {
  524. commandFixtureError(`unknown riskLevel ${String(riskLevel)}`);
  525. }
  526. if (current && current.riskLevel !== riskLevel) {
  527. commandFixtureError("riskLevel cannot change");
  528. }
  529. const version = fixture.version ?? current?.version;
  530. if (version !== undefined && (!Number.isInteger(version) || version <= 0)) {
  531. commandFixtureError("version must be a positive integer");
  532. }
  533. if (!current && !initialCommandStatuses[riskLevel].has(status)) {
  534. commandFixtureError(
  535. `illegal initial ${riskLevel} status ${status}`,
  536. );
  537. }
  538. if (current) {
  539. const isFinalBusinessSnapshot = (
  540. riskLevel === "L2"
  541. && current.status === "AWAITING_CONFIRMATION"
  542. && status === "SUCCEEDED"
  543. && fixture.resultFixtureId === "appointment-created-001"
  544. && fixture.result === undefined
  545. && fixture.version === undefined
  546. );
  547. // The exact legacy appointment fixture may close a missed EXECUTING gap.
  548. // Every current incremental or structured-result fixture stays strict.
  549. const transitionAllowed = commandTransitions[riskLevel][current.status]
  550. ?.has(status);
  551. if (
  552. !isFinalBusinessSnapshot
  553. && (
  554. !Number.isInteger(fixture.version)
  555. || !Number.isInteger(current.version)
  556. || fixture.version <= current.version
  557. )
  558. ) {
  559. commandFixtureError("version must explicitly increase");
  560. }
  561. if (!transitionAllowed && !isFinalBusinessSnapshot) {
  562. commandFixtureError(
  563. `illegal ${riskLevel} transition ${current.status} -> ${status}`,
  564. );
  565. }
  566. }
  567. const projection = {
  568. ...current,
  569. commandId,
  570. status,
  571. riskLevel,
  572. ...(version === undefined ? {} : { version }),
  573. ...(fixture.details === undefined
  574. ? {}
  575. : { details: structuredClone(fixture.details) }),
  576. ...(fixture.resultFixtureId === undefined
  577. ? {}
  578. : { resultFixtureId: fixture.resultFixtureId }),
  579. ...(fixture.result === undefined
  580. ? {}
  581. : { result: structuredClone(fixture.result) }),
  582. };
  583. let nextState = {
  584. ...state,
  585. appliedProjectionFixtureIds: [...appliedIds, id],
  586. commandProjections: {
  587. ...(state.commandProjections ?? {}),
  588. [commandId]: projection,
  589. },
  590. overlays: {
  591. ...state.overlays,
  592. commandId,
  593. },
  594. };
  595. if (
  596. status === "SUCCEEDED"
  597. && (
  598. fixture.result?.type === "APPOINTMENT_CREATED"
  599. || fixture.resultFixtureId === "appointment-created-001"
  600. )
  601. ) {
  602. nextState = applyAppointmentCreatedProjection(nextState, fixture);
  603. }
  604. return nextState;
  605. }
  606. export function buildCommandConfirmationRequest(
  607. state,
  608. commandId,
  609. { channel, idempotencyKey } = {},
  610. ) {
  611. const command = state?.commandProjections?.[commandId];
  612. if (!command) {
  613. throw new Error("Command 不存在,无法确认");
  614. }
  615. if (command.status === "UNKNOWN") {
  616. throw new Error("结果确认中,请勿重复操作");
  617. }
  618. if (command.status === "EXPIRED") {
  619. throw new Error("Command 已过期,请重新查询号源");
  620. }
  621. if (command.status !== "AWAITING_CONFIRMATION") {
  622. throw new Error(`当前状态 ${command.status} 不允许重复确认`);
  623. }
  624. if (!Number.isInteger(command.version) || command.version <= 0) {
  625. throw new Error("Command version 无效");
  626. }
  627. if (channel !== "CARD") {
  628. throw new Error("确认渠道不受支持");
  629. }
  630. if (typeof idempotencyKey !== "string" || !idempotencyKey.trim()) {
  631. throw new Error("幂等键不能为空");
  632. }
  633. return {
  634. path: `/api/v2/commands/${encodeURIComponent(commandId)}/confirm`,
  635. method: "POST",
  636. commandId,
  637. body: {
  638. commandVersion: command.version,
  639. confirmation: {
  640. channel,
  641. explicit: true,
  642. },
  643. },
  644. headers: {
  645. "X-Emoon-Idempotency-Key": idempotencyKey,
  646. },
  647. };
  648. }
  649. export function buildCommandRejectionRequest(
  650. state,
  651. commandId,
  652. { reason, idempotencyKey } = {},
  653. ) {
  654. const command = state?.commandProjections?.[commandId];
  655. if (!command) {
  656. throw new Error("Command 不存在,无法拒绝");
  657. }
  658. if (command.status !== "AWAITING_CONFIRMATION") {
  659. throw new Error(`当前状态 ${command.status} 不允许拒绝`);
  660. }
  661. if (!Number.isInteger(command.version) || command.version <= 0) {
  662. throw new Error("Command version 无效");
  663. }
  664. if (typeof idempotencyKey !== "string" || !idempotencyKey.trim()) {
  665. throw new Error("幂等键不能为空");
  666. }
  667. return {
  668. path: `/api/v2/commands/${encodeURIComponent(commandId)}/reject`,
  669. method: "POST",
  670. commandId,
  671. body: {
  672. commandVersion: command.version,
  673. ...(typeof reason === "string" && reason.trim()
  674. ? { reason: reason.trim() }
  675. : {}),
  676. },
  677. headers: {
  678. "X-Emoon-Idempotency-Key": idempotencyKey,
  679. },
  680. };
  681. }
  682. const statusRank = {
  683. ACTION_REQUIRED: 0,
  684. IN_PROGRESS: 1,
  685. UPCOMING: 2,
  686. COMPLETED: 3,
  687. };
  688. export const reportInterpretationProjectionFixture = deepFreeze({
  689. resultRef: "result-report-interpretation-001",
  690. resultVersion: 1,
  691. interpretation: {
  692. headline: "3 项指标轻度异常",
  693. summary: "这份报告有 3 项指标超出参考范围,变化幅度较小,需要结合近期症状和病史由医生综合判断。",
  694. safety: "本解释仅用于帮助理解报告指标,不用于判断疾病或替代医生意见。如有明显不适,请及时联系医务人员。",
  695. highlights: [
  696. { name: "白细胞计数", value: "11.2", flag: "偏高" },
  697. { name: "中性粒细胞比例", value: "78%", flag: "偏高" },
  698. { name: "淋巴细胞比例", value: "18%", flag: "偏低" },
  699. ],
  700. explanations: [
  701. {
  702. indicator: "白细胞 11.2 ×10⁹/L",
  703. explanation: "轻度高于报告所示参考范围,单项变化不能独立说明具体原因。",
  704. commonFactors: "近期感染、睡眠不足、运动或情绪紧张等。",
  705. },
  706. {
  707. indicator: "中性粒细胞比例 78%",
  708. explanation: "比例轻度升高,需结合白细胞总数和当前症状判断。",
  709. commonFactors: "细菌感染、急性炎症或生理应激等。",
  710. },
  711. {
  712. indicator: "淋巴细胞比例 18%",
  713. explanation: "比例轻度偏低,可能与中性粒细胞比例升高同时出现。",
  714. commonFactors: "感染恢复期、压力或个体短期波动等。",
  715. },
  716. ],
  717. canDo: [
  718. "查看并保存医院原始报告。",
  719. "结合近期症状和既往情况,向开单医生咨询。",
  720. "如有明显不适,及时联系医务人员。",
  721. ],
  722. evidence: [
  723. "医院原始报告中的指标结果、单位、参考范围和异常标记。",
  724. "医院已审核的终版报告。",
  725. ],
  726. },
  727. });
  728. const reportConsentId = (reportId) => `consent-ai-${reportId}`;
  729. const encodeReportId = (reportId) => String(reportId).replace(
  730. /[^A-Za-z0-9._-]/g,
  731. (character) => (
  732. `~${character.charCodeAt(0).toString(16).padStart(4, "0")}`
  733. ),
  734. );
  735. const reportResultSnapshotId = (reportId) => (
  736. `result-report-interpretation-${encodeReportId(reportId)}`
  737. );
  738. const reportFeedbackCategories = new Set([
  739. "HELPFUL",
  740. "METRIC_INACCURATE",
  741. "HARD_TO_UNDERSTAND",
  742. "DIFFERS_FROM_DOCTOR",
  743. "OTHER",
  744. ]);
  745. const readReportIssuedFixture = (fixture) => {
  746. if (
  747. fixture.payload !== undefined
  748. && !isRecord(fixture.payload)
  749. ) {
  750. return null;
  751. }
  752. const payload = fixture.payload ?? {};
  753. const isExamination = payload.family === "EXAM";
  754. const report = {
  755. ...bloodRoutineReportFixture,
  756. ...payload,
  757. interpretationStatus: "NOT_REQUESTED",
  758. metrics: Array.isArray(payload.metrics)
  759. ? payload.metrics
  760. : isExamination
  761. ? []
  762. : bloodRoutineReportFixture.metrics,
  763. };
  764. if (
  765. typeof report.reportId !== "string"
  766. || !reportIdPattern.test(report.reportId)
  767. || !Number.isInteger(report.sourceVersion)
  768. || report.sourceVersion <= 0
  769. || typeof report.sourceHash !== "string"
  770. || !reportHashPattern.test(report.sourceHash)
  771. || !reportSourceStatuses.has(report.sourceStatus)
  772. || !reportFamilies.has(report.family)
  773. || !reportTypes.has(report.reportType)
  774. || !reportVerificationStatuses.has(report.verificationStatus)
  775. || (report.family === "LAB" && report.reportType !== "BLOOD_ROUTINE")
  776. || (report.family === "EXAM" && report.reportType !== "IMAGING")
  777. ) {
  778. return null;
  779. }
  780. return deepFreeze(structuredClone(report));
  781. };
  782. export const hasValidReportConsent = (state, reportId) => {
  783. if (
  784. typeof reportId !== "string"
  785. || reportId.length === 0
  786. || !isRecord(state?.consents)
  787. || !isRecord(state?.consentScopeBindings)
  788. ) {
  789. return false;
  790. }
  791. const consentId = reportConsentId(reportId);
  792. const consent = Object.hasOwn(state.consents, consentId)
  793. ? state.consents[consentId]
  794. : null;
  795. const binding = Object.hasOwn(state.consentScopeBindings, consentId)
  796. ? state.consentScopeBindings[consentId]
  797. : null;
  798. const expiresAt = Date.parse(consent?.expiresAt);
  799. const evaluatedAt = Date.parse(state?.demoNow);
  800. return consent?.id === consentId
  801. && consent.status === "GRANTED"
  802. && Number.isFinite(expiresAt)
  803. && Number.isFinite(evaluatedAt)
  804. && expiresAt > evaluatedAt
  805. && binding?.patientId === state?.activePatientId
  806. && binding?.reportId === reportId;
  807. };
  808. export const selectValidReportResult = (state, reportId) => {
  809. if (typeof reportId !== "string" || reportId.length === 0) {
  810. return null;
  811. }
  812. const resultRef = reportResultSnapshotId(reportId);
  813. const reportSources = state?.reportSourceProjections;
  814. const results = state?.resultSnapshotProjections;
  815. const report = (
  816. isRecord(reportSources)
  817. && Object.hasOwn(reportSources, reportId)
  818. ) ? reportSources[reportId] : null;
  819. const result = (
  820. isRecord(results) && Object.hasOwn(results, resultRef)
  821. ) ? results[resultRef] : null;
  822. const hasScopedTask = Array.isArray(state?.patientWorkItems)
  823. && state.patientWorkItems.some((item) => (
  824. item?.type === "REPORT_REVIEW" && item.reportId === reportId
  825. ));
  826. return isRecord(report)
  827. && report.reportId === reportId
  828. && report.interpretationStatus === "READY"
  829. && hasScopedTask
  830. && hasValidReportConsent(state, reportId)
  831. && evaluateReportInterpretationGate(state, reportId).allowed
  832. && isRecord(result)
  833. && result.resultRef === resultRef
  834. && result.reportId === reportId
  835. && result.sourceVersion === report.sourceVersion
  836. && result.sourceHash === report.sourceHash
  837. && Number.isInteger(result.resultVersion)
  838. && result.resultVersion > 0
  839. && result.valid === true
  840. && isRecord(result.interpretation)
  841. ? result
  842. : null;
  843. };
  844. export function selectHomeTasks(state) {
  845. const patientWorkItems = Array.isArray(state?.patientWorkItems)
  846. ? state.patientWorkItems.filter(
  847. (item) => item !== null && typeof item === "object"
  848. && !Array.isArray(item),
  849. )
  850. : [];
  851. const priorityOf = ({ priority }) => (
  852. Number.isFinite(priority) ? priority : 0
  853. );
  854. const rankOf = ({ status }) => (
  855. typeof status === "string" && Object.hasOwn(statusRank, status)
  856. ? statusRank[status]
  857. : 4
  858. );
  859. return patientWorkItems.sort((left, right) => {
  860. const statusDelta = rankOf(left) - rankOf(right);
  861. return statusDelta || priorityOf(right) - priorityOf(left);
  862. });
  863. }
  864. export function selectReports(state, filters = {}) {
  865. const family = filters?.family ?? "ALL";
  866. const status = filters?.status ?? "ALL";
  867. const projections = state?.reportSourceProjections;
  868. if (!projections || typeof projections !== "object" || Array.isArray(projections)) {
  869. return [];
  870. }
  871. return Object.values(projections)
  872. .filter((report) => report && typeof report === "object" && !Array.isArray(report))
  873. .filter((report) => family === "ALL" || report.family === family)
  874. .filter((report) => status === "ALL" || report.readStatus === status)
  875. .sort((left, right) => {
  876. const issuedAtDelta = String(right.issuedAt ?? "")
  877. .localeCompare(String(left.issuedAt ?? ""));
  878. return issuedAtDelta || String(left.reportId ?? "")
  879. .localeCompare(String(right.reportId ?? ""));
  880. });
  881. }
  882. const upsertProjection = (items, nextItem) => {
  883. const source = Array.isArray(items) ? items : [];
  884. const index = source.findIndex((item) => item?.id === nextItem.id);
  885. if (index < 0) {
  886. return [...source, nextItem];
  887. }
  888. return source.map((item, itemIndex) => (
  889. itemIndex === index ? { ...item, ...nextItem } : item
  890. ));
  891. };
  892. const journeyTitleByStage = {
  893. WAITING_ARRIVAL: "待到院",
  894. CHECKIN_AVAILABLE: "可以签到",
  895. QUEUE_WAITING: "候诊中",
  896. VISIT_IN_PROGRESS: "就诊中",
  897. POST_VISIT: "诊后服务",
  898. };
  899. const updateJourneyProjection = (
  900. journeys,
  901. appointmentId,
  902. stage,
  903. details = {},
  904. ) => upsertProjection(journeys, {
  905. id: `journey-${appointmentId}`,
  906. appointmentId,
  907. stage,
  908. title: journeyTitleByStage[stage],
  909. ...details,
  910. });
  911. const appendJourneyMessage = (messages, fixture, text) => {
  912. if (!text) {
  913. return Array.isArray(messages) ? messages : [];
  914. }
  915. return [
  916. ...(Array.isArray(messages) ? messages : []),
  917. {
  918. id: `message-${fixture.id}`,
  919. type: "JOURNEY",
  920. unread: true,
  921. title: text,
  922. createdAt: fixture.occurredAt,
  923. },
  924. ];
  925. };
  926. const readReportInvalidationFixture = (fixture) => {
  927. const payload = isRecord(fixture?.payload) ? fixture.payload : null;
  928. const reportId = payload?.reportId;
  929. const sourceVersion = payload?.sourceVersion;
  930. const sourceHash = payload?.sourceHash;
  931. if (
  932. typeof reportId !== "string"
  933. || reportId.trim().length === 0
  934. || !/^[A-Za-z0-9._-]+$/.test(reportId)
  935. || !Number.isInteger(sourceVersion)
  936. || sourceVersion <= 0
  937. || typeof sourceHash !== "string"
  938. || sourceHash.trim().length === 0
  939. || !/^[A-Za-z0-9._:-]+$/.test(sourceHash)
  940. ) {
  941. return null;
  942. }
  943. return {
  944. reportId,
  945. sourceVersion,
  946. sourceHash: sourceHash.trim(),
  947. };
  948. };
  949. const applyReportInvalidationProjection = (state, nextState, fixture) => {
  950. const { reportId, sourceVersion, sourceHash } =
  951. readReportInvalidationFixture(fixture);
  952. const report = typeof reportId === "string"
  953. && isRecord(state.reportSourceProjections)
  954. && Object.hasOwn(state.reportSourceProjections, reportId)
  955. ? state.reportSourceProjections[reportId]
  956. : null;
  957. const latestKnownVersion = Math.max(
  958. Number.isInteger(report?.sourceVersion) ? report.sourceVersion : 0,
  959. Number.isInteger(report?.invalidationVersion)
  960. ? report.invalidationVersion
  961. : 0,
  962. );
  963. if (
  964. !isRecord(report)
  965. || report.reportId !== reportId
  966. || sourceVersion <= latestKnownVersion
  967. ) {
  968. return nextState;
  969. }
  970. const withdrawn = fixture.type === "REPORT_WITHDRAWN";
  971. const resultSnapshotProjections = Object.fromEntries(
  972. Object.entries(state.resultSnapshotProjections ?? {}).map(
  973. ([resultRef, result]) => (
  974. isRecord(result) && result.reportId === reportId
  975. ? [resultRef, {
  976. ...result,
  977. valid: false,
  978. invalidatedBy: fixture.id,
  979. invalidatedReason: withdrawn
  980. ? "REPORT_WITHDRAWN"
  981. : "REPORT_CORRECTED",
  982. invalidatedSourceVersion: sourceVersion,
  983. }]
  984. : [resultRef, result]
  985. ),
  986. ),
  987. );
  988. const reportSourceProjections = {
  989. ...state.reportSourceProjections,
  990. [reportId]: {
  991. ...report,
  992. interpretationStatus: "INVALIDATED",
  993. invalidationVersion: sourceVersion,
  994. invalidationHash: sourceHash,
  995. invalidatedAt: fixture.occurredAt,
  996. invalidationReason: withdrawn
  997. ? "REPORT_WITHDRAWN"
  998. : "REPORT_CORRECTED",
  999. },
  1000. };
  1001. const patientWorkItems = Array.isArray(state.patientWorkItems)
  1002. ? state.patientWorkItems.map((item) => (
  1003. item?.type === "REPORT_REVIEW" && item.reportId === reportId
  1004. ? {
  1005. ...item,
  1006. status: "ACTION_REQUIRED",
  1007. stage: "REPORT_INVALIDATED",
  1008. title: withdrawn ? "报告已撤回" : "报告已更正",
  1009. description: withdrawn
  1010. ? "医院已撤回当前报告,请以医院后续通知为准"
  1011. : "医院已更新报告,原辅助解释已失效",
  1012. primaryAction: "VIEW_REPORT",
  1013. secondaryAction: null,
  1014. }
  1015. : item
  1016. ))
  1017. : state.patientWorkItems;
  1018. const isActiveReport = state.overlays?.reportId === reportId;
  1019. return {
  1020. ...nextState,
  1021. reportSourceProjections,
  1022. patientWorkItems,
  1023. resultSnapshotProjections,
  1024. overlays: isActiveReport ? {
  1025. ...state.overlays,
  1026. consentId: null,
  1027. consentScope: null,
  1028. reportView: "REPORT_UNAVAILABLE",
  1029. reportUnavailableReason: "REPORT_INVALIDATED",
  1030. reportFeedbackOpen: false,
  1031. reportFeedbackSubmitted: false,
  1032. reportServiceNotice: null,
  1033. } : state.overlays,
  1034. };
  1035. };
  1036. export function applyServerProjectionFixture(state, fixture) {
  1037. if (
  1038. !isRecord(state)
  1039. || !Array.isArray(state.appliedProjectionFixtureIds)
  1040. || !isRecord(fixture)
  1041. || typeof fixture.id !== "string"
  1042. || fixture.id.trim().length === 0
  1043. || typeof fixture.type !== "string"
  1044. || fixture.type.trim().length === 0
  1045. ) {
  1046. return state;
  1047. }
  1048. if (state.appliedProjectionFixtureIds.includes(fixture.id)) {
  1049. return state;
  1050. }
  1051. const isReportInvalidation = fixture.type === "REPORT_CORRECTED"
  1052. || fixture.type === "REPORT_WITHDRAWN";
  1053. if (isReportInvalidation && !readReportInvalidationFixture(fixture)) {
  1054. return state;
  1055. }
  1056. const issuedReport = fixture.type === "REPORT_ISSUED"
  1057. ? readReportIssuedFixture(fixture)
  1058. : null;
  1059. if (fixture.type === "REPORT_ISSUED" && !issuedReport) {
  1060. return state;
  1061. }
  1062. const nextState = {
  1063. ...state,
  1064. appliedProjectionFixtureIds: [
  1065. ...state.appliedProjectionFixtureIds,
  1066. fixture.id,
  1067. ],
  1068. };
  1069. if (fixture.type === "REPORT_ISSUED") {
  1070. const report = issuedReport;
  1071. const isExamination = report.family === "EXAM";
  1072. const reportId = report.reportId;
  1073. const currentReport = state.reportSourceProjections?.[reportId];
  1074. const currentVersion = currentReport?.sourceVersion;
  1075. const invalidationVersion = currentReport?.invalidationVersion;
  1076. const conflictsWithInvalidation = Number.isInteger(invalidationVersion)
  1077. && (
  1078. report.sourceVersion < invalidationVersion
  1079. || (
  1080. report.sourceVersion === invalidationVersion
  1081. && report.sourceHash !== currentReport.invalidationHash
  1082. )
  1083. );
  1084. if (
  1085. currentReport
  1086. && (
  1087. !Number.isInteger(report.sourceVersion)
  1088. || !Number.isInteger(currentVersion)
  1089. || report.sourceVersion <= currentVersion
  1090. || conflictsWithInvalidation
  1091. )
  1092. ) {
  1093. return nextState;
  1094. }
  1095. const resultRef = reportResultSnapshotId(reportId);
  1096. const currentResult = isRecord(state.resultSnapshotProjections)
  1097. && Object.hasOwn(state.resultSnapshotProjections, resultRef)
  1098. ? state.resultSnapshotProjections[resultRef]
  1099. : null;
  1100. const resultSnapshotProjections = currentReport && isRecord(currentResult)
  1101. ? {
  1102. ...state.resultSnapshotProjections,
  1103. [resultRef]: {
  1104. ...currentResult,
  1105. valid: false,
  1106. invalidatedReason: "SOURCE_UPDATED",
  1107. invalidatedAt: fixture.occurredAt ?? state.demoNow,
  1108. },
  1109. }
  1110. : state.resultSnapshotProjections;
  1111. const consentId = reportConsentId(reportId);
  1112. const currentConsent = isRecord(state.consents)
  1113. && Object.hasOwn(state.consents, consentId)
  1114. ? state.consents[consentId]
  1115. : null;
  1116. const consents = currentReport && isRecord(currentConsent)
  1117. ? {
  1118. ...state.consents,
  1119. [consentId]: {
  1120. ...currentConsent,
  1121. status: "REVOKED",
  1122. revokedAt: fixture.occurredAt ?? state.demoNow,
  1123. },
  1124. }
  1125. : state.consents;
  1126. const consentScopeBindings = isRecord(state.consentScopeBindings)
  1127. ? Object.fromEntries(
  1128. Object.entries(state.consentScopeBindings).filter(
  1129. ([bindingId]) => !currentReport || bindingId !== consentId,
  1130. ),
  1131. )
  1132. : state.consentScopeBindings;
  1133. return {
  1134. ...nextState,
  1135. reportSourceProjections: {
  1136. ...(state.reportSourceProjections ?? {}),
  1137. [reportId]: report,
  1138. },
  1139. resultSnapshotProjections,
  1140. consents,
  1141. consentScopeBindings,
  1142. overlays: currentReport && state.overlays?.reportId === reportId
  1143. ? {
  1144. ...state.overlays,
  1145. consentId: null,
  1146. consentScope: null,
  1147. reportView: "REPORT_DETAIL",
  1148. reportUnavailableReason: null,
  1149. reportFeedbackOpen: false,
  1150. reportFeedbackSubmitted: false,
  1151. reportServiceNotice: null,
  1152. }
  1153. : state.overlays,
  1154. patientWorkItems: upsertProjection(
  1155. state.patientWorkItems,
  1156. {
  1157. id: `work-item-${reportId}`,
  1158. reportId,
  1159. type: "REPORT_REVIEW",
  1160. status: "ACTION_REQUIRED",
  1161. stage: "REPORT_AVAILABLE",
  1162. priority: 90,
  1163. title: `${typeof report.title === "string" && report.title.trim()
  1164. ? report.title.trim()
  1165. : isExamination ? "检查" : "检验"}报告已出`,
  1166. description: isExamination
  1167. ? "医院检查报告已审核,可查看报告详情"
  1168. : "18项指标中有3项超出参考范围",
  1169. primaryAction: isExamination ? "VIEW_REPORT" : "INTERPRET_REPORT",
  1170. secondaryAction: isExamination ? null : "VIEW_REPORT",
  1171. expiresAt: null,
  1172. },
  1173. ),
  1174. messages: upsertProjection(
  1175. state.messages,
  1176. {
  1177. id: `message-${reportId}`,
  1178. type: "REPORT",
  1179. reportId,
  1180. unread: true,
  1181. title: `${typeof report.title === "string" && report.title.trim()
  1182. ? report.title.trim()
  1183. : isExamination ? "检查" : "检验"}报告已出`,
  1184. createdAt: fixture.occurredAt,
  1185. },
  1186. ),
  1187. };
  1188. }
  1189. if (isReportInvalidation) {
  1190. return applyReportInvalidationProjection(state, nextState, fixture);
  1191. }
  1192. const payload = fixture.payload ?? {};
  1193. const appointmentId = payload.appointmentId ?? "appointment-001";
  1194. let patientWorkItems = state.patientWorkItems;
  1195. let journeyProjections = state.journeyProjections;
  1196. let messages = state.messages;
  1197. switch (fixture.type) {
  1198. case "APPOINTMENT_CREATED": {
  1199. patientWorkItems = upsertProjection(patientWorkItems, {
  1200. id: `work-item-${appointmentId}`,
  1201. appointmentId,
  1202. type: "APPOINTMENT",
  1203. status: "UPCOMING",
  1204. stage: "WAITING_ARRIVAL",
  1205. priority: 80,
  1206. title: `${payload.departmentName ?? "门诊"}预约成功`,
  1207. description: `${payload.visitTime ?? "09:30"} · ${payload.doctorName ?? "接诊医生"}`,
  1208. primaryAction: null,
  1209. secondaryAction: null,
  1210. expiresAt: null,
  1211. });
  1212. journeyProjections = updateJourneyProjection(
  1213. journeyProjections,
  1214. appointmentId,
  1215. "WAITING_ARRIVAL",
  1216. {
  1217. visitTime: payload.visitTime ?? "09:30",
  1218. departmentName: payload.departmentName ?? "神经内科",
  1219. doctorName: payload.doctorName ?? "李明",
  1220. },
  1221. );
  1222. messages = appendJourneyMessage(messages, fixture, "预约成功,请按时到院");
  1223. break;
  1224. }
  1225. case "CHECKIN_AVAILABLE": {
  1226. patientWorkItems = upsertProjection(patientWorkItems, {
  1227. id: `work-item-checkin-${appointmentId}`,
  1228. appointmentId,
  1229. type: "CHECK_IN",
  1230. status: "ACTION_REQUIRED",
  1231. stage: "CONFIRMATION_REQUIRED",
  1232. priority: 95,
  1233. title: "神经内科可以签到",
  1234. description: `已到达${payload.location ?? "门诊诊区"},请确认后签到`,
  1235. location: payload.location,
  1236. primaryAction: "CONFIRM_CHECKIN",
  1237. secondaryAction: "VIEW_ROUTE",
  1238. expiresAt: null,
  1239. });
  1240. journeyProjections = updateJourneyProjection(
  1241. journeyProjections,
  1242. appointmentId,
  1243. "CHECKIN_AVAILABLE",
  1244. { location: payload.location, arrivedAt: fixture.occurredAt },
  1245. );
  1246. messages = appendJourneyMessage(messages, fixture, "已到达诊区,可以签到");
  1247. break;
  1248. }
  1249. case "CHECKIN_COMPLETED": {
  1250. patientWorkItems = upsertProjection(patientWorkItems, {
  1251. id: `work-item-checkin-${appointmentId}`,
  1252. appointmentId,
  1253. type: "CHECK_IN",
  1254. status: "COMPLETED",
  1255. stage: "COMPLETED",
  1256. priority: 95,
  1257. title: "签到已完成",
  1258. description: "已进入候诊队列",
  1259. primaryAction: null,
  1260. secondaryAction: null,
  1261. expiresAt: null,
  1262. });
  1263. patientWorkItems = upsertProjection(patientWorkItems, {
  1264. id: `work-item-queue-${appointmentId}`,
  1265. appointmentId,
  1266. type: "WAITING_QUEUE",
  1267. status: "IN_PROGRESS",
  1268. stage: "QUEUE_WAITING",
  1269. priority: 90,
  1270. title: "神经内科候诊中",
  1271. description: `排队号 ${payload.queueNumber ?? "待更新"}`,
  1272. queueNumber: payload.queueNumber,
  1273. room: payload.room,
  1274. primaryAction: "VIEW_QUEUE",
  1275. secondaryAction: "VIEW_ROUTE",
  1276. expiresAt: null,
  1277. });
  1278. journeyProjections = updateJourneyProjection(
  1279. journeyProjections,
  1280. appointmentId,
  1281. "QUEUE_WAITING",
  1282. {
  1283. checkedInAt: fixture.occurredAt,
  1284. queueNumber: payload.queueNumber,
  1285. room: payload.room,
  1286. },
  1287. );
  1288. messages = appendJourneyMessage(messages, fixture, "签到成功,已进入候诊队列");
  1289. break;
  1290. }
  1291. case "QUEUE_UPDATED": {
  1292. const currentQueue = patientWorkItems.find(
  1293. (item) => item?.id === `work-item-queue-${appointmentId}`,
  1294. );
  1295. patientWorkItems = upsertProjection(patientWorkItems, {
  1296. ...(currentQueue ?? {}),
  1297. id: `work-item-queue-${appointmentId}`,
  1298. appointmentId,
  1299. type: "WAITING_QUEUE",
  1300. status: "IN_PROGRESS",
  1301. stage: "QUEUE_WAITING",
  1302. priority: 90,
  1303. title: "神经内科候诊中",
  1304. description: `前方 ${payload.peopleAhead ?? 0} 人,预计 ${payload.estimatedMinutes ?? 0} 分钟`,
  1305. queueNumber: payload.queueNumber ?? currentQueue?.queueNumber,
  1306. peopleAhead: payload.peopleAhead,
  1307. estimatedMinutes: payload.estimatedMinutes,
  1308. room: payload.room ?? currentQueue?.room,
  1309. delayed: Boolean(payload.delayed),
  1310. updatedAt: fixture.occurredAt,
  1311. primaryAction: "VIEW_QUEUE",
  1312. secondaryAction: "VIEW_ROUTE",
  1313. expiresAt: null,
  1314. });
  1315. journeyProjections = updateJourneyProjection(
  1316. journeyProjections,
  1317. appointmentId,
  1318. "QUEUE_WAITING",
  1319. {
  1320. queueNumber: payload.queueNumber,
  1321. peopleAhead: payload.peopleAhead,
  1322. estimatedMinutes: payload.estimatedMinutes,
  1323. room: payload.room,
  1324. delayed: Boolean(payload.delayed),
  1325. updatedAt: fixture.occurredAt,
  1326. },
  1327. );
  1328. messages = appendJourneyMessage(messages, fixture, "候诊队列已更新");
  1329. break;
  1330. }
  1331. case "VISIT_STARTED": {
  1332. patientWorkItems = upsertProjection(patientWorkItems, {
  1333. id: `work-item-queue-${appointmentId}`,
  1334. appointmentId,
  1335. type: "WAITING_QUEUE",
  1336. status: "COMPLETED",
  1337. stage: "COMPLETED",
  1338. priority: 90,
  1339. title: "候诊已结束",
  1340. description: "医生已开始接诊",
  1341. primaryAction: null,
  1342. secondaryAction: null,
  1343. expiresAt: null,
  1344. });
  1345. patientWorkItems = upsertProjection(patientWorkItems, {
  1346. id: `work-item-visit-${appointmentId}`,
  1347. appointmentId,
  1348. type: "VISIT",
  1349. status: "IN_PROGRESS",
  1350. stage: "VISIT_IN_PROGRESS",
  1351. priority: 100,
  1352. title: "医生接诊中",
  1353. description: payload.room ?? "请在诊室内完成就诊",
  1354. primaryAction: null,
  1355. secondaryAction: null,
  1356. expiresAt: null,
  1357. });
  1358. journeyProjections = updateJourneyProjection(
  1359. journeyProjections,
  1360. appointmentId,
  1361. "VISIT_IN_PROGRESS",
  1362. { room: payload.room, startedAt: fixture.occurredAt },
  1363. );
  1364. messages = appendJourneyMessage(messages, fixture, "医生已开始接诊");
  1365. break;
  1366. }
  1367. case "VISIT_COMPLETED": {
  1368. patientWorkItems = upsertProjection(patientWorkItems, {
  1369. id: `work-item-visit-${appointmentId}`,
  1370. appointmentId,
  1371. type: "VISIT",
  1372. status: "COMPLETED",
  1373. stage: "COMPLETED",
  1374. priority: 100,
  1375. title: "本次就诊已完成",
  1376. description: "诊后记录与服务已更新",
  1377. primaryAction: null,
  1378. secondaryAction: null,
  1379. expiresAt: null,
  1380. });
  1381. patientWorkItems = upsertProjection(patientWorkItems, {
  1382. id: `work-item-post-visit-${appointmentId}`,
  1383. appointmentId,
  1384. type: "POST_VISIT",
  1385. status: "ACTION_REQUIRED",
  1386. stage: "POST_VISIT",
  1387. priority: 70,
  1388. title: "查看诊后服务",
  1389. description: "查看门诊记录、处方和复诊建议",
  1390. primaryAction: null,
  1391. secondaryAction: null,
  1392. expiresAt: null,
  1393. });
  1394. journeyProjections = updateJourneyProjection(
  1395. journeyProjections,
  1396. appointmentId,
  1397. "POST_VISIT",
  1398. { completedAt: fixture.occurredAt },
  1399. );
  1400. messages = appendJourneyMessage(messages, fixture, "就诊完成,诊后服务已更新");
  1401. break;
  1402. }
  1403. default:
  1404. return nextState;
  1405. }
  1406. return {
  1407. ...nextState,
  1408. patientWorkItems,
  1409. journeyProjections,
  1410. messages,
  1411. activeAgentTaskProjection: state.activeAgentTaskProjection,
  1412. commandProjections: state.commandProjections,
  1413. };
  1414. }
  1415. export function requestReportInterpretation(state, reportId) {
  1416. if (!Array.isArray(state?.patientWorkItems)) {
  1417. return state;
  1418. }
  1419. const itemIndex = state.patientWorkItems.findIndex(
  1420. (item) => item?.type === "REPORT_REVIEW" && item.reportId === reportId,
  1421. ) ?? -1;
  1422. if (itemIndex < 0) {
  1423. return state;
  1424. }
  1425. const hasConsent = hasValidReportConsent(state, reportId);
  1426. const gate = evaluateReportInterpretationGate(state, reportId);
  1427. const consentId = reportConsentId(reportId);
  1428. if (!gate.allowed) {
  1429. const unavailableReason = [
  1430. "OCR_REVIEW_REQUIRED",
  1431. "CRITICAL_NOTICE",
  1432. "AI_UNAVAILABLE",
  1433. "UNSUPPORTED",
  1434. ].includes(gate.reason) ? gate.reason : "UNSUPPORTED";
  1435. const currentInterpretationStatus = state
  1436. .reportSourceProjections?.[reportId]?.interpretationStatus;
  1437. return {
  1438. ...state,
  1439. patientWorkItems: state.patientWorkItems.map((item, index) => (
  1440. index === itemIndex
  1441. ? { ...item, stage: "REPORT_UNAVAILABLE" }
  1442. : item
  1443. )),
  1444. reportSourceProjections: {
  1445. ...state.reportSourceProjections,
  1446. [reportId]: {
  1447. ...state.reportSourceProjections[reportId],
  1448. interpretationStatus: currentInterpretationStatus === "INVALIDATED"
  1449. ? "INVALIDATED"
  1450. : "NOT_REQUESTED",
  1451. },
  1452. },
  1453. overlays: {
  1454. ...state.overlays,
  1455. consentId: null,
  1456. consentScope: null,
  1457. reportId,
  1458. reportView: "REPORT_UNAVAILABLE",
  1459. reportUnavailableReason: unavailableReason,
  1460. },
  1461. };
  1462. }
  1463. const existingResult = selectValidReportResult(state, reportId);
  1464. if (
  1465. hasConsent
  1466. && gate.allowed
  1467. && state.reportSourceProjections?.[reportId]?.interpretationStatus
  1468. === "READY"
  1469. && existingResult
  1470. ) {
  1471. return {
  1472. ...state,
  1473. patientWorkItems: state.patientWorkItems.map((item, index) => (
  1474. index === itemIndex ? { ...item, stage: "INTERPRETED" } : item
  1475. )),
  1476. overlays: {
  1477. ...state.overlays,
  1478. consentId: null,
  1479. consentScope: null,
  1480. reportId,
  1481. reportView: "INTERPRETED",
  1482. },
  1483. };
  1484. }
  1485. const patientWorkItems = state.patientWorkItems.map((item, index) => (
  1486. index === itemIndex
  1487. ? { ...item, stage: hasConsent ? "INTERPRETING" : "CONSENT_REQUIRED" }
  1488. : item
  1489. ));
  1490. return {
  1491. ...state,
  1492. patientWorkItems,
  1493. reportSourceProjections: hasConsent ? {
  1494. ...state.reportSourceProjections,
  1495. [reportId]: {
  1496. ...state.reportSourceProjections[reportId],
  1497. interpretationStatus: "GENERATING",
  1498. },
  1499. } : state.reportSourceProjections,
  1500. overlays: {
  1501. ...state.overlays,
  1502. consentId: hasConsent ? null : consentId,
  1503. consentScope: hasConsent ? null : {
  1504. patientId: state.activePatientId,
  1505. reportId,
  1506. },
  1507. reportId,
  1508. reportView: hasConsent
  1509. ? "INTERPRETING"
  1510. : state?.overlays?.reportView ?? null,
  1511. },
  1512. };
  1513. }
  1514. export function evaluateReportInterpretationGate(state, reportId) {
  1515. const report = state?.reportSourceProjections?.[reportId];
  1516. if (!report || typeof report !== "object" || Array.isArray(report)) {
  1517. return { allowed: false, reason: "REPORT_NOT_FOUND" };
  1518. }
  1519. if (report.family !== "LAB" || report.reportType !== "BLOOD_ROUTINE") {
  1520. return { allowed: false, reason: "UNSUPPORTED" };
  1521. }
  1522. if (report.sourceStatus !== "FINAL") {
  1523. return { allowed: false, reason: "SOURCE_NOT_FINAL" };
  1524. }
  1525. if (report.verificationStatus !== "VERIFIED") {
  1526. return {
  1527. allowed: false,
  1528. reason: typeof report.verificationReason === "string"
  1529. ? report.verificationReason
  1530. : "VERIFICATION_REQUIRED",
  1531. };
  1532. }
  1533. if (report.criticalNotice === true) {
  1534. return { allowed: false, reason: "CRITICAL_NOTICE" };
  1535. }
  1536. if (report.interpretationStatus === "INVALIDATED") {
  1537. return { allowed: false, reason: "REPORT_INVALIDATED" };
  1538. }
  1539. if (
  1540. report.interpretationUnavailableReason === "AI_UNAVAILABLE"
  1541. || report.interpretationUnavailableReason === "UNSUPPORTED"
  1542. ) {
  1543. return {
  1544. allowed: false,
  1545. reason: report.interpretationUnavailableReason,
  1546. };
  1547. }
  1548. return { allowed: true, reason: null };
  1549. }
  1550. export function grantConsent(state, consentId) {
  1551. const reportId = state?.overlays?.reportId;
  1552. const expectedConsentId = typeof reportId === "string"
  1553. ? reportConsentId(reportId)
  1554. : null;
  1555. const scope = state?.overlays?.consentScope;
  1556. if (
  1557. consentId !== expectedConsentId
  1558. || state?.overlays?.consentId !== consentId
  1559. || scope?.patientId !== state?.activePatientId
  1560. || scope?.reportId !== reportId
  1561. || !Array.isArray(state?.patientWorkItems)
  1562. ) {
  1563. return state;
  1564. }
  1565. const itemIndex = state?.patientWorkItems?.findIndex(
  1566. (item) => item?.type === "REPORT_REVIEW"
  1567. && item.reportId === reportId
  1568. && item.stage === "CONSENT_REQUIRED",
  1569. ) ?? -1;
  1570. if (itemIndex < 0 || !evaluateReportInterpretationGate(state, reportId).allowed) {
  1571. return state;
  1572. }
  1573. const consent = {
  1574. id: consentId,
  1575. purpose: "用于本次报告的 AI 辅助解释",
  1576. scope: "当前血常规报告",
  1577. expiresAt: "2026-07-09T23:59:59+08:00",
  1578. revocable: true,
  1579. status: "GRANTED",
  1580. };
  1581. const binding = {
  1582. patientId: state.activePatientId,
  1583. reportId,
  1584. };
  1585. const pendingConsentState = {
  1586. ...state,
  1587. consents: {
  1588. ...state.consents,
  1589. [consentId]: consent,
  1590. },
  1591. consentScopeBindings: {
  1592. ...state.consentScopeBindings,
  1593. [consentId]: binding,
  1594. },
  1595. };
  1596. if (!hasValidReportConsent(pendingConsentState, reportId)) {
  1597. return {
  1598. ...state,
  1599. patientWorkItems: state.patientWorkItems.map((item, index) => (
  1600. index === itemIndex ? { ...item, stage: "REPORT_AVAILABLE" } : item
  1601. )),
  1602. reportSourceProjections: {
  1603. ...state.reportSourceProjections,
  1604. [reportId]: {
  1605. ...state.reportSourceProjections[reportId],
  1606. interpretationStatus: "NOT_REQUESTED",
  1607. },
  1608. },
  1609. overlays: {
  1610. ...state.overlays,
  1611. consentId: null,
  1612. consentScope: null,
  1613. reportView: "REPORT_DETAIL",
  1614. },
  1615. };
  1616. }
  1617. return {
  1618. ...state,
  1619. consents: pendingConsentState.consents,
  1620. consentScopeBindings: pendingConsentState.consentScopeBindings,
  1621. reportSourceProjections: {
  1622. ...state.reportSourceProjections,
  1623. [reportId]: {
  1624. ...state.reportSourceProjections[reportId],
  1625. interpretationStatus: "GENERATING",
  1626. },
  1627. },
  1628. patientWorkItems: state.patientWorkItems.map((item, index) => {
  1629. if (index === itemIndex) {
  1630. return { ...item, stage: "INTERPRETING" };
  1631. }
  1632. return item;
  1633. }),
  1634. resultSnapshotProjections: {
  1635. ...(state.resultSnapshotProjections ?? {}),
  1636. },
  1637. overlays: {
  1638. ...state.overlays,
  1639. consentId: null,
  1640. consentScope: null,
  1641. reportView: "INTERPRETING",
  1642. },
  1643. };
  1644. }
  1645. export function openReportDetails(state, reportId) {
  1646. const exists = state?.patientWorkItems?.some(
  1647. (item) => item?.type === "REPORT_REVIEW" && item.reportId === reportId,
  1648. );
  1649. const report = state?.reportSourceProjections?.[reportId];
  1650. const hasConsistentSource = (
  1651. report
  1652. && typeof report === "object"
  1653. && !Array.isArray(report)
  1654. && (report.reportId === undefined || report.reportId === reportId)
  1655. );
  1656. if (!exists || !hasConsistentSource) {
  1657. return state;
  1658. }
  1659. const isExamination = report.family === "EXAM";
  1660. const gate = isExamination
  1661. ? { allowed: true, reason: null }
  1662. : evaluateReportInterpretationGate(state, reportId);
  1663. const unavailableReason = gate.allowed ? null : (
  1664. [
  1665. "OCR_REVIEW_REQUIRED",
  1666. "CRITICAL_NOTICE",
  1667. "AI_UNAVAILABLE",
  1668. "UNSUPPORTED",
  1669. ].includes(gate.reason) ? gate.reason : "UNSUPPORTED"
  1670. );
  1671. return {
  1672. ...state,
  1673. reportSourceProjections: {
  1674. ...(state.reportSourceProjections ?? {}),
  1675. [reportId]: {
  1676. ...report,
  1677. readStatus: "READ",
  1678. },
  1679. },
  1680. overlays: {
  1681. ...state.overlays,
  1682. reportId,
  1683. reportView: unavailableReason
  1684. ? "REPORT_UNAVAILABLE"
  1685. : "REPORT_DETAIL",
  1686. reportUnavailableReason: unavailableReason,
  1687. reportExpanded: false,
  1688. reportFeedbackOpen: false,
  1689. reportFeedbackSubmitted: false,
  1690. reportServiceNotice: null,
  1691. },
  1692. };
  1693. }
  1694. export function closeReportView(state) {
  1695. if (!state?.overlays?.reportId && !state?.overlays?.reportView) {
  1696. return state;
  1697. }
  1698. return {
  1699. ...state,
  1700. overlays: {
  1701. ...state.overlays,
  1702. consentId: null,
  1703. consentScope: null,
  1704. reportId: null,
  1705. reportView: null,
  1706. reportUnavailableReason: null,
  1707. reportExpanded: false,
  1708. reportFeedbackOpen: false,
  1709. reportFeedbackSubmitted: false,
  1710. reportServiceNotice: null,
  1711. },
  1712. };
  1713. }
  1714. export function rejectConsent(state, consentId) {
  1715. const reportId = state?.overlays?.reportId;
  1716. if (
  1717. consentId !== reportConsentId(reportId)
  1718. || state?.overlays?.consentId !== consentId
  1719. || state?.overlays?.consentScope?.patientId !== state?.activePatientId
  1720. || state?.overlays?.consentScope?.reportId !== reportId
  1721. || !Array.isArray(state?.patientWorkItems)
  1722. ) {
  1723. return state;
  1724. }
  1725. const hasPendingItem = state.patientWorkItems?.some(
  1726. (item) => item?.type === "REPORT_REVIEW"
  1727. && item.reportId === reportId
  1728. && item.stage === "CONSENT_REQUIRED",
  1729. );
  1730. if (typeof reportId !== "string" || !hasPendingItem) {
  1731. return state;
  1732. }
  1733. return {
  1734. ...state,
  1735. patientWorkItems: state.patientWorkItems.map((item) => (
  1736. item?.type === "REPORT_REVIEW" && item.reportId === reportId
  1737. ? { ...item, stage: "REPORT_AVAILABLE" }
  1738. : item
  1739. )),
  1740. overlays: {
  1741. ...state.overlays,
  1742. consentId: null,
  1743. consentScope: null,
  1744. reportView: "REPORT_DETAIL",
  1745. },
  1746. };
  1747. }
  1748. export function applyReportInterpretationProjectionFixture(state, reportId) {
  1749. const itemIndex = state?.patientWorkItems?.findIndex(
  1750. (item) => item?.type === "REPORT_REVIEW"
  1751. && item.reportId === reportId
  1752. && item.stage === "INTERPRETING",
  1753. ) ?? -1;
  1754. const consentGranted = hasValidReportConsent(state, reportId);
  1755. const gate = evaluateReportInterpretationGate(state, reportId);
  1756. const source = state?.reportSourceProjections?.[reportId];
  1757. if (
  1758. itemIndex < 0
  1759. || !consentGranted
  1760. || !gate.allowed
  1761. || !isRecord(source)
  1762. ) {
  1763. return state;
  1764. }
  1765. const result = {
  1766. ...reportInterpretationProjectionFixture,
  1767. resultRef: reportResultSnapshotId(reportId),
  1768. reportId,
  1769. sourceVersion: source.sourceVersion,
  1770. sourceHash: source.sourceHash,
  1771. valid: true,
  1772. interpretation: {
  1773. ...reportInterpretationProjectionFixture.interpretation,
  1774. highlights: reportInterpretationProjectionFixture.interpretation
  1775. .highlights.map((highlight) => ({ ...highlight })),
  1776. explanations: reportInterpretationProjectionFixture.interpretation
  1777. .explanations.map((explanation) => ({ ...explanation })),
  1778. canDo: [
  1779. ...reportInterpretationProjectionFixture.interpretation.canDo,
  1780. ],
  1781. evidence: [
  1782. ...reportInterpretationProjectionFixture.interpretation.evidence,
  1783. ],
  1784. },
  1785. };
  1786. return {
  1787. ...state,
  1788. patientWorkItems: state.patientWorkItems.map((item, index) => (
  1789. index === itemIndex ? { ...item, stage: "INTERPRETED" } : item
  1790. )),
  1791. reportSourceProjections: {
  1792. ...state.reportSourceProjections,
  1793. [reportId]: {
  1794. ...state.reportSourceProjections[reportId],
  1795. interpretationStatus: "READY",
  1796. },
  1797. },
  1798. resultSnapshotProjections: {
  1799. ...state.resultSnapshotProjections,
  1800. [result.resultRef]: result,
  1801. },
  1802. overlays: {
  1803. ...state.overlays,
  1804. consentId: null,
  1805. reportId,
  1806. reportView: "INTERPRETED",
  1807. },
  1808. };
  1809. }
  1810. export function submitReportFeedback(state, payload = {}) {
  1811. const reportId = payload?.reportId;
  1812. const category = payload?.category;
  1813. const reportSources = state?.reportSourceProjections;
  1814. const report = (
  1815. typeof reportId === "string"
  1816. && reportId.length > 0
  1817. && reportSources
  1818. && typeof reportSources === "object"
  1819. && !Array.isArray(reportSources)
  1820. && Object.hasOwn(reportSources, reportId)
  1821. )
  1822. ? reportSources[reportId]
  1823. : null;
  1824. const hasReportTask = Array.isArray(state?.patientWorkItems)
  1825. && state.patientWorkItems.some((item) => (
  1826. item?.type === "REPORT_REVIEW" && item.reportId === reportId
  1827. ));
  1828. const result = selectValidReportResult(state, reportId);
  1829. if (
  1830. !report
  1831. || typeof report !== "object"
  1832. || Array.isArray(report)
  1833. || report.reportId !== reportId
  1834. || !hasReportTask
  1835. || !reportFeedbackCategories.has(category)
  1836. || !Array.isArray(state?.reportFeedback)
  1837. || !hasValidReportConsent(state, reportId)
  1838. || !evaluateReportInterpretationGate(state, reportId).allowed
  1839. || report.interpretationStatus !== "READY"
  1840. || !result
  1841. ) {
  1842. return state;
  1843. }
  1844. const feedback = {
  1845. id: [
  1846. "feedback",
  1847. encodeReportId(reportId),
  1848. report.sourceVersion,
  1849. category,
  1850. ].join("-"),
  1851. reportId,
  1852. sourceVersion: report.sourceVersion,
  1853. sourceHash: report.sourceHash,
  1854. resultRef: result.resultRef,
  1855. resultVersion: result.resultVersion,
  1856. category,
  1857. };
  1858. if (state.reportFeedback.some((item) => item?.id === feedback.id)) {
  1859. return state;
  1860. }
  1861. return {
  1862. ...state,
  1863. reportFeedback: [...state.reportFeedback, feedback],
  1864. };
  1865. }
  1866. export function revokeConsent(state, consentId) {
  1867. if (
  1868. !isRecord(state)
  1869. || typeof consentId !== "string"
  1870. || !isRecord(state.consents)
  1871. || !isRecord(state.consentScopeBindings)
  1872. || !isRecord(state.reportSourceProjections)
  1873. || !isRecord(state.resultSnapshotProjections)
  1874. || !isRecord(state.overlays)
  1875. || !Array.isArray(state.patientWorkItems)
  1876. || !Object.hasOwn(state.consents, consentId)
  1877. || !Object.hasOwn(state.consentScopeBindings, consentId)
  1878. ) {
  1879. return state;
  1880. }
  1881. const consent = state.consents[consentId];
  1882. const binding = state.consentScopeBindings[consentId];
  1883. if (
  1884. !isRecord(consent)
  1885. || !isRecord(binding)
  1886. || typeof binding.reportId !== "string"
  1887. || !Object.hasOwn(state.reportSourceProjections, binding.reportId)
  1888. || !isRecord(state.reportSourceProjections[binding.reportId])
  1889. || consentId !== reportConsentId(binding.reportId)
  1890. || consent?.id !== consentId
  1891. || binding?.patientId !== state?.activePatientId
  1892. ) {
  1893. return state;
  1894. }
  1895. const reportId = binding.reportId;
  1896. const consentScopeBindings = Object.fromEntries(
  1897. Object.entries(state.consentScopeBindings).filter(
  1898. ([bindingConsentId]) => bindingConsentId !== consentId,
  1899. ),
  1900. );
  1901. const resultSnapshotProjections = Object.fromEntries(
  1902. Object.entries(state.resultSnapshotProjections ?? {}).filter(
  1903. ([, result]) => result?.reportId !== reportId,
  1904. ),
  1905. );
  1906. return {
  1907. ...state,
  1908. consents: {
  1909. ...state.consents,
  1910. [consentId]: {
  1911. ...consent,
  1912. status: "REVOKED",
  1913. },
  1914. },
  1915. consentScopeBindings,
  1916. reportSourceProjections: {
  1917. ...state.reportSourceProjections,
  1918. [reportId]: {
  1919. ...state.reportSourceProjections[reportId],
  1920. interpretationStatus: "NOT_REQUESTED",
  1921. },
  1922. },
  1923. patientWorkItems: state.patientWorkItems.map((item) => (
  1924. item?.type === "REPORT_REVIEW" && item.reportId === reportId
  1925. ? { ...item, stage: "REPORT_AVAILABLE" }
  1926. : item
  1927. )),
  1928. resultSnapshotProjections,
  1929. overlays: {
  1930. ...state.overlays,
  1931. consentId: null,
  1932. consentScope: null,
  1933. reportId,
  1934. reportView: "REPORT_DETAIL",
  1935. reportFeedbackOpen: false,
  1936. reportFeedbackSubmitted: false,
  1937. reportServiceNotice: null,
  1938. },
  1939. };
  1940. }
  1941. const scenarioNames = new Set([
  1942. "REPORT_ARRIVED",
  1943. "REPORT_DETAIL",
  1944. "REPORT_CONSENT",
  1945. "REPORT_GENERATING",
  1946. "REPORT_READY",
  1947. "REPORT_NEED_REVIEW",
  1948. "REPORT_CRITICAL",
  1949. "REPORT_INVALIDATED",
  1950. "EMPTY_HOME",
  1951. "REGISTRATION_QUERY",
  1952. "COMMAND_PREPARED",
  1953. "APPOINTMENT_CREATED",
  1954. "CHECKIN_AVAILABLE",
  1955. "QUEUE_UPDATED",
  1956. ]);
  1957. const loadRegistrationScenario = (base) => submitInteraction(
  1958. base,
  1959. registrationDemoText,
  1960. );
  1961. const loadLockedRegistrationScenario = (base) => {
  1962. const queried = loadRegistrationScenario(base);
  1963. return applyCandidateSelectionResponseFixture(queried, {
  1964. id: "scenario-selection-success-001",
  1965. outcome: "SUCCESS",
  1966. resultRef: "result-slot-001",
  1967. resultVersion: 1,
  1968. candidateId: "candidate-slot-0930",
  1969. validation: {
  1970. tenantMatched: true,
  1971. patientMatched: true,
  1972. sessionMatched: true,
  1973. versionMatched: true,
  1974. notExpired: true,
  1975. realtimeAvailable: true,
  1976. l1LockSucceeded: true,
  1977. },
  1978. lock: {
  1979. lockId: "lock-slot-001",
  1980. expiresAt: "2026-07-03T09:05:00+08:00",
  1981. },
  1982. });
  1983. };
  1984. const loadCommandPreparedScenario = (base) => {
  1985. const locked = loadLockedRegistrationScenario(base);
  1986. return applyCommandProjectionFixture(locked, {
  1987. id: "scenario-command-awaiting-001",
  1988. commandId: "command-register-001",
  1989. version: 1,
  1990. riskLevel: "L2",
  1991. status: "AWAITING_CONFIRMATION",
  1992. details: {
  1993. operationName: "确认挂号",
  1994. patientName: "张先生",
  1995. departmentName: "神经内科",
  1996. doctorName: "李明",
  1997. doctorTitle: "主任医师",
  1998. visitAt: "2026-07-03 09:30",
  1999. fee: "30元",
  2000. expiresAt: "2026-07-03T09:05:00+08:00",
  2001. countdown: "剩余 5 分钟",
  2002. candidateId: "candidate-slot-0930",
  2003. },
  2004. });
  2005. };
  2006. export function loadScenario(state, scenarioName) {
  2007. const normalized = scenarioNames.has(scenarioName)
  2008. ? scenarioName
  2009. : "EMPTY_HOME";
  2010. const base = {
  2011. ...createInitialState({
  2012. activePatientId: state?.activePatientId ?? "patient-zhang-san",
  2013. }),
  2014. demoScenario: normalized,
  2015. };
  2016. const reportFixture = {
  2017. id: "scenario-report-arrived-001",
  2018. type: "REPORT_ISSUED",
  2019. occurredAt: "2026-07-03T09:30:00+08:00",
  2020. payload: { reportId: "report-blood-20260702" },
  2021. };
  2022. switch (normalized) {
  2023. case "REPORT_ARRIVED":
  2024. return { ...applyServerProjectionFixture(base, reportFixture), demoScenario: normalized };
  2025. case "REPORT_DETAIL": {
  2026. const report = applyServerProjectionFixture(base, reportFixture);
  2027. return {
  2028. ...openReportDetails(report, "report-blood-20260702"),
  2029. demoScenario: normalized,
  2030. };
  2031. }
  2032. case "REPORT_CONSENT": {
  2033. const report = openReportDetails(
  2034. applyServerProjectionFixture(base, reportFixture),
  2035. "report-blood-20260702",
  2036. );
  2037. return {
  2038. ...requestReportInterpretation(report, "report-blood-20260702"),
  2039. demoScenario: normalized,
  2040. };
  2041. }
  2042. case "REPORT_GENERATING": {
  2043. const pending = loadScenario(base, "REPORT_CONSENT");
  2044. return {
  2045. ...grantConsent(
  2046. pending,
  2047. "consent-ai-report-blood-20260702",
  2048. ),
  2049. demoScenario: normalized,
  2050. };
  2051. }
  2052. case "REPORT_NEED_REVIEW": {
  2053. const report = applyServerProjectionFixture(base, {
  2054. ...reportFixture,
  2055. payload: {
  2056. ...reportFixture.payload,
  2057. verificationStatus: "NEED_REVIEW",
  2058. verificationReason: "OCR_REVIEW_REQUIRED",
  2059. },
  2060. });
  2061. return {
  2062. ...openReportDetails(report, "report-blood-20260702"),
  2063. demoScenario: normalized,
  2064. };
  2065. }
  2066. case "REPORT_CRITICAL": {
  2067. const report = applyServerProjectionFixture(base, {
  2068. ...reportFixture,
  2069. payload: {
  2070. ...reportFixture.payload,
  2071. criticalNotice: true,
  2072. },
  2073. });
  2074. return {
  2075. ...openReportDetails(report, "report-blood-20260702"),
  2076. demoScenario: normalized,
  2077. };
  2078. }
  2079. case "REPORT_INVALIDATED": {
  2080. const ready = loadScenario(base, "REPORT_READY");
  2081. return {
  2082. ...applyServerProjectionFixture(ready, {
  2083. id: "scenario-report-corrected-002",
  2084. type: "REPORT_CORRECTED",
  2085. occurredAt: "2026-07-03T11:00:00+08:00",
  2086. payload: {
  2087. reportId: "report-blood-20260702",
  2088. sourceVersion: 2,
  2089. sourceHash: "sha256:blood-v2",
  2090. },
  2091. }),
  2092. demoScenario: normalized,
  2093. };
  2094. }
  2095. case "REPORT_READY": {
  2096. const report = applyServerProjectionFixture(base, reportFixture);
  2097. const pending = requestReportInterpretation(
  2098. report,
  2099. "report-blood-20260702",
  2100. );
  2101. const interpreting = grantConsent(
  2102. pending,
  2103. "consent-ai-report-blood-20260702",
  2104. );
  2105. return {
  2106. ...applyReportInterpretationProjectionFixture(
  2107. interpreting,
  2108. "report-blood-20260702",
  2109. ),
  2110. demoScenario: normalized,
  2111. };
  2112. }
  2113. case "REGISTRATION_QUERY":
  2114. return { ...loadRegistrationScenario(base), demoScenario: normalized };
  2115. case "COMMAND_PREPARED":
  2116. return { ...loadCommandPreparedScenario(base), demoScenario: normalized };
  2117. case "APPOINTMENT_CREATED": {
  2118. const awaiting = loadCommandPreparedScenario(base);
  2119. return {
  2120. ...applyCommandProjectionFixture(awaiting, {
  2121. id: "scenario-command-succeeded-001",
  2122. commandId: "command-register-001",
  2123. status: "SUCCEEDED",
  2124. resultFixtureId: "appointment-created-001",
  2125. }),
  2126. activeTab: "home",
  2127. demoScenario: normalized,
  2128. };
  2129. }
  2130. case "CHECKIN_AVAILABLE": {
  2131. const appointment = loadScenario(base, "APPOINTMENT_CREATED");
  2132. return {
  2133. ...applyServerProjectionFixture(appointment, {
  2134. id: "scenario-checkin-available-001",
  2135. type: "CHECKIN_AVAILABLE",
  2136. occurredAt: "2026-07-03T09:10:00+08:00",
  2137. payload: {
  2138. appointmentId: "appointment-001",
  2139. location: "门诊三楼神经内科诊区",
  2140. },
  2141. }),
  2142. demoScenario: normalized,
  2143. };
  2144. }
  2145. case "QUEUE_UPDATED": {
  2146. const arrived = loadScenario(base, "CHECKIN_AVAILABLE");
  2147. const checkedIn = applyServerProjectionFixture(arrived, {
  2148. id: "scenario-checkin-completed-001",
  2149. type: "CHECKIN_COMPLETED",
  2150. occurredAt: "2026-07-03T09:12:00+08:00",
  2151. payload: {
  2152. appointmentId: "appointment-001",
  2153. queueNumber: "A023",
  2154. room: "3F-08诊室",
  2155. },
  2156. });
  2157. return {
  2158. ...applyServerProjectionFixture(checkedIn, {
  2159. id: "scenario-queue-updated-001",
  2160. type: "QUEUE_UPDATED",
  2161. occurredAt: "2026-07-03T09:18:00+08:00",
  2162. payload: {
  2163. appointmentId: "appointment-001",
  2164. queueNumber: "A023",
  2165. peopleAhead: 4,
  2166. estimatedMinutes: 18,
  2167. room: "3F-08诊室",
  2168. delayed: true,
  2169. },
  2170. }),
  2171. demoScenario: normalized,
  2172. };
  2173. }
  2174. default:
  2175. return base;
  2176. }
  2177. }