Buyer Procurement
Business view
This is the clinic/pharmacy side: three tabs under Inventory → Procurement (clinic-web) or Procurement (pharmacy-web) that take a staff member from "we're running low on something" to "it's on the shelf and the bill is booked," without leaving Dentolize.
- Reorder list — Dentolize already knows each stock item's preferred (target) level and its current on-hand quantity. This tab surfaces every item that's fallen below its preferred level, with a suggested quantity to bring it back up.
- Marketplace — search for the product across suppliers who've listed it, add to a cart, and place the order. Note: a single order goes to a single supplier — the cart is grouped per-vendor, and "Place order" submits one vendor's cart at a time.
- Orders — track every order this clinic/pharmacy has placed with the status the supplier has set. Once an order is shipped or delivered, a "Receive into stock" button appears.
Clicking "Receive into stock" does two things in one action: it adds the ordered quantities into the clinic's inventory (exactly as if someone had manually entered a stock purchase), and it records the supplier's bill as an expense — correctly valued and taxed — in the clinic's books. There's a catch worth knowing up front: every product on the order has to already be linked to a matching stock item in the clinic's own inventory before it can be received; if even one line isn't linked yet, the whole receipt is blocked with a message naming exactly which products need linking first. This is by design — Dentolize won't guess which of your stock items a supplier's product corresponds to.
Technical view
Where this lives
- clinic-web:
packages/clinic-web/src/components/dashboard/inventories/procurement/Procurement.js, routed at/inventory/procurement, linked from the Inventory sidebar submenu. Gated at the nav level only by the generalinventorypermission flag — no separate procurement-specific nav permission. Note: an older, now-unlinked/inventory/marketroute (Marketplace.js) still exists in the router but has been superseded by this page's own Marketplace tab and removed from the sidebar (commented out). - pharmacy-web:
packages/pharmacy-web/src/modules/procurement/ProcurementPage.tsx, routed at/procurement. Nav visibility: any ofVIEW_MARKETPLACE,CREATE_PURCHASE_LIST,PLACE_TRADE_ORDER,RECEIVE_TRADE_ORDER,VIEW_INVENTORY(packages/pharmacy-web/src/lib/permissions.ts) — an OR, not an AND. Finer-grained:placeOrderrequiresPLACE_TRADE_ORDER,receiveOrderrequiresRECEIVE_TRADE_ORDER, gating the respective buttons independently of page-level visibility.
Both are functionally the same three-tab UX against the same backend queries/mutations — clinic-web uses antd components, pharmacy-web has its own hand-rolled design system, otherwise the flow is identical. Unlike the seller portal, buyer procurement has no entitlement/tier gating — it's controlled purely by permission flags, since entitlement tiers in this branch only apply to the vendor/seller side.
Reorder list (purchaseListSuggestions, packages/server/src/supplier/procurementService.js:86-118)
Reads InventorySubItem rows where preferredAmount is set and amount < preferredAmount (capped at 500 rows). For each:
suggestedQty = max(0, preferredAmount − onHand) (packages/server/src/supplier/reorder.js:9-14)
Rounded to 6 decimal places, isolated in its own pure-function file specifically so it's unit-testable without a database. Each row also reports mapped: !!catalogProductId — whether this stock line is linked to the shared product catalog. Only mapped items can jump to a pre-filtered Marketplace search ("Find suppliers"), because the marketplace prices products by that shared catalog id across vendors.
Marketplace browse (browseListings, procurementService.js:31-84)
Explicitly a minimal stopgap, per its own code comment: "so the branch is verifiable end-to-end before marketplace-core lands its advanced multi-vendor search." Returns active listings with availability in IN_STOCK, MADE_TO_ORDER, or BACKORDER (excludes OUT_OF_STOCK), optionally filtered by vendor, catalog product, or free-text search on name/SKU, ordered by price ascending, capped at 200 results. Joins each listing to a display vendor name (VendorProfile.displayName, falling back to Company.companyName, falling back to the literal string "Vendor").
This is a real divergence from the plan in docs/trade-platform/FEATURE-SUPPLIERS.md, which recommended handing the buyer's reorder list off to the marketplace-core engine's own cart/RFQ contract (submitPurchaseListToCart / createRfq). Instead, this PR built its own minimal browse + direct single-vendor cart so it could be verified independently before that contract exists. Expect this tab to be superseded once marketplace-core's richer search lands.
Placing an order (placeTradeOrder, packages/server/src/supplier/orderService.js:154-217)
Single-vendor only — the mutation requires one vendorCompanyId and rejects if any requested listingId doesn't belong to that vendor or isn't currently active. Per-line validation: quantity must be greater than zero. Server computes everything from live data, never trusting client-sent prices/totals:
subtotal = Σ(listing.price × qty) (each line rounded to cents)
tax = subtotal × (vendor.company.tax / 100)
total = subtotal + tax
currency = vendor's company currency → first listing's currency → 'SAR'
Creates the TradeOrder at status: 'PLACED', source: 'DIRECT_CART' (the only source value this branch produces — RFQ_ACCEPT and AUTO_CHEAPEST are reserved for marketplace-core's award/routing flows). delivery and discount are always 0 on this path — there's no delivery-fee or discount input on the direct-buy flow (offers submitted through the RFQ path can carry both).
Receiving into stock — the generic bridge (receiveTradeOrder, procurementService.js:187-319)
Documented in its own header comment as reused by the sibling labs branch, so it's kept deliberately generic. Runs as a sequence of three real writes, reusing Dentolize's existing canonical mutations rather than reinventing them (so cost layers, stock valuation, and AP posting behave exactly as a manually-entered purchase would):
- Guard checks — tenancy (order must belong to the calling company), not already received (
inventoryOrderIdmust be unset), status must beSHIPPEDorDELIVERED, and the order must have at least one non-SERVICEline. - Match every product line to existing stock — for each line, look up an
InventorySubItemin the buyer's own inventory sharing thatcatalogProductId. If even one product line has no match, the entire receive is rejected with an error naming every unmatched product, instructing the buyer to map them to a stock item first. This is all-or-nothing — there's no partial receive. - Stock in — calls the same
createNewInventoryOrderresolver a manual purchase uses, withtype: 'PURCHASE',status: 'COMPLETED', one input row per matched line (item, sub-item, destination storage — the line's own default storage, or the company's master storage as fallback — price, quantity). - Supplier bill — calls the same
addNewExpenseresolver a manual expense entry uses. The bill is valued at the matched lines' subtotal plus their proportional share of the order's tax (matchedSubtotal / orderSubtotal × order.tax) — since step 2 already guarantees all product lines matched, in practice this proportioning only ever excludesSERVICElines from the bill's tax base, not rejected products. Recorded withmainType: 'COST_OF_REVENUE'. - Close the loop — updates the
TradeOrdertostatus: 'RECEIVED', stampsreceivedAt, and writes backinventoryOrderId(the guard in step 1 that blocks double-receiving).RECEIVEDis set only by this bridge — the seller's own state machine never sets it (see Order Lifecycle).
GraphQL operations
purchaseListSuggestions, browseListings, myTradeOrders/myTradeOrder, placeTradeOrder, receiveTradeOrder — all shield-gated by isAuthenticated plus an or() of the relevant MARKETPLACE_BUYER_PERMISSIONS string(s) (packages/server/src/permissions/permissions.js:2924-2941; canonical permission strings defined in packages/server/src/permissions/tradePermissions.js).