@sealab/mcp-server 1.0.2 → 1.0.4

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,84 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { crossCheckPlacementsAgainstCart, type CartItemLike } from './cart-cross-check';
3
+
4
+ const cartItems: CartItemLike[] = [
5
+ { id: 'a1', positionName: 'U2A', serialNumber: 'WC_DD_1842', width: 18, height: 42, depth: 12 },
6
+ { id: 'a2', positionName: 'U2B', serialNumber: 'WC_DD_1842', width: 18, height: 42, depth: 12 },
7
+ ];
8
+
9
+ describe('crossCheckPlacementsAgainstCart', () => {
10
+ it('passes when each placement matches a real item with matching width/height', () => {
11
+ const result = crossCheckPlacementsAgainstCart(
12
+ [
13
+ { positionName: 'U2A', widthInches: 18, heightInches: 42 },
14
+ { positionName: 'U2B', widthInches: 18, heightInches: 42 },
15
+ ],
16
+ cartItems
17
+ );
18
+ expect(result.blocking).toHaveLength(0);
19
+ expect(result.matchedCount).toBe(2);
20
+ });
21
+
22
+ it('blocks a placement that references no cart item', () => {
23
+ const result = crossCheckPlacementsAgainstCart(
24
+ [{ positionName: 'GHOST', widthInches: 18, heightInches: 42 }],
25
+ cartItems
26
+ );
27
+ expect(result.blocking.some((b) => b.code === 'no_matching_cart_item')).toBe(true);
28
+ });
29
+
30
+ it('blocks a box stretched wider than its real cabinet (lumped run)', () => {
31
+ const result = crossCheckPlacementsAgainstCart(
32
+ // Trying to cover a 36" run with the single 18" U2A item.
33
+ [{ positionName: 'U2A', widthInches: 36, heightInches: 42 }],
34
+ cartItems
35
+ );
36
+ expect(result.blocking.some((b) => b.code === 'width_mismatch')).toBe(true);
37
+ });
38
+
39
+ it('blocks two placements that resolve to the same cart item', () => {
40
+ const result = crossCheckPlacementsAgainstCart(
41
+ [
42
+ { positionName: 'U2A', aiCartItemId: 'a1', widthInches: 18, heightInches: 42 },
43
+ { positionName: 'U2A_dup', aiCartItemId: 'a1', widthInches: 18, heightInches: 42 },
44
+ ],
45
+ cartItems
46
+ );
47
+ expect(result.blocking.some((b) => b.code === 'duplicate_cart_item_reference')).toBe(true);
48
+ });
49
+
50
+ it('reads dimensions from orderReadyPayload when not on the item directly', () => {
51
+ const payloadItems: CartItemLike[] = [
52
+ { id: 'b1', positionName: 'B1A', orderReadyPayload: { width: 24, height: 30, depth: 24 } },
53
+ ];
54
+ const ok = crossCheckPlacementsAgainstCart(
55
+ [{ positionName: 'B1A', widthInches: 24, heightInches: 30 }],
56
+ payloadItems
57
+ );
58
+ expect(ok.blocking).toHaveLength(0);
59
+
60
+ const bad = crossCheckPlacementsAgainstCart(
61
+ [{ positionName: 'B1A', widthInches: 48, heightInches: 30 }],
62
+ payloadItems
63
+ );
64
+ expect(bad.blocking.some((b) => b.code === 'width_mismatch')).toBe(true);
65
+ });
66
+
67
+ it('warns (not blocks) when cart item width is unknown', () => {
68
+ const result = crossCheckPlacementsAgainstCart(
69
+ [{ positionName: 'X', widthInches: 18 }],
70
+ [{ id: 'x', positionName: 'X', serialNumber: 'NOPE' }]
71
+ );
72
+ expect(result.blocking).toHaveLength(0);
73
+ expect(result.warnings.some((w) => w.code === 'cart_item_dimensions_unknown')).toBe(true);
74
+ });
75
+
76
+ it('reports coverage gaps only when requested', () => {
77
+ const result = crossCheckPlacementsAgainstCart(
78
+ [{ positionName: 'U2A', widthInches: 18, heightInches: 42 }],
79
+ cartItems,
80
+ { reportCoverage: true }
81
+ );
82
+ expect(result.warnings.some((w) => w.code === 'cart_item_without_placement' && w.positionName === 'U2B')).toBe(true);
83
+ });
84
+ });
@@ -0,0 +1,267 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Cart-manifest cross-check for placement projection.
3
+ //
4
+ // Ground-truth fix for under-segmentation: a placement may only exist for a
5
+ // real AI cart item, and its real-world size must match that item's catalog
6
+ // dimensions. This anchors the drawing to the manifest — you cannot stretch one
7
+ // 18" cabinet's box to cover a 36" run, and you cannot invent boxes for
8
+ // cabinets that were never configured. Combined with one-placement-per-item, a
9
+ // run can only hold as many cabinets as there are real items of the right size.
10
+ //
11
+ // Pure (no IO): the handler fetches session/cart state and passes the items in.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export interface CartItemLike {
15
+ id?: unknown;
16
+ aiCartItemId?: unknown;
17
+ positionName?: unknown;
18
+ serialNumber?: unknown;
19
+ displayName?: unknown;
20
+ width?: unknown;
21
+ height?: unknown;
22
+ depth?: unknown;
23
+ itemPayload?: unknown;
24
+ originalItemPayload?: unknown;
25
+ orderReadyPayload?: unknown;
26
+ }
27
+
28
+ export interface PlacementCheckSpec {
29
+ positionName: string;
30
+ aiCartItemId?: string;
31
+ widthInches: number;
32
+ heightInches?: number;
33
+ depthInches?: number;
34
+ }
35
+
36
+ export interface CartCrossCheckOptions {
37
+ widthToleranceInches?: number;
38
+ heightToleranceInches?: number;
39
+ depthToleranceInches?: number;
40
+ /** When true (replace mode), report active cart items that got no placement in this write. */
41
+ reportCoverage?: boolean;
42
+ }
43
+
44
+ export type CartCrossCheckBlockingCode =
45
+ | 'no_matching_cart_item'
46
+ | 'duplicate_cart_item_reference'
47
+ | 'width_mismatch'
48
+ | 'height_mismatch'
49
+ | 'depth_mismatch';
50
+
51
+ export interface CartCrossCheckBlocking {
52
+ positionName: string;
53
+ code: CartCrossCheckBlockingCode;
54
+ message: string;
55
+ }
56
+
57
+ export interface CartCrossCheckWarning {
58
+ positionName: string;
59
+ code: 'cart_item_dimensions_unknown' | 'cart_item_without_placement';
60
+ message: string;
61
+ }
62
+
63
+ export interface CartCrossCheckResult {
64
+ matchedCount: number;
65
+ blocking: CartCrossCheckBlocking[];
66
+ warnings: CartCrossCheckWarning[];
67
+ }
68
+
69
+ const DEFAULT_WIDTH_TOLERANCE = 1.0;
70
+ const DEFAULT_HEIGHT_TOLERANCE = 1.5;
71
+ const DEFAULT_DEPTH_TOLERANCE = 1.5;
72
+
73
+ function stringifyId(value: unknown): string | undefined {
74
+ if (value === null || value === undefined) {
75
+ return undefined;
76
+ }
77
+ const text = String(value).trim();
78
+ return text.length > 0 ? text : undefined;
79
+ }
80
+
81
+ function toFiniteNumber(value: unknown): number | undefined {
82
+ if (typeof value === 'number' && Number.isFinite(value)) {
83
+ return value;
84
+ }
85
+ if (typeof value === 'string' && value.trim() !== '') {
86
+ const parsed = Number(value);
87
+ return Number.isFinite(parsed) ? parsed : undefined;
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ function isRecord(value: unknown): value is Record<string, unknown> {
93
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
94
+ }
95
+
96
+ /** Read a cabinet dimension from the item itself or any of its payloads. */
97
+ function extractCartItemDimension(item: CartItemLike, key: 'width' | 'height' | 'depth'): number | undefined {
98
+ const direct = toFiniteNumber(item[key]);
99
+ if (direct !== undefined) {
100
+ return direct;
101
+ }
102
+ for (const payload of [item.orderReadyPayload, item.itemPayload, item.originalItemPayload]) {
103
+ if (isRecord(payload)) {
104
+ const value = toFiniteNumber(payload[key]);
105
+ if (value !== undefined) {
106
+ return value;
107
+ }
108
+ }
109
+ }
110
+ return undefined;
111
+ }
112
+
113
+ interface CartLookup {
114
+ byId: Map<string, CartItemLike>;
115
+ byPositionName: Map<string, CartItemLike>;
116
+ }
117
+
118
+ export function buildCartLookup(cartItems: CartItemLike[]): CartLookup {
119
+ const byId = new Map<string, CartItemLike>();
120
+ const byPositionName = new Map<string, CartItemLike>();
121
+ for (const item of cartItems) {
122
+ const itemId = stringifyId(item.id ?? item.aiCartItemId);
123
+ const positionName = stringifyId(item.positionName);
124
+ if (itemId) {
125
+ byId.set(itemId, item);
126
+ }
127
+ if (positionName) {
128
+ byPositionName.set(positionName, item);
129
+ }
130
+ }
131
+ return { byId, byPositionName };
132
+ }
133
+
134
+ function findMatchingCartItem(spec: PlacementCheckSpec, lookup: CartLookup): CartItemLike | undefined {
135
+ return (
136
+ (spec.aiCartItemId ? lookup.byId.get(spec.aiCartItemId) : undefined) ??
137
+ lookup.byPositionName.get(spec.positionName)
138
+ );
139
+ }
140
+
141
+ function cartItemKey(item: CartItemLike): string {
142
+ return stringifyId(item.id ?? item.aiCartItemId) ?? stringifyId(item.positionName) ?? '';
143
+ }
144
+
145
+ /**
146
+ * Cross-check projected placement specs against the real AI cart manifest.
147
+ * Returns blocking issues (refuse the write) and soft warnings.
148
+ */
149
+ export function crossCheckPlacementsAgainstCart(
150
+ specs: PlacementCheckSpec[],
151
+ cartItems: CartItemLike[],
152
+ options: CartCrossCheckOptions = {}
153
+ ): CartCrossCheckResult {
154
+ const widthTol = options.widthToleranceInches ?? DEFAULT_WIDTH_TOLERANCE;
155
+ const heightTol = options.heightToleranceInches ?? DEFAULT_HEIGHT_TOLERANCE;
156
+ const depthTol = options.depthToleranceInches ?? DEFAULT_DEPTH_TOLERANCE;
157
+
158
+ const lookup = buildCartLookup(cartItems);
159
+ const blocking: CartCrossCheckBlocking[] = [];
160
+ const warnings: CartCrossCheckWarning[] = [];
161
+ const usedItemKeys = new Map<string, string>(); // cart item key -> first positionName that used it
162
+ let matchedCount = 0;
163
+
164
+ for (const spec of specs) {
165
+ const item = findMatchingCartItem(spec, lookup);
166
+ if (!item) {
167
+ blocking.push({
168
+ positionName: spec.positionName,
169
+ code: 'no_matching_cart_item',
170
+ message:
171
+ `Placement "${spec.positionName}" does not match any active AI cart item by aiCartItemId or positionName. ` +
172
+ 'Create the AI cart item first (one physical cabinet = one cart item) and reference its backend id/positionName. ' +
173
+ 'Do not place cabinets that are not in the manifest.',
174
+ });
175
+ continue;
176
+ }
177
+
178
+ matchedCount += 1;
179
+
180
+ const key = cartItemKey(item);
181
+ const priorPositionName = usedItemKeys.get(key);
182
+ if (priorPositionName !== undefined && priorPositionName !== spec.positionName) {
183
+ blocking.push({
184
+ positionName: spec.positionName,
185
+ code: 'duplicate_cart_item_reference',
186
+ message:
187
+ `Placements "${priorPositionName}" and "${spec.positionName}" both resolve to the same AI cart item. ` +
188
+ 'One physical cabinet can have only one placement per canvas. If this is really two cabinets, create a ' +
189
+ 'second AI cart item; if it is one cabinet, use a single placement.',
190
+ });
191
+ } else if (priorPositionName === undefined) {
192
+ usedItemKeys.set(key, spec.positionName);
193
+ }
194
+
195
+ const cartWidth = extractCartItemDimension(item, 'width');
196
+ if (cartWidth === undefined) {
197
+ warnings.push({
198
+ positionName: spec.positionName,
199
+ code: 'cart_item_dimensions_unknown',
200
+ message:
201
+ `AI cart item for "${spec.positionName}" has no readable width; could not verify the placement width ` +
202
+ 'against the manifest. Confirm the cart item is configured with real catalog dimensions.',
203
+ });
204
+ } else if (Math.abs(cartWidth - spec.widthInches) > widthTol) {
205
+ blocking.push({
206
+ positionName: spec.positionName,
207
+ code: 'width_mismatch',
208
+ message:
209
+ `Placement "${spec.positionName}" width ${spec.widthInches}" does not match the AI cart item width ` +
210
+ `${cartWidth}" (tolerance ${widthTol}"). A box wider than its real cabinet usually means a run was lumped ` +
211
+ 'into one box; split it into one cabinet per item, or correct the cart item width.',
212
+ });
213
+ }
214
+
215
+ if (spec.heightInches !== undefined) {
216
+ const cartHeight = extractCartItemDimension(item, 'height');
217
+ if (cartHeight !== undefined && Math.abs(cartHeight - spec.heightInches) > heightTol) {
218
+ blocking.push({
219
+ positionName: spec.positionName,
220
+ code: 'height_mismatch',
221
+ message:
222
+ `Placement "${spec.positionName}" height ${spec.heightInches}" does not match the AI cart item height ` +
223
+ `${cartHeight}" (tolerance ${heightTol}"). For base cabinets use the body height (excluding toekick and ` +
224
+ 'countertop) and make the cart item height match.',
225
+ });
226
+ }
227
+ }
228
+
229
+ if (spec.depthInches !== undefined) {
230
+ const cartDepth = extractCartItemDimension(item, 'depth');
231
+ if (cartDepth !== undefined && Math.abs(cartDepth - spec.depthInches) > depthTol) {
232
+ blocking.push({
233
+ positionName: spec.positionName,
234
+ code: 'depth_mismatch',
235
+ message:
236
+ `Plan footprint "${spec.positionName}" depth ${spec.depthInches}" does not match the AI cart item depth ` +
237
+ `${cartDepth}" (tolerance ${depthTol}").`,
238
+ });
239
+ }
240
+ }
241
+ }
242
+
243
+ if (options.reportCoverage) {
244
+ const referencedKeys = new Set<string>();
245
+ for (const spec of specs) {
246
+ const item = findMatchingCartItem(spec, lookup);
247
+ if (item) {
248
+ referencedKeys.add(cartItemKey(item));
249
+ }
250
+ }
251
+ for (const item of cartItems) {
252
+ const key = cartItemKey(item);
253
+ if (key && !referencedKeys.has(key)) {
254
+ const positionName = stringifyId(item.positionName) ?? key;
255
+ warnings.push({
256
+ positionName,
257
+ code: 'cart_item_without_placement',
258
+ message:
259
+ `Active AI cart item "${positionName}" has no placement in this write. If it belongs on this canvas, ` +
260
+ 'add it; if it only appears on another view, this is expected.',
261
+ });
262
+ }
263
+ }
264
+ }
265
+
266
+ return { matchedCount, blocking, warnings };
267
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { segmentElevationRun, type ElevationRunSpec } from './elevation-run-segmentation';
3
+
4
+ const baseRun = (overrides: Partial<ElevationRunSpec> = {}): ElevationRunSpec => ({
5
+ runStartInches: 0,
6
+ modules: [
7
+ { positionName: 'U2A', widthInches: 18, heightInches: 42, frontType: 'double_door' },
8
+ { positionName: 'U2B', widthInches: 18, heightInches: 42, frontType: 'double_door' },
9
+ ],
10
+ ...overrides,
11
+ });
12
+
13
+ describe('segmentElevationRun', () => {
14
+ it('tiles modules contiguously, computing each along-wall start/end', () => {
15
+ const result = segmentElevationRun(baseRun());
16
+ expect(result.modules[0].horizontalStartInches).toBe(0);
17
+ expect(result.modules[0].horizontalEndInches).toBe(18);
18
+ expect(result.modules[1].horizontalStartInches).toBe(18);
19
+ expect(result.modules[1].horizontalEndInches).toBe(36);
20
+ expect(result.modulesWidthInches).toBe(36);
21
+ });
22
+
23
+ it('inserts reveal gaps between modules when specified', () => {
24
+ const result = segmentElevationRun(baseRun({ revealBetweenModulesInches: 2 }));
25
+ expect(result.modules[0].horizontalEndInches).toBe(18);
26
+ expect(result.modules[1].horizontalStartInches).toBe(20); // 18 + 2 reveal
27
+ });
28
+
29
+ it('warns when a single module exceeds the single-cabinet width threshold (under-segmentation)', () => {
30
+ const result = segmentElevationRun({
31
+ runStartInches: 0,
32
+ modules: [{ positionName: 'U2A', widthInches: 36, heightInches: 42, frontType: 'double_door' }],
33
+ maxSingleCabinetWidthInches: 24,
34
+ });
35
+ expect(result.warnings.some((w) => w.includes('single-cabinet threshold'))).toBe(true);
36
+ });
37
+
38
+ it('warns when a single_door is implausibly wide', () => {
39
+ const result = segmentElevationRun({
40
+ runStartInches: 0,
41
+ modules: [{ positionName: 'B1A', widthInches: 30, heightInches: 30, frontType: 'single_door' }],
42
+ });
43
+ expect(result.warnings.some((w) => w.includes('single door this wide') || w.includes('single_door'))).toBe(true);
44
+ });
45
+
46
+ it('warns when declared subdivisions exceed the module count', () => {
47
+ const result = segmentElevationRun({
48
+ runStartInches: 0,
49
+ modules: [{ positionName: 'U2A', widthInches: 36, heightInches: 42, frontType: 'double_door' }],
50
+ declaredSubdivisionCount: 2,
51
+ });
52
+ expect(result.warnings.some((w) => w.includes('equal subdivisions'))).toBe(true);
53
+ });
54
+
55
+ it('warns when tiled widths do not match the run dimension', () => {
56
+ const result = segmentElevationRun(baseRun({ expectedRunTotalInches: 48 }));
57
+ expect(result.warnings.some((w) => w.includes('run dimension'))).toBe(true);
58
+ });
59
+
60
+ it('does not warn for a clean two-module run that matches the run dimension', () => {
61
+ const result = segmentElevationRun(baseRun({ expectedRunTotalInches: 36 }));
62
+ expect(result.warnings).toHaveLength(0);
63
+ });
64
+ });
@@ -0,0 +1,175 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Elevation run segmentation.
3
+ //
4
+ // Second root cause of bad placement (after raw pixel-guessing): the agent
5
+ // does not reliably decide HOW MANY physical cabinets a dimensioned run holds,
6
+ // or WHAT front type each is. It draws one rectangle around a whole run — e.g.
7
+ // a 3'-0" "EQ A | EQ A" upper run that is actually two double-door cabinets gets
8
+ // treated as a single cabinet.
9
+ //
10
+ // This module makes segmentation explicit and checkable. The agent enumerates
11
+ // each module of a run with its real width and declared front type; this code
12
+ // tiles them contiguously (computing each module's along-wall start so there is
13
+ // exactly one box per cabinet) and emits warnings when the declared layout is
14
+ // implausible or contradicts the dimension chain the agent says it read. The
15
+ // act of filling in per-module front types + widths forces the single-vs-double
16
+ // and one-vs-many reasoning the model was skipping.
17
+ //
18
+ // Pure (no IO) so it can be unit-tested directly.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export type ElevationFrontType =
22
+ | 'single_door'
23
+ | 'double_door'
24
+ | 'pair_of_doors'
25
+ | 'drawer_stack'
26
+ | 'door_over_drawer'
27
+ | 'open_shelf'
28
+ | 'appliance_panel'
29
+ | 'filler';
30
+
31
+ export interface RunModuleSpec {
32
+ positionName: string;
33
+ aiCartItemId?: string;
34
+ widthInches: number;
35
+ heightInches: number;
36
+ bottomFromFloorInches?: number;
37
+ frontType: ElevationFrontType;
38
+ }
39
+
40
+ export interface ElevationRunSpec {
41
+ /** Along-wall inch coordinate of the run's left edge. */
42
+ runStartInches: number;
43
+ /** Ordered left-to-right modules that make up the run. */
44
+ modules: RunModuleSpec[];
45
+ /** Optional run total from the primary dimension string (e.g. 3'-0" => 36). */
46
+ expectedRunTotalInches?: number;
47
+ /** Optional equal-subdivision count the agent read from the secondary chain (e.g. "EQ A | EQ A" => 2). */
48
+ declaredSubdivisionCount?: number;
49
+ /** Reveal/gap between adjacent modules in inches (e.g. the "2" TYP." callout). Default 0. */
50
+ revealBetweenModulesInches?: number;
51
+ /** Width above which a single module is suspected to be more than one cabinet. Default 48. */
52
+ maxSingleCabinetWidthInches?: number;
53
+ }
54
+
55
+ export interface SegmentedModule extends RunModuleSpec {
56
+ /** Computed along-wall left edge for this module. */
57
+ horizontalStartInches: number;
58
+ /** Computed along-wall right edge for this module. */
59
+ horizontalEndInches: number;
60
+ }
61
+
62
+ export interface RunSegmentationResult {
63
+ modules: SegmentedModule[];
64
+ /** Sum of module widths (excludes reveals). */
65
+ modulesWidthInches: number;
66
+ /** Span from the first module's left edge to the last module's right edge (includes reveals). */
67
+ occupiedSpanInches: number;
68
+ warnings: string[];
69
+ }
70
+
71
+ const DEFAULT_MAX_SINGLE_CABINET_WIDTH = 48;
72
+ const DEFAULT_REVEAL = 0;
73
+ // Industry-typical door sizing used only to raise warnings, never hard errors.
74
+ const SINGLE_DOOR_MAX_WIDTH = 24;
75
+ const DOUBLE_DOOR_MIN_WIDTH = 12;
76
+ const RUN_TOTAL_TOLERANCE = 0.51;
77
+
78
+ /**
79
+ * Tile a run's modules left-to-right and collect segmentation warnings.
80
+ * This never throws; structural requirements (non-empty modules, positive
81
+ * widths, required frontType) are enforced by the Zod schema at the tool layer.
82
+ */
83
+ export function segmentElevationRun(run: ElevationRunSpec): RunSegmentationResult {
84
+ const reveal = run.revealBetweenModulesInches ?? DEFAULT_REVEAL;
85
+ const maxSingle = run.maxSingleCabinetWidthInches ?? DEFAULT_MAX_SINGLE_CABINET_WIDTH;
86
+
87
+ const modules: SegmentedModule[] = [];
88
+ let cursor = run.runStartInches;
89
+ for (let i = 0; i < run.modules.length; i += 1) {
90
+ const moduleSpec = run.modules[i];
91
+ const start = cursor;
92
+ const end = start + moduleSpec.widthInches;
93
+ modules.push({ ...moduleSpec, horizontalStartInches: start, horizontalEndInches: end });
94
+ cursor = end + reveal;
95
+ }
96
+
97
+ const modulesWidthInches = run.modules.reduce((sum, m) => sum + m.widthInches, 0);
98
+ const occupiedSpanInches =
99
+ modules.length > 0
100
+ ? modules[modules.length - 1].horizontalEndInches - modules[0].horizontalStartInches
101
+ : 0;
102
+
103
+ const warnings = collectSegmentationWarnings(run, modules, modulesWidthInches, maxSingle);
104
+
105
+ return { modules, modulesWidthInches, occupiedSpanInches, warnings };
106
+ }
107
+
108
+ function collectSegmentationWarnings(
109
+ run: ElevationRunSpec,
110
+ modules: SegmentedModule[],
111
+ modulesWidthInches: number,
112
+ maxSingle: number
113
+ ): string[] {
114
+ const warnings: string[] = [];
115
+
116
+ // Under-segmentation: a single very wide module is the classic "one box for
117
+ // multiple cabinets" failure.
118
+ for (const m of modules) {
119
+ if (m.widthInches > maxSingle) {
120
+ warnings.push(
121
+ `Module "${m.positionName}" is ${m.widthInches}" wide, above the ${maxSingle}" single-cabinet threshold. ` +
122
+ 'Verify this is one physical cabinet and not several lumped into one box; if the dimension chain subdivides ' +
123
+ 'this span, split it into separate cabinets with their own cart items and placements.'
124
+ );
125
+ }
126
+ }
127
+
128
+ // Front-type vs width plausibility.
129
+ for (const m of modules) {
130
+ if (m.frontType === 'single_door' && m.widthInches > SINGLE_DOOR_MAX_WIDTH) {
131
+ warnings.push(
132
+ `Module "${m.positionName}" is declared single_door but is ${m.widthInches}" wide (> ${SINGLE_DOOR_MAX_WIDTH}"). ` +
133
+ 'A single door this wide is unusual; confirm it is not a double_door cabinet or two separate cabinets.'
134
+ );
135
+ }
136
+ if ((m.frontType === 'double_door' || m.frontType === 'pair_of_doors') && m.widthInches < DOUBLE_DOOR_MIN_WIDTH) {
137
+ warnings.push(
138
+ `Module "${m.positionName}" is declared ${m.frontType} but is only ${m.widthInches}" wide (< ${DOUBLE_DOOR_MIN_WIDTH}"). ` +
139
+ 'Two doors in a span this narrow is unusual; confirm the front type.'
140
+ );
141
+ }
142
+ }
143
+
144
+ // Declared subdivisions vs module count: the secondary dimension chain
145
+ // ("EQ A | EQ A") is explicit segmentation evidence in the drawing.
146
+ if (run.declaredSubdivisionCount !== undefined && run.declaredSubdivisionCount > modules.length) {
147
+ warnings.push(
148
+ `You read ${run.declaredSubdivisionCount} equal subdivisions in the dimension chain but provided only ` +
149
+ `${modules.length} module(s). Confirm whether each subdivision is a separate cabinet (split it) or just ` +
150
+ 'multiple doors within one cabinet (keep one module and set the front type accordingly).'
151
+ );
152
+ }
153
+
154
+ // Tiled width vs the run's primary dimension string.
155
+ if (run.expectedRunTotalInches !== undefined) {
156
+ const reveal = run.revealBetweenModulesInches ?? DEFAULT_REVEAL;
157
+ const totalWithReveals = modulesWidthInches + reveal * Math.max(0, modules.length - 1);
158
+ const delta = run.expectedRunTotalInches - totalWithReveals;
159
+ if (Math.abs(delta) > RUN_TOTAL_TOLERANCE) {
160
+ warnings.push(
161
+ `Module widths${reveal ? ' plus reveals' : ''} total ${round2(totalWithReveals)}" but the run dimension is ` +
162
+ `${run.expectedRunTotalInches}" (off by ${round2(delta)}"). Re-check module count, widths, or reveal: ` +
163
+ (delta > 0
164
+ ? 'the run has unaccounted width — a cabinet or filler may be missing.'
165
+ : 'the modules overflow the run — a width is too large or there are too many cabinets.')
166
+ );
167
+ }
168
+ }
169
+
170
+ return warnings;
171
+ }
172
+
173
+ function round2(value: number): number {
174
+ return Math.round(value * 100) / 100;
175
+ }
@@ -212,7 +212,7 @@ async function applySavedSettingsToArticles<T extends { settingsName?: string; [
212
212
  return Promise.all(articles.map(applySavedSettingToArticle));
213
213
  }
214
214
 
215
- const ArticleItemSchema = z.object({
215
+ export const ArticleItemSchema = z.object({
216
216
  serialNumber: z.string(),
217
217
  positionName: z.string().max(25).describe(
218
218
  'Unique label for this cabinet within the order. Must be unique across ALL articles in the order. ' +
@@ -450,9 +450,13 @@ const DeleteSavedCartArticlesSchema = z.object({
450
450
  positionNames: flexArray(z.string()).describe('Position names of the articles to delete.'),
451
451
  });
452
452
 
453
- const CreateOrderSchema = z.object({
454
- articles: flexArray(ArticleItemSchema).describe('Line items to order.'),
455
- projectName: z.string().describe('Project name for this order'),
453
+ const CreateOrderSchema = z.object({
454
+ articles: flexArray(ArticleItemSchema).describe('Line items to order.'),
455
+ projectId: z.number().int().positive().optional().describe(
456
+ 'Optional existing project ID to place this order into. Use list_projects or create_project first. ' +
457
+ 'When provided, the backend copies that project name/address and pricing snapshot to the order.'
458
+ ),
459
+ projectName: z.string().describe('Project name for this order'),
456
460
  purchaseOrder: z.string().describe('Purchase order number'),
457
461
  projectAddress: ProjectAddressSchema.optional().describe('Project address (where cabinets will be installed)'),
458
462
  includeDrawerboxes: z.boolean().optional(),