@asteby/metacore-runtime-react 47.1.0 → 49.0.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 +51 -0
- package/dist/action-modal-dispatcher.d.ts +17 -3
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +80 -13
- package/dist/action-priority.d.ts +34 -0
- package/dist/action-priority.d.ts.map +1 -0
- package/dist/action-priority.js +60 -0
- package/dist/business/catalog-product-search.d.ts +32 -0
- package/dist/business/catalog-product-search.d.ts.map +1 -0
- package/dist/business/catalog-product-search.js +71 -0
- package/dist/business/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +2 -0
- package/dist/business/document-editor-model.d.ts +119 -0
- package/dist/business/document-editor-model.d.ts.map +1 -0
- package/dist/business/document-editor-model.js +317 -0
- package/dist/business/document-editor-panels.d.ts +64 -0
- package/dist/business/document-editor-panels.d.ts.map +1 -0
- package/dist/business/document-editor-panels.js +58 -0
- package/dist/business/document-editor.d.ts +27 -0
- package/dist/business/document-editor.d.ts.map +1 -0
- package/dist/business/document-editor.js +493 -0
- package/dist/business/document-lines.d.ts.map +1 -1
- package/dist/business/document-lines.js +5 -1
- package/dist/business/index.d.ts +5 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +5 -0
- package/dist/business/line-items.d.ts +16 -1
- package/dist/business/line-items.d.ts.map +1 -1
- package/dist/business/line-items.js +14 -2
- package/dist/business/payment-allocator.d.ts +19 -0
- package/dist/business/payment-allocator.d.ts.map +1 -0
- package/dist/business/payment-allocator.js +31 -0
- package/dist/business/product-search.d.ts +4 -0
- package/dist/business/product-search.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +14 -7
- package/dist/document/document-page.d.ts.map +1 -1
- package/dist/document/document-page.js +8 -1
- package/dist/document/evaluate.d.ts.map +1 -1
- package/dist/document/evaluate.js +17 -1
- package/dist/document/index.d.ts +1 -0
- package/dist/document/index.d.ts.map +1 -1
- package/dist/document/index.js +1 -0
- package/dist/document/secondary-bar.d.ts +20 -0
- package/dist/document/secondary-bar.d.ts.map +1 -0
- package/dist/document/secondary-bar.js +44 -0
- package/dist/document/types.d.ts +8 -0
- package/dist/document/types.d.ts.map +1 -1
- package/dist/document-form-dialog.d.ts +31 -2
- package/dist/document-form-dialog.d.ts.map +1 -1
- package/dist/document-form-dialog.js +46 -11
- package/dist/dynamic-columns-helpers.d.ts +9 -0
- package/dist/dynamic-columns-helpers.d.ts.map +1 -1
- package/dist/dynamic-columns-helpers.js +19 -0
- package/dist/dynamic-columns.d.ts +9 -13
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +27 -21
- package/dist/dynamic-crud-page.d.ts +18 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +15 -6
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +15 -3
- package/dist/dynamic-form.d.ts +16 -1
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +78 -20
- package/dist/dynamic-kanban.d.ts +9 -3
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +31 -12
- package/dist/dynamic-relation-helpers.d.ts.map +1 -1
- package/dist/dynamic-relation-helpers.js +8 -0
- package/dist/dynamic-relation.js +1 -1
- package/dist/dynamic-row-actions.d.ts +8 -0
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +12 -2
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +64 -63
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -2
- package/dist/installed-addons-context.d.ts +15 -0
- package/dist/installed-addons-context.d.ts.map +1 -0
- package/dist/installed-addons-context.js +39 -0
- package/dist/org-runtime-context.d.ts +8 -0
- package/dist/org-runtime-context.d.ts.map +1 -1
- package/dist/org-runtime-context.js +8 -0
- package/dist/org-runtime-provider.d.ts +3 -1
- package/dist/org-runtime-provider.d.ts.map +1 -1
- package/dist/org-runtime-provider.js +3 -3
- package/dist/primitives/aging.d.ts +34 -0
- package/dist/primitives/aging.d.ts.map +1 -0
- package/dist/primitives/aging.js +48 -0
- package/dist/primitives/allocation.d.ts +42 -0
- package/dist/primitives/allocation.d.ts.map +1 -0
- package/dist/primitives/allocation.js +59 -0
- package/dist/primitives/approvals.d.ts +71 -0
- package/dist/primitives/approvals.d.ts.map +1 -0
- package/dist/primitives/approvals.js +48 -0
- package/dist/primitives/contributions.d.ts +93 -0
- package/dist/primitives/contributions.d.ts.map +1 -0
- package/dist/primitives/contributions.js +163 -0
- package/dist/primitives/document-kinds.d.ts +25 -0
- package/dist/primitives/document-kinds.d.ts.map +1 -0
- package/dist/primitives/document-kinds.js +26 -0
- package/dist/primitives/index.d.ts +8 -0
- package/dist/primitives/index.d.ts.map +1 -0
- package/dist/primitives/index.js +23 -0
- package/dist/primitives/panels.d.ts +68 -0
- package/dist/primitives/panels.d.ts.map +1 -0
- package/dist/primitives/panels.js +3 -0
- package/dist/primitives/use-contributions.d.ts +15 -0
- package/dist/primitives/use-contributions.d.ts.map +1 -0
- package/dist/primitives/use-contributions.js +30 -0
- package/dist/record-actions.d.ts +21 -0
- package/dist/record-actions.d.ts.map +1 -0
- package/dist/record-actions.js +76 -0
- package/dist/record-prefill-bar.d.ts +11 -0
- package/dist/record-prefill-bar.d.ts.map +1 -0
- package/dist/record-prefill-bar.js +33 -0
- package/dist/row-actions-menu.d.ts +32 -0
- package/dist/row-actions-menu.d.ts.map +1 -0
- package/dist/row-actions-menu.js +59 -0
- package/dist/slot.d.ts +4 -21
- package/dist/slot.d.ts.map +1 -1
- package/dist/slot.js +14 -36
- package/dist/types.d.ts +134 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/catalog-product-search.test.ts +61 -0
- package/src/__tests__/document-editor-model.test.ts +221 -0
- package/src/__tests__/document-editor.test.tsx +340 -0
- package/src/__tests__/document-form-dialog.test.tsx +158 -3
- package/src/__tests__/dynamic-form-select-edit.test.tsx +155 -0
- package/src/__tests__/dynamic-kanban.test.tsx +24 -3
- package/src/__tests__/dynamic-record-extension-label.test.tsx +88 -0
- package/src/__tests__/installed-addons-context.test.tsx +50 -0
- package/src/__tests__/line-item-totals.test.ts +28 -0
- package/src/__tests__/prefill-scalar-from-record.test.ts +52 -1
- package/src/__tests__/primitives-federated.test.tsx +156 -0
- package/src/__tests__/primitives.test.ts +224 -0
- package/src/__tests__/record-prefill-bar.test.tsx +50 -0
- package/src/__tests__/row-action-link-navigation.test.ts +17 -0
- package/src/__tests__/secondary-actions.test.tsx +261 -0
- package/src/action-modal-dispatcher.tsx +97 -12
- package/src/action-priority.ts +82 -0
- package/src/business/business.test.ts +1 -1
- package/src/business/catalog-product-search.ts +105 -0
- package/src/business/catalog.ts +2 -0
- package/src/business/document-editor-model.ts +367 -0
- package/src/business/document-editor-panels.tsx +164 -0
- package/src/business/document-editor.tsx +781 -0
- package/src/business/document-lines.ts +5 -1
- package/src/business/index.ts +5 -0
- package/src/business/line-items.ts +31 -2
- package/src/business/payment-allocator.tsx +110 -0
- package/src/business/product-search.ts +4 -0
- package/src/dialogs/dynamic-record.tsx +29 -6
- package/src/document/document-page.tsx +15 -0
- package/src/document/evaluate.ts +17 -1
- package/src/document/index.ts +1 -0
- package/src/document/secondary-bar.tsx +92 -0
- package/src/document/types.ts +8 -0
- package/src/document-form-dialog.tsx +96 -12
- package/src/dynamic-columns-helpers.ts +22 -0
- package/src/dynamic-columns.tsx +35 -62
- package/src/dynamic-crud-page.tsx +33 -5
- package/src/dynamic-form-schema.ts +15 -2
- package/src/dynamic-form.tsx +117 -39
- package/src/dynamic-kanban.tsx +34 -7
- package/src/dynamic-relation-helpers.ts +9 -0
- package/src/dynamic-relation.tsx +7 -2
- package/src/dynamic-row-actions.tsx +12 -2
- package/src/dynamic-table.tsx +3 -0
- package/src/index.ts +46 -1
- package/src/installed-addons-context.tsx +58 -0
- package/src/org-runtime-context.ts +10 -0
- package/src/org-runtime-provider.tsx +7 -2
- package/src/primitives/aging.ts +76 -0
- package/src/primitives/allocation.ts +104 -0
- package/src/primitives/approvals.ts +103 -0
- package/src/primitives/contributions.ts +255 -0
- package/src/primitives/document-kinds.ts +62 -0
- package/src/primitives/index.ts +23 -0
- package/src/primitives/panels.ts +59 -0
- package/src/primitives/use-contributions.ts +42 -0
- package/src/record-actions.ts +101 -0
- package/src/record-prefill-bar.tsx +62 -0
- package/src/row-action-stage-gate.test.ts +33 -0
- package/src/row-actions-menu.tsx +159 -0
- package/src/slot.tsx +18 -53
- package/src/types.ts +129 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { catalogRecordToProduct, createCatalogProductSearch } from '../business/catalog-product-search'
|
|
3
|
+
import { applyProductToLine } from '../business/document-lines'
|
|
4
|
+
import { makeLine, parseLineItems, serializeLineItems } from '../business/line-items'
|
|
5
|
+
|
|
6
|
+
describe('catalogRecordToProduct', () => {
|
|
7
|
+
it('proyecta precio de venta, costo, unidad, SKU y claves de extensión', () => {
|
|
8
|
+
const p = catalogRecordToProduct(
|
|
9
|
+
{ id: 'p1', name: 'Balanceo', sku: 'SRV-1', unit_price: '350.50', cost_price: 100, unit_of_measure: 'servicio', product_type: 'service', fiscal_data: '{"mx_clave_prod_serv":"78181500","vacio":""}' },
|
|
10
|
+
0.16,
|
|
11
|
+
)
|
|
12
|
+
expect(p).toEqual({
|
|
13
|
+
id: 'p1',
|
|
14
|
+
name: 'Balanceo',
|
|
15
|
+
sku: 'SRV-1',
|
|
16
|
+
barcode: undefined,
|
|
17
|
+
kind: 'service',
|
|
18
|
+
price: 350.5,
|
|
19
|
+
cost: 100,
|
|
20
|
+
tax_rate: 0.16,
|
|
21
|
+
unit: 'servicio',
|
|
22
|
+
extensions: { mx_clave_prod_serv: '78181500' },
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('la tasa del producto gana sobre la de la org (acepta 16 o 0.16)', () => {
|
|
27
|
+
expect(catalogRecordToProduct({ id: 'a', name: 'A', tax_rate: 8 }, 0.16).tax_rate).toBe(0.08)
|
|
28
|
+
expect(catalogRecordToProduct({ id: 'a', name: 'A', tax_rate: '0' }, 0.16).tax_rate).toBe(0)
|
|
29
|
+
expect(catalogRecordToProduct({ id: 'a', name: 'A' }).tax_rate).toBeUndefined()
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('createCatalogProductSearch', () => {
|
|
34
|
+
it('consulta /data/<model> con search y omite productos inactivos', async () => {
|
|
35
|
+
const get = vi.fn().mockResolvedValue({ data: { success: true, data: [{ id: '1', name: 'A', unit_price: 10 }, { id: '2', name: 'B', is_active: false }] } })
|
|
36
|
+
const search = createCatalogProductSearch({ get }, { model: 'inventory.Item', limit: 5 })
|
|
37
|
+
const signal = new AbortController().signal
|
|
38
|
+
const res = await search({ kind: 'text', raw: ' a ', text: 'a' }, signal)
|
|
39
|
+
expect(get).toHaveBeenCalledWith('/data/inventory.Item', { params: { search: 'a', per_page: 5 }, signal })
|
|
40
|
+
expect(res.map((r) => r.id)).toEqual(['1'])
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('cantidad del renglón de producto', () => {
|
|
45
|
+
it('elegir un producto sobre un renglón con cantidad 0 o vacía deja cantidad 1', () => {
|
|
46
|
+
const product = { id: 'p', name: 'P', price: 100 }
|
|
47
|
+
expect(applyProductToLine(makeLine({ quantity: 0 }), product).quantity).toBe(1)
|
|
48
|
+
expect(applyProductToLine(makeLine({ quantity: '' as unknown as number }), product).quantity).toBe(1)
|
|
49
|
+
expect(applyProductToLine(makeLine({ quantity: 3 }), product).quantity).toBe(3)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('parseLineItems trata cantidad vacía como 1', () => {
|
|
53
|
+
expect(parseLineItems([{ description: 'x', quantity: '' }])[0].quantity).toBe(1)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('las extensiones viajan como fiscal_data y regresan al parsear', () => {
|
|
57
|
+
const [row] = serializeLineItems([makeLine({ description: 'x', extensions: { mx_clave_unidad: 'H87' } })])
|
|
58
|
+
expect(row.fiscal_data).toEqual({ mx_clave_unidad: 'H87' })
|
|
59
|
+
expect(parseLineItems([row])[0].extensions).toEqual({ mx_clave_unidad: 'H87' })
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
allocationAmountField,
|
|
4
|
+
allocationIssueMessages,
|
|
5
|
+
allocationPayload,
|
|
6
|
+
creditStatus,
|
|
7
|
+
editorLinesConfig,
|
|
8
|
+
friendlyOptionLabel,
|
|
9
|
+
isEditorLayout,
|
|
10
|
+
linesFromSource,
|
|
11
|
+
localIssues,
|
|
12
|
+
sourceLoadSummary,
|
|
13
|
+
sourceTracksRemaining,
|
|
14
|
+
overdueDays,
|
|
15
|
+
partyDefaults,
|
|
16
|
+
partySummaryRows,
|
|
17
|
+
splitEditorFields,
|
|
18
|
+
toOpenDocuments,
|
|
19
|
+
} from '../business/document-editor-model'
|
|
20
|
+
import { makeLine, serializeLineItems } from '../business/line-items'
|
|
21
|
+
import { allocatePayment, validateAllocation } from '../primitives/allocation'
|
|
22
|
+
import type { ActionFieldDef, DocumentFormOpenDocuments } from '../types'
|
|
23
|
+
|
|
24
|
+
describe('divulgación progresiva (splitEditorFields)', () => {
|
|
25
|
+
const fields: ActionFieldDef[] = [
|
|
26
|
+
{ key: 'customer_id', label: 'Cliente', type: 'dynamic_select', required: true },
|
|
27
|
+
{ key: 'payment_form', label: 'Forma de pago', type: 'select', required: true, options: [{ value: '03', label: '03 · Transferencia' }] },
|
|
28
|
+
{ key: 'method', label: 'Método', type: 'select', defaultValue: 'PUE', options: [{ value: 'PUE', label: 'PUE · Una exhibición' }] },
|
|
29
|
+
{ key: 'series', label: 'Serie', type: 'text', section: 'fiscal' },
|
|
30
|
+
{ key: 'notes', label: 'Notas', type: 'textarea' },
|
|
31
|
+
]
|
|
32
|
+
const ext: ActionFieldDef[] = [{ key: 'fiscal_data.uso_cfdi', label: 'Uso CFDI', type: 'select' }]
|
|
33
|
+
|
|
34
|
+
it('a la vista: contraparte y lo obligatorio; plegado: extensiones, section fiscal y catálogos con default', () => {
|
|
35
|
+
const g = splitEditorFields(fields, { partyField: 'customer_id', extensionFields: ext })
|
|
36
|
+
expect(g.party?.key).toBe('customer_id')
|
|
37
|
+
expect(g.essential.map((f) => f.key)).toEqual(['payment_form'])
|
|
38
|
+
expect(g.advanced.map((f) => f.key)).toEqual(['method', 'series', 'fiscal_data.uso_cfdi'])
|
|
39
|
+
expect(g.notes.map((f) => f.key)).toEqual(['notes'])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('essentialKeys gana (monto del cobro, campo de vínculo del origen)', () => {
|
|
43
|
+
const g = splitEditorFields(fields, { essentialKeys: ['method'] })
|
|
44
|
+
expect(g.essential.map((f) => f.key)).toContain('method')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('el código SAT queda como dato secundario', () => {
|
|
48
|
+
expect(friendlyOptionLabel('03 · Transferencia electrónica')).toBe('Transferencia electrónica · 03')
|
|
49
|
+
expect(friendlyOptionLabel('G03 · Gastos en general')).toBe('Gastos en general · G03')
|
|
50
|
+
expect(friendlyOptionLabel('PUE · Pago en una sola exhibición')).toBe('PUE · Pago en una sola exhibición')
|
|
51
|
+
expect(friendlyOptionLabel('Efectivo')).toBe('Efectivo')
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('layout y renglones', () => {
|
|
56
|
+
it('isEditorLayout solo con layout: editor', () => {
|
|
57
|
+
expect(isEditorLayout({ layout: 'editor' })).toBe(true)
|
|
58
|
+
expect(isEditorLayout({ layout: 'wizard' })).toBe(false)
|
|
59
|
+
expect(isEditorLayout({})).toBe(false)
|
|
60
|
+
// Un tipo con «Cargar desde…» usa el editor salvo wizard explícito.
|
|
61
|
+
const src = [{ key: 'sale', label: 'Venta', model: 'customers.SalesOrder', lines: 'items' }]
|
|
62
|
+
expect(isEditorLayout({ sources: src })).toBe(true)
|
|
63
|
+
expect(isEditorLayout({ layout: 'wizard', sources: src })).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
it('allocation usa `allocations` por defecto; el manifest manda', () => {
|
|
66
|
+
const base = { key: 'p', label: 'P', fields: [] }
|
|
67
|
+
expect(editorLinesConfig({ ...base, lines: { kind: 'allocation' } }, {})?.field).toBe('allocations')
|
|
68
|
+
expect(editorLinesConfig({ ...base, lines: true }, { lines_field: 'items' })?.field).toBe('items')
|
|
69
|
+
expect(editorLinesConfig(base, {})).toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('contraparte', () => {
|
|
74
|
+
const customer = { name: 'Llantas SA', currency_code: 'MXN', tax_id: 'XAXX010101000', fiscal_data: { uso_cfdi: 'G03', regimen: '601' } }
|
|
75
|
+
it('copia extensiones con la misma clave y no pisa lo capturado', () => {
|
|
76
|
+
const keys = ['customer_id', 'fiscal_data.uso_cfdi', 'fiscal_data.metodo_pago']
|
|
77
|
+
expect(partyDefaults(customer, keys, {})).toEqual({ 'fiscal_data.uso_cfdi': 'G03', currency_code: 'MXN' })
|
|
78
|
+
expect(partyDefaults(customer, keys, { 'fiscal_data.uso_cfdi': 'S01', currency_code: 'USD' })).toEqual({})
|
|
79
|
+
})
|
|
80
|
+
it('tarjeta: resumen + extensiones con etiqueta legible', () => {
|
|
81
|
+
const rows = partySummaryRows(customer, ['tax_id'], { 'fiscal_data.uso_cfdi': 'Uso CFDI' })
|
|
82
|
+
expect(rows.map((r) => r.key)).toEqual(['tax_id', 'fiscal_data.uso_cfdi', 'fiscal_data.regimen'])
|
|
83
|
+
expect(rows[1].label).toBe('Uso CFDI')
|
|
84
|
+
})
|
|
85
|
+
const cols = { limit: 'credit_limit', balance: 'balance_due', overdue: 'overdue_amount', hold_reason: 'credit_hold_reason' }
|
|
86
|
+
it('crédito: bloquea si el total supera lo disponible, avisa con vencido, null sin columnas', () => {
|
|
87
|
+
expect(creditStatus({ credit_limit: 10000, balance_due: 9000 }, cols, 2000)?.level).toBe('block')
|
|
88
|
+
expect(creditStatus({ credit_limit: 10000, balance_due: 100, overdue_amount: 50 }, cols, 100)?.level).toBe('warn')
|
|
89
|
+
expect(creditStatus({ name: 'x' }, cols, 1)).toBeNull()
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('linesFromSource', () => {
|
|
94
|
+
it('SalesOrderItem: product_name → description y tasa derivada de tax/subtotal', () => {
|
|
95
|
+
const [l] = linesFromSource([{ product_id: 'p1', product_name: 'Llanta 205/55R16', quantity: 4, unit_price: 1500, subtotal: 6000, tax_amount: 960 }])
|
|
96
|
+
expect(l).toMatchObject({ description: 'Llanta 205/55R16', tax_rate: 0.16, quantity: 4, product_id: 'p1' })
|
|
97
|
+
})
|
|
98
|
+
it('map traduce nombres (discount_pct) y la tasa 16 se normaliza a 0.16', () => {
|
|
99
|
+
const [l] = linesFromSource([{ description: 'Balanceo', quantity: 1, unit_price: 100, discount_pct: 10, tax_rate: 16 }], { discount: 'discount_pct' })
|
|
100
|
+
expect(l.discount).toBe(10)
|
|
101
|
+
expect(l.tax_rate).toBe(0.16)
|
|
102
|
+
})
|
|
103
|
+
it('NC (credit): la cantidad queda topada en lo facturado', () => {
|
|
104
|
+
const [l] = linesFromSource([{ description: 'Llanta', quantity: 4, unit_price: 1500, tax_rate: 0.16 }], {}, { kind: 'credit' })
|
|
105
|
+
expect(l.max_quantity).toBe(4)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('localIssues', () => {
|
|
110
|
+
it('sin renglones es error; precio en cero es aviso; sin ruido por renglón libre', () => {
|
|
111
|
+
expect(localIssues([]).some((i) => i.severity === 'error')).toBe(true)
|
|
112
|
+
const w = localIssues([makeLine({ description: 'Servicio' })])
|
|
113
|
+
expect(w).toEqual([expect.objectContaining({ severity: 'warning', message: 'Renglón 1: precio en cero.' })])
|
|
114
|
+
expect(localIssues([], { requireLines: false })).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('cobro / REP (allocation)', () => {
|
|
119
|
+
const cfg: DocumentFormOpenDocuments = {
|
|
120
|
+
model: 'customers.Invoice',
|
|
121
|
+
party_field: 'customer_id',
|
|
122
|
+
balance_field: 'amount_due',
|
|
123
|
+
number_field: 'number',
|
|
124
|
+
total_field: 'total',
|
|
125
|
+
due_field: 'due_date',
|
|
126
|
+
issued_field: 'issue_date',
|
|
127
|
+
method_field: 'fiscal_data.metodo_pago',
|
|
128
|
+
line_document_field: 'invoice_id',
|
|
129
|
+
line_amount_field: 'amount',
|
|
130
|
+
option_filter: [{ field: 'status', not_in: ['cancelled'] }],
|
|
131
|
+
}
|
|
132
|
+
const rows = [
|
|
133
|
+
{ id: 'a', number: 'A-2', total: 1000, amount_due: 1000, issue_date: '2026-09-01', due_date: '2026-10-01', status: 'open', fiscal_data: { metodo_pago: 'PPD' } },
|
|
134
|
+
{ id: 'b', number: 'A-1', total: 500, amount_due: 300, issue_date: '2026-08-01', due_date: '2026-09-01', status: 'open' },
|
|
135
|
+
{ id: 'c', number: 'A-0', total: 800, amount_due: 0, issue_date: '2026-07-01', status: 'paid' },
|
|
136
|
+
{ id: 'd', number: 'A-9', total: 900, amount_due: 900, issue_date: '2026-07-01', status: 'cancelled' },
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
it('toOpenDocuments: solo con saldo y que pasan el option_filter', () => {
|
|
140
|
+
const docs = toOpenDocuments(rows, cfg, 'MXN')
|
|
141
|
+
expect(docs.map((d) => d.id)).toEqual(['a', 'b'])
|
|
142
|
+
expect(docs[0]).toMatchObject({ number: 'A-2', balance: 1000, total: 1000, due_at: '2026-10-01', payment_method: 'PPD', currency: 'MXN' })
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('reparte del más vencido y arma el payload con los campos de la acción', () => {
|
|
146
|
+
const docs = toOpenDocuments(rows, cfg, 'MXN')
|
|
147
|
+
const r = allocatePayment(800, docs)
|
|
148
|
+
expect(r.allocations.map((a) => [a.document_id, a.amount])).toEqual([['b', 300], ['a', 500]])
|
|
149
|
+
expect(allocationPayload(r.allocations, cfg)).toEqual([
|
|
150
|
+
{ invoice_id: 'b', amount: 300 },
|
|
151
|
+
{ invoice_id: 'a', amount: 500 },
|
|
152
|
+
])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('mensajes claros con el número del documento', () => {
|
|
156
|
+
const docs = toOpenDocuments(rows, cfg, 'MXN')
|
|
157
|
+
const issues = validateAllocation(100, 'MXN', docs, [{ document_id: 'b', amount: 400, balance_before: 300, balance_after: 0, installment: 1 }])
|
|
158
|
+
const msgs = allocationIssueMessages(issues, docs).map((m) => m.message)
|
|
159
|
+
expect(msgs).toContain('A-1: Se aplica más que el saldo del documento.')
|
|
160
|
+
expect(msgs).toContain('Lo aplicado supera el monto recibido.')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('campo del monto: `amount` o el primer numérico', () => {
|
|
164
|
+
expect(allocationAmountField([{ key: 'x', label: '', type: 'text' }, { key: 'amount', label: '', type: 'number' }])).toBe('amount')
|
|
165
|
+
expect(allocationAmountField([{ key: 'total_paid', label: '', type: 'number' }])).toBe('total_paid')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('días de atraso', () => {
|
|
169
|
+
expect(overdueDays({ due_at: '2026-10-01' }, new Date(2026, 9, 6))).toBe(5)
|
|
170
|
+
expect(overdueDays({ due_at: '2026-10-30' }, new Date(2026, 9, 6))).toBe(0)
|
|
171
|
+
expect(overdueDays({ due_at: null })).toBe(0)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('linesFromSource con lo pendiente («crear desde»)', () => {
|
|
176
|
+
it('la cantidad sugerida y el tope son remaining_quantity; lo ya cubierto se omite; guarda el renglón origen', () => {
|
|
177
|
+
const rows = [
|
|
178
|
+
{ id: 'a', source_line_id: 'a', product_id: 'p1', product_name: 'Llanta', quantity: 4, remaining_quantity: 1, unit_price: 1500, discount: 5, tax_rate: 0.16 },
|
|
179
|
+
{ id: 'b', source_line_id: 'b', product_name: 'Balanceo', quantity: 2, remaining_quantity: 0, unit_price: 100 },
|
|
180
|
+
]
|
|
181
|
+
const lines = linesFromSource(rows, { description: 'product_name' })
|
|
182
|
+
expect(lines).toHaveLength(1)
|
|
183
|
+
expect(lines[0]).toMatchObject({ product_id: 'p1', description: 'Llanta', quantity: 1, max_quantity: 1, unit_price: 1500, discount: 5, tax_rate: 0.16, source_line_id: 'a' })
|
|
184
|
+
expect(sourceLoadSummary(rows)).toEqual({ loaded: 1, covered: 1 })
|
|
185
|
+
})
|
|
186
|
+
it('sin remaining_quantity conserva la cantidad del origen y usa su id como vínculo', () => {
|
|
187
|
+
const [l] = linesFromSource([{ id: 'x', description: 'Servicio', quantity: 3, unit_price: 10 }])
|
|
188
|
+
expect(l.quantity).toBe(3)
|
|
189
|
+
expect(l.max_quantity).toBeUndefined()
|
|
190
|
+
expect(l.source_line_id).toBe('x')
|
|
191
|
+
})
|
|
192
|
+
it('localIssues marca la cantidad que excede lo pendiente', () => {
|
|
193
|
+
const l = { ...makeLine({ description: 'Llanta', quantity: 3, unit_price: 10 }), max_quantity: 2 }
|
|
194
|
+
expect(localIssues([l]).some((i) => i.severity === 'error' && i.message.includes('excede lo pendiente'))).toBe(true)
|
|
195
|
+
})
|
|
196
|
+
it('sourceTracksRemaining: solo con line_link_field, remaining_qty_field o remaining_endpoint', () => {
|
|
197
|
+
expect(sourceTracksRemaining({ line_link_field: 'sales_order_item_id' })).toBe(true)
|
|
198
|
+
expect(sourceTracksRemaining({ remaining_endpoint: '/x' })).toBe(true)
|
|
199
|
+
expect(sourceTracksRemaining({})).toBe(false)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
describe('serializeLineItems con vínculo al renglón origen', () => {
|
|
205
|
+
it('escribe source_line_id en la columna line_link_field y no en renglones libres', () => {
|
|
206
|
+
const linked = { ...makeLine({ description: 'Llanta', quantity: 1, unit_price: 10 }), source_line_id: 'soi-1' }
|
|
207
|
+
const free = makeLine({ description: 'Flete', quantity: 1, unit_price: 50 })
|
|
208
|
+
const [a, b] = serializeLineItems([linked, free], { sourceLineField: 'sales_order_item_id' })
|
|
209
|
+
expect(a.sales_order_item_id).toBe('soi-1')
|
|
210
|
+
expect(b.sales_order_item_id).toBeUndefined()
|
|
211
|
+
expect(serializeLineItems([linked])[0].sales_order_item_id).toBeUndefined()
|
|
212
|
+
})
|
|
213
|
+
it('lleva subtotal e impuesto calculados (modelos de renglón sin tasa)', () => {
|
|
214
|
+
const l = { ...makeLine({ description: 'Llanta', quantity: 2, unit_price: 100, discount: 20, tax_rate: 0.16 }), discount_kind: 'amount' as const }
|
|
215
|
+
expect(serializeLineItems([l])[0]).toMatchObject({ subtotal: 180, tax_amount: 28.8, discount: 20, discount_kind: 'amount' })
|
|
216
|
+
})
|
|
217
|
+
it('linesFromSource con discount_mode amount conserva el descuento como importe', () => {
|
|
218
|
+
const [l] = linesFromSource([{ id: 'a', quantity: 2, unit_price: 100, discount: 20, remaining_quantity: 2 }], {}, { discountMode: 'amount' })
|
|
219
|
+
expect(l.discount_kind).toBe('amount')
|
|
220
|
+
})
|
|
221
|
+
})
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// DocumentEditor (layout: "editor"): una pantalla por secciones, «Opciones
|
|
3
|
+
// fiscales» plegadas, una sola acción primaria, prefill desde el origen y
|
|
4
|
+
// reparto de cobros con PaymentAllocator.
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
7
|
+
|
|
8
|
+
vi.mock('@tanstack/react-router', () => ({ useNavigate: () => () => {} }))
|
|
9
|
+
vi.mock('react-i18next', () => {
|
|
10
|
+
const i18n = { language: 'es' }
|
|
11
|
+
const t = (k: string, o?: Record<string, any>) => {
|
|
12
|
+
let s: string = o?.defaultValue ?? k
|
|
13
|
+
for (const [key, v] of Object.entries(o ?? {})) s = s.replace(`{{${key}}}`, String(v))
|
|
14
|
+
return s
|
|
15
|
+
}
|
|
16
|
+
const value = { t, i18n }
|
|
17
|
+
return { useTranslation: () => value }
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
import { DocumentEditor } from '../business/document-editor'
|
|
21
|
+
import { DocumentFormDialog } from '../document-form-dialog'
|
|
22
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
23
|
+
import { __resetContributions, registerDocumentContribution } from '../primitives/contributions'
|
|
24
|
+
import type { DocumentFormsManifest, DocumentFormType } from '../types'
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
cleanup()
|
|
28
|
+
__resetContributions()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
type Routes = Record<string, unknown>
|
|
32
|
+
|
|
33
|
+
function makeApi(routes: Routes = {}, post = vi.fn().mockResolvedValue({ data: { success: true, data: { id: 'new-1' } } })) {
|
|
34
|
+
const get = vi.fn(async (url: string, cfg?: { params?: Record<string, unknown> }) => {
|
|
35
|
+
for (const [prefix, body] of Object.entries(routes)) {
|
|
36
|
+
if (url.startsWith(prefix)) return { data: typeof body === 'function' ? (body as any)(url, cfg) : body }
|
|
37
|
+
}
|
|
38
|
+
return { data: { data: [] } }
|
|
39
|
+
})
|
|
40
|
+
const client = { get, post, put: vi.fn().mockResolvedValue({ data: { success: true, data: { id: 'new-1' } } }), delete: vi.fn() }
|
|
41
|
+
return { client: client as unknown as ApiClient, get, post }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const invoice: DocumentFormType = {
|
|
45
|
+
key: 'invoice',
|
|
46
|
+
label: 'Factura',
|
|
47
|
+
value: 'I',
|
|
48
|
+
layout: 'editor',
|
|
49
|
+
fields: [
|
|
50
|
+
{ key: 'customer_name', label: 'Cliente', type: 'text', required: true },
|
|
51
|
+
{ key: 'payment_form', label: 'Forma de pago', type: 'select', required: true, defaultValue: '03', options: [{ value: '03', label: '03 · Transferencia' }, { value: '01', label: '01 · Efectivo' }] },
|
|
52
|
+
{ key: 'payment_method', label: 'Método de pago', type: 'select', defaultValue: 'PUE', options: [{ value: 'PUE', label: 'PUE · Una exhibición' }, { value: 'PPD', label: 'PPD · Parcialidades' }] },
|
|
53
|
+
{ key: 'sales_order_id', label: 'Venta', type: 'text' },
|
|
54
|
+
],
|
|
55
|
+
lines: { columns: ['tax'] },
|
|
56
|
+
sources: [{ key: 'sales_order', label: 'Venta', model: 'customers.SalesOrder', lines: 'items', map: { description: 'product_name' }, link_field: 'sales_order_id' }],
|
|
57
|
+
preview: { action: 'preview_cfdi', label: 'Vista previa CFDI' },
|
|
58
|
+
submit_label: 'Guardar factura',
|
|
59
|
+
}
|
|
60
|
+
const forms: DocumentFormsManifest = { type_field: 'type', types: [invoice] }
|
|
61
|
+
|
|
62
|
+
function renderEditor(type: DocumentFormType, api: ReturnType<typeof makeApi>, extra: Partial<Parameters<typeof DocumentEditor>[0]> = {}) {
|
|
63
|
+
const onSaved = vi.fn()
|
|
64
|
+
render(
|
|
65
|
+
<ApiProvider client={api.client}>
|
|
66
|
+
<DocumentEditor model="customers.Invoice" forms={{ ...forms, types: [type] }} type={type} onCancel={() => {}} onSaved={onSaved} currency="MXN" {...extra} />
|
|
67
|
+
</ApiProvider>,
|
|
68
|
+
)
|
|
69
|
+
return { onSaved }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('DocumentEditor — divulgación progresiva', () => {
|
|
73
|
+
it('«Opciones fiscales» arranca plegada: lo esencial a la vista, el método con default adentro', () => {
|
|
74
|
+
renderEditor(invoice, makeApi())
|
|
75
|
+
const toggle = screen.getByRole('button', { name: /Opciones fiscales/ })
|
|
76
|
+
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
|
77
|
+
const panel = document.getElementById(toggle.getAttribute('aria-controls')!)!
|
|
78
|
+
expect(panel.hidden).toBe(true)
|
|
79
|
+
expect(within(panel).getByText('Método de pago')).toBeTruthy()
|
|
80
|
+
// Esencial: fuera de la sección plegada.
|
|
81
|
+
const essentials = document.querySelector('[data-slot="editor-essentials"]')!
|
|
82
|
+
expect(within(essentials as HTMLElement).getByText('Cliente')).toBeTruthy()
|
|
83
|
+
expect(within(essentials as HTMLElement).getByText('Forma de pago')).toBeTruthy()
|
|
84
|
+
expect(within(essentials as HTMLElement).queryByText('Método de pago')).toBeNull()
|
|
85
|
+
// Resumen de los defaults sin abrir.
|
|
86
|
+
expect(toggle.textContent).toContain('PUE')
|
|
87
|
+
fireEvent.click(toggle)
|
|
88
|
+
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
|
89
|
+
expect(panel.hidden).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('la vista previa es opcional y está cerrada por defecto', () => {
|
|
93
|
+
renderEditor(invoice, makeApi())
|
|
94
|
+
expect(screen.getByRole('button', { name: /Vista previa CFDI/ }).getAttribute('aria-expanded')).toBe('false')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('las etiquetas muestran el código SAT como dato secundario', () => {
|
|
98
|
+
renderEditor(invoice, makeApi())
|
|
99
|
+
expect(screen.getAllByText('Transferencia · 03').length).toBeGreaterThan(0)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('una contribución header.fields de otro addon entra en «Opciones fiscales»', () => {
|
|
103
|
+
registerDocumentContribution({
|
|
104
|
+
id: 'fiscal_mexico.header.cfdi',
|
|
105
|
+
kinds: ['invoice'],
|
|
106
|
+
region: 'header.fields',
|
|
107
|
+
requiresAddon: 'fiscal_mexico',
|
|
108
|
+
component: () => <p>Relación CFDI</p>,
|
|
109
|
+
})
|
|
110
|
+
renderEditor(invoice, makeApi())
|
|
111
|
+
const panel = document.querySelector('[data-slot="advanced-options"]')!
|
|
112
|
+
expect(within(panel as HTMLElement).getByText('Relación CFDI')).toBeTruthy()
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('DocumentEditor — una sola acción primaria', () => {
|
|
117
|
+
it('un solo botón primario (submit_label) y sin «Siguiente»', () => {
|
|
118
|
+
renderEditor(invoice, makeApi())
|
|
119
|
+
const primaries = document.querySelectorAll('[data-slot="document-editor"] [data-primary="true"]')
|
|
120
|
+
expect(primaries.length).toBe(1)
|
|
121
|
+
expect(primaries[0].textContent).toBe('Guardar factura')
|
|
122
|
+
expect(screen.queryByRole('button', { name: 'Siguiente' })).toBeNull()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('sin renglones no guarda: validación inline y revisión visible', async () => {
|
|
126
|
+
const api = makeApi()
|
|
127
|
+
renderEditor(invoice, api)
|
|
128
|
+
fireEvent.click(screen.getByRole('button', { name: 'Guardar factura' }))
|
|
129
|
+
await screen.findByText('Agrega al menos un renglón.')
|
|
130
|
+
expect(screen.getAllByText(/Cliente/).length).toBeGreaterThan(1) // etiqueta + error inline
|
|
131
|
+
expect(api.post).not.toHaveBeenCalled()
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('DocumentEditor — prefill desde el documento origen', () => {
|
|
136
|
+
it('initialSource carga los renglones y el vínculo; guarda con el tipo y los renglones serializados', async () => {
|
|
137
|
+
const api = makeApi({
|
|
138
|
+
'/metadata/table/customers.SalesOrder': { data: { relations: [{ name: 'items', kind: 'one_to_many', through: 'customers.SalesOrderItem', foreign_key: 'sales_order_id' }] } },
|
|
139
|
+
'/data/customers.SalesOrderItem': { data: [{ product_id: 'p1', product_name: 'Llanta 205/55R16', quantity: 4, unit_price: 1500, tax_rate: 0.16 }] },
|
|
140
|
+
'/data/customers.SalesOrder': { data: [{ id: 'so-1', customer_name: 'ACME' }] },
|
|
141
|
+
})
|
|
142
|
+
const { onSaved } = renderEditor(
|
|
143
|
+
{ ...invoice, sources: [{ ...invoice.sources![0], header: { customer_name: 'customer_name' } }] },
|
|
144
|
+
api,
|
|
145
|
+
{ initialSource: { key: 'sales_order', id: 'so-1' } },
|
|
146
|
+
)
|
|
147
|
+
await screen.findByDisplayValue('Llanta 205/55R16')
|
|
148
|
+
await screen.findByDisplayValue('ACME')
|
|
149
|
+
expect(api.get).toHaveBeenCalledWith('/data/customers.SalesOrderItem', expect.objectContaining({ params: expect.objectContaining({ f_sales_order_id: 'eq:so-1' }) }))
|
|
150
|
+
fireEvent.click(screen.getByRole('button', { name: 'Guardar factura' }))
|
|
151
|
+
await waitFor(() => expect(api.post).toHaveBeenCalled())
|
|
152
|
+
const [url, body] = api.post.mock.calls[0] as [string, any]
|
|
153
|
+
expect(url).toBe('/dynamic/customers.Invoice')
|
|
154
|
+
expect(body).toMatchObject({ type: 'I', customer_name: 'ACME', sales_order_id: 'so-1', payment_method: 'PUE', payment_form: '03' })
|
|
155
|
+
expect(body.lines).toEqual([expect.objectContaining({ product_id: 'p1', description: 'Llanta 205/55R16', quantity: 4, unit_price: 1500 })])
|
|
156
|
+
await waitFor(() => expect(onSaved).toHaveBeenCalledWith(expect.objectContaining({ id: 'new-1' })))
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('default_from_record siembra el encabezado desde el registro de la acción', () => {
|
|
160
|
+
const type: DocumentFormType = {
|
|
161
|
+
...invoice,
|
|
162
|
+
fields: [{ key: 'customer_name', label: 'Cliente', type: 'text', default_from_record: 'name' } as any],
|
|
163
|
+
}
|
|
164
|
+
renderEditor(type, makeApi(), { record: { id: 'c1', name: 'Taller Pérez' } })
|
|
165
|
+
expect(screen.getByDisplayValue('Taller Pérez')).toBeTruthy()
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('DocumentEditor — cobro / REP (lines.kind: allocation)', () => {
|
|
170
|
+
const payment: DocumentFormType = {
|
|
171
|
+
key: 'payment',
|
|
172
|
+
label: 'Registrar cobro',
|
|
173
|
+
layout: 'editor',
|
|
174
|
+
submit_action: 'collect_multi_payment_create',
|
|
175
|
+
submit_label: 'Registrar cobro',
|
|
176
|
+
party: { field: 'customer_id', model: 'customers.Customer' },
|
|
177
|
+
fields: [
|
|
178
|
+
{ key: 'customer_id', label: 'Cliente', type: 'text', required: true, default_from_record: 'customer_id' } as any,
|
|
179
|
+
{ key: 'amount', label: 'Monto recibido', type: 'number', required: true },
|
|
180
|
+
],
|
|
181
|
+
lines: {
|
|
182
|
+
kind: 'allocation',
|
|
183
|
+
open_documents: {
|
|
184
|
+
model: 'customers.Invoice',
|
|
185
|
+
party_field: 'customer_id',
|
|
186
|
+
balance_field: 'amount_due',
|
|
187
|
+
number_field: 'number',
|
|
188
|
+
due_field: 'due_date',
|
|
189
|
+
issued_field: 'issue_date',
|
|
190
|
+
line_document_field: 'invoice_id',
|
|
191
|
+
line_amount_field: 'amount',
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
const routes = {
|
|
196
|
+
'/data/customers.Customer': { data: [{ id: 'c1', name: 'Llantas SA' }] },
|
|
197
|
+
'/data/customers.Invoice': {
|
|
198
|
+
data: [
|
|
199
|
+
{ id: 'inv-new', number: 'A-2', amount_due: 1000, issue_date: '2026-09-01', due_date: '2026-10-30' },
|
|
200
|
+
{ id: 'inv-old', number: 'A-1', amount_due: 300, issue_date: '2026-08-01', due_date: '2026-09-01' },
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
it('lista las facturas abiertas, reparte del más vencido y guarda por la acción con allocations', async () => {
|
|
206
|
+
const api = makeApi(routes)
|
|
207
|
+
renderEditor(payment, api, { record: { customer_id: 'c1' }, today: new Date(2026, 9, 6) })
|
|
208
|
+
await screen.findByText('A-1')
|
|
209
|
+
expect(api.get).toHaveBeenCalledWith('/data/customers.Invoice', expect.objectContaining({ params: expect.objectContaining({ f_customer_id: 'eq:c1' }) }))
|
|
210
|
+
expect(await screen.findByText('Llantas SA')).toBeTruthy()
|
|
211
|
+
fireEvent.change(document.querySelector('[data-slot="editor-essentials"] input[type=number]')!, { target: { value: '500' } })
|
|
212
|
+
await waitFor(() => expect((screen.getByLabelText('Aplicar a A-1') as HTMLInputElement).value).toBe('300'))
|
|
213
|
+
expect((screen.getByLabelText('Aplicar a A-2') as HTMLInputElement).value).toBe('200')
|
|
214
|
+
expect(screen.getByText(/Parcialidad 1/)).toBeTruthy()
|
|
215
|
+
fireEvent.click(screen.getByRole('button', { name: 'Registrar cobro' }))
|
|
216
|
+
await waitFor(() => expect(api.post).toHaveBeenCalled())
|
|
217
|
+
const [url, body] = api.post.mock.calls[0] as [string, any]
|
|
218
|
+
expect(url).toBe('/dynamic/customers.Invoice/action/collect_multi_payment_create')
|
|
219
|
+
expect(body).toMatchObject({ customer_id: 'c1', amount: 500 })
|
|
220
|
+
expect(body.allocations).toEqual([
|
|
221
|
+
{ invoice_id: 'inv-old', amount: 300 },
|
|
222
|
+
{ invoice_id: 'inv-new', amount: 200 },
|
|
223
|
+
])
|
|
224
|
+
expect(body.type).toBeUndefined()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('editar un monto pasa a reparto manual; «Autoaplicar» regresa al automático', async () => {
|
|
228
|
+
const api = makeApi(routes)
|
|
229
|
+
renderEditor(payment, api, { record: { customer_id: 'c1' } })
|
|
230
|
+
await screen.findByText('A-1')
|
|
231
|
+
fireEvent.click(screen.getByRole('button', { name: /Cobrar todo/ }))
|
|
232
|
+
await waitFor(() => expect((screen.getByLabelText('Aplicar a A-2') as HTMLInputElement).value).toBe('1000'))
|
|
233
|
+
fireEvent.change(screen.getByLabelText('Aplicar a A-2'), { target: { value: '400' } })
|
|
234
|
+
expect(screen.getByText('Reparto manual')).toBeTruthy()
|
|
235
|
+
expect((screen.getByLabelText('Aplicar a A-2') as HTMLInputElement).value).toBe('400')
|
|
236
|
+
expect(screen.getByText('Sin aplicar (saldo a favor)')).toBeTruthy()
|
|
237
|
+
fireEvent.click(screen.getByRole('button', { name: 'Autoaplicar' }))
|
|
238
|
+
expect((screen.getByLabelText('Aplicar a A-2') as HTMLInputElement).value).toBe('1000')
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
describe('DocumentFormDialog + layout: editor', () => {
|
|
243
|
+
it('con layout editor abre el DocumentEditor; sin él sigue el wizard', () => {
|
|
244
|
+
const api = makeApi()
|
|
245
|
+
const { unmount } = render(
|
|
246
|
+
<ApiProvider client={api.client}>
|
|
247
|
+
<DocumentFormDialog open onOpenChange={() => {}} model="customers.Invoice" forms={forms} />
|
|
248
|
+
</ApiProvider>,
|
|
249
|
+
)
|
|
250
|
+
expect(document.querySelector('[data-slot="document-editor"]')).toBeTruthy()
|
|
251
|
+
expect(screen.queryByRole('button', { name: 'Siguiente' })).toBeNull()
|
|
252
|
+
unmount()
|
|
253
|
+
render(
|
|
254
|
+
<ApiProvider client={api.client}>
|
|
255
|
+
<DocumentFormDialog open onOpenChange={() => {}} model="customers.Invoice" forms={{ ...forms, types: [{ ...invoice, layout: undefined, sources: undefined }] }} />
|
|
256
|
+
</ApiProvider>,
|
|
257
|
+
)
|
|
258
|
+
expect(document.querySelector('[data-slot="document-editor"]')).toBeNull()
|
|
259
|
+
expect(screen.getByRole('button', { name: 'Siguiente' })).toBeTruthy()
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('un tipo con sources usa el editor («Cargar desde…») salvo layout: wizard explícito', () => {
|
|
263
|
+
const api = makeApi()
|
|
264
|
+
const { unmount } = render(
|
|
265
|
+
<ApiProvider client={api.client}>
|
|
266
|
+
<DocumentFormDialog open onOpenChange={() => {}} model="customers.Invoice" forms={{ ...forms, types: [{ ...invoice, layout: undefined }] }} />
|
|
267
|
+
</ApiProvider>,
|
|
268
|
+
)
|
|
269
|
+
expect(document.querySelector('[data-slot="load-from-source"]')).toBeTruthy()
|
|
270
|
+
unmount()
|
|
271
|
+
render(
|
|
272
|
+
<ApiProvider client={api.client}>
|
|
273
|
+
<DocumentFormDialog open onOpenChange={() => {}} model="customers.Invoice" forms={{ ...forms, types: [{ ...invoice, layout: 'wizard' }] }} />
|
|
274
|
+
</ApiProvider>,
|
|
275
|
+
)
|
|
276
|
+
expect(document.querySelector('[data-slot="document-editor"]')).toBeNull()
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
describe('DocumentEditor — «crear desde» con lo pendiente del servidor', () => {
|
|
281
|
+
const tracked: DocumentFormType = {
|
|
282
|
+
...invoice,
|
|
283
|
+
sources: [{ ...invoice.sources![0], line_link_field: 'sales_order_item_id', exclude_states: ['cancelled'] }],
|
|
284
|
+
}
|
|
285
|
+
const pendingRows = {
|
|
286
|
+
data: [
|
|
287
|
+
{ id: 'soi-1', source_line_id: 'soi-1', product_id: 'p1', product_name: 'Llanta 205/55R16', quantity: 4, remaining_quantity: 1, unit_price: 1500, discount: 0, tax_rate: 0.16 },
|
|
288
|
+
{ id: 'soi-2', source_line_id: 'soi-2', product_id: 'p2', product_name: 'Balanceo', quantity: 4, remaining_quantity: 0, unit_price: 100, tax_rate: 0.16 },
|
|
289
|
+
],
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
it('precarga lo pendiente (no lo vendido), omite lo ya facturado y guarda el vínculo por renglón', async () => {
|
|
293
|
+
const api = makeApi({
|
|
294
|
+
'/dynamic/customers.Invoice/source-lines': pendingRows,
|
|
295
|
+
'/data/customers.SalesOrder': { data: [{ id: 'so-1' }] },
|
|
296
|
+
})
|
|
297
|
+
renderEditor(tracked, api, { initialSource: { key: 'sales_order', id: 'so-1' } })
|
|
298
|
+
await screen.findByDisplayValue('Llanta 205/55R16')
|
|
299
|
+
expect(screen.queryByDisplayValue('Balanceo')).toBeNull()
|
|
300
|
+
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 pendiente · 1 ya cubiertos se omitieron')).toBeTruthy()
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('si el host aún no sirve source-lines (404) cae a la relación del origen', async () => {
|
|
305
|
+
const api = makeApi({
|
|
306
|
+
'/dynamic/customers.Invoice/source-lines': () => {
|
|
307
|
+
throw { response: { status: 404 } }
|
|
308
|
+
},
|
|
309
|
+
'/metadata/table/customers.SalesOrder': { data: { relations: [{ name: 'items', kind: 'one_to_many', through: 'customers.SalesOrderItem', foreign_key: 'sales_order_id' }] } },
|
|
310
|
+
'/data/customers.SalesOrderItem': { data: [{ id: 'soi-1', product_id: 'p1', product_name: 'Llanta 205/55R16', quantity: 4, unit_price: 1500, tax_rate: 0.16 }] },
|
|
311
|
+
})
|
|
312
|
+
renderEditor(tracked, api, { initialSource: { key: 'sales_order', id: 'so-1' } })
|
|
313
|
+
await screen.findByDisplayValue('Llanta 205/55R16')
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('el rechazo del servidor por exceso se muestra con su mensaje en español', async () => {
|
|
317
|
+
const post = vi.fn().mockRejectedValue({
|
|
318
|
+
response: {
|
|
319
|
+
status: 422,
|
|
320
|
+
data: {
|
|
321
|
+
success: false,
|
|
322
|
+
message: 'validation failed',
|
|
323
|
+
errors: { 'lines.0.quantity': [{ code: 'exceeds_remaining', message: 'Renglón 1: la cantidad 1 excede lo pendiente de «Venta» (0).' }] },
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
})
|
|
327
|
+
const api = makeApi({ '/dynamic/customers.Invoice/source-lines': pendingRows, '/data/customers.SalesOrder': { data: [{ id: 'so-1', customer_name: 'ACME' }] } }, post)
|
|
328
|
+
renderEditor(
|
|
329
|
+
{ ...tracked, sources: [{ ...tracked.sources![0], header: { customer_name: 'customer_name' } }] },
|
|
330
|
+
api,
|
|
331
|
+
{ initialSource: { key: 'sales_order', id: 'so-1' } },
|
|
332
|
+
)
|
|
333
|
+
await screen.findByDisplayValue('ACME')
|
|
334
|
+
fireEvent.click(screen.getByRole('button', { name: 'Guardar factura' }))
|
|
335
|
+
await waitFor(() => expect(post).toHaveBeenCalled())
|
|
336
|
+
const [, body] = post.mock.calls[0] as [string, any]
|
|
337
|
+
expect(body.lines).toEqual([expect.objectContaining({ product_id: 'p1', quantity: 1, sales_order_item_id: 'soi-1' })])
|
|
338
|
+
expect(await screen.findByText(/excede lo pendiente de «Venta»/)).toBeTruthy()
|
|
339
|
+
})
|
|
340
|
+
})
|