diff --git a/apps/companion/app/(tabs)/assets/[id].tsx b/apps/companion/app/(tabs)/assets/[id].tsx index 3b92b0731..a8f1ad7ee 100644 --- a/apps/companion/app/(tabs)/assets/[id].tsx +++ b/apps/companion/app/(tabs)/assets/[id].tsx @@ -15,6 +15,7 @@ import * as Haptics from "expo-haptics"; import { Image } from "expo-image"; import { useLocalSearchParams, useRouter, Stack } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; +import { releaseCategory } from "@shelf/quantity-control"; import { api, type AssetCustodyListEntry, @@ -314,6 +315,10 @@ export default function AssetDetailScreen() { const releaseMax = releaseQtyEntry ? releaseQtyEntry.releasableQuantity ?? releaseQtyEntry.quantity : 0; + // The shared predicate decides this, so the wording can never disagree with + // what the server does. Servers predating the field send no consumptionType, + // which falls through to the returnable copy — the server's own default. + const isConsumable = releaseCategory(asset.consumptionType) === "CONSUME"; // Custody holders the server hid from this caller (privacy filtering for // roles without view-all-custody). Shown as a muted "+N others" row. const custodyOthersCount = isQtyTracked @@ -498,9 +503,13 @@ export default function AssetDetailScreen() { } accessibilityLabel={ canReleaseRow - ? `Release custody from ${ - entry.custodian.name - }, holds ${qtyLabel ?? entry.quantity}` + ? isConsumable + ? `End hold on units held by ${ + entry.custodian.name + }, holds ${qtyLabel ?? entry.quantity}` + : `Release custody from ${ + entry.custodian.name + }, holds ${qtyLabel ?? entry.quantity}` : undefined } /> @@ -753,28 +762,49 @@ export default function AssetDetailScreen() { /> { + onSubmit={(quantity, consumed) => { const entry = releaseQtyEntry; setReleaseQtyEntry(null); if (entry) { - void performReleaseQuantity(entry.custodian.id, quantity); + void performReleaseQuantity( + entry.custodian.id, + quantity, + consumed + ); } }} onClose={() => setReleaseQtyEntry(null)} diff --git a/apps/companion/components/quantity-input-sheet.tsx b/apps/companion/components/quantity-input-sheet.tsx index c1bd547d1..fa4e06cf8 100644 --- a/apps/companion/components/quantity-input-sheet.tsx +++ b/apps/companion/components/quantity-input-sheet.tsx @@ -38,6 +38,18 @@ type Props = { defaultValue?: number; /** Display unit echoed under the input (e.g. "pcs"); null/undefined hides it. */ unitOfMeasure?: string | null; + /** + * Optional second numeric field rendered under the primary one. The ONE_WAY + * custody release uses it to ask how many of the released units were used + * up. Its value is clamped to the primary quantity, so it can never + * over-claim. + */ + secondary?: { + /** Field label, e.g. "Of those, how many were used up?". */ + label: string; + /** Initial value when the sheet opens (clamped to [0, primary]). */ + defaultValue?: number; + }; /** Confirm button label, e.g. "Assign" / "Release". */ confirmLabel: string; /** @@ -46,8 +58,11 @@ type Props = { * quick-actions.tsx `primaryActionGreen`). Default is the primary black. */ destructive?: boolean; - /** Called with the validated quantity when the user confirms. */ - onSubmit: (quantity: number) => void; + /** + * Called with the validated quantity when the user confirms, plus the + * secondary value when a `secondary` field is configured. + */ + onSubmit: (quantity: number, secondaryValue?: number) => void; /** Called when the user dismisses the sheet without confirming. */ onClose: () => void; }; @@ -69,6 +84,7 @@ export function QuantityInputSheet({ max, defaultValue, unitOfMeasure, + secondary, confirmLabel, destructive, onSubmit, @@ -78,27 +94,76 @@ export function QuantityInputSheet({ const styles = useStyles(); const [value, setValue] = useState("1"); + const [secondaryValue, setSecondaryValue] = useState("0"); const inputRef = useRef(null); - // Re-seed the input every time the sheet opens: each open targets a fresh + // The secondary field's presence and seed are read out as primitives so the + // re-seed effect below can depend on THEM rather than on the `secondary` + // object. Callers build that object inline, giving it a fresh identity on + // every parent render — as a dependency it would turn "re-seed on open" into + // "re-seed on every parent render", silently discarding a split the operator + // had already typed. + const hasSecondaryField = secondary != null; + const secondaryDefaultValue = secondary?.defaultValue; + + // Re-seed the inputs every time the sheet opens: each open targets a fresh // action (different member/holder), so stale values must not leak across. useEffect(() => { if (visible) { const seed = Math.min(Math.max(defaultValue ?? 1, 1), Math.max(max, 1)); setValue(String(seed)); + if (hasSecondaryField) { + // Clamp the secondary seed to the primary seed — the two fields move + // together and the secondary can never exceed the units being released. + setSecondaryValue( + String(Math.min(Math.max(secondaryDefaultValue ?? 0, 0), seed)) + ); + } } - }, [visible, defaultValue, max]); + }, [visible, defaultValue, max, hasSecondaryField, secondaryDefaultValue]); const parsed = value ? parseInt(value, 10) : NaN; const hasValue = Number.isFinite(parsed); const overMax = hasValue && parsed > max; const isValid = hasValue && parsed >= 1 && parsed <= max; + const parsedSecondary = secondaryValue ? parseInt(secondaryValue, 10) : NaN; + const hasSecondary = Number.isFinite(parsedSecondary); + // With no secondary field the sheet behaves exactly as it always has. + const isSecondaryValid = + !secondary || + (hasSecondary && + parsedSecondary >= 0 && + hasValue && + parsedSecondary <= parsed); + const canConfirm = isValid && isSecondaryValid; + + /** + * Pull the secondary value down when the primary quantity drops below it. + * + * Both fields seed to the same number for a consumable release, so without + * this a single tap of the minus button leaves the secondary above the + * primary and disables Confirm until the operator edits the second field by + * hand. Clamps DOWNWARD only, so someone who deliberately typed a smaller + * used-up count keeps it when they raise the primary again. The webapp's + * counterpart already does this in its reducer. + */ + const clampSecondaryTo = (nextPrimary: number) => { + if (!hasSecondaryField) return; + setSecondaryValue((prev) => { + const prevParsed = prev ? parseInt(prev, 10) : NaN; + if (!Number.isFinite(prevParsed) || prevParsed <= nextPrimary) + return prev; + return String(nextPrimary); + }); + }; + /** Step the current value by `delta`, clamped to [1, max]. */ const step = (delta: number) => { const current = hasValue ? parsed : 0; const next = Math.min(Math.max(current + delta, 1), Math.max(max, 1)); setValue(String(next)); + clampSecondaryTo(next); }; const maxLabel = formatQuantity(max, unitOfMeasure) ?? String(max); @@ -155,7 +220,13 @@ export function QuantityInputSheet({ onChangeText={(text) => { // Digits only — quantities are positive integers // (valuation-field.tsx pattern, minus the decimal point). - setValue(text.replace(/[^0-9]/g, "")); + const cleaned = text.replace(/[^0-9]/g, ""); + setValue(cleaned); + // Guard on finite: clearing the field must not write "NaN" + // into the secondary input. An empty primary already blocks + // Confirm via `isValid`. + const next = cleaned ? parseInt(cleaned, 10) : NaN; + if (Number.isFinite(next)) clampSecondaryTo(next); }} placeholder={`Max: ${max}`} placeholderTextColor={colors.placeholderText} @@ -188,20 +259,46 @@ export function QuantityInputSheet({ )} + {/* Optional second field, e.g. how many released units were used up */} + {secondary ? ( + + {secondary.label} + { + setSecondaryValue(text.replace(/[^0-9]/g, "")); + }} + placeholder="0" + placeholderTextColor={colors.placeholderText} + keyboardType="number-pad" + returnKeyType="done" + accessibilityLabel={secondary.label} + /> + {!isSecondaryValid ? ( + + Cannot exceed the {hasValue ? parsed : 0} being released. + + ) : null} + + ) : null} + {/* Confirm */} { - if (isValid) onSubmit(parsed); + if (canConfirm) { + onSubmit(parsed, secondary ? parsedSecondary : undefined); + } }} - disabled={!isValid} + disabled={!canConfirm} activeOpacity={0.7} accessibilityLabel={`${confirmLabel} ${echo ?? "quantity"}`} accessibilityRole="button" - accessibilityState={{ disabled: !isValid }} + accessibilityState={{ disabled: !canConfirm }} > {confirmLabel} @@ -280,6 +377,14 @@ const useStyles = createStyles((colors, shadows) => ({ color: colors.muted, textAlign: "center", }, + secondaryBlock: { + marginTop: spacing.md, + gap: spacing.xs, + }, + secondaryLabel: { + fontSize: fontSize.sm, + color: colors.muted, + }, errorHint: { fontSize: fontSize.sm, color: colors.error, diff --git a/apps/companion/hooks/use-custody-actions.ts b/apps/companion/hooks/use-custody-actions.ts index 392f53e81..4b02e6c21 100644 --- a/apps/companion/hooks/use-custody-actions.ts +++ b/apps/companion/hooks/use-custody-actions.ts @@ -27,10 +27,15 @@ interface UseCustodyActionsReturn { * Release `quantity` units of a QUANTITY_TRACKED asset from the custodian * identified by `custodianId` (team-member id). Confirmed by the sheet, * same as `performAssignQuantity`. + * + * `consumed` records how many of those units were used up rather than + * handed back. Omit it and the server derives the outcome from the asset's + * consumptionType. */ performReleaseQuantity: ( custodianId: string, - quantity: number + quantity: number, + consumed?: number ) => Promise; } @@ -142,7 +147,8 @@ export function useCustodyActions({ const performReleaseQuantity = async ( custodianId: string, - quantity: number + quantity: number, + consumed?: number ) => { if (!currentOrg || !asset) return; setIsActionLoading(true); @@ -151,7 +157,8 @@ export function useCustodyActions({ currentOrg.id, asset.id, custodianId, - quantity + quantity, + { consumed } ); if (err) Alert.alert("Error", err); else { diff --git a/apps/companion/lib/api/custody.ts b/apps/companion/lib/api/custody.ts index 15d637d4d..ec61526ac 100644 --- a/apps/companion/lib/api/custody.ts +++ b/apps/companion/lib/api/custody.ts @@ -65,23 +65,33 @@ export const custodyApi = { }, /** - * Release N units of a QUANTITY_TRACKED asset from a team member's custody. - * Mobile twin of the web's /api/assets/release-quantity-custody — only + * End a team member's hold on N units of a QUANTITY_TRACKED asset. Mobile + * twin of the web's /api/assets/release-quantity-custody — only * operator-assigned units are releasable (kit-allocated units are cleared * by releasing the kit's custody); the server enforces the held cap. + * + * `options.consumed` records how many of the released units were used up. + * Omit it and the server derives the outcome from the asset's + * consumptionType, so an older build still behaves correctly. */ releaseQuantityCustody: async ( orgId: string, assetId: string, teamMemberId: string, quantity: number, - note?: string + options?: { consumed?: number; note?: string } ) => { const result = await apiFetch( `/api/mobile/custody/release-quantity?orgId=${orgId}`, { method: "POST", - body: JSON.stringify({ assetId, teamMemberId, quantity, note }), + body: JSON.stringify({ + assetId, + teamMemberId, + quantity, + consumed: options?.consumed, + note: options?.note, + }), // why: non-idempotent — a timed-out-but-landed request must not be // auto-retried, or the release double-applies. retry: false, diff --git a/apps/companion/package.json b/apps/companion/package.json index b8cb1e954..8302a2e56 100644 --- a/apps/companion/package.json +++ b/apps/companion/package.json @@ -32,6 +32,7 @@ "@sentry/react-native": "~7.2.0", "@shelf/datetime": "workspace:*", "@shelf/labels": "workspace:*", + "@shelf/quantity-control": "workspace:*", "@supabase/supabase-js": "^2.49.1", "expo": "~54.0.33", "expo-av": "^16.0.8", diff --git a/apps/webapp/app/components/assets/quantity-custody-list.test.tsx b/apps/webapp/app/components/assets/quantity-custody-list.test.tsx new file mode 100644 index 000000000..9d4e0c129 --- /dev/null +++ b/apps/webapp/app/components/assets/quantity-custody-list.test.tsx @@ -0,0 +1,185 @@ +/** + * Tests for {@link QuantityCustodyList}'s release dialog. + * + * Scope is the one thing a client cannot get right on its own: what the user + * sees when the SERVER rejects a release. The dialog clamps `consumed` against + * the `maxQuantity` it was rendered with, so a page left open while someone + * else moves the same units still submits a quantity the service refuses — + * `releaseQuantity` throws 400 for a stale quantity, for `consumed` above the + * released amount, and for consuming a returnable asset. Those messages are + * written to be read by an operator, so they must reach one. + * + * Mocks: + * - `react-router`'s `useFetcher` — so each test can drive the response + * without a data router. + * - `~/hooks/use-disabled` — stable `false`, so submit gating is ours. + * - `~/components/shared/modal` — Radix AlertDialog portals its content and + * manages `open` internally, and this dialog keeps `open` in component + * state rather than a prop, so a click-to-open flow is not reliably + * drivable in happy-dom. The shell below renders content unconditionally: + * open/close gating belongs to Radix and is not what these tests are about. + * - `./quantity-custody-dialog` — the Assign counterpart, unrelated here and + * otherwise drags its own dependency graph into the render. + * + * @see {@link file://./quantity-custody-list.tsx} + * @see {@link file://./move-units-dialog.test.tsx} — the harness this mirrors + */ + +import type React from "react"; +import type { ReactNode } from "react"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { QuantityCustodyList } from "./quantity-custody-list"; + +/** Mutable per-test fetcher state; reassign before `render`. */ +type FetcherState = { + state: "idle" | "submitting" | "loading"; + data: { success?: boolean; error?: { message?: string } } | undefined; +}; + +let mockFetcherState: FetcherState = { state: "idle", data: undefined }; + +// why: useFetcher returns a Form component plus the response state this +// component reads; we need to control that state per test. +vi.mock("react-router", async () => { + const actual = await vi.importActual>("react-router"); + return { + ...actual, + Link: ({ children, ...rest }: { children: ReactNode }) => ( + {children} + ), + useFetcher: () => ({ + ...mockFetcherState, + // No submit handling: these tests drive the response state directly and + // never fire a submit, and the component passes no `onSubmit` to + // `fetcher.Form`, so there is nothing to intercept. + Form: ({ + children, + ...rest + }: { + children: ReactNode; + [key: string]: unknown; + }) =>
{children}
, + submit: vi.fn(), + load: vi.fn(), + }), + useNavigation: () => ({ state: "idle" }), + }; +}); + +// why: useDisabled derives from useNavigation; stabilise so submit gating is +// driven by this component's own conditions. +vi.mock("~/hooks/use-disabled", () => ({ + useDisabled: () => false, +})); + +// why: see the file-level note — Radix portals content and owns `open`, which +// this dialog holds in component state, so content is rendered unconditionally. +vi.mock("~/components/shared/modal", () => ({ + AlertDialog: ({ children }: { children: ReactNode }) => <>{children}, + AlertDialogTrigger: ({ children }: { children: ReactNode }) => ( + <>{children} + ), + AlertDialogContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + AlertDialogHeader: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + AlertDialogTitle: ({ children }: { children: ReactNode }) => ( +

