@@ -0,0 +1,92 @@
|
||||
// What the inventory module does, as pure TypeScript over the port in ports.ts.
|
||||
//
|
||||
// Items belong to the credential-bound app tenant and environment. Reservations
|
||||
// additionally belong to the resolved app project; another project's reservation
|
||||
// is not found. Platform project IDs never substitute for immutable binding IDs.
|
||||
|
||||
import { projectRef } from "@conusai-kit/business"
|
||||
import { notFound, type ProjectScope } from "@conusai-kit/core"
|
||||
import { type AvailabilityView, type ItemView, PAGE_LIMIT, type ReservationView } from "../contract/index.js"
|
||||
import type { InventoryReads, InventoryService, Page } from "./ports.js"
|
||||
|
||||
/** Canonical partition and resolved-project reference, shared with the service parser. */
|
||||
export const projectReference = (bindingId: string, projectId: string): string => projectRef(bindingId, projectId)
|
||||
|
||||
/** The service's idempotency key of a request: one project's request id never answers another's. */
|
||||
export const requestKey = (scope: Pick<ProjectScope, "projectId">, requestId: string): string =>
|
||||
`app-project:${scope.projectId}:${requestId}`
|
||||
|
||||
export function listItems(
|
||||
reads: InventoryReads,
|
||||
query: { q?: string; after?: string; limit?: number },
|
||||
): Promise<Page<ItemView>> {
|
||||
const q = query.q?.trim()
|
||||
return reads.listItems({
|
||||
...(q ? { q } : {}),
|
||||
...(query.after === undefined ? {} : { after: query.after }),
|
||||
limit: query.limit ?? PAGE_LIMIT,
|
||||
})
|
||||
}
|
||||
|
||||
export function createItem(
|
||||
service: InventoryService,
|
||||
input: { sku: string; name: string; unit: string; description?: string },
|
||||
): Promise<ItemView> {
|
||||
const description = input.description?.trim()
|
||||
return service.createItem({
|
||||
sku: input.sku,
|
||||
name: input.name.trim(),
|
||||
unit: input.unit,
|
||||
...(description ? { description } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function itemAvailability(
|
||||
reads: InventoryReads,
|
||||
itemId: string,
|
||||
): Promise<{ item: ItemView; availability: AvailabilityView }> {
|
||||
const [item, availability] = await Promise.all([reads.getItem(itemId), reads.availability(itemId)])
|
||||
return { item, availability }
|
||||
}
|
||||
|
||||
export function listProjectReservations(
|
||||
reads: InventoryReads,
|
||||
scope: ProjectScope,
|
||||
query: { status?: "open" | "closed"; after?: string; limit?: number },
|
||||
): Promise<Page<ReservationView>> {
|
||||
return reads.listReservations({
|
||||
reference: projectReference(reads.bindingId, scope.projectId),
|
||||
...(query.status === undefined ? {} : { status: query.status }),
|
||||
...(query.after === undefined ? {} : { after: query.after }),
|
||||
limit: query.limit ?? PAGE_LIMIT,
|
||||
})
|
||||
}
|
||||
|
||||
export function reserveStock(
|
||||
service: InventoryService,
|
||||
scope: ProjectScope,
|
||||
input: { itemId: string; locationId: string; quantity: number; requestId: string },
|
||||
): Promise<ReservationView> {
|
||||
return service.reserve({
|
||||
itemId: input.itemId,
|
||||
locationId: input.locationId,
|
||||
quantity: input.quantity,
|
||||
reference: projectReference(service.bindingId, scope.projectId),
|
||||
key: requestKey(scope, input.requestId),
|
||||
})
|
||||
}
|
||||
|
||||
export async function releaseStock(
|
||||
service: InventoryService,
|
||||
scope: ProjectScope,
|
||||
input: { reservationId: string; quantity?: number; requestId: string },
|
||||
): Promise<ReservationView> {
|
||||
const { reference } = await service.getReservation(input.reservationId)
|
||||
// Another project's reservation is not found, never forbidden: its existence does not leak.
|
||||
if (reference !== projectReference(service.bindingId, scope.projectId)) throw notFound("no such reservation")
|
||||
return service.release({
|
||||
reservationId: input.reservationId,
|
||||
...(input.quantity === undefined ? {} : { quantity: input.quantity }),
|
||||
key: requestKey(scope, input.requestId),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// The port the inventory module's use cases are written against: the platform's Inventory service,
|
||||
// in this module's own words. Core imports no client: adapters/service.ts is the one place that knows
|
||||
// `@conusai-sdk/inventory` and `@conusai-kit/business` exist, and it turns every refusal into the
|
||||
// kit's app errors (`businessAppError` of
|
||||
// `@conusai-kit/business`) before core sees it.
|
||||
|
||||
import { token } from "@conusai-kit/core"
|
||||
import type { AvailabilityView, ItemView, ReservationView } from "../contract/index.js"
|
||||
|
||||
/** One page of a service list: `after` asks for the next, absent on the last. */
|
||||
export interface Page<T> {
|
||||
readonly rows: T[]
|
||||
readonly after?: string
|
||||
}
|
||||
|
||||
/** What a `read` procedure may ask the service: nothing that changes a row. */
|
||||
export interface InventoryReads {
|
||||
/** The immutable app/environment binding UUID for project references. */
|
||||
readonly bindingId: string
|
||||
listItems(query: { q?: string; after?: string; limit: number }): Promise<Page<ItemView>>
|
||||
getItem(itemId: string): Promise<ItemView>
|
||||
availability(itemId: string): Promise<AvailabilityView>
|
||||
listReservations(query: {
|
||||
reference: string
|
||||
status?: "open" | "closed"
|
||||
after?: string
|
||||
limit: number
|
||||
}): Promise<Page<ReservationView>>
|
||||
getReservation(reservationId: string): Promise<{ reservation: ReservationView; reference: string }>
|
||||
}
|
||||
|
||||
export interface InventoryService extends InventoryReads {
|
||||
createItem(input: { sku: string; name: string; unit: string; description?: string }): Promise<ItemView>
|
||||
reserve(input: {
|
||||
itemId: string
|
||||
locationId: string
|
||||
quantity: number
|
||||
reference: string
|
||||
key: string
|
||||
}): Promise<ReservationView>
|
||||
release(input: { reservationId: string; quantity?: number; key: string }): Promise<ReservationView>
|
||||
}
|
||||
|
||||
/** The read view a `read` or `write` handler receives: the kind is a capability, not a label. */
|
||||
export const inventoryReadsOf = (service: InventoryReads): InventoryReads => ({
|
||||
bindingId: service.bindingId,
|
||||
listItems: (query) => service.listItems(query),
|
||||
getItem: (itemId) => service.getItem(itemId),
|
||||
availability: (itemId) => service.availability(itemId),
|
||||
listReservations: (query) => service.listReservations(query),
|
||||
getReservation: (reservationId) => service.getReservation(reservationId),
|
||||
})
|
||||
|
||||
/**
|
||||
* Bound by a composition root or a test that brings its own client (another organization, the
|
||||
* business-services fake). With nothing bound, the module calls the service of this deployment
|
||||
* (adapters/service.ts).
|
||||
*/
|
||||
export const INVENTORY_SERVICE = token<InventoryService>("inventory.service", {
|
||||
views: { read: inventoryReadsOf, write: inventoryReadsOf },
|
||||
})
|
||||
Reference in New Issue
Block a user