@unifiedcommerce/core 0.2.0 → 0.2.2
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/package.json +2 -1
- package/src/adapters/console-email.ts +43 -0
- package/src/auth/access.ts +187 -0
- package/src/auth/auth-schema.ts +139 -0
- package/src/auth/middleware.ts +161 -0
- package/src/auth/org.ts +41 -0
- package/src/auth/permissions.ts +28 -0
- package/src/auth/setup.ts +171 -0
- package/src/auth/system-actor.ts +19 -0
- package/src/auth/types.ts +10 -0
- package/src/config/defaults.ts +82 -0
- package/src/config/define-config.ts +53 -0
- package/src/config/types.ts +301 -0
- package/src/generated/plugin-capabilities.d.ts +20 -0
- package/src/generated/plugin-manifest.ts +23 -0
- package/src/generated/plugin-repositories.d.ts +20 -0
- package/src/hooks/checkout-completion.ts +262 -0
- package/src/hooks/checkout.ts +677 -0
- package/src/hooks/order-emails.ts +62 -0
- package/src/index.ts +215 -0
- package/src/interfaces/mcp/agent-prompt.ts +174 -0
- package/src/interfaces/mcp/context-enrichment.ts +177 -0
- package/src/interfaces/mcp/server.ts +47 -0
- package/src/interfaces/mcp/tool-builder.ts +261 -0
- package/src/interfaces/mcp/tools/analytics.ts +76 -0
- package/src/interfaces/mcp/tools/cart.ts +57 -0
- package/src/interfaces/mcp/tools/catalog.ts +299 -0
- package/src/interfaces/mcp/tools/index.ts +22 -0
- package/src/interfaces/mcp/tools/inventory.ts +161 -0
- package/src/interfaces/mcp/tools/orders.ts +104 -0
- package/src/interfaces/mcp/tools/pricing.ts +94 -0
- package/src/interfaces/mcp/tools/promotions.ts +106 -0
- package/src/interfaces/mcp/tools/registry.ts +101 -0
- package/src/interfaces/mcp/tools/search.ts +42 -0
- package/src/interfaces/mcp/tools/webhooks.ts +48 -0
- package/src/interfaces/mcp/transport.ts +128 -0
- package/src/interfaces/rest/customer-portal.ts +299 -0
- package/src/interfaces/rest/index.ts +74 -0
- package/src/interfaces/rest/router.ts +333 -0
- package/src/interfaces/rest/routes/admin-jobs.ts +58 -0
- package/src/interfaces/rest/routes/audit.ts +50 -0
- package/src/interfaces/rest/routes/carts.ts +89 -0
- package/src/interfaces/rest/routes/catalog.ts +493 -0
- package/src/interfaces/rest/routes/checkout.ts +284 -0
- package/src/interfaces/rest/routes/inventory.ts +70 -0
- package/src/interfaces/rest/routes/media.ts +86 -0
- package/src/interfaces/rest/routes/orders.ts +78 -0
- package/src/interfaces/rest/routes/payments.ts +60 -0
- package/src/interfaces/rest/routes/pricing.ts +57 -0
- package/src/interfaces/rest/routes/promotions.ts +93 -0
- package/src/interfaces/rest/routes/search.ts +71 -0
- package/src/interfaces/rest/routes/webhooks.ts +46 -0
- package/src/interfaces/rest/schemas/admin-jobs.ts +40 -0
- package/src/interfaces/rest/schemas/audit.ts +46 -0
- package/src/interfaces/rest/schemas/carts.ts +125 -0
- package/src/interfaces/rest/schemas/catalog.ts +450 -0
- package/src/interfaces/rest/schemas/checkout.ts +66 -0
- package/src/interfaces/rest/schemas/customer-portal.ts +195 -0
- package/src/interfaces/rest/schemas/inventory.ts +138 -0
- package/src/interfaces/rest/schemas/media.ts +75 -0
- package/src/interfaces/rest/schemas/orders.ts +104 -0
- package/src/interfaces/rest/schemas/pricing.ts +80 -0
- package/src/interfaces/rest/schemas/promotions.ts +110 -0
- package/src/interfaces/rest/schemas/responses.ts +85 -0
- package/src/interfaces/rest/schemas/search.ts +58 -0
- package/src/interfaces/rest/schemas/shared.ts +62 -0
- package/src/interfaces/rest/schemas/webhooks.ts +68 -0
- package/src/interfaces/rest/utils.ts +104 -0
- package/src/interfaces/rest/webhook-router.ts +50 -0
- package/src/kernel/compensation/executor.ts +61 -0
- package/src/kernel/compensation/types.ts +26 -0
- package/src/kernel/database/adapter.ts +21 -0
- package/src/kernel/database/drizzle-db.ts +56 -0
- package/src/kernel/database/migrate.ts +76 -0
- package/src/kernel/database/plugin-types.ts +34 -0
- package/src/kernel/database/schema.ts +49 -0
- package/src/kernel/database/scoped-db.ts +68 -0
- package/src/kernel/database/tx-context.ts +46 -0
- package/src/kernel/error-mapper.ts +15 -0
- package/src/kernel/errors.ts +89 -0
- package/src/kernel/factory/repository-factory.ts +244 -0
- package/src/kernel/hooks/create-context.ts +43 -0
- package/src/kernel/hooks/executor.ts +88 -0
- package/src/kernel/hooks/registry.ts +74 -0
- package/src/kernel/hooks/types.ts +52 -0
- package/src/kernel/http-error.ts +44 -0
- package/src/kernel/jobs/adapter.ts +36 -0
- package/src/kernel/jobs/drizzle-adapter.ts +58 -0
- package/src/kernel/jobs/runner.ts +153 -0
- package/src/kernel/jobs/schema.ts +46 -0
- package/src/kernel/jobs/types.ts +30 -0
- package/src/kernel/local-api.ts +187 -0
- package/src/kernel/plugin/manifest.ts +271 -0
- package/src/kernel/query/executor.ts +184 -0
- package/src/kernel/query/registry.ts +46 -0
- package/src/kernel/result.ts +33 -0
- package/src/kernel/schema/extra-columns.ts +37 -0
- package/src/kernel/service-registry.ts +76 -0
- package/src/kernel/service-timing.ts +89 -0
- package/src/kernel/state-machine/machine.ts +101 -0
- package/src/modules/analytics/drizzle-adapter.ts +426 -0
- package/src/modules/analytics/hooks.ts +11 -0
- package/src/modules/analytics/models.ts +125 -0
- package/src/modules/analytics/repository/index.ts +6 -0
- package/src/modules/analytics/service.ts +245 -0
- package/src/modules/analytics/types.ts +180 -0
- package/src/modules/audit/hooks.ts +78 -0
- package/src/modules/audit/schema.ts +33 -0
- package/src/modules/audit/service.ts +151 -0
- package/src/modules/cart/access.ts +27 -0
- package/src/modules/cart/matcher.ts +26 -0
- package/src/modules/cart/repository/index.ts +234 -0
- package/src/modules/cart/schema.ts +42 -0
- package/src/modules/cart/schemas.ts +38 -0
- package/src/modules/cart/service.ts +541 -0
- package/src/modules/catalog/repository/index.ts +772 -0
- package/src/modules/catalog/schema.ts +203 -0
- package/src/modules/catalog/schemas.ts +104 -0
- package/src/modules/catalog/service.ts +1544 -0
- package/src/modules/customers/repository/index.ts +327 -0
- package/src/modules/customers/schema.ts +64 -0
- package/src/modules/customers/service.ts +171 -0
- package/src/modules/fulfillment/repository/index.ts +426 -0
- package/src/modules/fulfillment/schema.ts +101 -0
- package/src/modules/fulfillment/service.ts +555 -0
- package/src/modules/fulfillment/types.ts +59 -0
- package/src/modules/inventory/repository/index.ts +509 -0
- package/src/modules/inventory/schema.ts +94 -0
- package/src/modules/inventory/schemas.ts +38 -0
- package/src/modules/inventory/service.ts +490 -0
- package/src/modules/media/adapter.ts +17 -0
- package/src/modules/media/repository/index.ts +274 -0
- package/src/modules/media/schema.ts +41 -0
- package/src/modules/media/service.ts +151 -0
- package/src/modules/orders/repository/index.ts +287 -0
- package/src/modules/orders/schema.ts +66 -0
- package/src/modules/orders/service.ts +619 -0
- package/src/modules/orders/stale-order-cleanup.ts +76 -0
- package/src/modules/organization/service.ts +191 -0
- package/src/modules/payments/adapter.ts +47 -0
- package/src/modules/payments/repository/index.ts +6 -0
- package/src/modules/payments/service.ts +107 -0
- package/src/modules/pricing/repository/index.ts +291 -0
- package/src/modules/pricing/schema.ts +71 -0
- package/src/modules/pricing/schemas.ts +38 -0
- package/src/modules/pricing/service.ts +494 -0
- package/src/modules/promotions/repository/index.ts +325 -0
- package/src/modules/promotions/schema.ts +62 -0
- package/src/modules/promotions/schemas.ts +38 -0
- package/src/modules/promotions/service.ts +598 -0
- package/src/modules/search/adapter.ts +57 -0
- package/src/modules/search/hooks.ts +12 -0
- package/src/modules/search/repository/index.ts +6 -0
- package/src/modules/search/service.ts +315 -0
- package/src/modules/shipping/calculator.ts +188 -0
- package/src/modules/shipping/repository/index.ts +6 -0
- package/src/modules/shipping/service.ts +51 -0
- package/src/modules/tax/adapter.ts +60 -0
- package/src/modules/tax/repository/index.ts +6 -0
- package/src/modules/tax/service.ts +53 -0
- package/src/modules/webhooks/hook.ts +34 -0
- package/src/modules/webhooks/repository/index.ts +278 -0
- package/src/modules/webhooks/schema.ts +56 -0
- package/src/modules/webhooks/service.ts +117 -0
- package/src/modules/webhooks/signing.ts +6 -0
- package/src/modules/webhooks/ssrf-guard.ts +71 -0
- package/src/modules/webhooks/tasks.ts +52 -0
- package/src/modules/webhooks/worker.ts +134 -0
- package/src/runtime/commerce.ts +145 -0
- package/src/runtime/kernel.ts +426 -0
- package/src/runtime/logger.ts +36 -0
- package/src/runtime/server.ts +355 -0
- package/src/runtime/shutdown.ts +43 -0
- package/src/test-utils/create-pglite-adapter.ts +129 -0
- package/src/test-utils/create-plugin-test-app.ts +128 -0
- package/src/test-utils/create-repository-test-harness.ts +16 -0
- package/src/test-utils/create-test-config.ts +190 -0
- package/src/test-utils/create-test-kernel.ts +7 -0
- package/src/test-utils/create-test-plugin-context.ts +75 -0
- package/src/test-utils/rest-api-test-utils.ts +265 -0
- package/src/test-utils/test-actors.ts +62 -0
- package/src/test-utils/typed-hooks.ts +54 -0
- package/src/types/commerce-types.ts +34 -0
- package/src/utils/id.ts +3 -0
- package/src/utils/logger.ts +18 -0
- package/src/utils/pagination.ts +22 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { eq, and, inArray, isNull, sql } from "drizzle-orm";
|
|
2
|
+
import type { TxContext } from "../../../kernel/database/tx-context.js";
|
|
3
|
+
import type {
|
|
4
|
+
DrizzleDatabase,
|
|
5
|
+
DbOrTx,
|
|
6
|
+
} from "../../../kernel/database/drizzle-db.js";
|
|
7
|
+
import { warehouses, inventoryLevels, inventoryMovements } from "../schema.js";
|
|
8
|
+
|
|
9
|
+
// Infer types from Drizzle schema
|
|
10
|
+
export type Warehouse = typeof warehouses.$inferSelect;
|
|
11
|
+
export type WarehouseInsert = typeof warehouses.$inferInsert;
|
|
12
|
+
export type InventoryLevel = typeof inventoryLevels.$inferSelect;
|
|
13
|
+
export type InventoryLevelInsert = typeof inventoryLevels.$inferInsert;
|
|
14
|
+
export type InventoryMovement = typeof inventoryMovements.$inferSelect;
|
|
15
|
+
export type InventoryMovementInsert = typeof inventoryMovements.$inferInsert;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* InventoryRepository provides type-safe database operations for inventory entities.
|
|
19
|
+
*
|
|
20
|
+
* This repository manages warehouses, inventory levels, and inventory movements.
|
|
21
|
+
* All methods support an optional TxContext parameter for transaction participation.
|
|
22
|
+
*/
|
|
23
|
+
export class InventoryRepository {
|
|
24
|
+
constructor(private readonly db: DrizzleDatabase) {}
|
|
25
|
+
|
|
26
|
+
private getDb(ctx?: TxContext): DbOrTx {
|
|
27
|
+
return (ctx?.tx as DbOrTx | undefined) ?? this.db;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
// Warehouses
|
|
32
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
async findWarehouseById(
|
|
35
|
+
id: string,
|
|
36
|
+
ctx?: TxContext,
|
|
37
|
+
): Promise<Warehouse | undefined> {
|
|
38
|
+
const db = this.getDb(ctx);
|
|
39
|
+
const rows = await db
|
|
40
|
+
.select()
|
|
41
|
+
.from(warehouses)
|
|
42
|
+
.where(eq(warehouses.id, id));
|
|
43
|
+
return rows[0];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async findWarehouseByCode(
|
|
47
|
+
orgId: string,
|
|
48
|
+
code: string,
|
|
49
|
+
ctx?: TxContext,
|
|
50
|
+
): Promise<Warehouse | undefined> {
|
|
51
|
+
const db = this.getDb(ctx);
|
|
52
|
+
const rows = await db
|
|
53
|
+
.select()
|
|
54
|
+
.from(warehouses)
|
|
55
|
+
.where(
|
|
56
|
+
and(
|
|
57
|
+
eq(warehouses.organizationId, orgId),
|
|
58
|
+
eq(warehouses.code, code),
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
return rows[0];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async findAllWarehouses(
|
|
65
|
+
orgId: string,
|
|
66
|
+
ctx?: TxContext,
|
|
67
|
+
): Promise<Warehouse[]> {
|
|
68
|
+
const db = this.getDb(ctx);
|
|
69
|
+
return db
|
|
70
|
+
.select()
|
|
71
|
+
.from(warehouses)
|
|
72
|
+
.where(eq(warehouses.organizationId, orgId));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async findActiveWarehouses(
|
|
76
|
+
orgId: string,
|
|
77
|
+
ctx?: TxContext,
|
|
78
|
+
): Promise<Warehouse[]> {
|
|
79
|
+
const db = this.getDb(ctx);
|
|
80
|
+
return db
|
|
81
|
+
.select()
|
|
82
|
+
.from(warehouses)
|
|
83
|
+
.where(
|
|
84
|
+
and(
|
|
85
|
+
eq(warehouses.organizationId, orgId),
|
|
86
|
+
eq(warehouses.isActive, true),
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async createWarehouse(
|
|
92
|
+
data: WarehouseInsert,
|
|
93
|
+
ctx?: TxContext,
|
|
94
|
+
): Promise<Warehouse> {
|
|
95
|
+
const db = this.getDb(ctx);
|
|
96
|
+
const rows = await db.insert(warehouses).values(data).returning();
|
|
97
|
+
return rows[0]!;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async updateWarehouse(
|
|
101
|
+
id: string,
|
|
102
|
+
data: Partial<Omit<WarehouseInsert, "id">>,
|
|
103
|
+
ctx?: TxContext,
|
|
104
|
+
): Promise<Warehouse | undefined> {
|
|
105
|
+
const db = this.getDb(ctx);
|
|
106
|
+
const rows = await db
|
|
107
|
+
.update(warehouses)
|
|
108
|
+
.set(data)
|
|
109
|
+
.where(eq(warehouses.id, id))
|
|
110
|
+
.returning();
|
|
111
|
+
return rows[0];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async deleteWarehouse(id: string, ctx?: TxContext): Promise<boolean> {
|
|
115
|
+
const db = this.getDb(ctx);
|
|
116
|
+
const result = await db
|
|
117
|
+
.delete(warehouses)
|
|
118
|
+
.where(eq(warehouses.id, id))
|
|
119
|
+
.returning();
|
|
120
|
+
return result.length > 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
124
|
+
// Inventory Levels
|
|
125
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
async findAllLevels(ctx?: TxContext): Promise<InventoryLevel[]> {
|
|
128
|
+
const db = this.getDb(ctx);
|
|
129
|
+
return db.select().from(inventoryLevels);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async findLevelById(
|
|
133
|
+
id: string,
|
|
134
|
+
ctx?: TxContext,
|
|
135
|
+
): Promise<InventoryLevel | undefined> {
|
|
136
|
+
const db = this.getDb(ctx);
|
|
137
|
+
const rows = await db
|
|
138
|
+
.select()
|
|
139
|
+
.from(inventoryLevels)
|
|
140
|
+
.where(eq(inventoryLevels.id, id));
|
|
141
|
+
return rows[0];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async findLevelByKey(
|
|
145
|
+
entityId: string,
|
|
146
|
+
warehouseId: string,
|
|
147
|
+
variantId?: string | null,
|
|
148
|
+
ctx?: TxContext,
|
|
149
|
+
): Promise<InventoryLevel | undefined> {
|
|
150
|
+
const db = this.getDb(ctx);
|
|
151
|
+
const conditions = [
|
|
152
|
+
eq(inventoryLevels.entityId, entityId),
|
|
153
|
+
eq(inventoryLevels.warehouseId, warehouseId),
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
// Only add variantId condition when it's a real string value — never pass null to eq()
|
|
157
|
+
if (variantId != null) {
|
|
158
|
+
conditions.push(eq(inventoryLevels.variantId, variantId));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const rows = await db
|
|
162
|
+
.select()
|
|
163
|
+
.from(inventoryLevels)
|
|
164
|
+
.where(and(...conditions));
|
|
165
|
+
|
|
166
|
+
// Post-filter for exact variantId match (handles SQL NULL correctly)
|
|
167
|
+
return rows.find((r) => r.variantId === (variantId ?? null));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async findLevelsByEntityId(
|
|
171
|
+
entityId: string,
|
|
172
|
+
ctx?: TxContext,
|
|
173
|
+
): Promise<InventoryLevel[]> {
|
|
174
|
+
const db = this.getDb(ctx);
|
|
175
|
+
return db
|
|
176
|
+
.select()
|
|
177
|
+
.from(inventoryLevels)
|
|
178
|
+
.where(eq(inventoryLevels.entityId, entityId));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async findLevelsByEntityAndVariant(
|
|
182
|
+
entityId: string,
|
|
183
|
+
variantId?: string | null,
|
|
184
|
+
ctx?: TxContext,
|
|
185
|
+
): Promise<InventoryLevel[]> {
|
|
186
|
+
const db = this.getDb(ctx);
|
|
187
|
+
const conditions = [eq(inventoryLevels.entityId, entityId)];
|
|
188
|
+
|
|
189
|
+
// Only add variantId condition when it's a real string value — never pass null to eq()
|
|
190
|
+
if (variantId != null) {
|
|
191
|
+
conditions.push(eq(inventoryLevels.variantId, variantId));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const rows = await db
|
|
195
|
+
.select()
|
|
196
|
+
.from(inventoryLevels)
|
|
197
|
+
.where(and(...conditions));
|
|
198
|
+
|
|
199
|
+
// Post-filter for exact variantId match (handles SQL NULL correctly in JS)
|
|
200
|
+
return rows.filter((r) =>
|
|
201
|
+
variantId == null ? r.variantId === null : r.variantId === variantId,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async findLevelsByWarehouseId(
|
|
206
|
+
warehouseId: string,
|
|
207
|
+
ctx?: TxContext,
|
|
208
|
+
): Promise<InventoryLevel[]> {
|
|
209
|
+
const db = this.getDb(ctx);
|
|
210
|
+
return db
|
|
211
|
+
.select()
|
|
212
|
+
.from(inventoryLevels)
|
|
213
|
+
.where(eq(inventoryLevels.warehouseId, warehouseId));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async createLevel(
|
|
217
|
+
data: InventoryLevelInsert,
|
|
218
|
+
ctx?: TxContext,
|
|
219
|
+
): Promise<InventoryLevel> {
|
|
220
|
+
const db = this.getDb(ctx);
|
|
221
|
+
const rows = await db.insert(inventoryLevels).values(data).returning();
|
|
222
|
+
return rows[0]!;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async updateLevel(
|
|
226
|
+
id: string,
|
|
227
|
+
data: Partial<Omit<InventoryLevelInsert, "id">>,
|
|
228
|
+
ctx?: TxContext,
|
|
229
|
+
): Promise<InventoryLevel | undefined> {
|
|
230
|
+
const db = this.getDb(ctx);
|
|
231
|
+
const rows = await db
|
|
232
|
+
.update(inventoryLevels)
|
|
233
|
+
.set({ ...data, updatedAt: new Date() })
|
|
234
|
+
.where(eq(inventoryLevels.id, id))
|
|
235
|
+
.returning();
|
|
236
|
+
return rows[0];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async upsertLevel(
|
|
240
|
+
entityId: string,
|
|
241
|
+
warehouseId: string,
|
|
242
|
+
variantId: string | undefined,
|
|
243
|
+
data: Omit<
|
|
244
|
+
InventoryLevelInsert,
|
|
245
|
+
"id" | "entityId" | "warehouseId" | "variantId"
|
|
246
|
+
>,
|
|
247
|
+
ctx?: TxContext,
|
|
248
|
+
): Promise<InventoryLevel> {
|
|
249
|
+
const existing = await this.findLevelByKey(
|
|
250
|
+
entityId,
|
|
251
|
+
warehouseId,
|
|
252
|
+
variantId,
|
|
253
|
+
ctx,
|
|
254
|
+
);
|
|
255
|
+
if (existing) {
|
|
256
|
+
const updated = await this.updateLevel(existing.id, data, ctx);
|
|
257
|
+
return updated!;
|
|
258
|
+
}
|
|
259
|
+
return this.createLevel(
|
|
260
|
+
{
|
|
261
|
+
...data,
|
|
262
|
+
entityId,
|
|
263
|
+
warehouseId,
|
|
264
|
+
...(variantId !== undefined ? { variantId } : {}),
|
|
265
|
+
},
|
|
266
|
+
ctx,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async deleteLevel(id: string, ctx?: TxContext): Promise<boolean> {
|
|
271
|
+
const db = this.getDb(ctx);
|
|
272
|
+
const result = await db
|
|
273
|
+
.delete(inventoryLevels)
|
|
274
|
+
.where(eq(inventoryLevels.id, id))
|
|
275
|
+
.returning();
|
|
276
|
+
return result.length > 0;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
280
|
+
// Inventory Movements
|
|
281
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
async findMovementById(
|
|
284
|
+
id: string,
|
|
285
|
+
ctx?: TxContext,
|
|
286
|
+
): Promise<InventoryMovement | undefined> {
|
|
287
|
+
const db = this.getDb(ctx);
|
|
288
|
+
const rows = await db
|
|
289
|
+
.select()
|
|
290
|
+
.from(inventoryMovements)
|
|
291
|
+
.where(eq(inventoryMovements.id, id));
|
|
292
|
+
return rows[0];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async findMovementsByEntityId(
|
|
296
|
+
entityId: string,
|
|
297
|
+
ctx?: TxContext,
|
|
298
|
+
): Promise<InventoryMovement[]> {
|
|
299
|
+
const db = this.getDb(ctx);
|
|
300
|
+
return db
|
|
301
|
+
.select()
|
|
302
|
+
.from(inventoryMovements)
|
|
303
|
+
.where(eq(inventoryMovements.entityId, entityId));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async findMovementsByReference(
|
|
307
|
+
referenceType: string,
|
|
308
|
+
referenceId: string,
|
|
309
|
+
ctx?: TxContext,
|
|
310
|
+
): Promise<InventoryMovement[]> {
|
|
311
|
+
const db = this.getDb(ctx);
|
|
312
|
+
return db
|
|
313
|
+
.select()
|
|
314
|
+
.from(inventoryMovements)
|
|
315
|
+
.where(
|
|
316
|
+
and(
|
|
317
|
+
eq(inventoryMovements.referenceType, referenceType),
|
|
318
|
+
eq(inventoryMovements.referenceId, referenceId),
|
|
319
|
+
),
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async createMovement(
|
|
324
|
+
data: InventoryMovementInsert,
|
|
325
|
+
ctx?: TxContext,
|
|
326
|
+
): Promise<InventoryMovement> {
|
|
327
|
+
const db = this.getDb(ctx);
|
|
328
|
+
const rows = await db.insert(inventoryMovements).values(data).returning();
|
|
329
|
+
return rows[0]!;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
333
|
+
// Concurrency-Safe Operations (SELECT FOR UPDATE)
|
|
334
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Issues SELECT ... FOR UPDATE on the inventory_levels row matching
|
|
338
|
+
* the given entity, variant, and warehouse within the provided transaction.
|
|
339
|
+
*
|
|
340
|
+
* MUST be called inside an active transaction (ctx.tx must be set).
|
|
341
|
+
* Calling outside a transaction provides no locking guarantee.
|
|
342
|
+
*
|
|
343
|
+
* Uses isNull() for null variantId instead of eq() to generate correct
|
|
344
|
+
* SQL (IS NULL instead of = NULL).
|
|
345
|
+
*/
|
|
346
|
+
async findLevelForUpdate(
|
|
347
|
+
entityId: string,
|
|
348
|
+
variantId: string | null,
|
|
349
|
+
warehouseId: string,
|
|
350
|
+
ctx: TxContext,
|
|
351
|
+
): Promise<InventoryLevel | undefined> {
|
|
352
|
+
const db = this.getDb(ctx);
|
|
353
|
+
|
|
354
|
+
const conditions = [
|
|
355
|
+
eq(inventoryLevels.entityId, entityId),
|
|
356
|
+
eq(inventoryLevels.warehouseId, warehouseId),
|
|
357
|
+
variantId != null
|
|
358
|
+
? eq(inventoryLevels.variantId, variantId)
|
|
359
|
+
: isNull(inventoryLevels.variantId),
|
|
360
|
+
];
|
|
361
|
+
|
|
362
|
+
// Use raw SQL for FOR UPDATE since Drizzle's .for() may not be available
|
|
363
|
+
// on all query builder paths. This is the most portable approach.
|
|
364
|
+
const rows = await db
|
|
365
|
+
.select()
|
|
366
|
+
.from(inventoryLevels)
|
|
367
|
+
.where(and(...conditions))
|
|
368
|
+
.for("update");
|
|
369
|
+
|
|
370
|
+
return rows[0];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Performs a read-modify-write under a row-level lock.
|
|
375
|
+
* This is the ONLY correct method for modifying quantityReserved
|
|
376
|
+
* in a concurrent environment. Must be called inside a transaction.
|
|
377
|
+
*
|
|
378
|
+
* The lock is held for the duration of the enclosing transaction,
|
|
379
|
+
* which is typically just the checkout reservation — microsecond-level.
|
|
380
|
+
*/
|
|
381
|
+
async reserveWithLock(
|
|
382
|
+
entityId: string,
|
|
383
|
+
variantId: string | null,
|
|
384
|
+
warehouseId: string,
|
|
385
|
+
quantity: number,
|
|
386
|
+
ctx: TxContext,
|
|
387
|
+
): Promise<
|
|
388
|
+
{ ok: true; level: InventoryLevel } | { ok: false; reason: string }
|
|
389
|
+
> {
|
|
390
|
+
const level = await this.findLevelForUpdate(
|
|
391
|
+
entityId,
|
|
392
|
+
variantId,
|
|
393
|
+
warehouseId,
|
|
394
|
+
ctx,
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
if (!level) {
|
|
398
|
+
return {
|
|
399
|
+
ok: false,
|
|
400
|
+
reason: "No inventory record found for this entity.",
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const available = level.quantityOnHand - level.quantityReserved;
|
|
405
|
+
if (available < quantity) {
|
|
406
|
+
return {
|
|
407
|
+
ok: false,
|
|
408
|
+
reason: `Insufficient stock. Available: ${available}, requested: ${quantity}.`,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const updated = await this.getDb(ctx)
|
|
413
|
+
.update(inventoryLevels)
|
|
414
|
+
.set({
|
|
415
|
+
quantityReserved: level.quantityReserved + quantity,
|
|
416
|
+
updatedAt: new Date(),
|
|
417
|
+
version: level.version + 1,
|
|
418
|
+
})
|
|
419
|
+
.where(eq(inventoryLevels.id, level.id))
|
|
420
|
+
.returning();
|
|
421
|
+
|
|
422
|
+
return { ok: true, level: updated[0]! };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Performs a release under a row-level lock, mirroring reserveWithLock.
|
|
427
|
+
* Used by compensation chains to undo a reservation.
|
|
428
|
+
*/
|
|
429
|
+
async releaseWithLock(
|
|
430
|
+
entityId: string,
|
|
431
|
+
variantId: string | null,
|
|
432
|
+
warehouseId: string,
|
|
433
|
+
quantity: number,
|
|
434
|
+
ctx: TxContext,
|
|
435
|
+
): Promise<
|
|
436
|
+
{ ok: true; level: InventoryLevel } | { ok: false; reason: string }
|
|
437
|
+
> {
|
|
438
|
+
const level = await this.findLevelForUpdate(
|
|
439
|
+
entityId,
|
|
440
|
+
variantId,
|
|
441
|
+
warehouseId,
|
|
442
|
+
ctx,
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
if (!level) {
|
|
446
|
+
return {
|
|
447
|
+
ok: false,
|
|
448
|
+
reason: "No inventory record found for this entity.",
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const updated = await this.getDb(ctx)
|
|
453
|
+
.update(inventoryLevels)
|
|
454
|
+
.set({
|
|
455
|
+
quantityReserved: Math.max(0, level.quantityReserved - quantity),
|
|
456
|
+
updatedAt: new Date(),
|
|
457
|
+
version: level.version + 1,
|
|
458
|
+
})
|
|
459
|
+
.where(eq(inventoryLevels.id, level.id))
|
|
460
|
+
.returning();
|
|
461
|
+
|
|
462
|
+
return { ok: true, level: updated[0]! };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
466
|
+
// Aggregate Queries
|
|
467
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
async getAvailableQuantity(
|
|
470
|
+
entityId: string,
|
|
471
|
+
variantId?: string | null,
|
|
472
|
+
ctx?: TxContext,
|
|
473
|
+
): Promise<number> {
|
|
474
|
+
const levels = await this.findLevelsByEntityAndVariant(
|
|
475
|
+
entityId,
|
|
476
|
+
variantId,
|
|
477
|
+
ctx,
|
|
478
|
+
);
|
|
479
|
+
return levels.reduce(
|
|
480
|
+
(sum, level) => sum + (level.quantityOnHand - level.quantityReserved),
|
|
481
|
+
0,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async getAvailableQuantities(
|
|
486
|
+
entityIds: string[],
|
|
487
|
+
ctx?: TxContext,
|
|
488
|
+
): Promise<Record<string, number>> {
|
|
489
|
+
if (entityIds.length === 0) return {};
|
|
490
|
+
|
|
491
|
+
const db = this.getDb(ctx);
|
|
492
|
+
const rows = await db
|
|
493
|
+
.select()
|
|
494
|
+
.from(inventoryLevels)
|
|
495
|
+
.where(inArray(inventoryLevels.entityId, entityIds));
|
|
496
|
+
|
|
497
|
+
const result: Record<string, number> = {};
|
|
498
|
+
for (const id of entityIds) {
|
|
499
|
+
result[id] = 0;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
for (const row of rows) {
|
|
503
|
+
const available = row.quantityOnHand - row.quantityReserved;
|
|
504
|
+
result[row.entityId] = (result[row.entityId] ?? 0) + available;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return result;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
boolean,
|
|
3
|
+
index,
|
|
4
|
+
integer,
|
|
5
|
+
jsonb,
|
|
6
|
+
pgTable,
|
|
7
|
+
text,
|
|
8
|
+
timestamp,
|
|
9
|
+
uniqueIndex,
|
|
10
|
+
uuid,
|
|
11
|
+
} from "drizzle-orm/pg-core";
|
|
12
|
+
import { organization } from "../../auth/auth-schema.js";
|
|
13
|
+
import { sellableEntities, variants } from "../catalog/schema.js";
|
|
14
|
+
|
|
15
|
+
export const warehouses = pgTable(
|
|
16
|
+
"warehouses",
|
|
17
|
+
{
|
|
18
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
19
|
+
organizationId: text("organization_id")
|
|
20
|
+
.notNull()
|
|
21
|
+
.references(() => organization.id, { onDelete: "cascade" }),
|
|
22
|
+
name: text("name").notNull(),
|
|
23
|
+
code: text("code").notNull(),
|
|
24
|
+
address: jsonb("address").$type<Record<string, unknown>>(),
|
|
25
|
+
isActive: boolean("is_active").notNull().default(true),
|
|
26
|
+
priority: integer("priority").notNull().default(0),
|
|
27
|
+
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
|
28
|
+
},
|
|
29
|
+
(table) => ({
|
|
30
|
+
orgIdx: index("idx_warehouses_org").on(table.organizationId),
|
|
31
|
+
orgCodeUnique: uniqueIndex("warehouses_org_code_unique").on(table.organizationId, table.code),
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export const inventoryLevels = pgTable(
|
|
36
|
+
"inventory_levels",
|
|
37
|
+
{
|
|
38
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
39
|
+
entityId: uuid("entity_id")
|
|
40
|
+
.references(() => sellableEntities.id, { onDelete: "cascade" })
|
|
41
|
+
.notNull(),
|
|
42
|
+
variantId: uuid("variant_id").references(() => variants.id, {
|
|
43
|
+
onDelete: "cascade",
|
|
44
|
+
}),
|
|
45
|
+
warehouseId: uuid("warehouse_id")
|
|
46
|
+
.references(() => warehouses.id)
|
|
47
|
+
.notNull(),
|
|
48
|
+
quantityOnHand: integer("quantity_on_hand").notNull().default(0),
|
|
49
|
+
quantityReserved: integer("quantity_reserved").notNull().default(0),
|
|
50
|
+
quantityIncoming: integer("quantity_incoming").notNull().default(0),
|
|
51
|
+
unitCost: integer("unit_cost"),
|
|
52
|
+
reorderThreshold: integer("reorder_threshold"),
|
|
53
|
+
reorderQuantity: integer("reorder_quantity"),
|
|
54
|
+
version: integer("version").notNull().default(0),
|
|
55
|
+
lastRestockedAt: timestamp("last_restocked_at", { withTimezone: true }),
|
|
56
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
57
|
+
},
|
|
58
|
+
(table) => ({
|
|
59
|
+
entityVariantWarehouseIdx: index("idx_inventory_entity_variant_warehouse").on(
|
|
60
|
+
table.entityId,
|
|
61
|
+
table.variantId,
|
|
62
|
+
table.warehouseId,
|
|
63
|
+
),
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
export const inventoryMovements = pgTable("inventory_movements", {
|
|
68
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
69
|
+
entityId: uuid("entity_id")
|
|
70
|
+
.references(() => sellableEntities.id)
|
|
71
|
+
.notNull(),
|
|
72
|
+
variantId: uuid("variant_id").references(() => variants.id),
|
|
73
|
+
warehouseId: uuid("warehouse_id")
|
|
74
|
+
.references(() => warehouses.id)
|
|
75
|
+
.notNull(),
|
|
76
|
+
type: text("type", {
|
|
77
|
+
enum: [
|
|
78
|
+
"receipt",
|
|
79
|
+
"sale",
|
|
80
|
+
"return",
|
|
81
|
+
"adjustment",
|
|
82
|
+
"transfer",
|
|
83
|
+
"reservation",
|
|
84
|
+
"release",
|
|
85
|
+
"fulfillment",
|
|
86
|
+
],
|
|
87
|
+
}).notNull(),
|
|
88
|
+
quantity: integer("quantity").notNull(),
|
|
89
|
+
referenceType: text("reference_type"),
|
|
90
|
+
referenceId: text("reference_id"),
|
|
91
|
+
reason: text("reason"),
|
|
92
|
+
performedBy: text("performed_by").notNull(),
|
|
93
|
+
performedAt: timestamp("performed_at", { withTimezone: true }).defaultNow().notNull(),
|
|
94
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { z } from "@hono/zod-openapi";
|
|
2
|
+
|
|
3
|
+
// ─── Zod Body Schemas (single source of truth) ─────────────────────────────
|
|
4
|
+
|
|
5
|
+
export const InventoryAdjustBodySchema = z.object({
|
|
6
|
+
entityId: z.string().openapi({ example: "550e8400-e29b-41d4-a716-446655440000" }),
|
|
7
|
+
variantId: z.string().optional().openapi({ example: "variant-uuid" }),
|
|
8
|
+
warehouseId: z.string().optional().openapi({ example: "warehouse-uuid" }),
|
|
9
|
+
adjustment: z.number().int().refine((v) => v !== 0, { message: "Adjustment cannot be zero" }).openapi({ example: 10 }),
|
|
10
|
+
reason: z.string().openapi({ example: "Restock from supplier" }),
|
|
11
|
+
performedBy: z.string().optional(),
|
|
12
|
+
referenceType: z.string().optional(),
|
|
13
|
+
referenceId: z.string().optional(),
|
|
14
|
+
}).openapi("InventoryAdjustRequest");
|
|
15
|
+
|
|
16
|
+
export const InventoryReserveBodySchema = z.object({
|
|
17
|
+
entityId: z.string().openapi({ example: "550e8400-e29b-41d4-a716-446655440000" }),
|
|
18
|
+
variantId: z.string().optional(),
|
|
19
|
+
warehouseId: z.string().optional(),
|
|
20
|
+
quantity: z.number().int().min(1).openapi({ example: 2 }),
|
|
21
|
+
orderId: z.string().openapi({ example: "order-uuid" }),
|
|
22
|
+
performedBy: z.string().optional(),
|
|
23
|
+
}).openapi("InventoryReserveRequest");
|
|
24
|
+
|
|
25
|
+
export const InventoryReleaseBodySchema = z.object({
|
|
26
|
+
entityId: z.string().openapi({ example: "550e8400-e29b-41d4-a716-446655440000" }),
|
|
27
|
+
variantId: z.string().optional(),
|
|
28
|
+
warehouseId: z.string().optional(),
|
|
29
|
+
quantity: z.number().int().min(1).openapi({ example: 2 }),
|
|
30
|
+
orderId: z.string().openapi({ example: "order-uuid" }),
|
|
31
|
+
performedBy: z.string().optional(),
|
|
32
|
+
}).openapi("InventoryReleaseRequest");
|
|
33
|
+
|
|
34
|
+
// ─── Derived Input Types ────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export type InventoryAdjustInput = z.infer<typeof InventoryAdjustBodySchema>;
|
|
37
|
+
export type InventoryReserveInput = z.infer<typeof InventoryReserveBodySchema>;
|
|
38
|
+
export type InventoryReleaseInput = z.infer<typeof InventoryReleaseBodySchema>;
|