{children}

+ ), + AlertDialogDescription: ({ children }: { children: ReactNode }) => ( +

{children}

+ ), + AlertDialogFooter: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + AlertDialogCancel: ({ children }: { children: ReactNode }) => <>{children}, +})); + +// why: the Assign dialog is a separate surface with its own dependency graph +// and nothing to do with release-error rendering. +vi.mock("./quantity-custody-dialog", () => ({ + QuantityCustodyDialog: () => null, +})); + +/** One operator-held custody row — the shape the overview loader returns. */ +const custodyRecord = { + createdAt: new Date("2026-08-01T00:00:00Z"), + quantity: 10, + custodian: { id: "tm-1", name: "Ada Lovelace" }, +}; + +function renderList( + props: Partial> = {} +) { + return render( + + ); +} + +describe("QuantityCustodyList — release dialog server errors", () => { + beforeEach(() => { + mockFetcherState = { state: "idle", data: undefined }; + }); + + it("surfaces a rejected release so the operator knows why nothing happened", () => { + // The exact 400 `releaseQuantity` throws when the page is stale and the + // custodian no longer holds what the form is trying to release. + mockFetcherState = { + state: "idle", + data: { + error: { + message: "Cannot release 10 units. The custodian only holds 4 units.", + }, + }, + }; + + renderList(); + + expect(screen.getByRole("alert")).toHaveTextContent( + /the custodian only holds 4 units/i + ); + }); + + it("surfaces a rejected consumed split", () => { + mockFetcherState = { + state: "idle", + data: { + error: { + message: + "Only consumable (one-way) assets can be marked as consumed.", + }, + }, + }; + + renderList({ consumptionType: "TWO_WAY" }); + + expect(screen.getByRole("alert")).toHaveTextContent( + /only consumable \(one-way\) assets/i + ); + }); + + it("renders no alert when the fetcher is idle and untouched", () => { + renderList(); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("renders no alert on a successful release", () => { + mockFetcherState = { state: "idle", data: { success: true } }; + + renderList(); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/webapp/app/components/assets/quantity-custody-list.tsx b/apps/webapp/app/components/assets/quantity-custody-list.tsx index f93ecb7e9..756b37c7e 100644 --- a/apps/webapp/app/components/assets/quantity-custody-list.tsx +++ b/apps/webapp/app/components/assets/quantity-custody-list.tsx @@ -3,8 +3,19 @@ * * Displays a breakdown of custodians and their assigned quantities for a * QUANTITY_TRACKED asset. Each row shows the custodian name, quantity held, - * and a "Release" button. An "Assign" button in the header opens the - * QuantityCustodyDialog. + * and an action that ends the hold. An "Assign" button in the header opens + * the QuantityCustodyDialog. + * + * The action's wording follows the asset's `consumptionType`: a returnable + * (`TWO_WAY`) asset offers "Release" back to the pool, a consumable + * (`ONE_WAY`) offers "Mark as consumed", which permanently reduces stock. A + * consumable additionally asks how many of the released units were used up, + * so unused stock can be handed back instead of destroyed. + * + * Both post to the same endpoint — the server derives the outcome from the + * asset row, so this is presentation only, never the authority. An explicit + * split can only ever narrow a consumable's outcome; the server rejects it + * outright for a returnable asset. * * If no custody records exist, a placeholder message with the available * quantity is shown instead. @@ -14,8 +25,9 @@ * @see {@link file://../../routes/_layout+/assets.$assetId.overview.tsx} - Consumer */ -import { useEffect, useRef, useState } from "react"; -import type { User } from "@prisma/client"; +import { useEffect, useReducer, useRef, useState } from "react"; +import type { ConsumptionType, User } from "@prisma/client"; +import { releaseCategory } from "@shelf/quantity-control"; import { Link, useFetcher } from "react-router"; import Input from "~/components/forms/input"; import { Button } from "~/components/shared/button"; @@ -73,6 +85,10 @@ export interface QuantityCustodyListProps { assetId: string; /** Optional unit of measure label (e.g., "pcs", "liters") */ unitOfMeasure?: string | null; + /** How the asset is consumed. `ONE_WAY` swaps "Release" for + * "Mark as consumed" and adds a second field for the used-up count, because + * a consumable's units default to gone for good. */ + consumptionType?: ConsumptionType | null; /** Quantity currently available for checkout */ availableQuantity?: number; /** Whether the current user is self-service */ @@ -103,6 +119,7 @@ export function QuantityCustodyList({ custody, assetId, unitOfMeasure, + consumptionType, availableQuantity, isSelfService = false, currentUserId, @@ -113,6 +130,13 @@ export function QuantityCustodyList({ const unitLabel = unitOfMeasure || "units"; const allRecords = custody ?? []; + /** + * The shared predicate decides this, so the label can never disagree with + * what the server does. Legacy rows without a `consumptionType` are + * returnable. + */ + const isConsumable = releaseCategory(consumptionType) === "CONSUME"; + /** * Filter visible custody records based on permissions. * Self-service/base users who can't view all custody only see @@ -181,6 +205,7 @@ export function QuantityCustodyList({ record={record} assetId={assetId} unitLabel={unitLabel} + isConsumable={isConsumable} canRelease={canRelease(record)} /> ))} @@ -215,15 +240,18 @@ interface CustodyRowProps { record: CustodyRecord; assetId: string; unitLabel: string; + /** Whether the asset is a ONE_WAY consumable (see QuantityCustodyListProps) */ + isConsumable?: boolean; /** Whether the current user can release this custody record */ canRelease?: boolean; } /** - * Renders a single custodian row with their name, quantity, and a release button. + * Renders a single custodian row with their name, quantity, and the action + * that ends the hold. * - * The release button opens a confirmation dialog where the user can specify - * how many units to release. + * The action opens a confirmation dialog where the user can specify how many + * units to release (or, for a consumable, mark as consumed). * * @param props - The custody record and context */ @@ -231,6 +259,7 @@ function CustodyRow({ record, assetId, unitLabel, + isConsumable = false, canRelease = true, }: CustodyRowProps) { const custodianName = resolveTeamMemberName(record.custodian); @@ -265,6 +294,7 @@ function CustodyRow({ teamMemberId={record.custodian.id} maxQuantity={quantity} unitLabel={unitLabel} + isConsumable={isConsumable} /> ) : null} @@ -322,17 +352,67 @@ function KitCustodyBadge({ /* ReleaseButton */ /* -------------------------------------------------------------------------- */ +/** + * The two coupled numeric fields of a consumable release. They move together: + * lowering the released quantity must pull the consumed count down with it, or + * the form would post a split the server rejects. + */ +type ReleaseFormState = { + /** Total units leaving the custodian's hold. */ + quantity: number; + /** How many of those were used up. Never exceeds `quantity`. */ + consumed: number; +}; + +/** Transitions for {@link ReleaseFormState}. */ +type ReleaseFormAction = + | { type: "reset"; max: number } + | { type: "set_quantity"; value: number; max: number } + | { type: "set_consumed"; value: number }; + +/** + * Reducer for the consumable release form. Every transition clamps, so the + * state can never describe an invalid split. + * + * @param state - Current field values + * @param action - The transition to apply + * @returns The next state + */ +function releaseFormReducer( + state: ReleaseFormState, + action: ReleaseFormAction +): ReleaseFormState { + switch (action.type) { + case "reset": + // Opening the dialog pre-fills a full consume — the common case for a + // consumable, and what the server would default to anyway. + return { quantity: action.max, consumed: action.max }; + case "set_quantity": { + const quantity = Math.min(Math.max(action.value, 0), action.max); + return { quantity, consumed: Math.min(state.consumed, quantity) }; + } + case "set_consumed": + return { + ...state, + consumed: Math.min(Math.max(action.value, 0), state.quantity), + }; + } +} + /** Props for the release button/dialog */ interface ReleaseButtonProps { assetId: string; teamMemberId: string; maxQuantity: number; unitLabel: string; + /** Consumable (ONE_WAY) assets are consumed, not returned */ + isConsumable?: boolean; } /** - * A button that opens a confirmation dialog to release (return) quantity - * from a custodian back to the available pool. + * A button that opens a confirmation dialog to end a custodian's hold on N + * units — returning them to the available pool for a `TWO_WAY` asset, or + * consuming them (permanently reducing stock) for a `ONE_WAY` consumable. * * @param props - Asset and custodian identifiers plus constraints */ @@ -341,6 +421,7 @@ function ReleaseButton({ teamMemberId, maxQuantity, unitLabel, + isConsumable = false, }: ReleaseButtonProps) { const [open, setOpen] = useState(false); const fetcher = useFetcher({ key: `release-qty-${teamMemberId}` }); @@ -352,6 +433,37 @@ function ReleaseButton({ // needed for the Radix portal mount. const quantityInputRef = useAutoFocus({ when: open }); + const [form, dispatch] = useReducer(releaseFormReducer, { + quantity: maxQuantity, + consumed: maxQuantity, + }); + + /** + * Re-seed on every closed → open flip: each open targets a fresh release, + * so a previous split must not leak into the next one. + */ + useEffect(() => { + if (open) { + dispatch({ type: "reset", max: maxQuantity }); + } + }, [open, maxQuantity]); + + /** + * Server-side rejection message, shown above the form. + * + * The reducer only clamps `consumed` against the `maxQuantity` this dialog + * was rendered with, so a page left open while the same units move + * elsewhere still submits a release the service refuses. `releaseQuantity` + * also rejects a `consumed` above the released amount, and any `consumed` + * on a returnable asset. Those messages are written for an operator to + * read — without this the dialog just sits there with its submit button + * re-enabled and no explanation. + */ + const serverErrorMessage = + fetcher.data?.error != null + ? (fetcher.data.error as { message?: string })?.message + : null; + /** Close the dialog after a successful release */ useEffect(() => { if (fetcher.state === "idle" && fetcher.data && !fetcher.data.error) { @@ -364,19 +476,45 @@ function ReleaseButton({ setOpen(false)}> - Release Quantity + + {isConsumable ? "Mark as consumed" : "Release Quantity"} + - Enter the number of {unitLabel} to release back to the available - pool. Maximum: {maxQuantity}. + {isConsumable ? ( + <> + Choose how many {unitLabel} leave this custodian's hold, and how + many of those were used up. Used-up units permanently reduce + total stock and cannot be restored; the rest go back to the + available pool. Maximum: {maxQuantity}. + + ) : ( + <> + Enter the number of {unitLabel} to release back to the available + pool. Maximum: {maxQuantity}. + + )} + {serverErrorMessage ? ( + // Mirrors the shape of `WarningBox` (text-sm + p-4 + 25/300/700 + // token ladder) so server-side block errors render with the same + // weight as the inline warnings used elsewhere — just in error tone. + // Matches `move-units-dialog.tsx`. +
+ {serverErrorMessage} +
+ ) : null} +
- + {isConsumable ? ( + <> + + dispatch({ + type: "set_quantity", + value: Number(event.target.value), + max: maxQuantity, + }) + } + /> + + + dispatch({ + type: "set_consumed", + value: Number(event.target.value), + }) + } + /> + +

