Skip to content

feat(pharmacy-orders, facilities): item-level partial fulfillment + distance-sorted facility selection - #154

Open
Oluwaferanmi18 wants to merge 12 commits into
DocForum:mainfrom
Oluwaferanmi18:feat/partial-fulfillment-item-level-dispensing-and-facility-location
Open

Oluwaferanmi18 wants to merge 12 commits into
DocForum:mainfrom
Oluwaferanmi18:feat/partial-fulfillment-item-level-dispensing-and-facility-location

Conversation

@Oluwaferanmi18

Copy link
Copy Markdown
Contributor

Closes #73
Closes #74

Two independently-scoped Phase 5 issues, landed together because they share the same schema migration and doc updates.

#73 — Partial fulfillment: item-level dispensing

What

  • New FulfillmentItem table: one row per routed order item under a FulfillmentRecord, with quantityDispensed and status (pending | dispensed | unfulfilled).
  • Order-level status now derives from items: all dispensed → fulfilled, some → partially_fulfilled (new enum values on OrderStatus and FulfillmentStatus). Applied post-commit by pharmacy-orders/services through the prescriptions/lab-orders module services (hard rules 4 and 5 — facilities never touch Prescription/LabOrder rows, pharmacy-orders never writes those tables directly), conditional and forward-only, so an expired/superseded order is never resurrected (integration-tested).
  • Split routing: POST /pharmacy-orders/fulfillments with an explicit orderItemIds creates a second record for just the remaining items; routing an already-dispensed item → 409.
  • Double-dispense is a DB constraint, not app timing: dispensedKey (= orderItemId when dispensed, NULL otherwise) under a plain unique index (orderType, dispensedKey) — NULLs don't collide in Postgres, so only two dispenses of the same item ever conflict. A literal WHERE status='dispensed' partial index would be equivalent SQL but Prisma can't represent it in schema.prisma and migrate diff would try to drop one it finds in the DB. Genuinely concurrent dispense attempts by two facilities are tested: exactly one 200, one 409.
  • Payment release rule documented in ARCHITECTURE.md §5.3: partial fulfillment never releases escrow; release eligibility requires the whole order to derive fulfilled. Proration is logged as an open question in §6.2 rather than silently decided.

Interpretation flag for reviewers (issue asked to flag design questions): this builds on the current polymorphic FulfillmentRecord — issue #13's keep-vs-split ADR is untouched and now needs to account for FulfillmentItem. Also, hard rule 1 ("never mutate a Prescription after issuance") is read together with §5.3 step 5 and the existing OrderStatus.fulfilled value: clinical content stays append-only; the lifecycle status field is the one sanctioned derived mutation. Both flagged in the schema and ARCHITECTURE §4.7.

Known gaps (recorded in ARCHITECTURE §6.2.7, not hidden)

  • A patient can't read record-level fulfillment yet — PatientProfile ↔ order linkage is Phase 4 (issue [Phase 4] Order models: Prescription + LabOrder (append-only) #9). The coarse GET /pharmacy-orders/orders/:id/fulfillment-status exists so patients get an honest status now.
  • quantityOrdered stays null until Phase 4's item models grow a quantity column — not faked as 0.

#74 — Facility location + distance-sorted selection

