@asteby/metacore-runtime-react 47.0.0 → 48.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 +42 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +24 -4
- package/dist/audit-info.d.ts +54 -0
- package/dist/audit-info.d.ts.map +1 -0
- package/dist/audit-info.js +142 -0
- package/dist/business/catalog-product-search.d.ts +25 -0
- package/dist/business/catalog-product-search.d.ts.map +1 -0
- package/dist/business/catalog-product-search.js +57 -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 +107 -0
- package/dist/business/document-editor-model.d.ts.map +1 -0
- package/dist/business/document-editor-model.js +279 -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 +445 -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 +7 -0
- package/dist/business/line-items.d.ts.map +1 -1
- package/dist/business/line-items.js +6 -1
- 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 +19 -8
- 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 +43 -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 +8 -2
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +21 -5
- package/dist/dynamic-crud-page.d.ts +6 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +14 -5
- 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 +81 -20
- package/dist/dynamic-kanban.d.ts +9 -3
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +39 -13
- package/dist/dynamic-line-items.d.ts.map +1 -1
- package/dist/dynamic-line-items.js +3 -0
- package/dist/dynamic-multi-select-field.d.ts.map +1 -1
- package/dist/dynamic-multi-select-field.js +2 -0
- 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-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +4 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -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/option-filter.d.ts +31 -0
- package/dist/option-filter.d.ts.map +1 -0
- package/dist/option-filter.js +49 -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 +82 -0
- package/dist/primitives/contributions.d.ts.map +1 -0
- package/dist/primitives/contributions.js +126 -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-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/types.d.ts +142 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-options-resolver.d.ts +8 -0
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +8 -2
- package/package.json +5 -5
- package/src/__tests__/action-modal-grid-layout.test.tsx +1 -1
- package/src/__tests__/apply-sidebar-layout.test.ts +1 -1
- package/src/__tests__/audit-info.test.tsx +103 -0
- package/src/__tests__/catalog-product-search.test.ts +61 -0
- package/src/__tests__/dependent-options.test.tsx +12 -5
- package/src/__tests__/document-editor-model.test.ts +167 -0
- package/src/__tests__/document-editor.test.tsx +261 -0
- package/src/__tests__/document-form-dialog.test.tsx +118 -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__/dynamic-table-infinite-scroll.test.tsx +4 -0
- package/src/__tests__/installed-addons-context.test.tsx +50 -0
- package/src/__tests__/line-item-totals.test.ts +28 -0
- package/src/__tests__/line-items-lock-rows.test.tsx +2 -1
- package/src/__tests__/option-filter.test.tsx +115 -0
- 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__/structured-view-value.test.tsx +5 -1
- package/src/__tests__/wizard-action-modal.test.tsx +1 -1
- package/src/action-modal-dispatcher.tsx +47 -4
- package/src/audit-info.tsx +219 -0
- package/src/business/catalog-product-search.ts +87 -0
- package/src/business/catalog.ts +2 -0
- package/src/business/document-editor-model.ts +325 -0
- package/src/business/document-editor-panels.tsx +164 -0
- package/src/business/document-editor.tsx +727 -0
- package/src/business/document-lines.ts +5 -1
- package/src/business/index.ts +5 -0
- package/src/business/line-items.ts +15 -1
- package/src/business/payment-allocator.tsx +110 -0
- package/src/business/product-search.ts +4 -0
- package/src/dialogs/dynamic-record.tsx +42 -6
- package/src/document-form-dialog.tsx +92 -12
- package/src/dropdown-menu-entry.test.ts +23 -0
- package/src/dynamic-columns-helpers.ts +22 -0
- package/src/dynamic-columns.tsx +23 -6
- package/src/dynamic-crud-page.tsx +22 -3
- package/src/dynamic-form-schema.ts +15 -2
- package/src/dynamic-form.tsx +120 -39
- package/src/dynamic-kanban.tsx +41 -10
- package/src/dynamic-line-items.tsx +3 -0
- package/src/dynamic-multi-select-field.tsx +2 -0
- package/src/dynamic-relation-helpers.ts +9 -0
- package/src/dynamic-relation.tsx +7 -2
- package/src/dynamic-select-field.tsx +4 -0
- package/src/index.ts +34 -1
- package/src/installed-addons-context.tsx +58 -0
- package/src/option-filter.ts +88 -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 +208 -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-prefill-bar.tsx +62 -0
- package/src/row-action-stage-gate.test.ts +33 -0
- package/src/types.ts +142 -0
- package/src/use-options-resolver.ts +19 -1
- package/vitest.config.ts +1 -0
- package/vitest.setup.ts +7 -0
|
@@ -0,0 +1,167 @@
|
|
|
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
|
+
overdueDays,
|
|
13
|
+
partyDefaults,
|
|
14
|
+
partySummaryRows,
|
|
15
|
+
splitEditorFields,
|
|
16
|
+
toOpenDocuments,
|
|
17
|
+
} from '../business/document-editor-model'
|
|
18
|
+
import { makeLine } from '../business/line-items'
|
|
19
|
+
import { allocatePayment, validateAllocation } from '../primitives/allocation'
|
|
20
|
+
import type { ActionFieldDef, DocumentFormOpenDocuments } from '../types'
|
|
21
|
+
|
|
22
|
+
describe('divulgación progresiva (splitEditorFields)', () => {
|
|
23
|
+
const fields: ActionFieldDef[] = [
|
|
24
|
+
{ key: 'customer_id', label: 'Cliente', type: 'dynamic_select', required: true },
|
|
25
|
+
{ key: 'payment_form', label: 'Forma de pago', type: 'select', required: true, options: [{ value: '03', label: '03 · Transferencia' }] },
|
|
26
|
+
{ key: 'method', label: 'Método', type: 'select', defaultValue: 'PUE', options: [{ value: 'PUE', label: 'PUE · Una exhibición' }] },
|
|
27
|
+
{ key: 'series', label: 'Serie', type: 'text', section: 'fiscal' },
|
|
28
|
+
{ key: 'notes', label: 'Notas', type: 'textarea' },
|
|
29
|
+
]
|
|
30
|
+
const ext: ActionFieldDef[] = [{ key: 'fiscal_data.uso_cfdi', label: 'Uso CFDI', type: 'select' }]
|
|
31
|
+
|
|
32
|
+
it('a la vista: contraparte y lo obligatorio; plegado: extensiones, section fiscal y catálogos con default', () => {
|
|
33
|
+
const g = splitEditorFields(fields, { partyField: 'customer_id', extensionFields: ext })
|
|
34
|
+
expect(g.party?.key).toBe('customer_id')
|
|
35
|
+
expect(g.essential.map((f) => f.key)).toEqual(['payment_form'])
|
|
36
|
+
expect(g.advanced.map((f) => f.key)).toEqual(['method', 'series', 'fiscal_data.uso_cfdi'])
|
|
37
|
+
expect(g.notes.map((f) => f.key)).toEqual(['notes'])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('essentialKeys gana (monto del cobro, campo de vínculo del origen)', () => {
|
|
41
|
+
const g = splitEditorFields(fields, { essentialKeys: ['method'] })
|
|
42
|
+
expect(g.essential.map((f) => f.key)).toContain('method')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('el código SAT queda como dato secundario', () => {
|
|
46
|
+
expect(friendlyOptionLabel('03 · Transferencia electrónica')).toBe('Transferencia electrónica · 03')
|
|
47
|
+
expect(friendlyOptionLabel('G03 · Gastos en general')).toBe('Gastos en general · G03')
|
|
48
|
+
expect(friendlyOptionLabel('PUE · Pago en una sola exhibición')).toBe('PUE · Pago en una sola exhibición')
|
|
49
|
+
expect(friendlyOptionLabel('Efectivo')).toBe('Efectivo')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('layout y renglones', () => {
|
|
54
|
+
it('isEditorLayout solo con layout: editor', () => {
|
|
55
|
+
expect(isEditorLayout({ layout: 'editor' })).toBe(true)
|
|
56
|
+
expect(isEditorLayout({ layout: 'wizard' })).toBe(false)
|
|
57
|
+
expect(isEditorLayout({})).toBe(false)
|
|
58
|
+
})
|
|
59
|
+
it('allocation usa `allocations` por defecto; el manifest manda', () => {
|
|
60
|
+
const base = { key: 'p', label: 'P', fields: [] }
|
|
61
|
+
expect(editorLinesConfig({ ...base, lines: { kind: 'allocation' } }, {})?.field).toBe('allocations')
|
|
62
|
+
expect(editorLinesConfig({ ...base, lines: true }, { lines_field: 'items' })?.field).toBe('items')
|
|
63
|
+
expect(editorLinesConfig(base, {})).toBeUndefined()
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('contraparte', () => {
|
|
68
|
+
const customer = { name: 'Llantas SA', currency_code: 'MXN', tax_id: 'XAXX010101000', fiscal_data: { uso_cfdi: 'G03', regimen: '601' } }
|
|
69
|
+
it('copia extensiones con la misma clave y no pisa lo capturado', () => {
|
|
70
|
+
const keys = ['customer_id', 'fiscal_data.uso_cfdi', 'fiscal_data.metodo_pago']
|
|
71
|
+
expect(partyDefaults(customer, keys, {})).toEqual({ 'fiscal_data.uso_cfdi': 'G03', currency_code: 'MXN' })
|
|
72
|
+
expect(partyDefaults(customer, keys, { 'fiscal_data.uso_cfdi': 'S01', currency_code: 'USD' })).toEqual({})
|
|
73
|
+
})
|
|
74
|
+
it('tarjeta: resumen + extensiones con etiqueta legible', () => {
|
|
75
|
+
const rows = partySummaryRows(customer, ['tax_id'], { 'fiscal_data.uso_cfdi': 'Uso CFDI' })
|
|
76
|
+
expect(rows.map((r) => r.key)).toEqual(['tax_id', 'fiscal_data.uso_cfdi', 'fiscal_data.regimen'])
|
|
77
|
+
expect(rows[1].label).toBe('Uso CFDI')
|
|
78
|
+
})
|
|
79
|
+
const cols = { limit: 'credit_limit', balance: 'balance_due', overdue: 'overdue_amount', hold_reason: 'credit_hold_reason' }
|
|
80
|
+
it('crédito: bloquea si el total supera lo disponible, avisa con vencido, null sin columnas', () => {
|
|
81
|
+
expect(creditStatus({ credit_limit: 10000, balance_due: 9000 }, cols, 2000)?.level).toBe('block')
|
|
82
|
+
expect(creditStatus({ credit_limit: 10000, balance_due: 100, overdue_amount: 50 }, cols, 100)?.level).toBe('warn')
|
|
83
|
+
expect(creditStatus({ name: 'x' }, cols, 1)).toBeNull()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('linesFromSource', () => {
|
|
88
|
+
it('SalesOrderItem: product_name → description y tasa derivada de tax/subtotal', () => {
|
|
89
|
+
const [l] = linesFromSource([{ product_id: 'p1', product_name: 'Llanta 205/55R16', quantity: 4, unit_price: 1500, subtotal: 6000, tax_amount: 960 }])
|
|
90
|
+
expect(l).toMatchObject({ description: 'Llanta 205/55R16', tax_rate: 0.16, quantity: 4, product_id: 'p1' })
|
|
91
|
+
})
|
|
92
|
+
it('map traduce nombres (discount_pct) y la tasa 16 se normaliza a 0.16', () => {
|
|
93
|
+
const [l] = linesFromSource([{ description: 'Balanceo', quantity: 1, unit_price: 100, discount_pct: 10, tax_rate: 16 }], { discount: 'discount_pct' })
|
|
94
|
+
expect(l.discount).toBe(10)
|
|
95
|
+
expect(l.tax_rate).toBe(0.16)
|
|
96
|
+
})
|
|
97
|
+
it('NC (credit): la cantidad queda topada en lo facturado', () => {
|
|
98
|
+
const [l] = linesFromSource([{ description: 'Llanta', quantity: 4, unit_price: 1500, tax_rate: 0.16 }], {}, { kind: 'credit' })
|
|
99
|
+
expect(l.max_quantity).toBe(4)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('localIssues', () => {
|
|
104
|
+
it('sin renglones es error; precio en cero es aviso; sin ruido por renglón libre', () => {
|
|
105
|
+
expect(localIssues([]).some((i) => i.severity === 'error')).toBe(true)
|
|
106
|
+
const w = localIssues([makeLine({ description: 'Servicio' })])
|
|
107
|
+
expect(w).toEqual([expect.objectContaining({ severity: 'warning', message: 'Renglón 1: precio en cero.' })])
|
|
108
|
+
expect(localIssues([], { requireLines: false })).toEqual([])
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('cobro / REP (allocation)', () => {
|
|
113
|
+
const cfg: DocumentFormOpenDocuments = {
|
|
114
|
+
model: 'customers.Invoice',
|
|
115
|
+
party_field: 'customer_id',
|
|
116
|
+
balance_field: 'amount_due',
|
|
117
|
+
number_field: 'number',
|
|
118
|
+
total_field: 'total',
|
|
119
|
+
due_field: 'due_date',
|
|
120
|
+
issued_field: 'issue_date',
|
|
121
|
+
method_field: 'fiscal_data.metodo_pago',
|
|
122
|
+
line_document_field: 'invoice_id',
|
|
123
|
+
line_amount_field: 'amount',
|
|
124
|
+
option_filter: [{ field: 'status', not_in: ['cancelled'] }],
|
|
125
|
+
}
|
|
126
|
+
const rows = [
|
|
127
|
+
{ 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' } },
|
|
128
|
+
{ id: 'b', number: 'A-1', total: 500, amount_due: 300, issue_date: '2026-08-01', due_date: '2026-09-01', status: 'open' },
|
|
129
|
+
{ id: 'c', number: 'A-0', total: 800, amount_due: 0, issue_date: '2026-07-01', status: 'paid' },
|
|
130
|
+
{ id: 'd', number: 'A-9', total: 900, amount_due: 900, issue_date: '2026-07-01', status: 'cancelled' },
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
it('toOpenDocuments: solo con saldo y que pasan el option_filter', () => {
|
|
134
|
+
const docs = toOpenDocuments(rows, cfg, 'MXN')
|
|
135
|
+
expect(docs.map((d) => d.id)).toEqual(['a', 'b'])
|
|
136
|
+
expect(docs[0]).toMatchObject({ number: 'A-2', balance: 1000, total: 1000, due_at: '2026-10-01', payment_method: 'PPD', currency: 'MXN' })
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('reparte del más vencido y arma el payload con los campos de la acción', () => {
|
|
140
|
+
const docs = toOpenDocuments(rows, cfg, 'MXN')
|
|
141
|
+
const r = allocatePayment(800, docs)
|
|
142
|
+
expect(r.allocations.map((a) => [a.document_id, a.amount])).toEqual([['b', 300], ['a', 500]])
|
|
143
|
+
expect(allocationPayload(r.allocations, cfg)).toEqual([
|
|
144
|
+
{ invoice_id: 'b', amount: 300 },
|
|
145
|
+
{ invoice_id: 'a', amount: 500 },
|
|
146
|
+
])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('mensajes claros con el número del documento', () => {
|
|
150
|
+
const docs = toOpenDocuments(rows, cfg, 'MXN')
|
|
151
|
+
const issues = validateAllocation(100, 'MXN', docs, [{ document_id: 'b', amount: 400, balance_before: 300, balance_after: 0, installment: 1 }])
|
|
152
|
+
const msgs = allocationIssueMessages(issues, docs).map((m) => m.message)
|
|
153
|
+
expect(msgs).toContain('A-1: Se aplica más que el saldo del documento.')
|
|
154
|
+
expect(msgs).toContain('Lo aplicado supera el monto recibido.')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('campo del monto: `amount` o el primer numérico', () => {
|
|
158
|
+
expect(allocationAmountField([{ key: 'x', label: '', type: 'text' }, { key: 'amount', label: '', type: 'number' }])).toBe('amount')
|
|
159
|
+
expect(allocationAmountField([{ key: 'total_paid', label: '', type: 'number' }])).toBe('total_paid')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('días de atraso', () => {
|
|
163
|
+
expect(overdueDays({ due_at: '2026-10-01' }, new Date(2026, 9, 6))).toBe(5)
|
|
164
|
+
expect(overdueDays({ due_at: '2026-10-30' }, new Date(2026, 9, 6))).toBe(0)
|
|
165
|
+
expect(overdueDays({ due_at: null })).toBe(0)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
@@ -0,0 +1,261 @@
|
|
|
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 }] }} />
|
|
256
|
+
</ApiProvider>,
|
|
257
|
+
)
|
|
258
|
+
expect(document.querySelector('[data-slot="document-editor"]')).toBeNull()
|
|
259
|
+
expect(screen.getByRole('button', { name: 'Siguiente' })).toBeTruthy()
|
|
260
|
+
})
|
|
261
|
+
})
|
|
@@ -12,10 +12,11 @@ vi.mock('react-i18next', () => {
|
|
|
12
12
|
return { useTranslation: () => value }
|
|
13
13
|
})
|
|
14
14
|
|
|
15
|
-
import { DocumentFormDialog, resolveDocumentForms } from '../document-form-dialog'
|
|
15
|
+
import { DocumentFormDialog, resolveDocumentForms, scopeDocumentFormsToFilter } from '../document-form-dialog'
|
|
16
16
|
import { DynamicCRUDPage } from '../dynamic-crud-page'
|
|
17
17
|
import { useMetadataCache } from '../metadata-cache'
|
|
18
18
|
import { ApiProvider, type ApiClient } from '../api-context'
|
|
19
|
+
import { OrgRuntimeProvider } from '../org-runtime-provider'
|
|
19
20
|
import type { DocumentFormsManifest } from '../types'
|
|
20
21
|
|
|
21
22
|
afterEach(cleanup)
|
|
@@ -128,6 +129,84 @@ describe('DocumentFormDialog', () => {
|
|
|
128
129
|
})
|
|
129
130
|
})
|
|
130
131
|
|
|
132
|
+
// Retest Pitsline 05/10: Facturas → Crear guardaba la factura en $0 porque el
|
|
133
|
+
// host (DynamicCRUDPage / ops) no pasa `searchProducts` y el paso de renglones
|
|
134
|
+
// solo ofrecía «Renglón libre» sin precio.
|
|
135
|
+
describe('DocumentFormDialog: buscador de catálogo por defecto', () => {
|
|
136
|
+
const invoiceOnly: DocumentFormsManifest = { ...forms, types: [forms.types[0]] }
|
|
137
|
+
const catalogRow = {
|
|
138
|
+
id: 'prod-1',
|
|
139
|
+
name: 'Llanta 205/55R16',
|
|
140
|
+
sku: 'LL-205',
|
|
141
|
+
unit_price: '1500.00',
|
|
142
|
+
cost_price: '900',
|
|
143
|
+
unit_of_measure: 'pza',
|
|
144
|
+
product_type: 'product',
|
|
145
|
+
fiscal_data: { mx_clave_prod_serv: '25172504', mx_clave_unidad: 'H87' },
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function mount(opts: { searchProducts?: any; taxRate?: number } = {}) {
|
|
149
|
+
const get = vi.fn().mockResolvedValue({ data: { success: true, data: [catalogRow] } })
|
|
150
|
+
const post = vi.fn().mockResolvedValue({ data: { success: true, data: { id: 'inv-1' } } })
|
|
151
|
+
const client = { get, post, put: vi.fn(), delete: vi.fn() } as unknown as ApiClient
|
|
152
|
+
render(
|
|
153
|
+
<ApiProvider client={client}>
|
|
154
|
+
<OrgRuntimeProvider taxRate={opts.taxRate}>
|
|
155
|
+
<DocumentFormDialog
|
|
156
|
+
open
|
|
157
|
+
onOpenChange={vi.fn()}
|
|
158
|
+
model="customers.Invoice"
|
|
159
|
+
forms={invoiceOnly}
|
|
160
|
+
searchProducts={opts.searchProducts}
|
|
161
|
+
/>
|
|
162
|
+
</OrgRuntimeProvider>
|
|
163
|
+
</ApiProvider>,
|
|
164
|
+
)
|
|
165
|
+
return { get, post }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function pickFirstProduct(query: string, name: RegExp) {
|
|
169
|
+
fireEvent.change(document.querySelectorAll('input')[0], { target: { value: 'ACME' } })
|
|
170
|
+
fireEvent.click(screen.getByRole('button', { name: 'Siguiente' }))
|
|
171
|
+
fireEvent.change(await screen.findByLabelText('Buscar producto'), { target: { value: query } })
|
|
172
|
+
fireEvent.click(await waitFor(() => screen.getByRole('button', { name }), { timeout: 2000 }))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
it('sin searchProducts del host busca en el catálogo y el renglón entra con precio, cantidad 1, unidad, SKU e IVA de la org', async () => {
|
|
176
|
+
const { get, post } = mount({ taxRate: 0.16 })
|
|
177
|
+
await pickFirstProduct('llanta', /Llanta 205\/55R16/)
|
|
178
|
+
expect(get).toHaveBeenCalledWith(
|
|
179
|
+
'/data/products.Product',
|
|
180
|
+
expect.objectContaining({ params: expect.objectContaining({ search: 'llanta' }) }),
|
|
181
|
+
)
|
|
182
|
+
fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
|
|
183
|
+
await waitFor(() => expect(post).toHaveBeenCalled())
|
|
184
|
+
const [line] = (post.mock.calls[0][1] as any).lines
|
|
185
|
+
expect(line).toMatchObject({
|
|
186
|
+
product_id: 'prod-1',
|
|
187
|
+
sku: 'LL-205',
|
|
188
|
+
description: 'Llanta 205/55R16',
|
|
189
|
+
quantity: 1,
|
|
190
|
+
unit_price: 1500,
|
|
191
|
+
tax_rate: 0.16,
|
|
192
|
+
unit: 'pza',
|
|
193
|
+
fiscal_data: { mx_clave_prod_serv: '25172504', mx_clave_unidad: 'H87' },
|
|
194
|
+
})
|
|
195
|
+
expect(line.unit_price).toBeGreaterThan(0)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('si el host pasa searchProducts, se usa ese y no el catálogo', async () => {
|
|
199
|
+
const searchProducts = vi.fn(async () => [{ id: 'h1', name: 'Servicio host', price: 250, tax_rate: 0.08 }])
|
|
200
|
+
const { get, post } = mount({ searchProducts })
|
|
201
|
+
await pickFirstProduct('serv', /Servicio host/)
|
|
202
|
+
expect(searchProducts).toHaveBeenCalled()
|
|
203
|
+
expect(get).not.toHaveBeenCalledWith('/data/products.Product', expect.anything())
|
|
204
|
+
fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
|
|
205
|
+
await waitFor(() => expect(post).toHaveBeenCalled())
|
|
206
|
+
expect((post.mock.calls[0][1] as any).lines[0]).toMatchObject({ product_id: 'h1', quantity: 1, unit_price: 250, tax_rate: 0.08 })
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
131
210
|
describe('resolveDocumentForms', () => {
|
|
132
211
|
it('sin manifest (o sin tipos) devuelve undefined: el alta genérica sigue igual', () => {
|
|
133
212
|
expect(resolveDocumentForms(null)).toBeUndefined()
|
|
@@ -157,7 +236,7 @@ describe('DynamicCRUDPage + document_forms', () => {
|
|
|
157
236
|
...extra,
|
|
158
237
|
}) as any
|
|
159
238
|
|
|
160
|
-
const mount = (m: any) => {
|
|
239
|
+
const mount = (m: any, filter?: Record<string, string>) => {
|
|
161
240
|
useMetadataCache.getState().setMetadata('fiscal_documents', m)
|
|
162
241
|
const client = {
|
|
163
242
|
get: vi.fn(async (url: string) =>
|
|
@@ -169,7 +248,7 @@ describe('DynamicCRUDPage + document_forms', () => {
|
|
|
169
248
|
} as unknown as ApiClient
|
|
170
249
|
render(
|
|
171
250
|
<ApiProvider client={client}>
|
|
172
|
-
<DynamicCRUDPage model="fiscal_documents" />
|
|
251
|
+
<DynamicCRUDPage model="fiscal_documents" filter={filter} />
|
|
173
252
|
</ApiProvider>,
|
|
174
253
|
)
|
|
175
254
|
}
|
|
@@ -187,4 +266,40 @@ describe('DynamicCRUDPage + document_forms', () => {
|
|
|
187
266
|
await new Promise((r) => setTimeout(r, 50))
|
|
188
267
|
expect(screen.queryByRole('radiogroup')).toBeNull()
|
|
189
268
|
})
|
|
269
|
+
|
|
270
|
+
// Retest r3 (05/10): «Documentos fiscales → Facturas» (nav filtrado por el
|
|
271
|
+
// type_field) ofrecía Crear y abría el REP, el único tipo declarado.
|
|
272
|
+
it('vista filtrada a un tipo sin formulario: sin botón Crear', async () => {
|
|
273
|
+
mount(meta({ document_forms: forms }), { type: 'P' })
|
|
274
|
+
await screen.findByText(/Nota/)
|
|
275
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
276
|
+
expect(screen.queryByText(/New Nota/)).toBeNull()
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('vista filtrada a un tipo con formulario: Crear abre ese tipo directo', async () => {
|
|
280
|
+
mount(meta({ document_forms: forms }), { type: 'eq:E' })
|
|
281
|
+
fireEvent.click((await screen.findByText(/New Nota/)).closest('button')!)
|
|
282
|
+
expect(await screen.findByText('Factura origen')).toBeTruthy()
|
|
283
|
+
expect(screen.queryByRole('radiogroup')).toBeNull()
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
describe('scopeDocumentFormsToFilter', () => {
|
|
288
|
+
it('sin filtro sobre el type_field no cambia nada', () => {
|
|
289
|
+
expect(scopeDocumentFormsToFilter(forms, undefined)).toEqual({ forms, hideCreate: false })
|
|
290
|
+
expect(scopeDocumentFormsToFilter(forms, { status: 'draft' })).toEqual({ forms, hideCreate: false })
|
|
291
|
+
expect(scopeDocumentFormsToFilter(undefined, { type: 'I' })).toEqual({ forms: undefined, hideCreate: false })
|
|
292
|
+
expect(scopeDocumentFormsToFilter({ ...forms, type_field: undefined }, { type: 'X' }).hideCreate).toBe(false)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('acota al tipo del filtro (valor o key, sin importar mayúsculas ni eq:)', () => {
|
|
296
|
+
expect(scopeDocumentFormsToFilter(forms, { type: 'e' }).forms?.types.map((x) => x.key)).toEqual(['credit_note'])
|
|
297
|
+
expect(scopeDocumentFormsToFilter(forms, { type: 'eq:I' }).forms?.types.map((x) => x.key)).toEqual(['invoice'])
|
|
298
|
+
const noValue: DocumentFormsManifest = { type_field: 'document_type', types: [{ key: 'payment_complement', label: 'REP', fields: [] }] }
|
|
299
|
+
expect(scopeDocumentFormsToFilter(noValue, { document_type: 'payment_complement' }).forms).toBe(noValue)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('filtro a un tipo sin formulario: oculta Crear', () => {
|
|
303
|
+
expect(scopeDocumentFormsToFilter(forms, { type: 'P' })).toEqual({ forms: undefined, hideCreate: true })
|
|
304
|
+
})
|
|
190
305
|
})
|