@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.
@@ -0,0 +1,278 @@
1
+ import { eq, and } from "drizzle-orm";
2
+ import { Ok, Err } from "@unifiedcommerce/core";
3
+ import type { PluginResult } from "@unifiedcommerce/core";
4
+ import { productionBoms, productionBomItems } from "../schema";
5
+ import type { Db, BOM, BOMItem } from "../types";
6
+
7
+ export interface ExplodedItem {
8
+ entityId: string;
9
+ itemName: string;
10
+ totalQuantity: number;
11
+ unitCost: number;
12
+ totalCost: number;
13
+ }
14
+
15
+ export class ProductionService {
16
+ constructor(private db: Db) {}
17
+
18
+ async createBOM(orgId: string, input: {
19
+ entityId: string;
20
+ name: string;
21
+ yieldQuantity?: number;
22
+ yieldUomId?: string;
23
+ level?: number;
24
+ items: Array<{
25
+ entityId: string;
26
+ itemName: string;
27
+ quantity: number;
28
+ unitCost: number;
29
+ uomId?: string;
30
+ isSubAssembly?: boolean;
31
+ subBomId?: string;
32
+ }>;
33
+ }): Promise<PluginResult<BOM & { items: BOMItem[] }>> {
34
+ const yieldQty = input.yieldQuantity ?? 1;
35
+
36
+ // Calculate total cost, resolving sub-assembly costs
37
+ let totalCost = 0;
38
+ const itemsWithCosts: Array<{
39
+ entityId: string;
40
+ itemName: string;
41
+ quantity: number;
42
+ unitCost: number;
43
+ totalCost: number;
44
+ uomId?: string | undefined;
45
+ isSubAssembly: boolean;
46
+ subBomId?: string | undefined;
47
+ sortOrder: number;
48
+ }> = [];
49
+
50
+ for (let i = 0; i < input.items.length; i++) {
51
+ const item = input.items[i]!;
52
+ let unitCost = item.unitCost;
53
+
54
+ if (item.isSubAssembly && item.subBomId) {
55
+ const subBom = await this.db.select().from(productionBoms)
56
+ .where(and(eq(productionBoms.id, item.subBomId), eq(productionBoms.organizationId, orgId)));
57
+ if (subBom.length > 0) {
58
+ const sub = subBom[0]!;
59
+ unitCost = Math.round((sub.totalCost ?? 0) / (sub.yieldQuantity ?? 1));
60
+ }
61
+ }
62
+
63
+ const itemTotal = item.quantity * unitCost;
64
+ totalCost += itemTotal;
65
+
66
+ itemsWithCosts.push({
67
+ entityId: item.entityId,
68
+ itemName: item.itemName,
69
+ quantity: item.quantity,
70
+ unitCost,
71
+ totalCost: itemTotal,
72
+ uomId: item.uomId,
73
+ isSubAssembly: item.isSubAssembly ?? false,
74
+ subBomId: item.subBomId,
75
+ sortOrder: i,
76
+ });
77
+ }
78
+
79
+ const bomRows = await this.db.insert(productionBoms).values({
80
+ organizationId: orgId,
81
+ entityId: input.entityId,
82
+ name: input.name,
83
+ yieldQuantity: yieldQty,
84
+ yieldUomId: input.yieldUomId,
85
+ level: input.level ?? 0,
86
+ totalCost,
87
+ }).returning();
88
+ const bom = bomRows[0]!;
89
+
90
+ const insertedItems: BOMItem[] = [];
91
+ for (const item of itemsWithCosts) {
92
+ const rows = await this.db.insert(productionBomItems).values({
93
+ bomId: bom.id,
94
+ entityId: item.entityId,
95
+ itemName: item.itemName,
96
+ quantity: item.quantity,
97
+ unitCost: item.unitCost,
98
+ totalCost: item.totalCost,
99
+ uomId: item.uomId,
100
+ isSubAssembly: item.isSubAssembly,
101
+ subBomId: item.subBomId,
102
+ sortOrder: item.sortOrder,
103
+ }).returning();
104
+ insertedItems.push(rows[0]!);
105
+ }
106
+
107
+ return Ok({ ...bom, items: insertedItems });
108
+ }
109
+
110
+ async getBOM(orgId: string, id: string): Promise<PluginResult<BOM & { items: BOMItem[] }>> {
111
+ const boms = await this.db.select().from(productionBoms)
112
+ .where(and(eq(productionBoms.id, id), eq(productionBoms.organizationId, orgId)));
113
+ if (boms.length === 0) return Err("BOM not found");
114
+ const bom = boms[0]!;
115
+
116
+ const items = await this.db.select().from(productionBomItems)
117
+ .where(eq(productionBomItems.bomId, id))
118
+ .orderBy(productionBomItems.sortOrder);
119
+
120
+ return Ok({ ...bom, items });
121
+ }
122
+
123
+ async listBOMs(orgId: string): Promise<PluginResult<BOM[]>> {
124
+ const rows = await this.db.select().from(productionBoms)
125
+ .where(eq(productionBoms.organizationId, orgId));
126
+ return Ok(rows);
127
+ }
128
+
129
+ async addBOMItem(orgId: string, bomId: string, input: {
130
+ entityId: string;
131
+ itemName: string;
132
+ quantity: number;
133
+ unitCost: number;
134
+ uomId?: string;
135
+ isSubAssembly?: boolean;
136
+ subBomId?: string;
137
+ }): Promise<PluginResult<BOMItem>> {
138
+ // Verify BOM exists and belongs to org
139
+ const boms = await this.db.select().from(productionBoms)
140
+ .where(and(eq(productionBoms.id, bomId), eq(productionBoms.organizationId, orgId)));
141
+ if (boms.length === 0) return Err("BOM not found");
142
+
143
+ let unitCost = input.unitCost;
144
+ if (input.isSubAssembly && input.subBomId) {
145
+ const subBom = await this.db.select().from(productionBoms)
146
+ .where(and(eq(productionBoms.id, input.subBomId), eq(productionBoms.organizationId, orgId)));
147
+ if (subBom.length > 0) {
148
+ const sub = subBom[0]!;
149
+ unitCost = Math.round((sub.totalCost ?? 0) / (sub.yieldQuantity ?? 1));
150
+ }
151
+ }
152
+
153
+ const itemTotal = input.quantity * unitCost;
154
+
155
+ // Get current max sort order
156
+ const existingItems = await this.db.select().from(productionBomItems)
157
+ .where(eq(productionBomItems.bomId, bomId));
158
+ const maxSort = existingItems.reduce((max, i) => Math.max(max, i.sortOrder ?? 0), -1);
159
+
160
+ const rows = await this.db.insert(productionBomItems).values({
161
+ bomId,
162
+ entityId: input.entityId,
163
+ itemName: input.itemName,
164
+ quantity: input.quantity,
165
+ unitCost,
166
+ totalCost: itemTotal,
167
+ uomId: input.uomId,
168
+ isSubAssembly: input.isSubAssembly ?? false,
169
+ subBomId: input.subBomId,
170
+ sortOrder: maxSort + 1,
171
+ }).returning();
172
+
173
+ // Recalculate BOM total cost
174
+ const allItems = await this.db.select().from(productionBomItems)
175
+ .where(eq(productionBomItems.bomId, bomId));
176
+ const newTotal = allItems.reduce((sum, i) => sum + (i.totalCost ?? 0), 0);
177
+ await this.db.update(productionBoms).set({
178
+ totalCost: newTotal,
179
+ updatedAt: new Date(),
180
+ }).where(eq(productionBoms.id, bomId));
181
+
182
+ return Ok(rows[0]!);
183
+ }
184
+
185
+ async costRollup(orgId: string, id: string): Promise<PluginResult<BOM>> {
186
+ const boms = await this.db.select().from(productionBoms)
187
+ .where(and(eq(productionBoms.id, id), eq(productionBoms.organizationId, orgId)));
188
+ if (boms.length === 0) return Err("BOM not found");
189
+
190
+ const items = await this.db.select().from(productionBomItems)
191
+ .where(eq(productionBomItems.bomId, id));
192
+
193
+ let totalCost = 0;
194
+ for (const item of items) {
195
+ let unitCost = item.unitCost ?? 0;
196
+
197
+ if (item.isSubAssembly && item.subBomId) {
198
+ // Recursively roll up sub-assembly first
199
+ const subRollup = await this.costRollup(orgId, item.subBomId);
200
+ if (subRollup.ok) {
201
+ unitCost = Math.round((subRollup.value.totalCost ?? 0) / (subRollup.value.yieldQuantity ?? 1));
202
+ }
203
+ }
204
+
205
+ const itemTotal = (item.quantity ?? 0) * unitCost;
206
+ totalCost += itemTotal;
207
+
208
+ // Update item costs
209
+ await this.db.update(productionBomItems).set({
210
+ unitCost,
211
+ totalCost: itemTotal,
212
+ }).where(eq(productionBomItems.id, item.id));
213
+ }
214
+
215
+ // Update BOM total
216
+ const updated = await this.db.update(productionBoms).set({
217
+ totalCost,
218
+ updatedAt: new Date(),
219
+ }).where(eq(productionBoms.id, id)).returning();
220
+
221
+ return Ok(updated[0]!);
222
+ }
223
+
224
+ async explode(orgId: string, bomId: string, quantity: number): Promise<PluginResult<ExplodedItem[]>> {
225
+ const boms = await this.db.select().from(productionBoms)
226
+ .where(and(eq(productionBoms.id, bomId), eq(productionBoms.organizationId, orgId)));
227
+ if (boms.length === 0) return Err("BOM not found");
228
+ const bom = boms[0]!;
229
+
230
+ const materialMap = new Map<string, ExplodedItem>();
231
+ await this.explodeRecursive(orgId, bomId, quantity, bom.yieldQuantity ?? 1, materialMap);
232
+
233
+ return Ok(Array.from(materialMap.values()));
234
+ }
235
+
236
+ private async explodeRecursive(
237
+ orgId: string,
238
+ bomId: string,
239
+ quantity: number,
240
+ yieldQuantity: number,
241
+ materialMap: Map<string, ExplodedItem>,
242
+ ): Promise<void> {
243
+ const items = await this.db.select().from(productionBomItems)
244
+ .where(eq(productionBomItems.bomId, bomId))
245
+ .orderBy(productionBomItems.sortOrder);
246
+
247
+ const multiplier = quantity / yieldQuantity;
248
+
249
+ for (const item of items) {
250
+ const requiredQty = Math.round((item.quantity ?? 0) * multiplier);
251
+
252
+ if (item.isSubAssembly && item.subBomId) {
253
+ // Recurse into sub-assembly
254
+ const subBoms = await this.db.select().from(productionBoms)
255
+ .where(and(eq(productionBoms.id, item.subBomId), eq(productionBoms.organizationId, orgId)));
256
+ if (subBoms.length > 0) {
257
+ const subBom = subBoms[0]!;
258
+ await this.explodeRecursive(orgId, item.subBomId, requiredQty, subBom.yieldQuantity ?? 1, materialMap);
259
+ }
260
+ } else {
261
+ // Raw material — accumulate
262
+ const existing = materialMap.get(item.entityId);
263
+ if (existing) {
264
+ existing.totalQuantity += requiredQty;
265
+ existing.totalCost += requiredQty * (item.unitCost ?? 0);
266
+ } else {
267
+ materialMap.set(item.entityId, {
268
+ entityId: item.entityId,
269
+ itemName: item.itemName,
270
+ totalQuantity: requiredQty,
271
+ unitCost: item.unitCost ?? 0,
272
+ totalCost: requiredQty * (item.unitCost ?? 0),
273
+ });
274
+ }
275
+ }
276
+ }
277
+ }
278
+ }
package/src/types.ts ADDED
@@ -0,0 +1,6 @@
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;