What

  • FacilityProfile gains nullable address, latitude, longitude + composite (latitude, longitude) index (migration).
  • GET /facilities/eligible?capabilityType=…&near=lat,lng&radiusKm=… — indexed bounding box + haversine in SQL, ORDER BY distance ASC NULLS LAST. PostGIS rejected: it would require an extension on every deployment (CI throwaway DBs, Render free-tier preview, bare local PG) to order one column — justification + math in ARCHITECTURE.md §5.4. Box vs circle correctness: the box is a provable superset (equatorial km/degree constants), so the result is exact.
  • Patient privacy: near is only ever a bind parameter — never stored, never logged, never echoed back (response carries per-facility distanceKm only; test asserts the point doesn't appear in the payload).
  • Facilities without coordinates are never dropped — they appear with distanceKm: null, sorted last (tested, including inside a radius search, per the issue's edge case literally).
  • backend/prisma/seed.ts (npx prisma db seed): 8 fictional facilities with Lagos-area coordinates, .test emails, 2 deliberately without coordinates — no real facilities' private data.

Performance measurement (issue #74 DoD)

  • backend/tests/integration/facility-distance.test.mts, 3005 facilities, near+radiusKm=60, 25 runs: p50 5.2 ms, p95 7.1 ms, max 7.8 ms.
  • EXPLAIN (ANALYZE, BUFFERS) against 5008 facilities: Bitmap Index Scan on "FacilityProfile_latitude_longitude_idx" (Index Cond on the latitude/longitude range), Execution Time: 5.101 ms, 180 shared buffer hits, no seq scan on FacilityProfile.

Test output

$ npm run typecheck   # clean
$ npm test
 Test Files  6 passed (6)
      Tests  30 passed (30)

$ npm run test:integration
 ✓ tests/integration/booking.test.mts
 ✓ tests/integration/csrf.test.mts
 ✓ tests/integration/email-verification.test.mts
 ✓ tests/integration/login-throttle.test.mts
 ✓ tests/integration/fulfillment.test.mts (6 tests) 4481ms
 ✓ tests/integration/facility-distance.test.mts (5 tests)
 [issue #74 perf] 3005 facilities, near+radiusKm=60, 25 runs → p50 5.2ms, p95 7.1ms, max 7.8ms

 Test Files  6 passed (6)
      Tests  33 passed (33)

$ npm run build        # clean

New integration coverage (11 tests): split routing across two facilities with derivation at each step, concurrent double-dispense race (exactly one winner), one-shot item claims, hard-rule-5 cross-facility 403s, expired-order non-resurrection, lab-order single-item flow, distance ordering vs. a haversine reference, unlocated-facility placement, query-contract validation, and the perf measurement above.

Docs (per AGENTS.md)

Also fills in two schema TODOs that ARCHITECTURE §4.5/§4.7 had already specified: PrescriptionItem.prescriptionId FK and FulfillmentRecord.facilityId FK.

…istance-sorted facility selection

Closes DocForum#73: FulfillmentItem rows under a FulfillmentRecord carry
dispensed quantity/status per routed order item; order status derives
from items (partially_fulfilled/fulfilled) via forward-only conditional
updates through the prescriptions/lab-orders services; patients can
route remaining items to a second facility; a unique index on
(orderType, dispensedKey) makes two-facility double-dispense a
constraint-level 409 under concurrency. Payment-release rule documented
in ARCHITECTURE.md §5.3: no release until the whole order fulfills.

Closes DocForum#74: FacilityProfile gains address/latitude/longitude + composite
index; GET /facilities/eligible?near=lat,lng sorts by bounding box +
haversine (PostGIS justification in ARCHITECTURE.md §5.4); the patient's
point is never stored; unlocated facilities stay listed, sorted last;
fictional seeded coordinates; perf measured at ~5ms p50 over ~3000
facilities.

Also fills in PrescriptionItem.prescriptionId and
FulfillmentRecord.facilityId (both already documented in ARCHITECTURE §4,
schema was behind) and updates ROADMAP/ARCHITECTURE_ESSENTIALS/docs/api.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@drips-wave

drips-wave Bot commented Oct 17, 2026

Copy link
Copy Markdown

@Oluwaferanmi18 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

CI is failing on this PR. main itself is green, so the cause is in this branch:

Integration tests fail (10 of 53), including your new tests/integration/facility-distance.test.mts: name sorting without a point, radius filtering of unlocated facilities, haversine distance ordering, and query validation. Please run npm run test:integration -- facility-distance locally and fix those before re-pushing.

Please push a fix and CI will re-run. Before pushing, run npm run prisma:generate && npm run typecheck && npm test && npm run test:integration in backend/.

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@Oluwaferanmi18

CI fails in tests/integration/facility-distance.test.mts: GET /facilities/eligible returns 404 instead of 200. You added facilitiesRouter, but it's never mounted in backend/src/app.ts. Add app.use('/facilities', facilitiesRouter) there. It also conflicts with main. Please rebase onto the latest main (or merge main into your branch), resolve the conflicts, and push.

Oluwaferanmi18 and others added 4 commits October 19, 2026 09:34
The merge in 536504e resolved app.ts, ROADMAP.md and docs/api/README.md
toward main's side, silently dropping content from b888485:

- /facilities and /pharmacy-orders router imports + mounts (this is what
  failed CI — every fulfillment route 404'd in the integration tests)
- the 2026-10-17 Phase 5 changelog entry in ROADMAP.md
- the Facilities and Pharmacy orders endpoint tables in docs/api/README.md

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
…ng-and-facility-location

Brings in upstream DocForum#151 (Prisma 7.10.1 + driver adapter), DocForum#155 (patient
data export), DocForum#150 (field-level encryption), DocForum#147 (access-control tests).

Conflicts resolved in backend/src/app.ts (kept /facilities and
/pharmacy-orders mounts alongside /patients) and docs/api/README.md
(kept the new Facilities/Pharmacy-orders tables; facilities and
pharmacy-orders come out of the "no routes yet" list since this branch
gives them routes).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
…ver adapters

Prisma 7's driver adapter sends bound params with an unspecified type
OID, so Postgres does the inference itself. The bounding-box predicate
`fp."latitude" BETWEEN ${lat} - ${boxLat} AND ...` was therefore
`unknown - unknown`, which Postgres rejects as ambiguous (SQLSTATE
42725) — every ?near+radiusKm query 500'd after the Prisma 7 upgrade.

The box bounds are now precomputed in JS (they never depended on row
data), and every numeric param that reaches the SQL carries an explicit
`::float8`. Result shape, ordering and index usage are unchanged;
facility-distance integration tests (incl. the perf measurement, p50
4.4ms over 3005 facilities) pass again.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
Prisma 7 stopped reading `prisma.seed` from package.json (the seed
command now lives in prisma.config.ts's migrations section) and
`new PrismaClient()` without a driver adapter throws — the issue DocForum#74 dev
seed hit both. Seed command moved to prisma.config.ts, seed.ts now
builds the same PrismaPg adapter as src/db/prisma-client.ts.

Verified end-to-end against a throwaway embedded Postgres: `npx prisma
db seed` runs and is idempotent (8 fictional facilities, 11
capabilities, 6 with coordinates after two consecutive runs).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@Oluwaferanmi18 Thanks for the PR. It can't be merged yet. To unblock it:

Merge conflicts: this branch conflicts with main. Please rebase onto the latest main (git fetch upstream && git rebase upstream/main), resolve the conflicts (often ROADMAP.md, ARCHITECTURE*.md or prisma/schema.prisma), and force-push.

Push to this same branch and CI will re-run. Once it's green and conflict-free, it'll be reviewed for merge.

@github-actions github-actions Bot added the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 20, 2026
@github-actions github-actions Bot removed the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 21, 2026
@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@Oluwaferanmi18 CI is failing on this branch against the latest main, so it can't be merged yet. The errors from the latest run:

Backend (typecheck + test + build)

Error: Prisma schema validation - (get-dmmf wasm)
error: Error validating field `facility` in model `FulfillmentRecord`: The relation field `facility` on model `FulfillmentRecord` is missing an opposite relation field on the model `FacilityProfile`. Either run `prisma format` or add it manually.

FulfillmentRecord.facility needs a back-relation field on FacilityProfile. Run npx prisma format in backend/ and commit the result.

@github-actions github-actions Bot added the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 22, 2026
@github-actions

Copy link
Copy Markdown

@Oluwaferanmi18 This PR now has merge conflicts with main, most likely because #230 (docs(adr): resolve polymorphic FulfillmentRecord design and implement schema) was just merged, so it can't be merged until it's rebased:

git fetch upstream            # upstream = https://github.com/DocForum/docforum-core.git
git rebase upstream/main
# resolve the conflicts, re-run the tests, then:
git push --force-with-lease

The usual culprits are shared docs like ROADMAP.md and ARCHITECTURE*.md. If the conflict is in the ROADMAP.md changelog, move your entry into a new file under changelog.d/ (see changelog.d/README.md) rather than resolving it in place, so it can't conflict again. CI re-runs when you push, and the needs-rebase label is removed automatically once the conflicts are resolved.

Automated message. It won't repeat unless the PR conflicts again after your next push.

@github-actions github-actions Bot removed the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 22, 2026
@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@Oluwaferanmi18 CI is failing on this branch against the latest main, so it can't be merged yet. The errors from the latest run:

Backend (typecheck + test + build)

src/app.ts(13,10): error TS2300: Duplicate identifier 'facilitiesRouter'.
src/app.ts(16,10): error TS2300: Duplicate identifier 'facilitiesRouter'.
src/modules/pharmacy-orders/repositories/index.ts(31,17): error TS2339: Property 'fulfillmentItem' does not exist on type 'PrismaClient<{ adapter: PrismaPg; }, never, DefaultArgs>'.
src/modules/pharmacy-orders/repositories/index.ts(36,12): error TS7006: Parameter 'rows' implicitly has an 'any' type.
src/modules/pharmacy-orders/repositories/index.ts(36,31): error TS7006: Parameter 'row' implicitly has an 'any' type.
src/modules/pharmacy-orders/repositories/index.ts(41,17): error TS2339: Property 'fulfillmentItem' does not exist on type 'PrismaClient<{ adapter: PrismaPg; }, never, DefaultArgs>'.
src/modules/pharmacy-orders/repositories/index.ts(57,7): error TS2353: Object literal may only specify known properties, and 'orderType' does not exist in type 'Without<FulfillmentRecordCreateInput, FulfillmentRecordUncheckedCreateInput> & FulfillmentRecordUnc
src/modules/pharmacy-orders/repositories/index.ts(62,16): error TS2353: Object literal may only specify known properties, and 'items' does not exist in type 'FulfillmentRecordInclude<DefaultArgs>'.

app.ts imports facilitiesRouter twice (lines 13 and 16), so remove one. prisma.fulfillmentItem doesn't exist because the model is missing from schema.prisma; add it and its migration.

@github-actions github-actions Bot added the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 22, 2026
@github-actions

Copy link
Copy Markdown

@Oluwaferanmi18 This PR now has merge conflicts with main, most likely because #196 (feat: facility fulfillment proof and patient confirm/dispute workflow) was just merged, so it can't be merged until it's rebased:

git fetch upstream            # upstream = https://github.com/DocForum/docforum-core.git
git rebase upstream/main
# resolve the conflicts, re-run the tests, then:
git push --force-with-lease

The usual culprits are shared docs like ROADMAP.md and ARCHITECTURE*.md. If the conflict is in the ROADMAP.md changelog, move your entry into a new file under changelog.d/ (see changelog.d/README.md) rather than resolving it in place, so it can't conflict again. CI re-runs when you push, and the needs-rebase label is removed automatically once the conflicts are resolved.

Automated message. It won't repeat unless the PR conflicts again after your next push.

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@Oluwaferanmi18 CI is failing at npm run typecheck:

src/app.ts(13,10): error TS2300: Duplicate identifier 'pharmacyOrdersRouter'.
src/app.ts(16,10): error TS2300: Duplicate identifier 'facilitiesRouter'.
tests/integration/facility-distance.test.mts(238,9): error TS2353: ... 'near' does not exist in type '{ type: "lab" | "pharmacy"; catalogItemCode?: ...; asset?: ...; at?: Date | undefined; }'.
tests/integration/fulfillment.test.mts(82,5): error TS2322: Type '{ items: { create: {...}[] } }' is not assignable to type '...PrescriptionCreateInput...'
tests/integration/fulfillment.test.mts(106,28): error TS2339: Property 'fulfillmentItem' does not exist on type 'PrismaClient<...>'.

src/app.ts imports both routers twice (a merge leftover), and the Prisma schema is missing the fulfillmentItem model / prescription items relation that the tests use. Please fix, run npm run typecheck in backend/, and push.

@github-actions github-actions Bot added the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 27, 2026
@github-actions

Copy link
Copy Markdown

@Oluwaferanmi18 This PR now has merge conflicts with main, most likely because #164 (Implement cancellation policy (issue #56), waitlist with time-boxed offers (issue #55), and reschedule (issue #54)) was just merged, so it can't be merged until it's rebased:

git fetch upstream            # upstream = https://github.com/DocForum/docforum-core.git
git rebase upstream/main
# resolve the conflicts, re-run the tests, then:
git push --force-with-lease

The usual culprits are shared docs like ROADMAP.md and ARCHITECTURE*.md. If the conflict is in the ROADMAP.md changelog, move your entry into a new file under changelog.d/ (see changelog.d/README.md) rather than resolving it in place, so it can't conflict again. CI re-runs when you push, and the needs-rebase label is removed automatically once the conflicts are resolved.

Automated message. It won't repeat unless the PR conflicts again after your next push.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-rebase Has merge conflicts with main; the author needs to rebase

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Phase 5] Facility location and distance-sorted facility selection [Phase 5] Partial fulfillment: item-level dispensing for multi-item prescriptions

2 participants