@asteby/metacore-runtime-react 49.2.0 → 49.3.0
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/CHANGELOG.md +35 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +12 -3
- package/dist/business/document-editor-model.d.ts +33 -1
- package/dist/business/document-editor-model.d.ts.map +1 -1
- package/dist/business/document-editor-model.js +105 -3
- package/dist/business/document-editor.d.ts.map +1 -1
- package/dist/business/document-editor.js +54 -25
- package/dist/dynamic-form-schema.d.ts +3 -26
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +4 -56
- package/dist/dynamic-row-actions.d.ts +6 -0
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +9 -4
- package/dist/option-when.d.ts +27 -0
- package/dist/option-when.d.ts.map +1 -0
- package/dist/option-when.js +56 -0
- package/dist/validator.d.ts.map +1 -1
- package/dist/validator.js +17 -7
- package/package.json +1 -1
- package/src/__tests__/document-editor-model.test.ts +14 -1
- package/src/__tests__/document-editor.test.tsx +23 -1
- package/src/__tests__/invoice-document-editor.test.tsx +4 -3
- package/src/__tests__/option-when-validation.test.tsx +87 -0
- package/src/__tests__/r6-editor-option-when.test.tsx +104 -0
- package/src/__tests__/r6-editor-source-seeds.test.tsx +137 -0
- package/src/__tests__/row-action-link-navigation.test.ts +10 -6
- package/src/action-modal-dispatcher.tsx +14 -5
- package/src/business/document-editor-model.ts +116 -3
- package/src/business/document-editor.tsx +59 -19
- package/src/dynamic-form-schema.ts +5 -55
- package/src/dynamic-row-actions.tsx +12 -4
- package/src/option-when.ts +61 -0
- package/src/validator.ts +17 -5
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
// metacore-ui primitives.
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { fieldValidationOf } from './validator';
|
|
6
|
+
import { applyOptionWhen, getDependsOn } from './option-when';
|
|
6
7
|
import { resolveValidatorToken } from './use-org-config-bridge';
|
|
8
|
+
// Viven en option-when.ts (el validador también los usa); se re-exportan aquí
|
|
9
|
+
// para no romper a quien los importaba de este módulo.
|
|
10
|
+
export { applyOptionWhen, getDependsOn };
|
|
7
11
|
/**
|
|
8
12
|
* Built-in validators the SDK knows how to apply by symbolic name. Apps
|
|
9
13
|
* that wire `OrgConfigProvider` map `$org.<key>` references to one of
|
|
@@ -364,19 +368,6 @@ export function getFieldRef(field) {
|
|
|
364
368
|
export function fieldHasRef(field) {
|
|
365
369
|
return getFieldRef(field) !== undefined;
|
|
366
370
|
}
|
|
367
|
-
/**
|
|
368
|
-
* Resolves a field's cascade dependency — the key of another form field whose
|
|
369
|
-
* current value scopes this picker's options (`filter_value`). Tolerates the
|
|
370
|
-
* camelCase `dependsOn` (authored SDK shape) and the snake_case `depends_on`
|
|
371
|
-
* the kernel manifest serves. Returns the trimmed field key, or `undefined`
|
|
372
|
-
* when the field declares no dependency.
|
|
373
|
-
*/
|
|
374
|
-
export function getDependsOn(field) {
|
|
375
|
-
const dep = field.dependsOn ?? field.depends_on;
|
|
376
|
-
if (typeof dep === 'string' && dep.trim() !== '')
|
|
377
|
-
return dep.trim();
|
|
378
|
-
return undefined;
|
|
379
|
-
}
|
|
380
371
|
/**
|
|
381
372
|
* Resolves the cascade `filter_value` for a field from the surrounding form
|
|
382
373
|
* context. The depended-on key is matched against the current row first (a
|
|
@@ -395,49 +386,6 @@ export function resolveDependsValue(field, formValues, rowValues) {
|
|
|
395
386
|
return '';
|
|
396
387
|
return String(raw);
|
|
397
388
|
}
|
|
398
|
-
/**
|
|
399
|
-
* Filters a STATIC enum's `options[]` by each option's `when` gate against the
|
|
400
|
-
* current form values. Pure — no React, no side effects.
|
|
401
|
-
*
|
|
402
|
-
* Rule per option:
|
|
403
|
-
* - No `when` → always included (retrocompat; existing enums untouched).
|
|
404
|
-
* - With `when`: the gating field is `when.field ?? dependsOn`. If neither is
|
|
405
|
-
* present the option is included (nothing to gate on). Otherwise the form's
|
|
406
|
-
* value for that field is compared AS STRING: included when (no `in`, or
|
|
407
|
-
* value ∈ `in`) AND (no `not_in`, or value ∉ `not_in`). Tolerates the
|
|
408
|
-
* snake_case `not_in` the kernel serves alongside camelCase `notIn`.
|
|
409
|
-
*
|
|
410
|
-
* `formValues` is the flat map of the surrounding form/row values the gating
|
|
411
|
-
* field is read from; `dependsOn` is the containing field's declared dependency
|
|
412
|
-
* used as the default gating field.
|
|
413
|
-
*/
|
|
414
|
-
export function applyOptionWhen(options, formValues, dependsOn) {
|
|
415
|
-
if (!Array.isArray(options))
|
|
416
|
-
return [];
|
|
417
|
-
return options.filter((opt) => {
|
|
418
|
-
const when = opt?.when;
|
|
419
|
-
if (!when)
|
|
420
|
-
return true;
|
|
421
|
-
const gate = (typeof when.field === 'string' && when.field.trim() !== '')
|
|
422
|
-
? when.field.trim()
|
|
423
|
-
: dependsOn;
|
|
424
|
-
if (!gate)
|
|
425
|
-
return true;
|
|
426
|
-
const raw = formValues ? formValues[gate] : undefined;
|
|
427
|
-
const current = raw == null ? '' : String(raw);
|
|
428
|
-
const inList = when.in;
|
|
429
|
-
const notIn = when.notIn ?? when.not_in;
|
|
430
|
-
if (Array.isArray(inList) && inList.length > 0) {
|
|
431
|
-
if (!inList.some((v) => String(v) === current))
|
|
432
|
-
return false;
|
|
433
|
-
}
|
|
434
|
-
if (Array.isArray(notIn) && notIn.length > 0) {
|
|
435
|
-
if (notIn.some((v) => String(v) === current))
|
|
436
|
-
return false;
|
|
437
|
-
}
|
|
438
|
-
return true;
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
389
|
/**
|
|
442
390
|
* Reads a field's `visible_when` predicate, tolerating the camelCase alias
|
|
443
391
|
* (`visibleWhen`) an app may author and the snake_case (`visible_when`) the
|
|
@@ -4,6 +4,12 @@ import type { TableMetadata } from './types';
|
|
|
4
4
|
* metida en `to` el router la tomaba como parte del path y la ruta no casaba.
|
|
5
5
|
*/
|
|
6
6
|
export declare function linkNavigation(url: string): {
|
|
7
|
+
to: '/m/$model';
|
|
8
|
+
params: {
|
|
9
|
+
model: string;
|
|
10
|
+
};
|
|
11
|
+
search?: Record<string, string>;
|
|
12
|
+
} | {
|
|
7
13
|
to: string;
|
|
8
14
|
search?: Record<string, string>;
|
|
9
15
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-row-actions.d.ts","sourceRoot":"","sources":["../src/dynamic-row-actions.tsx"],"names":[],"mappings":"AAwCA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,SAAS,CAAA;AAE3E;;;GAGG;AACH,wBAAgB,cAAc,
|
|
1
|
+
{"version":3,"file":"dynamic-row-actions.d.ts","sourceRoot":"","sources":["../src/dynamic-row-actions.tsx"],"names":[],"mappings":"AAwCA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,SAAS,CAAA;AAE3E;;;GAGG;AACH,wBAAgB,cAAc,CAC1B,GAAG,EAAE,MAAM,GACZ;IAAE,EAAE,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CASnI;AAGD,MAAM,WAAW,0BAA0B;IACvC,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAA;IACb;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,2EAA2E;IAC3E,QAAQ,EAAE,aAAa,GAAG,IAAI,CAAA;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C,4EAA4E;IAC5E,SAAS,EAAE,MAAM,IAAI,CAAA;CACxB;AAED,MAAM,WAAW,iBAAiB;IAC9B,+CAA+C;IAC/C,oBAAoB,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IACjE;;;OAGG;IACH,OAAO,EAAE,KAAK,CAAC,YAAY,CAAA;CAC9B;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,EACjC,KAAK,EACL,QAAQ,EACR,gBAAgB,EAChB,QAAQ,EACR,QAAQ,EACR,SAAS,GACZ,EAAE,0BAA0B,GAAG,iBAAiB,CA6IhD"}
|
|
@@ -36,9 +36,14 @@ import { RequiresAddonDialog, resolveRequiresAddon } from './requires-addon';
|
|
|
36
36
|
*/
|
|
37
37
|
export function linkNavigation(url) {
|
|
38
38
|
const i = url.indexOf('?');
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
const path = i < 0 ? url : url.slice(0, i);
|
|
40
|
+
const search = i < 0 ? undefined : Object.fromEntries(new URLSearchParams(url.slice(i + 1)));
|
|
41
|
+
// /m/<tabla>?create=&from=&from_id= → ruta parametrizada (retest r6b: el menú
|
|
42
|
+
// «Nota de crédito» de la factura aterrizaba en la lista sin abrir el editor).
|
|
43
|
+
const m = path.match(/^\/m\/([^/?#]+)\/?$/);
|
|
44
|
+
if (m)
|
|
45
|
+
return { to: '/m/$model', params: { model: m[1] }, search };
|
|
46
|
+
return search ? { to: path, search } : { to: path };
|
|
42
47
|
}
|
|
43
48
|
/**
|
|
44
49
|
* Owns the state + dialogs + dispatch logic for per-row actions, shared by
|
|
@@ -78,7 +83,7 @@ export function useDynamicRowActions({ model, endpoint, mutationEndpoint, metada
|
|
|
78
83
|
}
|
|
79
84
|
const linkDef = metadata?.actions?.find((a) => a.key === action && a.type === 'link');
|
|
80
85
|
if (linkDef?.linkUrl) {
|
|
81
|
-
const url = linkDef.linkUrl.replace(/\{(\w+)\}/g, (_, field) =>
|
|
86
|
+
const url = linkDef.linkUrl.replace(/\{(\w+)\}/g, (_, field) => String(row[field] ?? ''));
|
|
82
87
|
navigate(linkNavigation(url));
|
|
83
88
|
return;
|
|
84
89
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ActionFieldDef, OptionDef } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Resolves a field's cascade dependency — the key of another form field whose
|
|
4
|
+
* current value scopes this picker's options (`filter_value`). Tolerates the
|
|
5
|
+
* camelCase `dependsOn` (authored SDK shape) and the snake_case `depends_on`
|
|
6
|
+
* the kernel manifest serves. Returns the trimmed field key, or `undefined`
|
|
7
|
+
* when the field declares no dependency.
|
|
8
|
+
*/
|
|
9
|
+
export declare function getDependsOn(field: ActionFieldDef): string | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Filters a STATIC enum's `options[]` by each option's `when` gate against the
|
|
12
|
+
* current form values. Pure — no React, no side effects.
|
|
13
|
+
*
|
|
14
|
+
* Rule per option:
|
|
15
|
+
* - No `when` → always included (retrocompat; existing enums untouched).
|
|
16
|
+
* - With `when`: the gating field is `when.field ?? dependsOn`. If neither is
|
|
17
|
+
* present the option is included (nothing to gate on). Otherwise the form's
|
|
18
|
+
* value for that field is compared AS STRING: included when (no `in`, or
|
|
19
|
+
* value ∈ `in`) AND (no `not_in`, or value ∉ `not_in`). Tolerates the
|
|
20
|
+
* snake_case `not_in` the kernel serves alongside camelCase `notIn`.
|
|
21
|
+
*
|
|
22
|
+
* `formValues` is the flat map of the surrounding form/row values the gating
|
|
23
|
+
* field is read from; `dependsOn` is the containing field's declared dependency
|
|
24
|
+
* used as the default gating field.
|
|
25
|
+
*/
|
|
26
|
+
export declare function applyOptionWhen(options: OptionDef[] | undefined, formValues: Record<string, any> | null | undefined, dependsOn?: string): OptionDef[];
|
|
27
|
+
//# sourceMappingURL=option-when.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"option-when.d.ts","sourceRoot":"","sources":["../src/option-when.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,SAAS,CAAA;AAExD;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,GAAG,SAAS,CAItE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAC3B,OAAO,EAAE,SAAS,EAAE,GAAG,SAAS,EAChC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,IAAI,GAAG,SAAS,EAClD,SAAS,CAAC,EAAE,MAAM,GACnB,SAAS,EAAE,CAqBb"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves a field's cascade dependency — the key of another form field whose
|
|
3
|
+
* current value scopes this picker's options (`filter_value`). Tolerates the
|
|
4
|
+
* camelCase `dependsOn` (authored SDK shape) and the snake_case `depends_on`
|
|
5
|
+
* the kernel manifest serves. Returns the trimmed field key, or `undefined`
|
|
6
|
+
* when the field declares no dependency.
|
|
7
|
+
*/
|
|
8
|
+
export function getDependsOn(field) {
|
|
9
|
+
const dep = field.dependsOn ?? field.depends_on;
|
|
10
|
+
if (typeof dep === 'string' && dep.trim() !== '')
|
|
11
|
+
return dep.trim();
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Filters a STATIC enum's `options[]` by each option's `when` gate against the
|
|
16
|
+
* current form values. Pure — no React, no side effects.
|
|
17
|
+
*
|
|
18
|
+
* Rule per option:
|
|
19
|
+
* - No `when` → always included (retrocompat; existing enums untouched).
|
|
20
|
+
* - With `when`: the gating field is `when.field ?? dependsOn`. If neither is
|
|
21
|
+
* present the option is included (nothing to gate on). Otherwise the form's
|
|
22
|
+
* value for that field is compared AS STRING: included when (no `in`, or
|
|
23
|
+
* value ∈ `in`) AND (no `not_in`, or value ∉ `not_in`). Tolerates the
|
|
24
|
+
* snake_case `not_in` the kernel serves alongside camelCase `notIn`.
|
|
25
|
+
*
|
|
26
|
+
* `formValues` is the flat map of the surrounding form/row values the gating
|
|
27
|
+
* field is read from; `dependsOn` is the containing field's declared dependency
|
|
28
|
+
* used as the default gating field.
|
|
29
|
+
*/
|
|
30
|
+
export function applyOptionWhen(options, formValues, dependsOn) {
|
|
31
|
+
if (!Array.isArray(options))
|
|
32
|
+
return [];
|
|
33
|
+
return options.filter((opt) => {
|
|
34
|
+
const when = opt?.when;
|
|
35
|
+
if (!when)
|
|
36
|
+
return true;
|
|
37
|
+
const gate = (typeof when.field === 'string' && when.field.trim() !== '')
|
|
38
|
+
? when.field.trim()
|
|
39
|
+
: dependsOn;
|
|
40
|
+
if (!gate)
|
|
41
|
+
return true;
|
|
42
|
+
const raw = formValues ? formValues[gate] : undefined;
|
|
43
|
+
const current = raw == null ? '' : String(raw);
|
|
44
|
+
const inList = when.in;
|
|
45
|
+
const notIn = when.notIn ?? when.not_in;
|
|
46
|
+
if (Array.isArray(inList) && inList.length > 0) {
|
|
47
|
+
if (!inList.some((v) => String(v) === current))
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (Array.isArray(notIn) && notIn.length > 0) {
|
|
51
|
+
if (notIn.some((v) => String(v) === current))
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
});
|
|
56
|
+
}
|
package/dist/validator.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validator.d.ts","sourceRoot":"","sources":["../src/validator.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AAC9D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;
|
|
1
|
+
{"version":3,"file":"validator.d.ts","sourceRoot":"","sources":["../src/validator.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AAC9D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAMhD,MAAM,WAAW,cAAc;IAC3B,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;CACrB;AAQD,iFAAiF;AACjF,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,GAAG,cAAc,CAyCzD;AAqCD,gFAAgF;AAChF,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,eAAe,CAUlG;AAiCD,4EAA4E;AAC5E,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,cAAc,GAAG,UAAU,EAAE,CAmC7E;AAyDD;+DAC+D;AAC/D,wBAAgB,cAAc,CAC1B,MAAM,EAAE,SAAS,cAAc,EAAE,EACjC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,MAAM,CAAC,MAAM,EAAE,UAAU,EAAE,CAAC,CAI9B;AAgCD,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,EAAE,CAAC,GAAG,OAAO,CAEvE;AAYD;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CACrC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,EAAE,CAAC,EACjC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG,SAAS,GACtD,MAAM,CAAC,MAAM,EAAE,UAAU,EAAE,CAAC,CAY9B"}
|
package/dist/validator.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { applyOptionWhen, getDependsOn } from './option-when';
|
|
1
2
|
const NIL_UUID = '00000000-0000-0000-0000-000000000000';
|
|
2
3
|
const NUMERIC_TYPES = new Set(['int', 'integer', 'bigint', 'decimal', 'numeric', 'number', 'float', 'double']);
|
|
3
4
|
const RULE_NAMES = new Set([
|
|
@@ -203,7 +204,13 @@ function checkBuiltin(slug, value) {
|
|
|
203
204
|
return undefined;
|
|
204
205
|
}
|
|
205
206
|
}
|
|
206
|
-
|
|
207
|
+
/**
|
|
208
|
+
* `context` son los valores hermanos contra los que se evalúa el `when` de
|
|
209
|
+
* cada opción: una opción que su `when` descarta con el valor actual (forma de
|
|
210
|
+
* pago «99» con método PUE) es `invalid_option`, igual que una fuera de catálogo
|
|
211
|
+
* — y como la rechaza el kernel al guardar.
|
|
212
|
+
*/
|
|
213
|
+
function specFromField(field, context) {
|
|
207
214
|
const v = fieldValidationOf(field);
|
|
208
215
|
const spec = {
|
|
209
216
|
required: !!field.required,
|
|
@@ -213,8 +220,10 @@ function specFromField(field) {
|
|
|
213
220
|
max: v.max,
|
|
214
221
|
custom: v.custom,
|
|
215
222
|
};
|
|
216
|
-
if (field.options?.length)
|
|
217
|
-
|
|
223
|
+
if (field.options?.length) {
|
|
224
|
+
const applicable = context ? applyOptionWhen(field.options, context, getDependsOn(field)) : field.options;
|
|
225
|
+
spec.options = applicable.map(o => String(o.value));
|
|
226
|
+
}
|
|
218
227
|
return spec;
|
|
219
228
|
}
|
|
220
229
|
function isLineItems(field) {
|
|
@@ -229,10 +238,10 @@ function itemFieldsOf(field) {
|
|
|
229
238
|
* field issue, dotted keys for line-items (`items.0.qty`). */
|
|
230
239
|
export function validateValues(fields, values) {
|
|
231
240
|
const bag = {};
|
|
232
|
-
walk(fields, values ?? {}, '', bag);
|
|
241
|
+
walk(fields, values ?? {}, '', bag, values ?? {});
|
|
233
242
|
return bag;
|
|
234
243
|
}
|
|
235
|
-
function walk(fields, values, prefix, bag) {
|
|
244
|
+
function walk(fields, values, prefix, bag, context) {
|
|
236
245
|
for (const field of fields) {
|
|
237
246
|
const key = field.key;
|
|
238
247
|
if (!key)
|
|
@@ -247,11 +256,12 @@ function walk(fields, values, prefix, bag) {
|
|
|
247
256
|
}
|
|
248
257
|
rows.forEach((row, i) => {
|
|
249
258
|
const obj = row && typeof row === 'object' ? row : {};
|
|
250
|
-
|
|
259
|
+
// Una celda puede depender de otra del renglón o del encabezado.
|
|
260
|
+
walk(itemFieldsOf(field), obj, `${path}.${i}`, bag, { ...context, ...obj });
|
|
251
261
|
});
|
|
252
262
|
continue;
|
|
253
263
|
}
|
|
254
|
-
const issues = checkValue(raw, specFromField(field));
|
|
264
|
+
const issues = checkValue(raw, specFromField(field, context));
|
|
255
265
|
if (issues.length)
|
|
256
266
|
bag[path] = issues;
|
|
257
267
|
}
|
package/package.json
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
splitEditorFields,
|
|
18
18
|
toOpenDocuments,
|
|
19
19
|
} from '../business/document-editor-model'
|
|
20
|
-
import { makeLine, serializeLineItems } from '../business/line-items'
|
|
20
|
+
import { makeLine, serializeLineItems, validateLineItems } from '../business/line-items'
|
|
21
21
|
import { allocatePayment, validateAllocation } from '../primitives/allocation'
|
|
22
22
|
import type { ActionFieldDef, DocumentFormOpenDocuments } from '../types'
|
|
23
23
|
|
|
@@ -100,6 +100,19 @@ describe('linesFromSource', () => {
|
|
|
100
100
|
expect(l.discount).toBe(10)
|
|
101
101
|
expect(l.tax_rate).toBe(0.16)
|
|
102
102
|
})
|
|
103
|
+
it('carga parcial: el descuento en importe se prorratea a lo pendiente (no supera el importe)', () => {
|
|
104
|
+
// Cotización 4 llantas × $1,000 con $400 de descuento; 3 ya facturadas.
|
|
105
|
+
const rows = [{ id: 'q1', description: 'Llanta', quantity: 4, unit_price: 1000, discount_amount: 400, tax_rate: 16, source_quantity: 4, remaining_quantity: 1 }]
|
|
106
|
+
const [l] = linesFromSource(rows, { discount: 'discount_amount' }, { discountMode: 'amount' })
|
|
107
|
+
expect(l).toMatchObject({ quantity: 1, max_quantity: 1, discount: 100, discount_kind: 'amount' })
|
|
108
|
+
expect(validateLineItems([l]).errors).toEqual({})
|
|
109
|
+
// Sin lo pendiente servido (o completo) el importe queda intacto.
|
|
110
|
+
const [full] = linesFromSource([{ ...rows[0], remaining_quantity: 4 }], { discount: 'discount_amount' }, { discountMode: 'amount' })
|
|
111
|
+
expect(full.discount).toBe(400)
|
|
112
|
+
// Un porcentaje no depende de la cantidad.
|
|
113
|
+
const [pct] = linesFromSource([{ ...rows[0], discount_pct: 10 }], { discount: 'discount_pct' })
|
|
114
|
+
expect(pct.discount).toBe(10)
|
|
115
|
+
})
|
|
103
116
|
it('NC (credit): la cantidad queda topada en lo facturado', () => {
|
|
104
117
|
const [l] = linesFromSource([{ description: 'Llanta', quantity: 4, unit_price: 1500, tax_rate: 0.16 }], {}, { kind: 'credit' })
|
|
105
118
|
expect(l.max_quantity).toBe(4)
|
|
@@ -224,6 +224,28 @@ describe('DocumentEditor — cobro / REP (lines.kind: allocation)', () => {
|
|
|
224
224
|
expect(body.type).toBeUndefined()
|
|
225
225
|
})
|
|
226
226
|
|
|
227
|
+
it('«crear desde» una factura: toma su cliente y lista sus facturas abiertas con saldo', async () => {
|
|
228
|
+
const fromInvoice: DocumentFormType = {
|
|
229
|
+
...payment,
|
|
230
|
+
fields: payment.fields.map((f) => ({ ...f, default_from_record: undefined }) as any),
|
|
231
|
+
sources: [{ key: 'invoice', label: 'Factura', model: 'customers.Invoice', lines: 'items', header: { customer_id: 'customer_id' } }],
|
|
232
|
+
}
|
|
233
|
+
const api = makeApi({
|
|
234
|
+
...routes,
|
|
235
|
+
'/data/customers.Invoice': (_url: string, cfg?: { params?: Record<string, unknown> }) =>
|
|
236
|
+
cfg?.params?.f_id
|
|
237
|
+
? { data: [{ id: 'inv-new', number: 'A-2', customer_id: 'c1', amount_due: 1000 }] }
|
|
238
|
+
: (routes['/data/customers.Invoice'] as unknown),
|
|
239
|
+
})
|
|
240
|
+
renderEditor(fromInvoice, api, { initialSource: { key: 'invoice', id: 'inv-new' } })
|
|
241
|
+
await screen.findByText('A-1')
|
|
242
|
+
expect(screen.getByText('A-2')).toBeTruthy()
|
|
243
|
+
expect(api.get).toHaveBeenCalledWith('/data/customers.Invoice', expect.objectContaining({ params: expect.objectContaining({ f_id: 'eq:inv-new' }) }))
|
|
244
|
+
expect(api.get).toHaveBeenCalledWith('/data/customers.Invoice', expect.objectContaining({ params: expect.objectContaining({ f_customer_id: 'eq:c1' }) }))
|
|
245
|
+
// Sin renglones que copiar: no pide source-lines ni la relación.
|
|
246
|
+
expect(api.get.mock.calls.some(([u]) => String(u).includes('source-lines'))).toBe(false)
|
|
247
|
+
})
|
|
248
|
+
|
|
227
249
|
it('editar un monto pasa a reparto manual; «Autoaplicar» regresa al automático', async () => {
|
|
228
250
|
const api = makeApi(routes)
|
|
229
251
|
renderEditor(payment, api, { record: { customer_id: 'c1' } })
|
|
@@ -298,7 +320,7 @@ describe('DocumentEditor — «crear desde» con lo pendiente del servidor', ()
|
|
|
298
320
|
await screen.findByDisplayValue('Llanta 205/55R16')
|
|
299
321
|
expect(screen.queryByDisplayValue('Balanceo')).toBeNull()
|
|
300
322
|
expect(api.get).toHaveBeenCalledWith('/dynamic/customers.Invoice/source-lines', { params: { source: 'sales_order', id: 'so-1' } })
|
|
301
|
-
expect(await screen.findByText('1 renglones con lo
|
|
323
|
+
expect(await screen.findByText('1 renglones con lo que falta · 1 ya completos (omitidos)')).toBeTruthy()
|
|
302
324
|
})
|
|
303
325
|
|
|
304
326
|
it('si el host aún no sirve source-lines (404) cae a la relación del origen', async () => {
|
|
@@ -164,7 +164,7 @@ describe('Factura con layout editor — pantalla única', () => {
|
|
|
164
164
|
|
|
165
165
|
it('«Cargar desde» ofrece venta, OT y cotización como opciones', () => {
|
|
166
166
|
renderInvoice(makeApi())
|
|
167
|
-
const group = screen.getByRole('radiogroup', { name: '
|
|
167
|
+
const group = screen.getByRole('radiogroup', { name: 'Documento de origen' })
|
|
168
168
|
expect(within(group).getAllByRole('radio').map((r) => r.textContent)).toEqual(['Venta', 'Orden de trabajo', 'Cotización'])
|
|
169
169
|
fireEvent.click(within(group).getByRole('radio', { name: 'Orden de trabajo' }))
|
|
170
170
|
expect(within(group).getByRole('radio', { name: 'Orden de trabajo' }).getAttribute('aria-checked')).toBe('true')
|
|
@@ -180,7 +180,7 @@ describe('Factura — «Cargar desde» OT y cotización con lo restante', () =>
|
|
|
180
180
|
expect(screen.getByDisplayValue('Alineación eje direccional')).toBeTruthy()
|
|
181
181
|
expect(screen.queryByDisplayValue('Montaje y balanceo')).toBeNull()
|
|
182
182
|
expect(api.get).toHaveBeenCalledWith('/dynamic/customers.Invoice/source-lines', { params: { source: 'work_order', id: 'wo-1' } })
|
|
183
|
-
expect(await screen.findByText('2 renglones con lo
|
|
183
|
+
expect(await screen.findByText('2 renglones con lo que falta · 1 ya completos (omitidos)')).toBeTruthy()
|
|
184
184
|
|
|
185
185
|
// La tarjeta del cliente: datos fiscales con etiquetas del modelo.
|
|
186
186
|
await waitFor(() => expect(slot('party-card')).toBeTruthy())
|
|
@@ -214,8 +214,9 @@ describe('Factura — «Cargar desde» OT y cotización con lo restante', () =>
|
|
|
214
214
|
const [, body] = api.post.mock.calls[0] as [string, any]
|
|
215
215
|
expect(body.quote_id).toBe('q-1')
|
|
216
216
|
expect(body.currency_code).toBe('MXN')
|
|
217
|
+
// El descuento ($1,200 de las 6 piezas) se prorratea a las 4 pendientes.
|
|
217
218
|
expect(body.items).toEqual([
|
|
218
|
-
expect.objectContaining({ product_id: 'p-llanta', quantity: 4, unit_price: 5890, discount:
|
|
219
|
+
expect.objectContaining({ product_id: 'p-llanta', quantity: 4, unit_price: 5890, discount: 800, tax_rate: 0.16, quote_item_id: 'qi-1' }),
|
|
219
220
|
])
|
|
220
221
|
expect(body.items[0]).not.toHaveProperty('work_order_item_id')
|
|
221
222
|
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// Forma de pago «99 · Por definir» solo con método PPD, y con PPD solo «99»:
|
|
3
|
+
// la regla viaja en el `when` de las opciones del manifest. El validador y el
|
|
4
|
+
// render compartido de campos (renderField, que usan el DocumentEditor, el
|
|
5
|
+
// diálogo de document_forms y los modales de acción) la respetan.
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
import { cleanup, render } from '@testing-library/react'
|
|
8
|
+
|
|
9
|
+
vi.mock('react-i18next', () => ({
|
|
10
|
+
useTranslation: () => ({ t: (k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? k, i18n: { language: 'es' } }),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
import { validateValues } from '../validator'
|
|
14
|
+
import { renderField } from '../action-modal-dispatcher'
|
|
15
|
+
import type { ActionFieldDef } from '../types'
|
|
16
|
+
|
|
17
|
+
const notPPD = { field: 'fiscal_data.metodo_pago', not_in: ['PPD'] }
|
|
18
|
+
const ppd = { field: 'fiscal_data.metodo_pago', in: ['PPD'] }
|
|
19
|
+
const fields: ActionFieldDef[] = [
|
|
20
|
+
{
|
|
21
|
+
key: 'fiscal_data.metodo_pago',
|
|
22
|
+
label: 'Método',
|
|
23
|
+
type: 'select',
|
|
24
|
+
options: [
|
|
25
|
+
{ value: 'PUE', label: 'PUE' },
|
|
26
|
+
{ value: 'PPD', label: 'PPD' },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
key: 'fiscal_data.forma_pago',
|
|
31
|
+
label: 'Forma',
|
|
32
|
+
type: 'select',
|
|
33
|
+
options: [
|
|
34
|
+
{ value: '01', label: 'Efectivo', when: notPPD },
|
|
35
|
+
{ value: '03', label: 'Transferencia', when: notPPD },
|
|
36
|
+
{ value: '99', label: 'Por definir', when: ppd },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
const header = (metodo: string, forma: string) => ({ 'fiscal_data.metodo_pago': metodo, 'fiscal_data.forma_pago': forma })
|
|
42
|
+
|
|
43
|
+
afterEach(cleanup)
|
|
44
|
+
|
|
45
|
+
describe('validateValues con options[].when', () => {
|
|
46
|
+
it('99 con PUE es invalid_option', () => {
|
|
47
|
+
const bag = validateValues(fields, header('PUE', '99'))
|
|
48
|
+
expect(bag['fiscal_data.forma_pago']?.[0]?.code).toBe('invalid_option')
|
|
49
|
+
expect(bag['fiscal_data.forma_pago']?.[0]?.params).toEqual({ allowed: ['01', '03'] })
|
|
50
|
+
})
|
|
51
|
+
it('PPD exige 99', () => {
|
|
52
|
+
const bag = validateValues(fields, header('PPD', '01'))
|
|
53
|
+
expect(bag['fiscal_data.forma_pago']?.[0]).toEqual({ code: 'invalid_option', params: { allowed: ['99'] } })
|
|
54
|
+
})
|
|
55
|
+
it('PPD + 99 y PUE + 01 pasan', () => {
|
|
56
|
+
expect(validateValues(fields, header('PPD', '99'))).toEqual({})
|
|
57
|
+
expect(validateValues(fields, header('PUE', '01'))).toEqual({})
|
|
58
|
+
})
|
|
59
|
+
it('una celda de renglón se evalúa contra su renglón y el encabezado', () => {
|
|
60
|
+
const lines: ActionFieldDef = {
|
|
61
|
+
key: 'items',
|
|
62
|
+
label: 'Renglones',
|
|
63
|
+
type: 'array',
|
|
64
|
+
itemFields: [{ ...fields[1]!, key: 'forma' }],
|
|
65
|
+
}
|
|
66
|
+
const bag = validateValues([fields[0]!, lines], { 'fiscal_data.metodo_pago': 'PPD', items: [{ forma: '03' }, { forma: '99' }] })
|
|
67
|
+
expect(bag['items.0.forma']?.[0]?.code).toBe('invalid_option')
|
|
68
|
+
expect(bag['items.1.forma']).toBeUndefined()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('renderField con options[].when', () => {
|
|
73
|
+
const trigger = (metodo: string, forma: string) => {
|
|
74
|
+
const { container } = render(<>{renderField(fields[1]!, forma, () => {}, header(metodo, forma))}</>)
|
|
75
|
+
return container.querySelector('[aria-invalid], button, [role="combobox"]') as HTMLElement
|
|
76
|
+
}
|
|
77
|
+
it('marca en el acto una forma que el método ya no permite', () => {
|
|
78
|
+
const el = trigger('PUE', '99')
|
|
79
|
+
expect(el.getAttribute('aria-invalid')).toBe('true')
|
|
80
|
+
expect(el.getAttribute('data-stale-option')).toBe('true')
|
|
81
|
+
})
|
|
82
|
+
it('una combinación válida no se marca', () => {
|
|
83
|
+
const el = trigger('PPD', '99')
|
|
84
|
+
expect(el.getAttribute('aria-invalid')).toBeNull()
|
|
85
|
+
expect(el.getAttribute('data-stale-option')).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// Retest Pitsline r6 (P0 UX): en el editor de la factura, método PPD exige
|
|
3
|
+
// forma de pago 99 (`options[].when` del manifest). Elegir PPD fija 99 solo y
|
|
4
|
+
// el alta lleva los dos valores, no solo el diálogo de Timbrar.
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
7
|
+
|
|
8
|
+
vi.mock('@tanstack/react-router', () => ({ useNavigate: () => () => {} }))
|
|
9
|
+
vi.mock('react-i18next', () => {
|
|
10
|
+
const value = { t: (k: string, o?: Record<string, any>) => o?.defaultValue ?? k, i18n: { language: 'es' } }
|
|
11
|
+
return { useTranslation: () => value }
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
import { DocumentFormDialog } from '../document-form-dialog'
|
|
15
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
16
|
+
import { TaxRateContext } from '../org-runtime-context'
|
|
17
|
+
import { gatedOptionFixes } from '../business/document-editor-model'
|
|
18
|
+
import type { ActionFieldDef, DocumentFormsManifest, DocumentFormType } from '../types'
|
|
19
|
+
|
|
20
|
+
afterEach(cleanup)
|
|
21
|
+
|
|
22
|
+
const notPPD = { field: 'fiscal_data.metodo_pago', not_in: ['PPD'] }
|
|
23
|
+
const formaPago: ActionFieldDef = {
|
|
24
|
+
key: 'fiscal_data.forma_pago',
|
|
25
|
+
label: 'Forma de pago',
|
|
26
|
+
type: 'select',
|
|
27
|
+
required: true,
|
|
28
|
+
defaultValue: '01',
|
|
29
|
+
options: [
|
|
30
|
+
{ value: '01', label: 'Efectivo · 01', when: notPPD },
|
|
31
|
+
{ value: '03', label: 'Transferencia · 03', when: notPPD },
|
|
32
|
+
{ value: '99', label: 'Por definir · 99', when: { field: 'fiscal_data.metodo_pago', in: ['PPD'] } },
|
|
33
|
+
],
|
|
34
|
+
} as ActionFieldDef
|
|
35
|
+
const metodoPago: ActionFieldDef = {
|
|
36
|
+
key: 'fiscal_data.metodo_pago',
|
|
37
|
+
label: 'Método de pago',
|
|
38
|
+
type: 'select',
|
|
39
|
+
required: true,
|
|
40
|
+
defaultValue: 'PUE',
|
|
41
|
+
options: [
|
|
42
|
+
{ value: 'PUE', label: 'Una sola exhibición · PUE' },
|
|
43
|
+
{ value: 'PPD', label: 'Parcialidades o diferido · PPD' },
|
|
44
|
+
],
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const invoice: DocumentFormType = {
|
|
48
|
+
key: 'invoice',
|
|
49
|
+
label: 'Factura',
|
|
50
|
+
layout: 'editor',
|
|
51
|
+
fields: [{ key: 'customer_id', label: 'Cliente', type: 'text', required: true }, metodoPago, formaPago],
|
|
52
|
+
defaults: { 'fiscal_data.metodo_pago': 'PUE', 'fiscal_data.forma_pago': '01' },
|
|
53
|
+
lines: { title: 'Renglones', required: false },
|
|
54
|
+
submit_label: 'Crear factura',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function renderInvoice(post: ReturnType<typeof vi.fn>) {
|
|
58
|
+
const client = { get: vi.fn(async () => ({ data: { data: [] } })), post, put: vi.fn(), delete: vi.fn() } as unknown as ApiClient
|
|
59
|
+
const forms: DocumentFormsManifest = { lines_field: 'items', types: [invoice] }
|
|
60
|
+
return render(
|
|
61
|
+
<ApiProvider client={client}>
|
|
62
|
+
<TaxRateContext.Provider value={0.16}>
|
|
63
|
+
<DocumentFormDialog open onOpenChange={() => {}} model="customers.Invoice" forms={forms} initialType="invoice" currency="MXN" />
|
|
64
|
+
</TaxRateContext.Provider>
|
|
65
|
+
</ApiProvider>,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function pick(fieldLabel: RegExp, option: RegExp) {
|
|
70
|
+
const trigger = screen.getAllByRole('combobox').find((el) => fieldLabel.test(el.closest('[data-slot="field-cell"], div')?.textContent ?? '')) as HTMLElement
|
|
71
|
+
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' })
|
|
72
|
+
fireEvent.click(await screen.findByRole('option', { name: option }))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('factura: método PPD ⇒ forma de pago 99', () => {
|
|
76
|
+
it('elegir PPD fija la forma 99 y el alta la lleva', async () => {
|
|
77
|
+
const post = vi.fn().mockResolvedValue({ data: { success: true, data: { id: 'inv-1' } } })
|
|
78
|
+
renderInvoice(post)
|
|
79
|
+
fireEvent.change(document.getElementById('customer_id')!, { target: { value: 'c-1' } })
|
|
80
|
+
|
|
81
|
+
await pick(/Método de pago/, /PPD/)
|
|
82
|
+
await waitFor(() => expect(screen.getAllByText('Por definir · 99').length).toBeGreaterThan(0))
|
|
83
|
+
fireEvent.click(screen.getByRole('button', { name: 'Crear factura' }))
|
|
84
|
+
await waitFor(() => expect(post).toHaveBeenCalled())
|
|
85
|
+
const body = post.mock.calls[0][1] as Record<string, unknown>
|
|
86
|
+
expect(body['fiscal_data.metodo_pago']).toBe('PPD')
|
|
87
|
+
expect(body['fiscal_data.forma_pago']).toBe('99')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('gatedOptionFixes', () => {
|
|
92
|
+
const fields = [metodoPago, formaPago]
|
|
93
|
+
it('PPD con forma 01 → 99 (la única que aplica)', () => {
|
|
94
|
+
expect(gatedOptionFixes(fields, { 'fiscal_data.metodo_pago': 'PPD', 'fiscal_data.forma_pago': '01' })).toEqual({ 'fiscal_data.forma_pago': '99' })
|
|
95
|
+
})
|
|
96
|
+
it('PUE con forma 99 → el default del campo', () => {
|
|
97
|
+
expect(gatedOptionFixes(fields, { 'fiscal_data.metodo_pago': 'PUE', 'fiscal_data.forma_pago': '99' })).toEqual({ 'fiscal_data.forma_pago': '01' })
|
|
98
|
+
})
|
|
99
|
+
it('valor válido o vacío con varias opciones: sin cambios (converge)', () => {
|
|
100
|
+
expect(gatedOptionFixes(fields, { 'fiscal_data.metodo_pago': 'PUE', 'fiscal_data.forma_pago': '03' })).toEqual({})
|
|
101
|
+
expect(gatedOptionFixes(fields, { 'fiscal_data.metodo_pago': 'PUE', 'fiscal_data.forma_pago': '' })).toEqual({})
|
|
102
|
+
expect(gatedOptionFixes(fields, { 'fiscal_data.metodo_pago': 'PPD', 'fiscal_data.forma_pago': '99' })).toEqual({})
|
|
103
|
+
})
|
|
104
|
+
})
|