@unifiedcommerce/plugin-warehouse 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ export type { PluginDb as Db } from "@unifiedcommerce/core";
2
+ import type { warehouseBins, stockTransfers, stockTransferItems, wastageNotes, wastageNoteItems, stockReconciliations, reconciliationItems } from "./schema";
3
+ export type WarehouseBin = typeof warehouseBins.$inferSelect;
4
+ export type StockTransfer = typeof stockTransfers.$inferSelect;
5
+ export type StockTransferItem = typeof stockTransferItems.$inferSelect;
6
+ export type WastageNote = typeof wastageNotes.$inferSelect;
7
+ export type WastageNoteItem = typeof wastageNoteItems.$inferSelect;
8
+ export type StockReconciliation = typeof stockReconciliations.$inferSelect;
9
+ export type ReconciliationItem = typeof reconciliationItems.$inferSelect;
10
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,uBAAuB,CAAC;AAC5D,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,kBAAkB,EAAE,YAAY,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAC7J,MAAM,MAAM,YAAY,GAAG,OAAO,aAAa,CAAC,YAAY,CAAC;AAC7D,MAAM,MAAM,aAAa,GAAG,OAAO,cAAc,CAAC,YAAY,CAAC;AAC/D,MAAM,MAAM,iBAAiB,GAAG,OAAO,kBAAkB,CAAC,YAAY,CAAC;AACvE,MAAM,MAAM,WAAW,GAAG,OAAO,YAAY,CAAC,YAAY,CAAC;AAC3D,MAAM,MAAM,eAAe,GAAG,OAAO,gBAAgB,CAAC,YAAY,CAAC;AACnE,MAAM,MAAM,mBAAmB,GAAG,OAAO,oBAAoB,CAAC,YAAY,CAAC;AAC3E,MAAM,MAAM,kBAAkB,GAAG,OAAO,mBAAmB,CAAC,YAAY,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@unifiedcommerce/plugin-warehouse",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./dist/index.d.ts",
8
+ "default": "./dist/index.js"
9
+ }
10
+ },
11
+ "scripts": {
12
+ "build": "tsc -p tsconfig.build.json",
13
+ "check-types": "tsc --noEmit",
14
+ "test": "vitest run"
15
+ },
16
+ "dependencies": {
17
+ "@hono/zod-openapi": "^1.2.2",
18
+ "@unifiedcommerce/core": "*",
19
+ "drizzle-orm": "^0.45.1",
20
+ "hono": "^4.12.5"
21
+ },
22
+ "devDependencies": {
23
+ "@repo/eslint-config": "*",
24
+ "@repo/typescript-config": "*",
25
+ "@types/node": "^24.5.2",
26
+ "typescript": "5.9.2",
27
+ "vitest": "^3.2.4"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "src",
35
+ "README.md"
36
+ ]
37
+ }
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { defineCommercePlugin } from "@unifiedcommerce/core";
2
+ import { warehouseBins, stockTransfers, stockTransferItems, wastageNotes, wastageNoteItems, stockReconciliations, reconciliationItems } from "./schema";
3
+ import { TransferService } from "./services/transfer-service";
4
+ import { WastageService } from "./services/wastage-service";
5
+ import { ReconciliationService } from "./services/reconciliation-service";
6
+ import { buildWarehouseRoutes } from "./routes/warehouse";
7
+ import type { Db } from "./types";
8
+
9
+ export type { Db } from "./types";
10
+ export { TransferService } from "./services/transfer-service";
11
+ export { WastageService } from "./services/wastage-service";
12
+ export { ReconciliationService } from "./services/reconciliation-service";
13
+
14
+ export function warehousePlugin() {
15
+ return defineCommercePlugin({
16
+ id: "warehouse",
17
+ version: "1.0.0",
18
+ permissions: [
19
+ { scope: "warehouse:admin", description: "Approve transfers, wastage, reconciliations." },
20
+ { scope: "warehouse:operate", description: "Create transfers, wastage notes, reconciliations." },
21
+ { scope: "warehouse:read", description: "View transfers, wastage, reconciliations." },
22
+ ],
23
+ schema: () => ({ warehouseBins, stockTransfers, stockTransferItems, wastageNotes, wastageNoteItems, stockReconciliations, reconciliationItems }),
24
+ hooks: () => [],
25
+ routes: (ctx) => {
26
+ const db = ctx.database.db as unknown as Db;
27
+ if (!db) return [];
28
+ return buildWarehouseRoutes(new TransferService(db), new WastageService(db), new ReconciliationService(db), ctx);
29
+ },
30
+ });
31
+ }
@@ -0,0 +1,134 @@
1
+ import { router } from "@unifiedcommerce/core";
2
+ import { z } from "@hono/zod-openapi";
3
+ import type { TransferService } from "../services/transfer-service";
4
+ import type { WastageService } from "../services/wastage-service";
5
+ import type { ReconciliationService } from "../services/reconciliation-service";
6
+ import type { PluginRouteRegistration } from "@unifiedcommerce/core";
7
+
8
+ export function buildWarehouseRoutes(
9
+ transferSvc: TransferService, wastageSvc: WastageService, recSvc: ReconciliationService,
10
+ ctx: { services?: Record<string, unknown>; database?: { db: unknown } },
11
+ ): PluginRouteRegistration[] {
12
+ const r = router("Warehouse", "/warehouse", ctx);
13
+
14
+ // Transfers
15
+ r.post("/transfers").summary("Create stock transfer").permission("warehouse:operate")
16
+ .input(z.object({
17
+ fromWarehouseId: z.string().uuid(), toWarehouseId: z.string().uuid(), type: z.enum(["requisition", "direct", "return"]).optional(), notes: z.string().optional(),
18
+ items: z.array(z.object({ entityId: z.string().uuid(), variantId: z.string().uuid().optional(), itemName: z.string(), quantityRequested: z.number().int().positive(), batchNumber: z.string().optional() })).min(1),
19
+ }))
20
+ .handler(async ({ input, actor, orgId }) => {
21
+ const body = input as Parameters<typeof transferSvc.create>[1];
22
+ const result = await transferSvc.create(orgId, { ...body, requestedBy: actor!.userId });
23
+ if (!result.ok) throw new Error(result.error);
24
+ return result.value;
25
+ });
26
+
27
+ r.get("/transfers").summary("List transfers").permission("warehouse:read")
28
+ .query(z.object({ status: z.string().optional() }))
29
+ .handler(async ({ query, orgId }) => {
30
+ const result = await transferSvc.list(orgId, (query as { status?: string }).status);
31
+ if (!result.ok) throw new Error(result.error);
32
+ return result.value;
33
+ });
34
+
35
+ r.get("/transfers/{id}").summary("Get transfer with items").permission("warehouse:read")
36
+ .handler(async ({ params, orgId }) => {
37
+ const result = await transferSvc.getById(orgId, params.id!);
38
+ if (!result.ok) throw new Error(result.error);
39
+ return result.value;
40
+ });
41
+
42
+ r.post("/transfers/{id}/approve").summary("Approve transfer").permission("warehouse:admin")
43
+ .handler(async ({ params, actor, orgId }) => {
44
+ const result = await transferSvc.approve(orgId, params.id!, actor!.userId);
45
+ if (!result.ok) throw new Error(result.error);
46
+ return result.value;
47
+ });
48
+
49
+ r.post("/transfers/{id}/dispatch").summary("Dispatch transfer").permission("warehouse:operate")
50
+ .handler(async ({ params, orgId }) => {
51
+ const result = await transferSvc.dispatch(orgId, params.id!);
52
+ if (!result.ok) throw new Error(result.error);
53
+ return result.value;
54
+ });
55
+
56
+ r.post("/transfers/{id}/receive").summary("Receive transfer").permission("warehouse:operate")
57
+ .input(z.object({ items: z.array(z.object({ itemId: z.string().uuid(), quantityReceived: z.number().int() })).min(1) }))
58
+ .handler(async ({ params, input, orgId }) => {
59
+ const body = input as { items: Array<{ itemId: string; quantityReceived: number }> };
60
+ const result = await transferSvc.receive(orgId, params.id!, body.items);
61
+ if (!result.ok) throw new Error(result.error);
62
+ return result.value;
63
+ });
64
+
65
+ // Wastage
66
+ r.post("/wastage").summary("Create wastage note").permission("warehouse:operate")
67
+ .input(z.object({
68
+ warehouseId: z.string().uuid(), type: z.enum(["spoilage", "damage", "expiry", "theft", "prep_waste", "other"]), notes: z.string().optional(),
69
+ items: z.array(z.object({ entityId: z.string().uuid(), variantId: z.string().uuid().optional(), itemName: z.string(), quantity: z.number().int().positive(), unitCost: z.number().int(), reason: z.string().optional(), batchNumber: z.string().optional() })).min(1),
70
+ }))
71
+ .handler(async ({ input, actor, orgId }) => {
72
+ const body = input as Parameters<typeof wastageSvc.create>[1];
73
+ const result = await wastageSvc.create(orgId, { ...body, recordedBy: actor!.userId });
74
+ if (!result.ok) throw new Error(result.error);
75
+ return result.value;
76
+ });
77
+
78
+ r.get("/wastage").summary("List wastage notes").permission("warehouse:read")
79
+ .handler(async ({ orgId }) => {
80
+ const result = await wastageSvc.list(orgId);
81
+ if (!result.ok) throw new Error(result.error);
82
+ return result.value;
83
+ });
84
+
85
+ r.post("/wastage/{id}/approve").summary("Approve wastage").permission("warehouse:admin")
86
+ .handler(async ({ params, actor, orgId }) => {
87
+ const result = await wastageSvc.approve(orgId, params.id!, actor!.userId);
88
+ if (!result.ok) throw new Error(result.error);
89
+ return result.value;
90
+ });
91
+
92
+ // Reconciliation
93
+ r.post("/reconciliations").summary("Create stock reconciliation").permission("warehouse:operate")
94
+ .input(z.object({
95
+ warehouseId: z.string().uuid(),
96
+ items: z.array(z.object({ entityId: z.string().uuid(), variantId: z.string().uuid().optional(), itemName: z.string(), systemQuantity: z.number().int(), physicalQuantity: z.number().int(), notes: z.string().optional() })).min(1),
97
+ }))
98
+ .handler(async ({ input, actor, orgId }) => {
99
+ const body = input as Parameters<typeof recSvc.create>[1];
100
+ const result = await recSvc.create(orgId, { ...body, countedBy: actor!.userId });
101
+ if (!result.ok) throw new Error(result.error);
102
+ return result.value;
103
+ });
104
+
105
+ r.get("/reconciliations").summary("List reconciliations").permission("warehouse:read")
106
+ .handler(async ({ orgId }) => {
107
+ const result = await recSvc.list(orgId);
108
+ if (!result.ok) throw new Error(result.error);
109
+ return result.value;
110
+ });
111
+
112
+ r.get("/reconciliations/{id}").summary("Get reconciliation with items").permission("warehouse:read")
113
+ .handler(async ({ params, orgId }) => {
114
+ const result = await recSvc.getById(orgId, params.id!);
115
+ if (!result.ok) throw new Error(result.error);
116
+ return result.value;
117
+ });
118
+
119
+ r.post("/reconciliations/{id}/submit").summary("Submit reconciliation").permission("warehouse:operate")
120
+ .handler(async ({ params, orgId }) => {
121
+ const result = await recSvc.submit(orgId, params.id!);
122
+ if (!result.ok) throw new Error(result.error);
123
+ return result.value;
124
+ });
125
+
126
+ r.post("/reconciliations/{id}/approve").summary("Approve reconciliation").permission("warehouse:admin")
127
+ .handler(async ({ params, actor, orgId }) => {
128
+ const result = await recSvc.approve(orgId, params.id!, actor!.userId);
129
+ if (!result.ok) throw new Error(result.error);
130
+ return result.value;
131
+ });
132
+
133
+ return r.routes();
134
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,118 @@
1
+ import { pgTable, uuid, text, integer, boolean, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
2
+
3
+ export const warehouseBins = pgTable("warehouse_bins", {
4
+ id: uuid("id").defaultRandom().primaryKey(),
5
+ organizationId: text("organization_id").notNull(),
6
+ warehouseId: uuid("warehouse_id").notNull(),
7
+ code: text("code").notNull(),
8
+ name: text("name").notNull(),
9
+ type: text("type", { enum: ["general", "cold", "frozen", "dry", "hazardous", "display"] }).notNull().default("general"),
10
+ isActive: boolean("is_active").notNull().default(true),
11
+ capacity: integer("capacity"),
12
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
13
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
14
+ }, (table) => ({
15
+ orgIdx: index("idx_warehouse_bins_org").on(table.organizationId),
16
+ codeUnique: uniqueIndex("warehouse_bins_org_wh_code_unique").on(table.organizationId, table.warehouseId, table.code),
17
+ }));
18
+
19
+ export const stockTransfers = pgTable("stock_transfers", {
20
+ id: uuid("id").defaultRandom().primaryKey(),
21
+ organizationId: text("organization_id").notNull(),
22
+ transferNumber: text("transfer_number").notNull(),
23
+ type: text("type", { enum: ["requisition", "direct", "return"] }).notNull().default("requisition"),
24
+ status: text("status", { enum: ["draft", "pending_approval", "approved", "in_transit", "received", "cancelled"] }).notNull().default("draft"),
25
+ fromWarehouseId: uuid("from_warehouse_id").notNull(),
26
+ toWarehouseId: uuid("to_warehouse_id").notNull(),
27
+ requestedBy: text("requested_by").notNull(),
28
+ approvedBy: text("approved_by"),
29
+ dispatchedAt: timestamp("dispatched_at", { withTimezone: true }),
30
+ receivedAt: timestamp("received_at", { withTimezone: true }),
31
+ notes: text("notes"),
32
+ metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
33
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
34
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
35
+ }, (table) => ({
36
+ orgIdx: index("idx_stock_transfers_org").on(table.organizationId),
37
+ statusIdx: index("idx_stock_transfers_status").on(table.status),
38
+ numUnique: uniqueIndex("stock_transfers_org_num_unique").on(table.organizationId, table.transferNumber),
39
+ }));
40
+
41
+ export const stockTransferItems = pgTable("stock_transfer_items", {
42
+ id: uuid("id").defaultRandom().primaryKey(),
43
+ transferId: uuid("transfer_id").references(() => stockTransfers.id, { onDelete: "cascade" }).notNull(),
44
+ entityId: uuid("entity_id").notNull(),
45
+ variantId: uuid("variant_id"),
46
+ itemName: text("item_name").notNull(),
47
+ quantityRequested: integer("quantity_requested").notNull(),
48
+ quantityDispatched: integer("quantity_dispatched").notNull().default(0),
49
+ quantityReceived: integer("quantity_received").notNull().default(0),
50
+ batchNumber: text("batch_number"),
51
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
52
+ }, (table) => ({
53
+ transferIdx: index("idx_stock_transfer_items_transfer").on(table.transferId),
54
+ }));
55
+
56
+ export const wastageNotes = pgTable("wastage_notes", {
57
+ id: uuid("id").defaultRandom().primaryKey(),
58
+ organizationId: text("organization_id").notNull(),
59
+ noteNumber: text("note_number").notNull(),
60
+ warehouseId: uuid("warehouse_id").notNull(),
61
+ type: text("type", { enum: ["spoilage", "damage", "expiry", "theft", "prep_waste", "other"] }).notNull(),
62
+ recordedBy: text("recorded_by").notNull(),
63
+ approvedBy: text("approved_by"),
64
+ totalCost: integer("total_cost").notNull().default(0),
65
+ notes: text("notes"),
66
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
67
+ }, (table) => ({
68
+ orgIdx: index("idx_wastage_notes_org").on(table.organizationId),
69
+ numUnique: uniqueIndex("wastage_notes_org_num_unique").on(table.organizationId, table.noteNumber),
70
+ }));
71
+
72
+ export const wastageNoteItems = pgTable("wastage_note_items", {
73
+ id: uuid("id").defaultRandom().primaryKey(),
74
+ noteId: uuid("note_id").references(() => wastageNotes.id, { onDelete: "cascade" }).notNull(),
75
+ entityId: uuid("entity_id").notNull(),
76
+ variantId: uuid("variant_id"),
77
+ itemName: text("item_name").notNull(),
78
+ quantity: integer("quantity").notNull(),
79
+ unitCost: integer("unit_cost").notNull(),
80
+ totalCost: integer("total_cost").notNull(),
81
+ reason: text("reason"),
82
+ batchNumber: text("batch_number"),
83
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
84
+ }, (table) => ({
85
+ noteIdx: index("idx_wastage_note_items_note").on(table.noteId),
86
+ }));
87
+
88
+ export const stockReconciliations = pgTable("stock_reconciliations", {
89
+ id: uuid("id").defaultRandom().primaryKey(),
90
+ organizationId: text("organization_id").notNull(),
91
+ reconciliationNumber: text("reconciliation_number").notNull(),
92
+ warehouseId: uuid("warehouse_id").notNull(),
93
+ status: text("status", { enum: ["draft", "counting", "submitted", "approved", "adjusted"] }).notNull().default("draft"),
94
+ countedBy: text("counted_by").notNull(),
95
+ approvedBy: text("approved_by"),
96
+ countedAt: timestamp("counted_at", { withTimezone: true }),
97
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
98
+ }, (table) => ({
99
+ orgIdx: index("idx_stock_reconciliations_org").on(table.organizationId),
100
+ numUnique: uniqueIndex("stock_reconciliations_org_num_unique").on(table.organizationId, table.reconciliationNumber),
101
+ }));
102
+
103
+ export const reconciliationItems = pgTable("reconciliation_items", {
104
+ id: uuid("id").defaultRandom().primaryKey(),
105
+ reconciliationId: uuid("reconciliation_id").references(() => stockReconciliations.id, { onDelete: "cascade" }).notNull(),
106
+ entityId: uuid("entity_id").notNull(),
107
+ variantId: uuid("variant_id"),
108
+ itemName: text("item_name").notNull(),
109
+ systemQuantity: integer("system_quantity").notNull(),
110
+ physicalQuantity: integer("physical_quantity").notNull(),
111
+ variance: integer("variance").notNull(),
112
+ varianceCost: integer("variance_cost").notNull().default(0),
113
+ adjustmentMade: boolean("adjustment_made").notNull().default(false),
114
+ notes: text("notes"),
115
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
116
+ }, (table) => ({
117
+ recIdx: index("idx_reconciliation_items_rec").on(table.reconciliationId),
118
+ }));
@@ -0,0 +1,69 @@
1
+ import { eq, and, sql } from "drizzle-orm";
2
+ import { Ok, Err } from "@unifiedcommerce/core";
3
+ import type { PluginResult } from "@unifiedcommerce/core";
4
+ import { stockReconciliations, reconciliationItems } from "../schema";
5
+ import type { Db, StockReconciliation, ReconciliationItem } from "../types";
6
+
7
+ export class ReconciliationService {
8
+ constructor(private db: Db) {}
9
+
10
+ async create(orgId: string, input: {
11
+ warehouseId: string; countedBy: string;
12
+ items: Array<{ entityId: string; variantId?: string; itemName: string; systemQuantity: number; physicalQuantity: number; notes?: string }>;
13
+ }): Promise<PluginResult<StockReconciliation>> {
14
+ const recNumber = await this.generateNumber(orgId);
15
+ const rows = await this.db.insert(stockReconciliations).values({
16
+ organizationId: orgId, reconciliationNumber: recNumber,
17
+ warehouseId: input.warehouseId, countedBy: input.countedBy, countedAt: new Date(),
18
+ }).returning();
19
+ const rec = rows[0]!;
20
+
21
+ for (const item of input.items) {
22
+ const variance = item.physicalQuantity - item.systemQuantity;
23
+ await this.db.insert(reconciliationItems).values({
24
+ reconciliationId: rec.id, entityId: item.entityId, variantId: item.variantId,
25
+ itemName: item.itemName, systemQuantity: item.systemQuantity,
26
+ physicalQuantity: item.physicalQuantity, variance, notes: item.notes,
27
+ });
28
+ }
29
+ return Ok(rec);
30
+ }
31
+
32
+ async list(orgId: string): Promise<PluginResult<StockReconciliation[]>> {
33
+ return Ok(await this.db.select().from(stockReconciliations).where(eq(stockReconciliations.organizationId, orgId)));
34
+ }
35
+
36
+ async getById(orgId: string, id: string): Promise<PluginResult<{ reconciliation: StockReconciliation; items: ReconciliationItem[] }>> {
37
+ const rows = await this.db.select().from(stockReconciliations)
38
+ .where(and(eq(stockReconciliations.id, id), eq(stockReconciliations.organizationId, orgId)));
39
+ if (rows.length === 0) return Err("Reconciliation not found");
40
+ const items = await this.db.select().from(reconciliationItems).where(eq(reconciliationItems.reconciliationId, id));
41
+ return Ok({ reconciliation: rows[0]!, items });
42
+ }
43
+
44
+ async submit(orgId: string, id: string): Promise<PluginResult<StockReconciliation>> {
45
+ const rows = await this.db.update(stockReconciliations).set({ status: "submitted" })
46
+ .where(and(eq(stockReconciliations.id, id), eq(stockReconciliations.organizationId, orgId), eq(stockReconciliations.status, "draft"))).returning();
47
+ if (rows.length === 0) return Err("Not found or not in draft status");
48
+ return Ok(rows[0]!);
49
+ }
50
+
51
+ async approve(orgId: string, id: string, approvedBy: string): Promise<PluginResult<StockReconciliation>> {
52
+ // Mark items with variance as adjusted
53
+ const items = await this.db.select().from(reconciliationItems).where(eq(reconciliationItems.reconciliationId, id));
54
+ for (const item of items) {
55
+ if (item.variance !== 0) {
56
+ await this.db.update(reconciliationItems).set({ adjustmentMade: true }).where(eq(reconciliationItems.id, item.id));
57
+ }
58
+ }
59
+ const rows = await this.db.update(stockReconciliations).set({ status: "approved", approvedBy })
60
+ .where(and(eq(stockReconciliations.id, id), eq(stockReconciliations.organizationId, orgId), eq(stockReconciliations.status, "submitted"))).returning();
61
+ if (rows.length === 0) return Err("Not found or not in submitted status");
62
+ return Ok(rows[0]!);
63
+ }
64
+
65
+ private async generateNumber(orgId: string): Promise<string> {
66
+ const countRows = await this.db.select({ count: sql<number>`COUNT(*)`.as("count") }).from(stockReconciliations).where(eq(stockReconciliations.organizationId, orgId));
67
+ return `REC-${String(Number(countRows[0]?.count ?? 0) + 1).padStart(4, "0")}`;
68
+ }
69
+ }
@@ -0,0 +1,74 @@
1
+ import { eq, and, sql } from "drizzle-orm";
2
+ import { Ok, Err } from "@unifiedcommerce/core";
3
+ import type { PluginResult } from "@unifiedcommerce/core";
4
+ import { stockTransfers, stockTransferItems } from "../schema";
5
+ import type { Db, StockTransfer, StockTransferItem } from "../types";
6
+
7
+ export class TransferService {
8
+ constructor(private db: Db) {}
9
+
10
+ async create(orgId: string, input: {
11
+ fromWarehouseId: string; toWarehouseId: string; requestedBy: string;
12
+ type?: "requisition" | "direct" | "return"; notes?: string;
13
+ items: Array<{ entityId: string; variantId?: string; itemName: string; quantityRequested: number; batchNumber?: string }>;
14
+ }): Promise<PluginResult<StockTransfer>> {
15
+ if (input.fromWarehouseId === input.toWarehouseId) return Err("Cannot transfer to the same warehouse");
16
+ const transferNumber = await this.generateNumber(orgId, "TRF");
17
+ const rows = await this.db.insert(stockTransfers).values({
18
+ organizationId: orgId, transferNumber, type: input.type ?? "requisition",
19
+ fromWarehouseId: input.fromWarehouseId, toWarehouseId: input.toWarehouseId,
20
+ requestedBy: input.requestedBy, notes: input.notes,
21
+ }).returning();
22
+ const transfer = rows[0]!;
23
+ for (const item of input.items) {
24
+ await this.db.insert(stockTransferItems).values({ transferId: transfer.id, ...item });
25
+ }
26
+ return Ok(transfer);
27
+ }
28
+
29
+ async list(orgId: string, status?: string): Promise<PluginResult<StockTransfer[]>> {
30
+ const conditions = [eq(stockTransfers.organizationId, orgId)];
31
+ if (status) conditions.push(eq(stockTransfers.status, status as StockTransfer["status"]));
32
+ return Ok(await this.db.select().from(stockTransfers).where(and(...conditions)));
33
+ }
34
+
35
+ async getById(orgId: string, id: string): Promise<PluginResult<{ transfer: StockTransfer; items: StockTransferItem[] }>> {
36
+ const rows = await this.db.select().from(stockTransfers).where(and(eq(stockTransfers.id, id), eq(stockTransfers.organizationId, orgId)));
37
+ if (rows.length === 0) return Err("Transfer not found");
38
+ const items = await this.db.select().from(stockTransferItems).where(eq(stockTransferItems.transferId, id));
39
+ return Ok({ transfer: rows[0]!, items });
40
+ }
41
+
42
+ async approve(orgId: string, id: string, approvedBy: string): Promise<PluginResult<StockTransfer>> {
43
+ const rows = await this.db.update(stockTransfers).set({ status: "approved", approvedBy, updatedAt: new Date() })
44
+ .where(and(eq(stockTransfers.id, id), eq(stockTransfers.organizationId, orgId), eq(stockTransfers.status, "draft"))).returning();
45
+ if (rows.length === 0) return Err("Transfer not found or not in draft status");
46
+ return Ok(rows[0]!);
47
+ }
48
+
49
+ async dispatch(orgId: string, id: string): Promise<PluginResult<StockTransfer>> {
50
+ const items = await this.db.select().from(stockTransferItems).where(eq(stockTransferItems.transferId, id));
51
+ for (const item of items) {
52
+ await this.db.update(stockTransferItems).set({ quantityDispatched: item.quantityRequested }).where(eq(stockTransferItems.id, item.id));
53
+ }
54
+ const rows = await this.db.update(stockTransfers).set({ status: "in_transit", dispatchedAt: new Date(), updatedAt: new Date() })
55
+ .where(and(eq(stockTransfers.id, id), eq(stockTransfers.organizationId, orgId), eq(stockTransfers.status, "approved"))).returning();
56
+ if (rows.length === 0) return Err("Transfer not found or not in approved status");
57
+ return Ok(rows[0]!);
58
+ }
59
+
60
+ async receive(orgId: string, id: string, receivedItems: Array<{ itemId: string; quantityReceived: number }>): Promise<PluginResult<StockTransfer>> {
61
+ for (const ri of receivedItems) {
62
+ await this.db.update(stockTransferItems).set({ quantityReceived: ri.quantityReceived }).where(eq(stockTransferItems.id, ri.itemId));
63
+ }
64
+ const rows = await this.db.update(stockTransfers).set({ status: "received", receivedAt: new Date(), updatedAt: new Date() })
65
+ .where(and(eq(stockTransfers.id, id), eq(stockTransfers.organizationId, orgId), eq(stockTransfers.status, "in_transit"))).returning();
66
+ if (rows.length === 0) return Err("Transfer not found or not in transit");
67
+ return Ok(rows[0]!);
68
+ }
69
+
70
+ private async generateNumber(orgId: string, prefix: string): Promise<string> {
71
+ const countRows = await this.db.select({ count: sql<number>`COUNT(*)`.as("count") }).from(stockTransfers).where(eq(stockTransfers.organizationId, orgId));
72
+ return `${prefix}-${String(Number(countRows[0]?.count ?? 0) + 1).padStart(4, "0")}`;
73
+ }
74
+ }
@@ -0,0 +1,50 @@
1
+ import { eq, and, sql } from "drizzle-orm";
2
+ import { Ok, Err } from "@unifiedcommerce/core";
3
+ import type { PluginResult } from "@unifiedcommerce/core";
4
+ import { wastageNotes, wastageNoteItems } from "../schema";
5
+ import type { Db, WastageNote, WastageNoteItem } from "../types";
6
+
7
+ export class WastageService {
8
+ constructor(private db: Db) {}
9
+
10
+ async create(orgId: string, input: {
11
+ warehouseId: string; type: "spoilage" | "damage" | "expiry" | "theft" | "prep_waste" | "other";
12
+ recordedBy: string; notes?: string;
13
+ items: Array<{ entityId: string; variantId?: string; itemName: string; quantity: number; unitCost: number; reason?: string; batchNumber?: string }>;
14
+ }): Promise<PluginResult<WastageNote>> {
15
+ let totalCost = 0;
16
+ for (const item of input.items) totalCost += item.quantity * item.unitCost;
17
+
18
+ const noteNumber = await this.generateNumber(orgId);
19
+ const rows = await this.db.insert(wastageNotes).values({
20
+ organizationId: orgId, noteNumber, warehouseId: input.warehouseId,
21
+ type: input.type, recordedBy: input.recordedBy, totalCost, notes: input.notes,
22
+ }).returning();
23
+ const note = rows[0]!;
24
+
25
+ for (const item of input.items) {
26
+ await this.db.insert(wastageNoteItems).values({
27
+ noteId: note.id, entityId: item.entityId, variantId: item.variantId,
28
+ itemName: item.itemName, quantity: item.quantity, unitCost: item.unitCost,
29
+ totalCost: item.quantity * item.unitCost, reason: item.reason, batchNumber: item.batchNumber,
30
+ });
31
+ }
32
+ return Ok(note);
33
+ }
34
+
35
+ async list(orgId: string): Promise<PluginResult<WastageNote[]>> {
36
+ return Ok(await this.db.select().from(wastageNotes).where(eq(wastageNotes.organizationId, orgId)));
37
+ }
38
+
39
+ async approve(orgId: string, id: string, approvedBy: string): Promise<PluginResult<WastageNote>> {
40
+ const rows = await this.db.update(wastageNotes).set({ approvedBy })
41
+ .where(and(eq(wastageNotes.id, id), eq(wastageNotes.organizationId, orgId))).returning();
42
+ if (rows.length === 0) return Err("Wastage note not found");
43
+ return Ok(rows[0]!);
44
+ }
45
+
46
+ private async generateNumber(orgId: string): Promise<string> {
47
+ const countRows = await this.db.select({ count: sql<number>`COUNT(*)`.as("count") }).from(wastageNotes).where(eq(wastageNotes.organizationId, orgId));
48
+ return `WST-${String(Number(countRows[0]?.count ?? 0) + 1).padStart(4, "0")}`;
49
+ }
50
+ }
package/src/types.ts ADDED
@@ -0,0 +1,9 @@
1
+ export type { PluginDb as Db } from "@unifiedcommerce/core";
2
+ import type { warehouseBins, stockTransfers, stockTransferItems, wastageNotes, wastageNoteItems, stockReconciliations, reconciliationItems } from "./schema";
3
+ export type WarehouseBin = typeof warehouseBins.$inferSelect;
4
+ export type StockTransfer = typeof stockTransfers.$inferSelect;
5
+ export type StockTransferItem = typeof stockTransferItems.$inferSelect;
6
+ export type WastageNote = typeof wastageNotes.$inferSelect;
7
+ export type WastageNoteItem = typeof wastageNoteItems.$inferSelect;
8
+ export type StockReconciliation = typeof stockReconciliations.$inferSelect;
9
+ export type ReconciliationItem = typeof reconciliationItems.$inferSelect;