@unifiedcommerce/plugin-production 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.
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/routes/production.d.ts +10 -0
- package/dist/routes/production.d.ts.map +1 -0
- package/dist/routes/production.js +156 -0
- package/dist/schema.d.ts +879 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +71 -0
- package/dist/services/production-order-service.d.ts +34 -0
- package/dist/services/production-order-service.d.ts.map +1 -0
- package/dist/services/production-order-service.js +154 -0
- package/dist/services/production-service.d.ts +48 -0
- package/dist/services/production-service.d.ts.map +1 -0
- package/dist/services/production-service.js +202 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +37 -0
- package/src/index.ts +33 -0
- package/src/routes/production.ts +178 -0
- package/src/schema.ts +75 -0
- package/src/services/production-order-service.ts +182 -0
- package/src/services/production-service.ts +278 -0
- package/src/types.ts +6 -0
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type { PluginDb as Db } from "@unifiedcommerce/core";
|
|
2
|
+
import type { productionBoms, productionBomItems, productionOrders, productionConsumption } from "./schema";
|
|
3
|
+
export type BOM = typeof productionBoms.$inferSelect;
|
|
4
|
+
export type BOMItem = typeof productionBomItems.$inferSelect;
|
|
5
|
+
export type ProductionOrder = typeof productionOrders.$inferSelect;
|
|
6
|
+
export type Consumption = typeof productionConsumption.$inferSelect;
|
|
7
|
+
//# 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,cAAc,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AAC5G,MAAM,MAAM,GAAG,GAAG,OAAO,cAAc,CAAC,YAAY,CAAC;AACrD,MAAM,MAAM,OAAO,GAAG,OAAO,kBAAkB,CAAC,YAAY,CAAC;AAC7D,MAAM,MAAM,eAAe,GAAG,OAAO,gBAAgB,CAAC,YAAY,CAAC;AACnE,MAAM,MAAM,WAAW,GAAG,OAAO,qBAAqB,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-production",
|
|
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,33 @@
|
|
|
1
|
+
import { defineCommercePlugin } from "@unifiedcommerce/core";
|
|
2
|
+
import { productionBoms, productionBomItems, productionOrders, productionConsumption } from "./schema";
|
|
3
|
+
import { ProductionService } from "./services/production-service";
|
|
4
|
+
import { ProductionOrderService } from "./services/production-order-service";
|
|
5
|
+
import { buildProductionRoutes } from "./routes/production";
|
|
6
|
+
import type { Db } from "./types";
|
|
7
|
+
|
|
8
|
+
export type { Db } from "./types";
|
|
9
|
+
export { ProductionService } from "./services/production-service";
|
|
10
|
+
export { ProductionOrderService } from "./services/production-order-service";
|
|
11
|
+
|
|
12
|
+
export function productionPlugin() {
|
|
13
|
+
return defineCommercePlugin({
|
|
14
|
+
id: "production",
|
|
15
|
+
version: "1.0.0",
|
|
16
|
+
permissions: [
|
|
17
|
+
{ scope: "production:admin", description: "Create/edit BOMs, cost rollup, cancel orders." },
|
|
18
|
+
{ scope: "production:create", description: "Create and manage production orders." },
|
|
19
|
+
{ scope: "production:read", description: "View BOMs, orders, and BOM explosion." },
|
|
20
|
+
],
|
|
21
|
+
schema: () => ({ productionBoms, productionBomItems, productionOrders, productionConsumption }),
|
|
22
|
+
hooks: () => [],
|
|
23
|
+
routes: (ctx) => {
|
|
24
|
+
const db = ctx.database.db as unknown as Db;
|
|
25
|
+
if (!db) return [];
|
|
26
|
+
return buildProductionRoutes(
|
|
27
|
+
new ProductionService(db),
|
|
28
|
+
new ProductionOrderService(db),
|
|
29
|
+
ctx,
|
|
30
|
+
);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { router } from "@unifiedcommerce/core";
|
|
2
|
+
import { z } from "@hono/zod-openapi";
|
|
3
|
+
import type { ProductionService } from "../services/production-service";
|
|
4
|
+
import type { ProductionOrderService } from "../services/production-order-service";
|
|
5
|
+
import type { PluginRouteRegistration } from "@unifiedcommerce/core";
|
|
6
|
+
|
|
7
|
+
export function buildProductionRoutes(
|
|
8
|
+
bomSvc: ProductionService,
|
|
9
|
+
orderSvc: ProductionOrderService,
|
|
10
|
+
ctx: { services?: Record<string, unknown>; database?: { db: unknown } },
|
|
11
|
+
): PluginRouteRegistration[] {
|
|
12
|
+
const r = router("Production", "/production", ctx);
|
|
13
|
+
|
|
14
|
+
// --- BOM Routes ---
|
|
15
|
+
|
|
16
|
+
r.post("/boms").summary("Create BOM").permission("production:admin")
|
|
17
|
+
.input(z.object({
|
|
18
|
+
entityId: z.string().uuid(),
|
|
19
|
+
name: z.string().min(1),
|
|
20
|
+
yieldQuantity: z.number().int().positive().optional(),
|
|
21
|
+
yieldUomId: z.string().uuid().optional(),
|
|
22
|
+
level: z.number().int().min(0).optional(),
|
|
23
|
+
items: z.array(z.object({
|
|
24
|
+
entityId: z.string().uuid(),
|
|
25
|
+
itemName: z.string().min(1),
|
|
26
|
+
quantity: z.number().int().positive(),
|
|
27
|
+
unitCost: z.number().int().min(0),
|
|
28
|
+
uomId: z.string().uuid().optional(),
|
|
29
|
+
isSubAssembly: z.boolean().optional(),
|
|
30
|
+
subBomId: z.string().uuid().optional(),
|
|
31
|
+
})).min(1),
|
|
32
|
+
}))
|
|
33
|
+
.handler(async ({ input, orgId }) => {
|
|
34
|
+
const body = input as {
|
|
35
|
+
entityId: string; name: string; yieldQuantity?: number; yieldUomId?: string; level?: number;
|
|
36
|
+
items: Array<{ entityId: string; itemName: string; quantity: number; unitCost: number; uomId?: string; isSubAssembly?: boolean; subBomId?: string }>;
|
|
37
|
+
};
|
|
38
|
+
const result = await bomSvc.createBOM(orgId, body);
|
|
39
|
+
if (!result.ok) throw new Error(result.error);
|
|
40
|
+
return result.value;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
r.get("/boms").summary("List BOMs").permission("production:read")
|
|
44
|
+
.handler(async ({ orgId }) => {
|
|
45
|
+
const result = await bomSvc.listBOMs(orgId);
|
|
46
|
+
if (!result.ok) throw new Error(result.error);
|
|
47
|
+
return result.value;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
r.get("/boms/{id}").summary("Get BOM").permission("production:read")
|
|
51
|
+
.handler(async ({ params, orgId }) => {
|
|
52
|
+
const result = await bomSvc.getBOM(orgId, params.id!);
|
|
53
|
+
if (!result.ok) throw new Error(result.error);
|
|
54
|
+
return result.value;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
r.post("/boms/{id}/items").summary("Add item to BOM").permission("production:admin")
|
|
58
|
+
.input(z.object({
|
|
59
|
+
entityId: z.string().uuid(),
|
|
60
|
+
itemName: z.string().min(1),
|
|
61
|
+
quantity: z.number().int().positive(),
|
|
62
|
+
unitCost: z.number().int().min(0),
|
|
63
|
+
uomId: z.string().uuid().optional(),
|
|
64
|
+
isSubAssembly: z.boolean().optional(),
|
|
65
|
+
subBomId: z.string().uuid().optional(),
|
|
66
|
+
}))
|
|
67
|
+
.handler(async ({ params, input, orgId }) => {
|
|
68
|
+
const body = input as {
|
|
69
|
+
entityId: string; itemName: string; quantity: number; unitCost: number;
|
|
70
|
+
uomId?: string; isSubAssembly?: boolean; subBomId?: string;
|
|
71
|
+
};
|
|
72
|
+
const result = await bomSvc.addBOMItem(orgId, params.id!, body);
|
|
73
|
+
if (!result.ok) throw new Error(result.error);
|
|
74
|
+
return result.value;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
r.post("/boms/{id}/cost-rollup").summary("Cost rollup").permission("production:admin")
|
|
78
|
+
.handler(async ({ params, orgId }) => {
|
|
79
|
+
const result = await bomSvc.costRollup(orgId, params.id!);
|
|
80
|
+
if (!result.ok) throw new Error(result.error);
|
|
81
|
+
return result.value;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
r.post("/boms/{id}/explode").summary("BOM explosion").permission("production:read")
|
|
85
|
+
.input(z.object({ quantity: z.number().int().positive() }))
|
|
86
|
+
.handler(async ({ params, input, orgId }) => {
|
|
87
|
+
const body = input as { quantity: number };
|
|
88
|
+
const result = await bomSvc.explode(orgId, params.id!, body.quantity);
|
|
89
|
+
if (!result.ok) throw new Error(result.error);
|
|
90
|
+
return result.value;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// --- Production Order Routes ---
|
|
94
|
+
|
|
95
|
+
r.post("/orders").summary("Create production order").permission("production:create")
|
|
96
|
+
.input(z.object({
|
|
97
|
+
bomId: z.string().uuid(),
|
|
98
|
+
entityId: z.string().uuid(),
|
|
99
|
+
quantity: z.number().int().positive(),
|
|
100
|
+
warehouseId: z.string().uuid(),
|
|
101
|
+
plannedDate: z.string(),
|
|
102
|
+
notes: z.string().optional(),
|
|
103
|
+
}))
|
|
104
|
+
.handler(async ({ input, orgId }) => {
|
|
105
|
+
const body = input as { bomId: string; entityId: string; quantity: number; warehouseId: string; plannedDate: string; notes?: string };
|
|
106
|
+
const result = await orderSvc.create(orgId, {
|
|
107
|
+
...body,
|
|
108
|
+
plannedDate: new Date(body.plannedDate),
|
|
109
|
+
});
|
|
110
|
+
if (!result.ok) throw new Error(result.error);
|
|
111
|
+
return result.value;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
r.get("/orders").summary("List production orders").permission("production:read")
|
|
115
|
+
.query(z.object({ status: z.enum(["planned", "in_progress", "completed", "cancelled"]).optional() }))
|
|
116
|
+
.handler(async ({ query, orgId }) => {
|
|
117
|
+
const q = query as { status?: string };
|
|
118
|
+
const result = await orderSvc.list(orgId, q.status);
|
|
119
|
+
if (!result.ok) throw new Error(result.error);
|
|
120
|
+
return result.value;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
r.get("/orders/{id}").summary("Get production order").permission("production:read")
|
|
124
|
+
.handler(async ({ params, orgId }) => {
|
|
125
|
+
const result = await orderSvc.getById(orgId, params.id!);
|
|
126
|
+
if (!result.ok) throw new Error(result.error);
|
|
127
|
+
return result.value;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
r.post("/orders/{id}/start").summary("Start production order").permission("production:create")
|
|
131
|
+
.input(z.object({ producedBy: z.string().min(1) }))
|
|
132
|
+
.handler(async ({ params, input, orgId }) => {
|
|
133
|
+
const body = input as { producedBy: string };
|
|
134
|
+
const result = await orderSvc.start(orgId, params.id!, body.producedBy);
|
|
135
|
+
if (!result.ok) throw new Error(result.error);
|
|
136
|
+
return result.value;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
r.post("/orders/{id}/consume").summary("Record consumption").permission("production:create")
|
|
140
|
+
.input(z.object({
|
|
141
|
+
items: z.array(z.object({
|
|
142
|
+
entityId: z.string().uuid(),
|
|
143
|
+
variantId: z.string().uuid().optional(),
|
|
144
|
+
plannedQuantity: z.number().int().min(0),
|
|
145
|
+
actualQuantity: z.number().int().min(0),
|
|
146
|
+
uomId: z.string().uuid().optional(),
|
|
147
|
+
unitCost: z.number().int().min(0),
|
|
148
|
+
batchNumber: z.string().optional(),
|
|
149
|
+
})).min(1),
|
|
150
|
+
}))
|
|
151
|
+
.handler(async ({ params, input, orgId }) => {
|
|
152
|
+
const body = input as {
|
|
153
|
+
items: Array<{
|
|
154
|
+
entityId: string; variantId?: string; plannedQuantity: number;
|
|
155
|
+
actualQuantity: number; uomId?: string; unitCost: number; batchNumber?: string;
|
|
156
|
+
}>;
|
|
157
|
+
};
|
|
158
|
+
const result = await orderSvc.recordConsumption(orgId, params.id!, body.items);
|
|
159
|
+
if (!result.ok) throw new Error(result.error);
|
|
160
|
+
return result.value;
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
r.post("/orders/{id}/complete").summary("Complete production order").permission("production:create")
|
|
164
|
+
.handler(async ({ params, orgId }) => {
|
|
165
|
+
const result = await orderSvc.complete(orgId, params.id!);
|
|
166
|
+
if (!result.ok) throw new Error(result.error);
|
|
167
|
+
return result.value;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
r.post("/orders/{id}/cancel").summary("Cancel production order").permission("production:admin")
|
|
171
|
+
.handler(async ({ params, orgId }) => {
|
|
172
|
+
const result = await orderSvc.cancel(orgId, params.id!);
|
|
173
|
+
if (!result.ok) throw new Error(result.error);
|
|
174
|
+
return result.value;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return r.routes();
|
|
178
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { pgTable, uuid, text, integer, boolean, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
|
|
2
|
+
|
|
3
|
+
export const productionBoms = pgTable("production_boms", {
|
|
4
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
5
|
+
organizationId: text("organization_id").notNull(),
|
|
6
|
+
entityId: uuid("entity_id").notNull(),
|
|
7
|
+
name: text("name").notNull(),
|
|
8
|
+
version: integer("version").default(1),
|
|
9
|
+
yieldQuantity: integer("yield_quantity").default(1),
|
|
10
|
+
yieldUomId: uuid("yield_uom_id"),
|
|
11
|
+
isActive: boolean("is_active").default(true),
|
|
12
|
+
level: integer("level").default(0),
|
|
13
|
+
totalCost: integer("total_cost").default(0),
|
|
14
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
15
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
16
|
+
}, (table) => ({
|
|
17
|
+
orgIdx: index("idx_production_boms_org").on(table.organizationId),
|
|
18
|
+
entityIdx: index("idx_production_boms_entity").on(table.entityId),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
export const productionBomItems = pgTable("production_bom_items", {
|
|
22
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
23
|
+
bomId: uuid("bom_id").references(() => productionBoms.id, { onDelete: "cascade" }).notNull(),
|
|
24
|
+
entityId: uuid("entity_id").notNull(),
|
|
25
|
+
itemName: text("item_name").notNull(),
|
|
26
|
+
quantity: integer("quantity").notNull(),
|
|
27
|
+
unitCost: integer("unit_cost").default(0),
|
|
28
|
+
totalCost: integer("total_cost").default(0),
|
|
29
|
+
uomId: uuid("uom_id"),
|
|
30
|
+
isSubAssembly: boolean("is_sub_assembly").default(false),
|
|
31
|
+
subBomId: uuid("sub_bom_id").references(() => productionBoms.id),
|
|
32
|
+
sortOrder: integer("sort_order").default(0),
|
|
33
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
34
|
+
}, (table) => ({
|
|
35
|
+
bomIdx: index("idx_production_bom_items_bom").on(table.bomId),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
export const productionOrders = pgTable("production_orders", {
|
|
39
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
40
|
+
organizationId: text("organization_id").notNull(),
|
|
41
|
+
orderNumber: text("order_number").notNull(),
|
|
42
|
+
bomId: uuid("bom_id").references(() => productionBoms.id).notNull(),
|
|
43
|
+
entityId: uuid("entity_id").notNull(),
|
|
44
|
+
quantity: integer("quantity").notNull(),
|
|
45
|
+
warehouseId: uuid("warehouse_id").notNull(),
|
|
46
|
+
status: text("status", { enum: ["planned", "in_progress", "completed", "cancelled"] }).default("planned").notNull(),
|
|
47
|
+
plannedDate: timestamp("planned_date", { withTimezone: true }).notNull(),
|
|
48
|
+
startedAt: timestamp("started_at", { withTimezone: true }),
|
|
49
|
+
completedAt: timestamp("completed_at", { withTimezone: true }),
|
|
50
|
+
producedBy: text("produced_by"),
|
|
51
|
+
notes: text("notes"),
|
|
52
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
53
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
54
|
+
}, (table) => ({
|
|
55
|
+
orgIdx: index("idx_production_orders_org").on(table.organizationId),
|
|
56
|
+
statusIdx: index("idx_production_orders_status").on(table.status),
|
|
57
|
+
orderNumUnique: uniqueIndex("production_orders_org_number_unique").on(table.organizationId, table.orderNumber),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
export const productionConsumption = pgTable("production_consumption", {
|
|
61
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
62
|
+
productionOrderId: uuid("production_order_id").references(() => productionOrders.id, { onDelete: "cascade" }).notNull(),
|
|
63
|
+
entityId: uuid("entity_id").notNull(),
|
|
64
|
+
variantId: uuid("variant_id"),
|
|
65
|
+
plannedQuantity: integer("planned_quantity").notNull(),
|
|
66
|
+
actualQuantity: integer("actual_quantity").notNull(),
|
|
67
|
+
uomId: uuid("uom_id"),
|
|
68
|
+
unitCost: integer("unit_cost").default(0),
|
|
69
|
+
totalCost: integer("total_cost").default(0),
|
|
70
|
+
batchNumber: text("batch_number"),
|
|
71
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
72
|
+
}, (table) => ({
|
|
73
|
+
orderIdx: index("idx_production_consumption_order").on(table.productionOrderId),
|
|
74
|
+
entityIdx: index("idx_production_consumption_entity").on(table.entityId),
|
|
75
|
+
}));
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { eq, and, sql } from "drizzle-orm";
|
|
2
|
+
import { Ok, Err } from "@unifiedcommerce/core";
|
|
3
|
+
import type { PluginResult } from "@unifiedcommerce/core";
|
|
4
|
+
import { productionBoms, productionBomItems, productionOrders, productionConsumption } from "../schema";
|
|
5
|
+
import type { Db, ProductionOrder, Consumption } from "../types";
|
|
6
|
+
|
|
7
|
+
export class ProductionOrderService {
|
|
8
|
+
constructor(private db: Db) {}
|
|
9
|
+
|
|
10
|
+
async create(orgId: string, input: {
|
|
11
|
+
bomId: string;
|
|
12
|
+
entityId: string;
|
|
13
|
+
quantity: number;
|
|
14
|
+
warehouseId: string;
|
|
15
|
+
plannedDate: Date;
|
|
16
|
+
notes?: string;
|
|
17
|
+
}): Promise<PluginResult<ProductionOrder>> {
|
|
18
|
+
// Verify BOM exists
|
|
19
|
+
const boms = await this.db.select().from(productionBoms)
|
|
20
|
+
.where(and(eq(productionBoms.id, input.bomId), eq(productionBoms.organizationId, orgId)));
|
|
21
|
+
if (boms.length === 0) return Err("BOM not found");
|
|
22
|
+
|
|
23
|
+
const orderNumber = await this.generateOrderNumber(orgId);
|
|
24
|
+
const rows = await this.db.insert(productionOrders).values({
|
|
25
|
+
organizationId: orgId,
|
|
26
|
+
orderNumber,
|
|
27
|
+
bomId: input.bomId,
|
|
28
|
+
entityId: input.entityId,
|
|
29
|
+
quantity: input.quantity,
|
|
30
|
+
warehouseId: input.warehouseId,
|
|
31
|
+
plannedDate: input.plannedDate,
|
|
32
|
+
notes: input.notes,
|
|
33
|
+
}).returning();
|
|
34
|
+
|
|
35
|
+
return Ok(rows[0]!);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async list(orgId: string, status?: string): Promise<PluginResult<ProductionOrder[]>> {
|
|
39
|
+
const conditions = [eq(productionOrders.organizationId, orgId)];
|
|
40
|
+
if (status) {
|
|
41
|
+
conditions.push(eq(productionOrders.status, status as "planned" | "in_progress" | "completed" | "cancelled"));
|
|
42
|
+
}
|
|
43
|
+
const rows = await this.db.select().from(productionOrders).where(and(...conditions));
|
|
44
|
+
return Ok(rows);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async getById(orgId: string, id: string): Promise<PluginResult<ProductionOrder & { consumption: Consumption[] }>> {
|
|
48
|
+
const orders = await this.db.select().from(productionOrders)
|
|
49
|
+
.where(and(eq(productionOrders.id, id), eq(productionOrders.organizationId, orgId)));
|
|
50
|
+
if (orders.length === 0) return Err("Order not found");
|
|
51
|
+
const order = orders[0]!;
|
|
52
|
+
|
|
53
|
+
const consumption = await this.db.select().from(productionConsumption)
|
|
54
|
+
.where(eq(productionConsumption.productionOrderId, id));
|
|
55
|
+
|
|
56
|
+
return Ok({ ...order, consumption });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async start(orgId: string, id: string, producedBy: string): Promise<PluginResult<ProductionOrder>> {
|
|
60
|
+
const orders = await this.db.select().from(productionOrders)
|
|
61
|
+
.where(and(eq(productionOrders.id, id), eq(productionOrders.organizationId, orgId)));
|
|
62
|
+
if (orders.length === 0) return Err("Order not found");
|
|
63
|
+
const order = orders[0]!;
|
|
64
|
+
if (order.status !== "planned") return Err(`Cannot start order in '${order.status}' status`);
|
|
65
|
+
|
|
66
|
+
const rows = await this.db.update(productionOrders).set({
|
|
67
|
+
status: "in_progress",
|
|
68
|
+
producedBy,
|
|
69
|
+
startedAt: new Date(),
|
|
70
|
+
updatedAt: new Date(),
|
|
71
|
+
}).where(eq(productionOrders.id, id)).returning();
|
|
72
|
+
|
|
73
|
+
return Ok(rows[0]!);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async recordConsumption(orgId: string, orderId: string, items: Array<{
|
|
77
|
+
entityId: string;
|
|
78
|
+
variantId?: string;
|
|
79
|
+
plannedQuantity: number;
|
|
80
|
+
actualQuantity: number;
|
|
81
|
+
uomId?: string;
|
|
82
|
+
unitCost: number;
|
|
83
|
+
batchNumber?: string;
|
|
84
|
+
}>): Promise<PluginResult<Consumption[]>> {
|
|
85
|
+
// Verify order exists and is in_progress
|
|
86
|
+
const orders = await this.db.select().from(productionOrders)
|
|
87
|
+
.where(and(eq(productionOrders.id, orderId), eq(productionOrders.organizationId, orgId)));
|
|
88
|
+
if (orders.length === 0) return Err("Order not found");
|
|
89
|
+
const order = orders[0]!;
|
|
90
|
+
if (order.status !== "in_progress") return Err(`Cannot record consumption for order in '${order.status}' status`);
|
|
91
|
+
|
|
92
|
+
const insertedItems: Consumption[] = [];
|
|
93
|
+
for (const item of items) {
|
|
94
|
+
const totalCost = item.actualQuantity * item.unitCost;
|
|
95
|
+
const rows = await this.db.insert(productionConsumption).values({
|
|
96
|
+
productionOrderId: orderId,
|
|
97
|
+
entityId: item.entityId,
|
|
98
|
+
variantId: item.variantId,
|
|
99
|
+
plannedQuantity: item.plannedQuantity,
|
|
100
|
+
actualQuantity: item.actualQuantity,
|
|
101
|
+
uomId: item.uomId,
|
|
102
|
+
unitCost: item.unitCost,
|
|
103
|
+
totalCost,
|
|
104
|
+
batchNumber: item.batchNumber,
|
|
105
|
+
}).returning();
|
|
106
|
+
insertedItems.push(rows[0]!);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return Ok(insertedItems);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async complete(orgId: string, id: string): Promise<PluginResult<ProductionOrder & { consumption: Consumption[] }>> {
|
|
113
|
+
const orders = await this.db.select().from(productionOrders)
|
|
114
|
+
.where(and(eq(productionOrders.id, id), eq(productionOrders.organizationId, orgId)));
|
|
115
|
+
if (orders.length === 0) return Err("Order not found");
|
|
116
|
+
const order = orders[0]!;
|
|
117
|
+
if (order.status !== "in_progress") return Err(`Cannot complete order in '${order.status}' status`);
|
|
118
|
+
|
|
119
|
+
// If no consumption records yet, auto-generate from BOM explosion
|
|
120
|
+
const existingConsumption = await this.db.select().from(productionConsumption)
|
|
121
|
+
.where(eq(productionConsumption.productionOrderId, id));
|
|
122
|
+
|
|
123
|
+
if (existingConsumption.length === 0) {
|
|
124
|
+
// Auto-consume based on BOM items
|
|
125
|
+
const bomItems = await this.db.select().from(productionBomItems)
|
|
126
|
+
.where(eq(productionBomItems.bomId, order.bomId));
|
|
127
|
+
|
|
128
|
+
const boms = await this.db.select().from(productionBoms)
|
|
129
|
+
.where(eq(productionBoms.id, order.bomId));
|
|
130
|
+
const yieldQty = boms.length > 0 ? (boms[0]!.yieldQuantity ?? 1) : 1;
|
|
131
|
+
const multiplier = order.quantity / yieldQty;
|
|
132
|
+
|
|
133
|
+
for (const bomItem of bomItems) {
|
|
134
|
+
if (bomItem.isSubAssembly) continue; // Skip sub-assemblies for auto-consumption
|
|
135
|
+
const plannedQty = Math.round((bomItem.quantity ?? 0) * multiplier);
|
|
136
|
+
const unitCost = bomItem.unitCost ?? 0;
|
|
137
|
+
await this.db.insert(productionConsumption).values({
|
|
138
|
+
productionOrderId: id,
|
|
139
|
+
entityId: bomItem.entityId,
|
|
140
|
+
plannedQuantity: plannedQty,
|
|
141
|
+
actualQuantity: plannedQty,
|
|
142
|
+
unitCost,
|
|
143
|
+
totalCost: plannedQty * unitCost,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const rows = await this.db.update(productionOrders).set({
|
|
149
|
+
status: "completed",
|
|
150
|
+
completedAt: new Date(),
|
|
151
|
+
updatedAt: new Date(),
|
|
152
|
+
}).where(eq(productionOrders.id, id)).returning();
|
|
153
|
+
|
|
154
|
+
const consumption = await this.db.select().from(productionConsumption)
|
|
155
|
+
.where(eq(productionConsumption.productionOrderId, id));
|
|
156
|
+
|
|
157
|
+
return Ok({ ...rows[0]!, consumption });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async cancel(orgId: string, id: string): Promise<PluginResult<ProductionOrder>> {
|
|
161
|
+
const orders = await this.db.select().from(productionOrders)
|
|
162
|
+
.where(and(eq(productionOrders.id, id), eq(productionOrders.organizationId, orgId)));
|
|
163
|
+
if (orders.length === 0) return Err("Order not found");
|
|
164
|
+
const order = orders[0]!;
|
|
165
|
+
if (order.status === "completed") return Err("Cannot cancel a completed order");
|
|
166
|
+
if (order.status === "cancelled") return Err("Order is already cancelled");
|
|
167
|
+
|
|
168
|
+
const rows = await this.db.update(productionOrders).set({
|
|
169
|
+
status: "cancelled",
|
|
170
|
+
updatedAt: new Date(),
|
|
171
|
+
}).where(eq(productionOrders.id, id)).returning();
|
|
172
|
+
|
|
173
|
+
return Ok(rows[0]!);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async generateOrderNumber(orgId: string): Promise<string> {
|
|
177
|
+
const countRows = await this.db.select({ count: sql<number>`COUNT(*)`.as("count") })
|
|
178
|
+
.from(productionOrders).where(eq(productionOrders.organizationId, orgId));
|
|
179
|
+
const seq = Number(countRows[0]?.count ?? 0) + 1;
|
|
180
|
+
return `PRD-${String(seq).padStart(4, "0")}`;
|
|
181
|
+
}
|
|
182
|
+
}
|