+ {form.consumed} of {form.quantity} {unitLabel} will be removed + from stock permanently + {form.quantity - form.consumed > 0 + ? `; the remaining ${ + form.quantity - form.consumed + } go back to the available pool` + : ""} + . +

+ + ) : ( + + )}
@@ -416,7 +609,13 @@ function ReleaseButton({
diff --git a/apps/webapp/app/modules/asset/service.server.test.ts b/apps/webapp/app/modules/asset/service.server.test.ts index 6caa8fd6a..9f34a508c 100644 --- a/apps/webapp/app/modules/asset/service.server.test.ts +++ b/apps/webapp/app/modules/asset/service.server.test.ts @@ -965,7 +965,9 @@ describe("releaseQuantity — activity events", () => { assetId: "asset-1", teamMemberId: "tm-1", targetUserId: "user-42", - meta: { quantity: 4, viaQuantity: true }, + // The split is recorded on the event so reports can tell a return + // from a consume without re-deriving it from the asset row. + meta: { quantity: 4, viaQuantity: true, consumed: 0, returned: 4 }, }), expect.anything() ); @@ -1028,6 +1030,354 @@ describe("releaseQuantity — activity events", () => { }); }); +/** + * Ending a custodian's hold means something different per `consumptionType`: + * a TWO_WAY asset's units go back in the pool, a ONE_WAY consumable's units + * are gone. Before this suite nothing on the direct-custody path exercised + * `consumptionType` at all, which is how the consumable case shipped writing + * RETURN and handing the stock back. + */ +describe("releaseQuantity — consumptionType disposition", () => { + const mockLock = lockAssetForQuantityUpdate as ReturnType; + const mockCustodyFindFirst = db.custody.findFirst as ReturnType< + typeof vitest.fn + >; + const mockTeamMemberFindUnique = db.teamMember.findFirst as ReturnType< + typeof vitest.fn + >; + const mockCreateConsumptionLog = createConsumptionLog as ReturnType< + typeof vitest.fn + >; + const mockRecordEvent = recordEvent as ReturnType; + const mockAssetUpdate = db.asset.update as ReturnType; + + /** Base locked-asset row; each test sets the `consumptionType` under test. */ + const baseLockedAsset = { + id: "asset-1", + title: "Nitrile Gloves", + organizationId: "org-1", + type: "QUANTITY_TRACKED" as const, + quantity: 500, + }; + + beforeEach(() => { + vitest.clearAllMocks(); + mockTeamMemberFindUnique.mockResolvedValue({ user: { id: "user-42" } }); + // Custodian holds 40 units; every test releases 10 of them (partial), so + // the status-flip branch stays out of the way of the quantity assertions. + mockCustodyFindFirst.mockResolvedValue({ + id: "custody-1", + assetId: "asset-1", + teamMemberId: "tm-1", + quantity: 40, + }); + (db.custody.count as ReturnType).mockResolvedValue(1); + // why: the `refreshExpiredAssetImages` suite earlier in this file leaves a + // rejection implementation on the asset write mocks that `clearAllMocks` + // does not undo (it only clears call history). + mockAssetUpdate.mockResolvedValue({}); + (db.asset.updateMany as ReturnType).mockResolvedValue({ + count: 1, + }); + }); + + it("consumes the whole release for a ONE_WAY consumable by default", async () => { + mockLock.mockResolvedValue({ + ...baseLockedAsset, + consumptionType: "ONE_WAY", + }); + + const result = await releaseQuantity({ + assetId: "asset-1", + teamMemberId: "tm-1", + quantity: 10, + userId: "user-1", + organizationId: "org-1", + }); + + // Exactly one log, classified as consumption. Writing RETURN here is the + // shipped bug: consumption reporting counts the units as back on the shelf. + expect(mockCreateConsumptionLog).toHaveBeenCalledTimes(1); + expect(mockCreateConsumptionLog).toHaveBeenCalledWith( + expect.objectContaining({ + assetId: "asset-1", + category: "CONSUME", + quantity: 10, + custodianId: "tm-1", + }) + ); + expect(mockAssetUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "asset-1" }, + data: { quantity: { decrement: 10 } }, + }) + ); + expect(result.consumed).toBe(10); + expect(result.returned).toBe(0); + }); + + it("splits a partial consume: two logs, and only the consumed units leave stock", async () => { + mockLock.mockResolvedValue({ + ...baseLockedAsset, + consumptionType: "ONE_WAY", + }); + + const result = await releaseQuantity({ + assetId: "asset-1", + teamMemberId: "tm-1", + quantity: 40, + consumed: 10, + userId: "user-1", + organizationId: "org-1", + }); + + // 10 gloves used up, 30 handed back in good condition. Destroying all 40 + // is the over-correction this split exists to prevent. + expect(mockCreateConsumptionLog).toHaveBeenCalledTimes(2); + expect(mockCreateConsumptionLog).toHaveBeenCalledWith( + expect.objectContaining({ category: "CONSUME", quantity: 10 }) + ); + expect(mockCreateConsumptionLog).toHaveBeenCalledWith( + expect.objectContaining({ category: "RETURN", quantity: 30 }) + ); + expect(mockAssetUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "asset-1" }, + data: { quantity: { decrement: 10 } }, + }) + ); + expect(result.consumed).toBe(10); + expect(result.returned).toBe(30); + }); + + it("emits ASSET_QUANTITY_CHANGED for the consumed units only", async () => { + mockLock.mockResolvedValue({ + ...baseLockedAsset, + consumptionType: "ONE_WAY", + }); + + await releaseQuantity({ + assetId: "asset-1", + teamMemberId: "tm-1", + quantity: 40, + consumed: 10, + userId: "user-1", + organizationId: "org-1", + }); + + // One event per field that changed: stock dropped by the consumed + // amount, not by the full release. + expect(mockRecordEvent).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: "org-1", + actorUserId: "user-1", + action: "ASSET_QUANTITY_CHANGED", + entityType: "ASSET", + entityId: "asset-1", + assetId: "asset-1", + field: "quantity", + fromValue: 500, + toValue: 490, + }), + // Second arg is the tx client — the event must commit with the write. + expect.anything() + ); + expect(mockRecordEvent).toHaveBeenCalledWith( + expect.objectContaining({ + action: "CUSTODY_RELEASED", + meta: { quantity: 40, viaQuantity: true, consumed: 10, returned: 30 }, + }), + expect.anything() + ); + expect(mockRecordEvent).toHaveBeenCalledTimes(2); + }); + + it("an explicit consumed of 0 on a consumable returns everything and never touches stock", async () => { + mockLock.mockResolvedValue({ + ...baseLockedAsset, + consumptionType: "ONE_WAY", + }); + + const result = await releaseQuantity({ + assetId: "asset-1", + teamMemberId: "tm-1", + quantity: 10, + consumed: 0, + userId: "user-1", + organizationId: "org-1", + }); + + expect(mockCreateConsumptionLog).toHaveBeenCalledTimes(1); + expect(mockCreateConsumptionLog).toHaveBeenCalledWith( + expect.objectContaining({ category: "RETURN", quantity: 10 }) + ); + // No quantity write at all — the returnable path stays byte-identical. + // Match ANY `quantity` payload rather than `decrement: 0`: the service + // gates the whole decrement block on `consumedUnits > 0`, so asserting + // the zero case alone could never fail even if it wrongly decremented. + // (The status-flip write carries no `quantity` key, so it can't match.) + expect(mockAssetUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ quantity: expect.anything() }), + }) + ); + expect(mockRecordEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ action: "ASSET_QUANTITY_CHANGED" }), + expect.anything() + ); + expect(result.consumed).toBe(0); + expect(result.returned).toBe(10); + }); + + it("leaves TWO_WAY behaviour untouched: RETURN log, no stock decrement", async () => { + mockLock.mockResolvedValue({ + ...baseLockedAsset, + consumptionType: "TWO_WAY", + }); + + const result = await releaseQuantity({ + assetId: "asset-1", + teamMemberId: "tm-1", + quantity: 10, + userId: "user-1", + organizationId: "org-1", + }); + + expect(mockCreateConsumptionLog).toHaveBeenCalledWith( + expect.objectContaining({ category: "RETURN", quantity: 10 }) + ); + expect(mockRecordEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ action: "ASSET_QUANTITY_CHANGED" }), + expect.anything() + ); + expect(result.consumed).toBe(0); + expect(result.returned).toBe(10); + }); + + it("treats a legacy null consumptionType as returnable", async () => { + mockLock.mockResolvedValue({ + ...baseLockedAsset, + consumptionType: null, + }); + + const result = await releaseQuantity({ + assetId: "asset-1", + teamMemberId: "tm-1", + quantity: 10, + userId: "user-1", + organizationId: "org-1", + }); + + expect(mockCreateConsumptionLog).toHaveBeenCalledWith( + expect.objectContaining({ category: "RETURN", quantity: 10 }) + ); + expect(result.consumed).toBe(0); + expect(result.returned).toBe(10); + }); + + it("rejects consuming a returnable asset", async () => { + mockLock.mockResolvedValue({ + ...baseLockedAsset, + consumptionType: "TWO_WAY", + }); + + // A client must never be able to destroy stock that is meant to come back. + await expect( + releaseQuantity({ + assetId: "asset-1", + teamMemberId: "tm-1", + quantity: 10, + consumed: 5, + userId: "user-1", + organizationId: "org-1", + }) + ).rejects.toThrow(/consumable/i); + + expect(mockCreateConsumptionLog).not.toHaveBeenCalled(); + }); + + it("rejects a consumed amount larger than the release", async () => { + mockLock.mockResolvedValue({ + ...baseLockedAsset, + consumptionType: "ONE_WAY", + }); + + await expect( + releaseQuantity({ + assetId: "asset-1", + teamMemberId: "tm-1", + quantity: 10, + consumed: 11, + userId: "user-1", + organizationId: "org-1", + }) + ).rejects.toThrow(); + + expect(mockCreateConsumptionLog).not.toHaveBeenCalled(); + }); + + it("does not touch AssetLocation on consume (documented deferral, matches booking check-in)", async () => { + // A CONSUME lowers `Asset.quantity` and deliberately leaves placements + // alone, so `SUM(AssetLocation.quantity)` can end up above the total. This + // is pre-existing, not introduced by the consumable branch: the booking + // service makes no `assetLocation` write at all, and the manual + // stock-lowering guard (`assertAssetQuantityNotBelowReservations`) queries + // custody / assetKit / bookingAsset / consumptionLog, never assetLocation. + // Custody carries no location, so there is nothing here to identify WHICH + // placement the used-up units came off. + // + // This test pins the deferral rather than the desired end state: when the + // location axis is reconciled across every path that lowers + // `Asset.quantity`, this is the assertion that should fail and be rewritten. + mockLock.mockResolvedValue({ + ...baseLockedAsset, + consumptionType: "ONE_WAY", + }); + + await releaseQuantity({ + assetId: "asset-1", + teamMemberId: "tm-1", + quantity: 10, + userId: "user-1", + organizationId: "org-1", + }); + + // The three write methods the mocked client exposes — the same set + // `moveAssetLocationUnits` / `placeUnplacedUnits` drive when they DO + // adjust placements. + expect(db.assetLocation.create).not.toHaveBeenCalled(); + expect(db.assetLocation.update).not.toHaveBeenCalled(); + expect(db.assetLocation.delete).not.toHaveBeenCalled(); + }); + + it("still flips Asset.status to AVAILABLE when a consume empties the last custody row", async () => { + mockLock.mockResolvedValue({ + ...baseLockedAsset, + consumptionType: "ONE_WAY", + }); + // Full release of the 40-unit row → no custody rows remain. + (db.custody.count as ReturnType).mockResolvedValue(0); + + await releaseQuantity({ + assetId: "asset-1", + teamMemberId: "tm-1", + quantity: 40, + userId: "user-1", + organizationId: "org-1", + }); + + expect(mockAssetUpdate).toHaveBeenCalledWith({ + where: { id: "asset-1" }, + data: { status: "AVAILABLE" }, + }); + expect(mockAssetUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: { quantity: { decrement: 40 } }, + }) + ); + }); +}); + describe("bulkDeleteAssets — activity events", () => { const mockAssetFindMany = db.asset.findMany as ReturnType; const mockAssetDeleteMany = db.asset.deleteMany as ReturnType< diff --git a/apps/webapp/app/modules/asset/service.server.ts b/apps/webapp/app/modules/asset/service.server.ts index 590c01d27..7a4d19bf6 100644 --- a/apps/webapp/app/modules/asset/service.server.ts +++ b/apps/webapp/app/modules/asset/service.server.ts @@ -25,6 +25,7 @@ import { Prisma, TagUseFor, } from "@prisma/client"; +import { releaseCategory } from "@shelf/quantity-control"; import { LRUCache } from "lru-cache"; import type { LoaderFunctionArgs } from "react-router"; import { extractStoragePath } from "~/components/assets/asset-image/utils"; @@ -7724,7 +7725,7 @@ export async function checkOutQuantity({ } } -/** Arguments for releasing (returning) a quantity from a custodian back to the available pool. */ +/** Arguments for ending a custodian's hold on N units of a QT asset. */ type ReleaseQuantityArgs = { /** The asset to release units for */ assetId: string; @@ -7738,10 +7739,17 @@ type ReleaseQuantityArgs = { organizationId: string; /** Optional note explaining the release */ note?: string; + /** + * How many of the released units were used up rather than handed back. + * Omit to let the server derive it from the asset's `consumptionType` + * (consume everything for a one-way consumable, nothing for a returnable). + * Only a consumable accepts a non-zero value. + */ + consumed?: number; }; /** - * Releases a quantity of units from a custodian back to the available pool. + * Ends a custodian's hold on N units of a QUANTITY_TRACKED asset. * * Runs inside an interactive transaction with a row-level lock to prevent * concurrent modifications. Validates that a custody record exists for the @@ -7749,13 +7757,29 @@ type ReleaseQuantityArgs = { * the custodian currently holds. * * If releasing the full custodied amount, the Custody record is deleted. - * Otherwise, the quantity is decremented. An immutable RETURN consumption - * log entry is always created. + * Otherwise, the quantity is decremented. + * + * **What happens to the units depends on `Asset.consumptionType`, and on an + * optional explicit split:** + * + * - `TWO_WAY` / legacy `null` — the units return to the available pool. A + * `RETURN` consumption log is written and `Asset.quantity` is untouched. + * These assets reject a non-zero `consumed`. + * - `ONE_WAY` — the units default to consumed and are gone for good: a + * `CONSUME` log is written and `Asset.quantity` decremented, matching what + * booking check-in already does for a consumable. An explicit `consumed` + * splits the release, so unused units can still be handed back. + * + * The default is taken here rather than in a sibling `consumeQuantity` service + * so it is always derived from the asset row itself: both the web and mobile + * release endpoints hit this one function, and neither can silently pick the + * wrong outcome for a consumable. * * @param args - The release details - * @returns The updated Asset record - * @throws {ShelfError} If no custody record exists or the release quantity - * exceeds the custodied amount + * @returns The updated Asset record plus the `consumed` / `returned` split + * @throws {ShelfError} If no custody record exists, the release quantity + * exceeds the custodied amount, `consumed` is out of range, or a returnable + * asset was asked to consume */ export async function releaseQuantity({ assetId, @@ -7764,6 +7788,7 @@ export async function releaseQuantity({ userId, organizationId, note, + consumed, }: ReleaseQuantityArgs) { try { if (quantity <= 0) { @@ -7806,6 +7831,54 @@ export async function releaseQuantity({ }); } + /** + * Step 3b: Resolve how many units were used up vs. handed back. + * + * The DEFAULT comes from the LOCKED asset row — never from the caller + * alone — so a stale client can't return a consumable's units to the + * pool. `lockAssetForQuantityUpdate` does `SELECT *`, so + * `consumptionType` is already on hand. + * + * An explicit `consumed` lets an operator record a partial use: 40 + * gloves come back, 10 of them actually used. Without it the only + * available action would destroy all 40, which is the same split + * booking check-in already offers for consumables. It can only ever + * narrow a consumable's outcome — a returnable asset rejects it below. + */ + const canConsume = releaseCategory(asset.consumptionType) === "CONSUME"; + const consumedUnits = consumed ?? (canConsume ? quantity : 0); + const returnedUnits = quantity - consumedUnits; + + if ( + !Number.isInteger(consumedUnits) || + consumedUnits < 0 || + consumedUnits > quantity + ) { + throw new ShelfError({ + cause: null, + message: `Cannot mark ${consumedUnits} of ${quantity} unit(s) as consumed. The consumed amount must be a whole number between 0 and the quantity being released.`, + label, + status: 400, + shouldBeCaptured: false, + additionalData: { assetId, teamMemberId, quantity, consumed }, + }); + } + + if (consumedUnits > 0 && !canConsume) { + throw new ShelfError({ + cause: null, + message: + "Only consumable (one-way) assets can be marked as consumed. This asset's units return to the available pool when released.", + label, + status: 400, + shouldBeCaptured: false, + additionalData: { + assetId, + consumptionType: asset.consumptionType, + }, + }); + } + /** * Step 4: Find the OPERATOR-allocated custody row for this * (asset, teamMember) pair. `findFirst` filtered to @@ -7885,22 +7958,119 @@ export async function releaseQuantity({ }); } - /** Step 7: Create an immutable audit log entry */ - await createConsumptionLog({ - assetId, - category: "RETURN", - quantity, - userId, - custodianId: teamMemberId, - note, - tx, - }); + /** + * Step 6c: Consumed units did not come back — they were used up. + * Permanently remove exactly those from stock, mirroring the `CONSUME` + * branch in booking check-in. Returned units are untouched here: they + * are already back in the pool the moment custody dropped. + * + * No pool-drain guard is needed (unlike booking check-in, which + * decrements the pool WITHOUT touching custody). With + * `available = Asset.quantity - SUM(Custody.quantity)`, this step + * changes the total by `-consumedUnits` while step 6 changed custody by + * `-quantity`, so available moves by exactly `returnedUnits` and never + * goes negative: `consumedUnits <= quantity <= custody.quantity <= C <= Q`. + * The same cancellation holds for `bookable` and `physicalAvailable`, + * both of which subtract `inCustody` from `total` — which is why no + * reservation guard is required either. + * + * `AssetLocation` placements are deliberately NOT adjusted, matching + * the booking check-in CONSUME path (the booking service makes no + * `assetLocation` write at all). Placement is an orthogonal axis and we + * cannot know which location the consumed units came off. + * + * Be aware this leaves the location axis able to drift ABOVE the total: + * `asset_location_sum_within_total` is `AFTER INSERT OR UPDATE OR + * DELETE ON "AssetLocation"` (see + * `20260519143054_add_asset_location_pivot`), so it does not fire on an + * `Asset` write and nothing aborts here — but + * `SUM(AssetLocation.quantity WHERE assetKitId IS NULL)` can end up + * exceeding `Asset.quantity`. Consume 10 of 100 placed units and the + * location page reads 100 while the asset reads 90; a later placement + * edit then trips the constraint on a write that is itself legitimate. + * `assertAssetQuantityNotBelowReservations` does not close this either + * — it queries custody / assetKit / bookingAsset / consumptionLog, not + * assetLocation — so the manual stock-lowering path drifts the same + * way. Reconciling the location axis on stock decrease is a separate + * piece of work across every path that lowers `Asset.quantity`. + */ + if (consumedUnits > 0) { + const beforeQuantity = asset.quantity ?? 0; + await tx.asset.update({ + // eslint-disable-next-line local-rules/require-org-scope-on-id-queries -- idor-safe: `assetId` org-verified earlier via lockAssetForQuantityUpdate + the organizationId guard in this function + where: { id: assetId }, + data: { quantity: { decrement: consumedUnits } }, + }); + + /** + * Audit the stock drop as its own event. Consuming changes TWO + * things — who holds the units (CUSTODY_RELEASED, below) and how + * many exist (this one) — and per the one-event-per-field rule each + * gets its own row so reports can aggregate stock movement without + * parsing custody meta. + */ + await recordEvent( + { + organizationId, + actorUserId: userId, + action: "ASSET_QUANTITY_CHANGED", + entityType: "ASSET", + entityId: assetId, + assetId, + field: "quantity", + fromValue: beforeQuantity, + toValue: beforeQuantity - consumedUnits, + }, + tx + ); + } + + /** + * Step 7: Immutable audit log — one entry per non-zero leg. `CONSUME` + * records units that were used up (the decrement above); `RETURN` + * records units that went back into the available pool. Same category + * discriminator booking check-in uses, so consumption reporting sees + * every path identically. + * + * Both calls are conditional because `createConsumptionLog` rejects a + * non-positive quantity. A pure return therefore writes exactly the one + * RETURN row it always did. + * + * A split attaches the operator's note to both rows: it explains the + * single action the operator took, and there is no per-leg note field. + */ + if (consumedUnits > 0) { + await createConsumptionLog({ + assetId, + category: "CONSUME", + quantity: consumedUnits, + userId, + custodianId: teamMemberId, + note, + tx, + }); + } + + if (returnedUnits > 0) { + await createConsumptionLog({ + assetId, + category: "RETURN", + quantity: returnedUnits, + userId, + custodianId: teamMemberId, + note, + tx, + }); + } /** * Step 8: Activity event — emit `CUSTODY_RELEASED` inside the tx so * it commits atomically with the custody decrement/delete. Mirrors * `checkOutQuantity` — the `viaQuantity` meta flag distinguishes - * qty-tracked releases from INDIVIDUAL-asset custody releases. + * qty-tracked releases from INDIVIDUAL-asset custody releases. The + * custodian stops holding the units either way, so this event is + * emitted for both outcomes; `meta.consumed` / `meta.returned` record + * the split. */ const custodianTeamMember = await tx.teamMember.findFirst({ // org-scoped: teamMemberId is request input, so scope the lookup to @@ -7918,16 +8088,27 @@ export async function releaseQuantity({ assetId, teamMemberId, targetUserId: custodianTeamMember?.user?.id ?? undefined, - meta: { quantity, viaQuantity: true }, + meta: { + quantity, + viaQuantity: true, + consumed: consumedUnits, + returned: returnedUnits, + }, }, tx ); - /** Step 9: Return the refreshed asset */ - return tx.asset.findUniqueOrThrow({ + /** Step 9: Return the refreshed asset plus the split that was applied */ + const updatedAsset = await tx.asset.findUniqueOrThrow({ // eslint-disable-next-line local-rules/require-org-scope-on-id-queries -- idor-safe: `assetId` org-verified earlier via lockAssetForQuantityUpdate + the organizationId guard in this function where: { id: assetId }, }); + + return { + asset: updatedAsset, + consumed: consumedUnits, + returned: returnedUnits, + }; }); } catch (cause) { if (cause instanceof ShelfError) { diff --git a/apps/webapp/app/routes/_layout+/assets.$assetId.overview.tsx b/apps/webapp/app/routes/_layout+/assets.$assetId.overview.tsx index 568bffd00..8c79380d9 100644 --- a/apps/webapp/app/routes/_layout+/assets.$assetId.overview.tsx +++ b/apps/webapp/app/routes/_layout+/assets.$assetId.overview.tsx @@ -1779,6 +1779,7 @@ export default function AssetOverview() { custody={asset.custody} assetId={asset.id} unitOfMeasure={asset.unitOfMeasure} + consumptionType={asset.consumptionType} availableQuantity={quantityData?.custodyAvailable} isSelfService={isSelfService} currentUserId={userId} diff --git a/apps/webapp/app/routes/api+/assets.release-quantity-custody.ts b/apps/webapp/app/routes/api+/assets.release-quantity-custody.ts index 3eefccf67..848ae9d51 100644 --- a/apps/webapp/app/routes/api+/assets.release-quantity-custody.ts +++ b/apps/webapp/app/routes/api+/assets.release-quantity-custody.ts @@ -1,13 +1,27 @@ /** * API Route: Release Quantity Custody * - * Handles POST requests to release (return) a specific quantity of a - * QUANTITY_TRACKED asset from a team member back to the available pool. - * Validates permissions, parses form data with Zod, delegates to - * `releaseQuantity`, and sends a success notification. + * Handles POST requests that end a team member's hold on a specific quantity of + * a QUANTITY_TRACKED asset. Validates permissions, parses form data with Zod, + * delegates to `releaseQuantity`, and sends a success notification. + * + * **What happens to the units is decided server-side** from the asset's + * `consumptionType` (see `releaseCategory` in `@shelf/quantity-control`), with + * an optional operator-supplied split: + * + * - `RETURN` (`TWO_WAY`, and legacy rows with no `consumptionType`) — the units + * go back into the available pool and `Asset.quantity` is untouched. These + * assets reject a non-zero `consumed`. + * - `CONSUME` (`ONE_WAY` consumables) — the units default to used-up, so + * `Asset.quantity` is permanently decremented. A `consumed` field below the + * released quantity hands the remainder back instead. + * + * The audit note and the toast are worded from the split the service reports + * back, so what the operator reads always matches what was persisted. * * @see {@link file://./../../modules/asset/service.server.ts} — releaseQuantity * @see {@link file://./assets.assign-quantity-custody.ts} — Counterpart checkout route + * @see {@link file://./mobile+/custody.release-quantity.ts} — the mirrored mobile route */ import type { Prisma } from "@prisma/client"; @@ -42,6 +56,12 @@ export const ReleaseQuantityCustodySchema = z.object({ .number() .int() .positive("Quantity must be a positive integer"), + /** + * How many of the released units were used up. Optional: when absent the + * server derives it from the asset's consumptionType. Only a consumable + * accepts a non-zero value, which the service enforces. + */ + consumed: z.coerce.number().int().nonnegative().optional(), note: z .string() .optional() @@ -64,7 +84,7 @@ export async function action({ context, request }: ActionFunctionArgs) { const formData = await request.formData(); - const { assetId, teamMemberId, quantity, note } = parseData( + const { assetId, teamMemberId, quantity, consumed, note } = parseData( formData, ReleaseQuantityCustodySchema ); @@ -92,14 +112,22 @@ export async function action({ context, request }: ActionFunctionArgs) { }); } - await releaseQuantity({ - assetId, - teamMemberId, - quantity, - userId, - organizationId, - note, - }); + /** + * The service resolves the split from `Asset.consumptionType` when the + * caller sends no `consumed`, and reports back what it persisted — so the + * audit note and the toast below describe reality instead of re-deriving + * the branch here. + */ + const { consumed: consumedUnits, returned: returnedUnits } = + await releaseQuantity({ + assetId, + teamMemberId, + quantity, + consumed, + userId, + organizationId, + note, + }); /** Best-effort audit note — don't fail the action if note creation fails */ try { @@ -125,7 +153,17 @@ export async function action({ context, request }: ActionFunctionArgs) { }, }); - const baseLine = `${actor} released **${quantity}** unit(s) from ${custodianDisplay}'s custody.`; + /** + * Three shapes, worded from what was actually persisted. The + * return-only line is unchanged from before consumables were handled, + * so a returnable asset's audit trail reads exactly as it always has. + */ + const baseLine = + consumedUnits > 0 && returnedUnits > 0 + ? `${actor} ended ${custodianDisplay}'s hold on **${quantity}** unit(s): **${consumedUnits}** consumed and **${returnedUnits}** returned to stock.` + : consumedUnits > 0 + ? `${actor} marked **${consumedUnits}** unit(s) held by ${custodianDisplay} as consumed. Stock reduced permanently.` + : `${actor} released **${returnedUnits}** unit(s) from ${custodianDisplay}'s custody.`; const noteContent = appendUserTextToNote(baseLine, note); await createNote({ @@ -147,19 +185,31 @@ export async function action({ context, request }: ActionFunctionArgs) { } sendNotification({ - title: `${quantity} unit(s) released successfully`, - message: "The quantity has been returned to the available pool.", + title: + consumedUnits > 0 && returnedUnits > 0 + ? `${consumedUnits} consumed, ${returnedUnits} returned` + : consumedUnits > 0 + ? `${consumedUnits} unit(s) marked as consumed` + : `${returnedUnits} unit(s) released successfully`, + message: + consumedUnits > 0 && returnedUnits > 0 + ? "The consumed units were removed from stock; the rest are back in the available pool." + : consumedUnits > 0 + ? "The units were used up and have been removed from stock." + : "The quantity has been returned to the available pool.", icon: { name: "success", variant: "success" }, senderId: userId, }); - // Releasing custody RAISES available stock and can move the asset back - // above its low-stock threshold. Run the debounced notifier so the - // `lowStockNotifiedAt` marker is cleared (and the "back in stock" notice - // sent) on recovery — otherwise a stale marker would suppress the next - // genuine low-stock alert. Best-effort: `releaseQuantity` has already - // committed, so a notifier failure must NOT surface as an action error - // (the client could retry the non-idempotent release). + // Available stock is `Asset.quantity - SUM(Custody.quantity)`. Ending a + // hold drops custody by the full release and total by the consumed part, + // so available rises by exactly the RETURNED units — and is unchanged when + // everything was consumed. Run the debounced notifier so a recovery clears + // the stale `lowStockNotifiedAt` marker (and sends the "back in stock" + // notice); without that, the next genuine low-stock alert is suppressed. + // Best-effort: `releaseQuantity` has already committed, so a notifier + // failure must NOT surface as an action error (the client could retry the + // non-idempotent release). try { await checkAndNotifyLowStock({ assetId, userId, organizationId }); } catch (lowStockError) { diff --git a/apps/webapp/app/routes/api+/mobile+/custody.release-quantity.ts b/apps/webapp/app/routes/api+/mobile+/custody.release-quantity.ts index 5f8b1f79c..b584acb62 100644 --- a/apps/webapp/app/routes/api+/mobile+/custody.release-quantity.ts +++ b/apps/webapp/app/routes/api+/mobile+/custody.release-quantity.ts @@ -1,17 +1,34 @@ /** * POST /api/mobile/custody/release-quantity * - * Releases (returns) N units of a QUANTITY_TRACKED asset from a team member - * back to the available pool. Mobile twin of the web's - * `/api/assets/release-quantity-custody` route — same Zod schema, same - * SELF_SERVICE guard, same `releaseQuantity` service call, same best-effort - * audit note. Runs the debounced low-stock notifier (best-effort): release - * RAISES available stock and can move the asset back above its threshold, so - * the notifier must run to clear the `lowStockNotifiedAt` marker (and send the - * "back in stock" notice) on recovery — otherwise a stale marker would suppress - * the next genuine low-stock alert. Mirrors the web release route. + * Ends a team member's hold on N units of a QUANTITY_TRACKED asset. Mobile twin + * of the web's `/api/assets/release-quantity-custody` route — same Zod schema, + * same SELF_SERVICE guard, same `releaseQuantity` service call, same + * best-effort audit note. * - * Body: { assetId: string, teamMemberId: string, quantity: number, note?: string } + * **What happens to the units is decided server-side** from the asset's + * `consumptionType` (see `releaseCategory` in `@shelf/quantity-control`), with + * an optional operator-supplied split: + * + * - `RETURN` (`TWO_WAY`, and legacy rows with no `consumptionType`) — the units + * go back into the available pool and `Asset.quantity` is untouched. These + * assets reject a non-zero `consumed`. + * - `CONSUME` (`ONE_WAY` consumables) — the units default to used-up, so + * `Asset.quantity` is permanently decremented. A `consumed` field below the + * released quantity hands the remainder back instead. + * + * The audit note is worded from the split the service reports back, so what the + * operator reads always matches what was persisted. + * + * Runs the debounced low-stock notifier (best-effort) after every release. + * Available stock is `Asset.quantity - SUM(Custody.quantity)`: ending a hold + * drops custody by the full release and total by the consumed part, so + * available rises by exactly the RETURNED units — and is unchanged when + * everything was consumed. The notifier still runs so a recovery clears the + * now-stale debounce marker and sends the recovery notice, or the next genuine + * alert is suppressed. Mirrors the web route. + * + * Body: { assetId: string, teamMemberId: string, quantity: number, consumed?: number, note?: string } * Org: `?orgId=` query param or `x-shelf-organization` header. * * Success envelope: `{ success: true, asset }` where `asset` is the @@ -63,6 +80,12 @@ const ReleaseQuantityCustodySchema = z.object({ .number() .int() .positive("Quantity must be a positive integer"), + /** + * How many of the released units were used up. Optional: when absent the + * server derives it from the asset's consumptionType. Only a consumable + * accepts a non-zero value, which the service enforces. + */ + consumed: z.coerce.number().int().nonnegative().optional(), note: z .string() .optional() @@ -111,7 +134,7 @@ export async function action({ request }: ActionFunctionArgs) { status: 400, }); } - const { assetId, teamMemberId, quantity, note } = parsed.data; + const { assetId, teamMemberId, quantity, consumed, note } = parsed.data; /** * Validate that the team member belongs to the same organization. @@ -154,14 +177,23 @@ export async function action({ request }: ActionFunctionArgs) { // lookup, over-release check) lives inside the service. Kit-allocated // custody rows are NOT releasable here by design — only the operator // row (kitCustodyId: null) is targeted. - await releaseQuantity({ - assetId, - teamMemberId, - quantity, - userId: user.id, - organizationId, - note, - }); + /** + * The service resolves the split from `Asset.consumptionType` when the + * caller sends no `consumed`, and reports back what it persisted — so the + * audit note below describes reality instead of re-deriving the branch + * here. App builds predating the field simply omit it and keep the + * server-derived outcome they always had. + */ + const { consumed: consumedUnits, returned: returnedUnits } = + await releaseQuantity({ + assetId, + teamMemberId, + quantity, + consumed, + userId: user.id, + organizationId, + note, + }); /** Best-effort audit note — don't fail the action if note creation fails */ try { @@ -187,7 +219,16 @@ export async function action({ request }: ActionFunctionArgs) { }, }); - const baseLine = `${actor} released **${quantity}** unit(s) from ${custodianDisplay}'s custody.`; + /** + * Same three shapes the web route writes, so an activity feed reads the + * same whichever client performed the release. + */ + const baseLine = + consumedUnits > 0 && returnedUnits > 0 + ? `${actor} ended ${custodianDisplay}'s hold on **${quantity}** unit(s): **${consumedUnits}** consumed and **${returnedUnits}** returned to stock.` + : consumedUnits > 0 + ? `${actor} marked **${consumedUnits}** unit(s) held by ${custodianDisplay} as consumed. Stock reduced permanently.` + : `${actor} released **${returnedUnits}** unit(s) from ${custodianDisplay}'s custody.`; const noteContent = appendUserTextToNote(baseLine, note); await createNote({ @@ -211,12 +252,14 @@ export async function action({ request }: ActionFunctionArgs) { // No route-level sendNotification success toast here: that's the web's // SSE emitter and mobile has no listener (matches custody.assign.ts). - // Releasing custody raises available stock and can move the asset back - // above its low-stock threshold — run the notifier to clear a now-stale - // debounce marker (and send the recovery notice) so the next genuine - // low-stock alert isn't suppressed. Best-effort: releaseQuantity has - // already committed, so a notifier failure must NOT surface as an action - // error (the client could retry the non-idempotent release). + // Available stock is `Asset.quantity - SUM(Custody.quantity)`. Ending a + // hold drops custody by the full release and total by the consumed part, + // so available rises by exactly the RETURNED units — and is unchanged when + // everything was consumed. Run the notifier so a recovery clears the + // now-stale debounce marker and sends the recovery notice, or the next + // genuine alert is suppressed. Best-effort: releaseQuantity has already + // committed, so a notifier failure must NOT surface as an action error + // (the client could retry the non-idempotent release). try { await checkAndNotifyLowStock({ assetId, diff --git a/apps/webapp/test/routes-tests/api+/mobile.custody.release-quantity.test.ts b/apps/webapp/test/routes-tests/api+/mobile.custody.release-quantity.test.ts index d433d593d..c95f5fead 100644 --- a/apps/webapp/test/routes-tests/api+/mobile.custody.release-quantity.test.ts +++ b/apps/webapp/test/routes-tests/api+/mobile.custody.release-quantity.test.ts @@ -9,6 +9,11 @@ * asset above its threshold, so the debounce marker must be cleared; web * parity), and the refreshed viewer-shaped asset in the success envelope. * + * Also pins the three audit-note wordings the route derives from the + * consumed/returned split the service reports back — the return-only line is + * the pre-consumable wording and must not drift, since an activity feed has to + * read identically whichever client performed the release. + * * @see {@link file://../../../app/routes/api+/mobile+/custody.release-quantity.ts} */ import { action } from "~/routes/api+/mobile+/custody.release-quantity"; @@ -50,7 +55,19 @@ vitest.mock("~/modules/api/mobile-auth.server", () => ({ // why: external service — we mock the quantity release without hitting the // database (whole-module mock also keeps the heavy component import graph out) vitest.mock("~/modules/asset/service.server", () => ({ - releaseQuantity: vitest.fn().mockResolvedValue({ id: "asset-1" }), + // The service reports back the split it actually persisted; the route words + // its audit note from those counts, so the mock has to carry them. + releaseQuantity: vitest + .fn() + .mockResolvedValue({ asset: { id: "asset-1" }, consumed: 0, returned: 3 }), +})); + +// why: the per-user rate limiter is an in-process counter that survives across +// tests in this file, and the route's "bulk" bucket allows only 10 requests a +// minute — every case here posts as the same user, so without a no-op the +// suite's own size would start returning 429s. +vitest.mock("~/utils/rate-limit.server", () => ({ + enforceUserRateLimit: vitest.fn(), })); // why: external service — we mock the team member lookup without hitting the database @@ -185,7 +202,12 @@ describe("POST /api/mobile/custody/release-quantity", () => { (createNote as any).mockResolvedValue(undefined); - (releaseQuantity as any).mockResolvedValue({ id: "asset-1" }); + // Default: a plain return of the 3 units the happy-path test releases. + (releaseQuantity as any).mockResolvedValue({ + asset: { id: "asset-1" }, + consumed: 0, + returned: 3, + }); (getMobileAssetForViewer as any).mockResolvedValue(mockShapedAsset); }); @@ -243,6 +265,93 @@ describe("POST /api/mobile/custody/release-quantity", () => { ); }); + describe("audit note wording", () => { + /** Read the note body the route wrote on its single createNote call. */ + function noteContent() { + const [firstCall] = (createNote as any).mock.calls; + return firstCall[0].content as string; + } + + it("keeps the pre-consumable wording when nothing was consumed", async () => { + // A returnable asset's activity trail must read exactly as it always + // has — this is the line existing workspaces already have on file. + const request = createReleaseQuantityRequest({ + assetId: "asset-1", + teamMemberId: "tm-1", + quantity: 3, + }); + + const result = await action(createActionArgs({ request })); + + expect((result as unknown as Response).status).toBe(200); + expect(noteContent()).toContain("released **3** unit(s)"); + expect(noteContent()).not.toContain("consumed"); + }); + + it("words the note as a full consume when every released unit was used up", async () => { + (releaseQuantity as any).mockResolvedValue({ + asset: { id: "asset-1" }, + consumed: 10, + returned: 0, + }); + + const request = createReleaseQuantityRequest({ + assetId: "asset-1", + teamMemberId: "tm-1", + quantity: 10, + consumed: 10, + }); + + const result = await action(createActionArgs({ request })); + + expect((result as unknown as Response).status).toBe(200); + expect(releaseQuantity).toHaveBeenCalledWith( + expect.objectContaining({ quantity: 10, consumed: 10 }) + ); + expect(noteContent()).toContain("**10** unit(s)"); + expect(noteContent()).toContain("as consumed"); + expect(noteContent()).toContain("Stock reduced permanently"); + }); + + it("names both legs when only some of the released units were used up", async () => { + (releaseQuantity as any).mockResolvedValue({ + asset: { id: "asset-1" }, + consumed: 10, + returned: 30, + }); + + const request = createReleaseQuantityRequest({ + assetId: "asset-1", + teamMemberId: "tm-1", + quantity: 40, + consumed: 10, + }); + + const result = await action(createActionArgs({ request })); + + expect((result as unknown as Response).status).toBe(200); + expect(noteContent()).toContain("hold on **40** unit(s)"); + expect(noteContent()).toContain("**10** consumed"); + expect(noteContent()).toContain("**30** returned to stock"); + }); + + it("forwards an absent consumed field as undefined so the server derives the split", async () => { + // An app build predating the split sends no `consumed`; the service must + // still be free to pick the outcome from the asset row. + const request = createReleaseQuantityRequest({ + assetId: "asset-1", + teamMemberId: "tm-1", + quantity: 3, + }); + + await action(createActionArgs({ request })); + + expect(releaseQuantity).toHaveBeenCalledWith( + expect.objectContaining({ consumed: undefined }) + ); + }); + }); + it("surfaces the service's 400 when releasing more than the custodian holds", async () => { (releaseQuantity as any).mockRejectedValue({ message: "Cannot release 5 units. The custodian only holds 2 units.", diff --git a/packages/quantity-control/package.json b/packages/quantity-control/package.json index c990a9349..9e1a0437a 100644 --- a/packages/quantity-control/package.json +++ b/packages/quantity-control/package.json @@ -2,7 +2,7 @@ "name": "@shelf/quantity-control", "version": "0.0.0", "private": true, - "description": "Pure, dependency-free quantity/availability domain for QUANTITY_TRACKED assets, shared by the webapp (and, later, the companion app) so their availability math never drifts.", + "description": "Pure, dependency-free quantity/availability domain for QUANTITY_TRACKED assets, shared by the webapp and the companion app so their availability math never drifts.", "type": "module", "exports": { ".": { diff --git a/packages/quantity-control/src/availability.test.ts b/packages/quantity-control/src/availability.test.ts index 5fc99a29d..9889363c5 100644 --- a/packages/quantity-control/src/availability.test.ts +++ b/packages/quantity-control/src/availability.test.ts @@ -18,8 +18,8 @@ import { peakConcurrent, RESERVATION_REDUCING_CATEGORIES, resolveIntervalTo, -} from "./availability.js"; -import type { AvailabilityInterval } from "./types.js"; +} from "./availability"; +import type { AvailabilityInterval } from "./types"; /** Terse Date builder — days from a fixed epoch, so intervals read clearly. */ const EPOCH = Date.UTC(2026, 0, 1); diff --git a/packages/quantity-control/src/availability.ts b/packages/quantity-control/src/availability.ts index db9337db2..acaa93825 100644 --- a/packages/quantity-control/src/availability.ts +++ b/packages/quantity-control/src/availability.ts @@ -37,7 +37,7 @@ import type { AvailabilityInterval, QtBookingStatus, QtConsumptionCategory, -} from "./types.js"; +} from "./types"; /** * "No known return date" sentinel used as an OVERDUE booking's effective diff --git a/packages/quantity-control/src/dispositions.test.ts b/packages/quantity-control/src/dispositions.test.ts index fecc72df7..a2843e3bb 100644 --- a/packages/quantity-control/src/dispositions.test.ts +++ b/packages/quantity-control/src/dispositions.test.ts @@ -13,8 +13,9 @@ import { capExceeded, defaultDisposition, poolDecrement, + releaseCategory, sumDisposition, -} from "./dispositions.js"; +} from "./dispositions"; /* ----------------------------- defaultDisposition ------------------------- */ @@ -63,3 +64,28 @@ test("capExceeded: the default disposition never exceeds its own cap", () => { assert.equal(capExceeded(defaultDisposition("ONE_WAY", 5), 5), false); assert.equal(capExceeded(defaultDisposition("TWO_WAY", 5), 5), false); }); + +/* ----------------------------- releaseCategory ---------------------------- */ + +test("releaseCategory: a ONE_WAY consumable's units are used up", () => { + assert.equal(releaseCategory("ONE_WAY"), "CONSUME"); +}); + +test("releaseCategory: a TWO_WAY asset's units return to the pool", () => { + assert.equal(releaseCategory("TWO_WAY"), "RETURN"); +}); + +test("releaseCategory: legacy rows without a consumptionType are returnable", () => { + // Rows created before the column existed must keep the pre-consumable + // behaviour — silently consuming their stock would be a data-loss bug. + assert.equal(releaseCategory(null), "RETURN"); + assert.equal(releaseCategory(undefined), "RETURN"); +}); + +test("defaultDisposition agrees with releaseCategory for every consumption type", () => { + // The two must never diverge: defaultDisposition is built on the predicate. + assert.deepEqual(defaultDisposition("ONE_WAY", 3), { consumed: 3 }); + assert.equal(releaseCategory("ONE_WAY"), "CONSUME"); + assert.deepEqual(defaultDisposition("TWO_WAY", 3), { returned: 3 }); + assert.equal(releaseCategory("TWO_WAY"), "RETURN"); +}); diff --git a/packages/quantity-control/src/dispositions.ts b/packages/quantity-control/src/dispositions.ts index c52d0bc6f..436371a18 100644 --- a/packages/quantity-control/src/dispositions.ts +++ b/packages/quantity-control/src/dispositions.ts @@ -11,7 +11,7 @@ * @see {@link file://./types.ts} */ -import type { QtConsumptionType } from "./types.js"; +import type { QtConsumptionCategory, QtConsumptionType } from "./types"; /** * A per-row check-in disposition: how many of the checked-in units were @@ -29,6 +29,42 @@ export type Disposition = { damaged?: number; }; +/** + * The two terminal categories a custody release can resolve to. Narrowed from + * {@link QtConsumptionCategory} so the literals can never drift from the + * database enum — the enum-parity test in `./enums.test.ts` guards that union + * against `schema.prisma`. + */ +export type ReleaseCategory = Extract< + QtConsumptionCategory, + "RETURN" | "CONSUME" +>; + +/** + * What ending a hold on a QUANTITY_TRACKED asset's units MEANS for a given + * consumption type. + * + * `ONE_WAY` assets (gloves, batteries, cable ties) are used up in the field: + * the units never come back, so the pool must shrink. `TWO_WAY` assets are + * returnable and their units go back into the available pool. A `null` / + * `undefined` type is a legacy row that predates the column and is treated as + * returnable — the pre-existing behaviour, because silently consuming that + * stock would destroy data. + * + * This is the ONE place the rule is encoded. The webapp service, the webapp + * custody list and the companion asset screen all import it: a `.server` + * module cannot be reached by a client component, and neither can be reached + * by React Native, so the shared package is the only common ground. + * + * @param consumptionType - The asset's `consumptionType` (nullable on legacy rows). + * @returns `"CONSUME"` for a one-way consumable, `"RETURN"` otherwise. + */ +export function releaseCategory( + consumptionType: QtConsumptionType | null | undefined +): ReleaseCategory { + return consumptionType === "ONE_WAY" ? "CONSUME" : "RETURN"; +} + /** * The auto-default disposition for a given consumption type, claiming exactly * `cap` units (the remaining amount on the booking slice): @@ -43,7 +79,9 @@ export function defaultDisposition( consumptionType: QtConsumptionType, cap: number ): Disposition { - return consumptionType === "ONE_WAY" ? { consumed: cap } : { returned: cap }; + return releaseCategory(consumptionType) === "CONSUME" + ? { consumed: cap } + : { returned: cap }; } /** diff --git a/packages/quantity-control/src/enums.test.ts b/packages/quantity-control/src/enums.test.ts index 8114a0451..0171fd6cf 100644 --- a/packages/quantity-control/src/enums.test.ts +++ b/packages/quantity-control/src/enums.test.ts @@ -20,7 +20,7 @@ import { QT_BOOKING_STATUSES, QT_CONSUMPTION_CATEGORIES, QT_CONSUMPTION_TYPES, -} from "./types.js"; +} from "./types"; /** Compares two string lists as sets (order-independent, duplicate-free). */ function assertSameSet(actual: readonly string[], expected: readonly string[]) { diff --git a/packages/quantity-control/src/format.test.ts b/packages/quantity-control/src/format.test.ts index 8684f0b4d..8dc632a32 100644 --- a/packages/quantity-control/src/format.test.ts +++ b/packages/quantity-control/src/format.test.ts @@ -7,7 +7,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { formatUnitCount } from "./format.js"; +import { formatUnitCount } from "./format"; test("formatUnitCount: uses the supplied unit of measure", () => { assert.equal(formatUnitCount(10, "boxes"), "10 boxes"); diff --git a/packages/quantity-control/src/guards.test.ts b/packages/quantity-control/src/guards.test.ts index b6de21cee..186362cad 100644 --- a/packages/quantity-control/src/guards.test.ts +++ b/packages/quantity-control/src/guards.test.ts @@ -16,7 +16,7 @@ import { checkQuantityAvailable, checkQuantityNotBelowCommitted, describeOverCommitment, -} from "./guards.js"; +} from "./guards"; /* ------------------------- checkQuantityAvailable ------------------------- */ diff --git a/packages/quantity-control/src/guards.ts b/packages/quantity-control/src/guards.ts index 3e30cad2c..8998c7063 100644 --- a/packages/quantity-control/src/guards.ts +++ b/packages/quantity-control/src/guards.ts @@ -18,8 +18,8 @@ * @see {@link file://./types.ts} */ -import { computeAvailability } from "./availability.js"; -import type { AvailabilityInputs } from "./types.js"; +import { computeAvailability } from "./availability"; +import type { AvailabilityInputs } from "./types"; /* -------------------------------------------------------------------------- */ /* Verdicts */ diff --git a/packages/quantity-control/src/index.ts b/packages/quantity-control/src/index.ts index 8d62d5ee8..07215cb3c 100644 --- a/packages/quantity-control/src/index.ts +++ b/packages/quantity-control/src/index.ts @@ -10,24 +10,35 @@ * on Vite's SSR pipeline — bundle it because it is listed in `ssr.noExternal` * (see `apps/webapp/vite.config.ts`); TypeScript resolves it via this package's * `exports` `src` entrypoint and type-checks the source through its own module - * resolution (`tsc` never reads the Vite config). When the companion app - * (Metro, which cannot - * consume raw TS) is wired to this package in the mobile lane, add a compiled - * output THEN — do not reintroduce a `prepare` script: it runs during the - * turbo-pruned Docker `deps` install where the source/tsconfig are absent and - * breaks the image build. + * resolution (`tsc` never reads the Vite config). The companion app consumes + * this same raw `src` entrypoint through Metro, which transpiles workspace + * TypeScript via babel-preset-expo — `@shelf/datetime` ships the identical + * `exports` map and is imported across the companion. Do NOT add a `prepare` + * script to produce a compiled output: it runs during the turbo-pruned Docker + * `deps` install where the source and tsconfig are absent, and breaks the + * image build. + * + * Relative specifiers inside this package are written WITHOUT a file + * extension, and must stay that way. Metro resolves a specifier by appending + * its `sourceExts` to the literal path, so a NodeNext-style `./types.js` is + * probed as `types.js`, `types.js.ts`, … and never finds `types.ts` — the + * companion's bundle would fail on this entrypoint. `moduleResolution` is + * `"Bundler"` (see `tooling/typescript/base.json`), so extensionless is + * correct for `tsc`, Vite, Vitest and the package's own `tsx` test runner + * alike. * * @see {@link file://./types.ts} — shared enums + value-objects. * @see {@link file://./availability.ts} — peak/sweep/formula primitives. * @see {@link file://./guards.ts} — pure availability verdicts + cause/cure copy. * @see {@link file://./low-stock.ts} — low-stock threshold predicates. - * @see {@link file://./dispositions.ts} — check-in disposition arithmetic. + * @see {@link file://./dispositions.ts} — check-in disposition arithmetic and + * the shared release-category predicate. * @see {@link file://./format.ts} — unit-count formatting. */ -export * from "./types.js"; -export * from "./availability.js"; -export * from "./guards.js"; -export * from "./low-stock.js"; -export * from "./dispositions.js"; -export * from "./format.js"; +export * from "./types"; +export * from "./availability"; +export * from "./guards"; +export * from "./low-stock"; +export * from "./dispositions"; +export * from "./format"; diff --git a/packages/quantity-control/src/low-stock.test.ts b/packages/quantity-control/src/low-stock.test.ts index 089263779..1a0a40e53 100644 --- a/packages/quantity-control/src/low-stock.test.ts +++ b/packages/quantity-control/src/low-stock.test.ts @@ -9,7 +9,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { crossedLowStockThreshold, isLowStock } from "./low-stock.js"; +import { crossedLowStockThreshold, isLowStock } from "./low-stock"; /* -------------------------------- isLowStock ------------------------------ */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efc98fd86..4c2e97030 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,6 +85,9 @@ importers: '@shelf/labels': specifier: workspace:* version: link:../../packages/labels + '@shelf/quantity-control': + specifier: workspace:* + version: link:../../packages/quantity-control '@supabase/supabase-js': specifier: ^2.49.1 version: 2.98.0 @@ -2025,7 +2028,6 @@ packages: '@evilmartians/lefthook@2.1.6': resolution: {integrity: sha512-ysZbzryf74wlISmgm0PH/n1lJ0HD7AHmI6DoJgWO9qzIETQknhGdmKbkOi0MjLYtiOHWQl8Dr2qwg0ksDpNZjA==} - cpu: [x64, arm64, ia32] os: [darwin, linux, win32] hasBin: true