@asteby/metacore-runtime-react 49.0.0 → 49.2.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 +45 -0
- package/dist/action-modal-dispatcher.d.ts +18 -1
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +66 -11
- package/dist/business/document-editor-model.d.ts +6 -1
- package/dist/business/document-editor-model.d.ts.map +1 -1
- package/dist/business/document-editor-model.js +48 -7
- package/dist/business/document-editor-panels.d.ts +18 -0
- package/dist/business/document-editor-panels.d.ts.map +1 -1
- package/dist/business/document-editor-panels.js +9 -0
- package/dist/business/document-editor.d.ts +7 -1
- package/dist/business/document-editor.d.ts.map +1 -1
- package/dist/business/document-editor.js +72 -26
- package/dist/calendar-date.d.ts +12 -0
- package/dist/calendar-date.d.ts.map +1 -1
- package/dist/calendar-date.js +36 -0
- package/dist/document-form-dialog.d.ts +23 -2
- package/dist/document-form-dialog.d.ts.map +1 -1
- package/dist/document-form-dialog.js +49 -8
- package/dist/dynamic-crud-page.d.ts +7 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +5 -4
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/record-mutation-events.d.ts +12 -0
- package/dist/record-mutation-events.d.ts.map +1 -1
- package/dist/record-mutation-events.js +18 -0
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/document-editor-model.test.ts +27 -0
- package/src/__tests__/document-editor.test.tsx +60 -0
- package/src/__tests__/document-form-create-model.test.tsx +143 -0
- package/src/__tests__/field-default-today.test.tsx +116 -0
- package/src/__tests__/invoice-document-editor.test.tsx +267 -0
- package/src/__tests__/r5-self-label-settled.test.ts +57 -0
- package/src/action-modal-dispatcher.tsx +78 -11
- package/src/business/document-editor-model.ts +50 -7
- package/src/business/document-editor-panels.tsx +49 -0
- package/src/business/document-editor.tsx +249 -166
- package/src/calendar-date.ts +36 -0
- package/src/document-form-dialog.tsx +72 -8
- package/src/dynamic-crud-page.tsx +20 -2
- package/src/index.ts +8 -0
- package/src/record-mutation-events.ts +23 -0
- package/src/types.ts +7 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// Facturas → Crear (P0 UX Acxel, 2026-10-06): el tipo factura con
|
|
3
|
+
// `layout: "editor"` abre el DocumentEditor a pantalla completa, no el wizard
|
|
4
|
+
// «Datos → Siguiente». A la vista solo cliente (con su tarjeta fiscal), método
|
|
5
|
+
// y forma de pago, renglones y totales; lo demás en «Opciones fiscales»
|
|
6
|
+
// plegada, una sola acción primaria y la vista previa plegada. «Cargar desde»
|
|
7
|
+
// OT de taller y cotización precarga lo que queda por facturar.
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
9
|
+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
10
|
+
|
|
11
|
+
vi.mock('@tanstack/react-router', () => ({ useNavigate: () => () => {} }))
|
|
12
|
+
vi.mock('react-i18next', () => {
|
|
13
|
+
const i18n = { language: 'es' }
|
|
14
|
+
const dict: Record<string, string> = { 'customers.field.tax_id': 'RFC' }
|
|
15
|
+
const t = (k: string, o?: Record<string, any>) => {
|
|
16
|
+
let s: string = dict[k] ?? o?.defaultValue ?? k
|
|
17
|
+
for (const [key, v] of Object.entries(o ?? {})) s = s.replace(`{{${key}}}`, String(v))
|
|
18
|
+
return s
|
|
19
|
+
}
|
|
20
|
+
const value = { t, i18n }
|
|
21
|
+
return { useTranslation: () => value }
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
import { DocumentFormDialog } from '../document-form-dialog'
|
|
25
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
26
|
+
import { TaxRateContext } from '../org-runtime-context'
|
|
27
|
+
import { linesFromSource, splitEditorFields } from '../business/document-editor-model'
|
|
28
|
+
import type { DocumentFormsManifest, DocumentFormType } from '../types'
|
|
29
|
+
|
|
30
|
+
afterEach(cleanup)
|
|
31
|
+
|
|
32
|
+
// Espejo del tipo `invoice` de customers.Invoice (addons, manifest.json).
|
|
33
|
+
const invoice: DocumentFormType = {
|
|
34
|
+
key: 'invoice',
|
|
35
|
+
label: 'Factura',
|
|
36
|
+
value: 'I',
|
|
37
|
+
layout: 'editor',
|
|
38
|
+
party: {
|
|
39
|
+
field: 'customer_id',
|
|
40
|
+
model: 'customers.Customer',
|
|
41
|
+
summary: ['tax_id', 'fiscal_data.regimen_receptor', 'fiscal_data.codigo_postal', 'fiscal_data.uso_cfdi'],
|
|
42
|
+
credit: { limit: 'credit_limit', balance: 'balance_due', overdue: 'overdue_amount', hold_reason: 'credit_hold_reason' },
|
|
43
|
+
},
|
|
44
|
+
fields: [
|
|
45
|
+
{ key: 'customer_id', label: 'Cliente', type: 'text', required: true },
|
|
46
|
+
{ key: 'invoice_date', label: 'Fecha de emisión', type: 'date', required: true, defaultValue: '$today' },
|
|
47
|
+
{ key: 'due_date', label: 'Vencimiento', type: 'date' },
|
|
48
|
+
{ key: 'currency_code', label: 'Moneda', type: 'select', defaultValue: 'MXN', options: [{ value: 'MXN', label: 'Peso mexicano · MXN' }, { value: 'USD', label: 'Dólar · USD' }] },
|
|
49
|
+
{ key: 'fiscal_data.metodo_pago', label: 'Método de pago', type: 'select', required: true, defaultValue: 'PUE', options: [{ value: 'PUE', label: 'Pago en una sola exhibición · PUE' }, { value: 'PPD', label: 'Pago en parcialidades o diferido · PPD' }] },
|
|
50
|
+
{ key: 'fiscal_data.forma_pago', label: 'Forma de pago', type: 'select', required: true, defaultValue: '01', options: [{ value: '01', label: 'Efectivo · 01' }, { value: '03', label: 'Transferencia electrónica · 03' }] },
|
|
51
|
+
{ key: 'notes', label: 'Notas', type: 'textarea' },
|
|
52
|
+
],
|
|
53
|
+
lines: { columns: ['discount', 'tax'], discount_mode: 'amount', title: 'Renglones' },
|
|
54
|
+
sources: [
|
|
55
|
+
{ key: 'sales_order', label: 'Venta', model: 'customers.SalesOrder', lines: 'items', map: { description: 'product_name' }, header: { customer_id: 'customer_id' }, link_field: 'sales_order_id', line_link_field: 'sales_order_item_id', exclude_states: ['cancelled', 'void'] },
|
|
56
|
+
{ key: 'work_order', label: 'Orden de trabajo', model: 'workshop.WorkOrder', lines: 'items', header: { customer_id: 'customer_id' }, link_field: 'work_order_id', line_link_field: 'work_order_item_id', exclude_states: ['cancelled', 'void'] },
|
|
57
|
+
{ key: 'quote', label: 'Cotización', model: 'quotes.Quote', lines: 'items', map: { discount: 'discount_amount' }, header: { customer_id: 'customer_id', currency_code: 'currency_code' }, link_field: 'quote_id', line_link_field: 'quote_item_id', exclude_states: ['cancelled', 'void'] },
|
|
58
|
+
],
|
|
59
|
+
submit_label: 'Crear factura',
|
|
60
|
+
}
|
|
61
|
+
const forms: DocumentFormsManifest = { lines_field: 'items', types: [invoice] }
|
|
62
|
+
|
|
63
|
+
const customer = {
|
|
64
|
+
id: 'c-1',
|
|
65
|
+
name: 'Transportes del Bajío',
|
|
66
|
+
tax_id: 'TBA850312KJ9',
|
|
67
|
+
credit_limit: 100000,
|
|
68
|
+
balance_due: 15750,
|
|
69
|
+
fiscal_data: { regimen_receptor: '601', codigo_postal: '37000', uso_cfdi: 'G03' },
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Lo pendiente lo sirve el runtime (kernel source-lines): cantidad − lo ya facturado.
|
|
73
|
+
const workOrderLines = {
|
|
74
|
+
data: [
|
|
75
|
+
{ id: 'woi-1', source_line_id: 'woi-1', kind: 'labor', description: 'Alineación eje direccional', quantity: 1, remaining_quantity: 1, unit_price: 650 },
|
|
76
|
+
{ id: 'woi-2', source_line_id: 'woi-2', kind: 'part', product_id: 'p-valv', description: 'Válvula de alta presión', quantity: 6, remaining_quantity: 2, unit_price: 95 },
|
|
77
|
+
{ id: 'woi-3', source_line_id: 'woi-3', kind: 'labor', description: 'Montaje y balanceo', quantity: 6, remaining_quantity: 0, unit_price: 180 },
|
|
78
|
+
],
|
|
79
|
+
}
|
|
80
|
+
const quoteLines = {
|
|
81
|
+
data: [
|
|
82
|
+
{ id: 'qi-1', source_line_id: 'qi-1', product_id: 'p-llanta', description: 'Llanta 295/80R22.5', quantity: 6, remaining_quantity: 4, unit_price: 5890, discount_amount: 1200, tax_rate: 0.16, tax_amount: 5462.4, subtotal: 34140 },
|
|
83
|
+
],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function makeApi(sourceLines: Record<string, unknown> = {}, post = vi.fn().mockResolvedValue({ data: { success: true, data: { id: 'inv-1' } } })) {
|
|
87
|
+
const get = vi.fn(async (url: string, cfg?: { params?: Record<string, any> }) => {
|
|
88
|
+
if (url === '/metadata/modal/customers.Customer') {
|
|
89
|
+
return { data: { data: { fields: [{ key: 'tax_id', label: 'customers.field.tax_id' }, { key: 'fiscal_data.regimen_receptor', label: 'Régimen fiscal' }, { key: 'fiscal_data.codigo_postal', label: 'CP fiscal' }, { key: 'fiscal_data.uso_cfdi', label: 'Uso CFDI' }] } } }
|
|
90
|
+
}
|
|
91
|
+
if (url === '/data/customers.Customer') return { data: { data: [customer] } }
|
|
92
|
+
if (url.endsWith('/source-lines')) return { data: sourceLines[cfg?.params?.source] ?? { data: [] } }
|
|
93
|
+
if (url === '/data/workshop.WorkOrder') return { data: { data: [{ id: 'wo-1', customer_id: 'c-1' }] } }
|
|
94
|
+
if (url === '/data/quotes.Quote') return { data: { data: [{ id: 'q-1', customer_id: 'c-1', currency_code: 'MXN' }] } }
|
|
95
|
+
return { data: { data: [] } }
|
|
96
|
+
})
|
|
97
|
+
const client = { get, post, put: vi.fn().mockResolvedValue({ data: { success: true, data: { id: 'inv-1' } } }), delete: vi.fn() }
|
|
98
|
+
return { client: client as unknown as ApiClient, get, post }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function renderInvoice(api: ReturnType<typeof makeApi>, initialSource?: { key: string; id: string }) {
|
|
102
|
+
return render(
|
|
103
|
+
<ApiProvider client={api.client}>
|
|
104
|
+
<TaxRateContext.Provider value={0.16}>
|
|
105
|
+
<DocumentFormDialog
|
|
106
|
+
open
|
|
107
|
+
onOpenChange={() => {}}
|
|
108
|
+
model="customers.Invoice"
|
|
109
|
+
forms={forms}
|
|
110
|
+
initialType={initialSource ? 'invoice' : undefined}
|
|
111
|
+
initialSource={initialSource}
|
|
112
|
+
currency="MXN"
|
|
113
|
+
/>
|
|
114
|
+
</TaxRateContext.Provider>
|
|
115
|
+
</ApiProvider>,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const slot = (name: string) => document.querySelector(`[data-slot="${name}"]`) as HTMLElement
|
|
120
|
+
|
|
121
|
+
describe('Factura con layout editor — pantalla única', () => {
|
|
122
|
+
it('abre el DocumentEditor a pantalla completa, no el wizard', () => {
|
|
123
|
+
renderInvoice(makeApi())
|
|
124
|
+
const dialog = slot('document-editor-dialog')
|
|
125
|
+
expect(dialog.getAttribute('data-fullscreen')).toBe('true')
|
|
126
|
+
expect(dialog.style.height).toBe('calc(100dvh - 2rem)')
|
|
127
|
+
expect(slot('document-editor').getAttribute('data-layout')).toBe('fullscreen')
|
|
128
|
+
expect(screen.queryByRole('button', { name: 'Siguiente' })).toBeNull()
|
|
129
|
+
expect(document.querySelector('[data-slot="document-form-dialog"]')).toBeNull()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('secciones: lo esencial a la vista, «Opciones fiscales» plegada, totales y vista previa al lado', () => {
|
|
133
|
+
renderInvoice(makeApi())
|
|
134
|
+
const main = slot('editor-main')
|
|
135
|
+
const essentials = within(slot('editor-essentials'))
|
|
136
|
+
for (const label of ['Cliente', 'Fecha de emisión', 'Vencimiento', 'Método de pago', 'Forma de pago']) {
|
|
137
|
+
expect(essentials.getByText(label)).toBeTruthy()
|
|
138
|
+
}
|
|
139
|
+
// Etiqueta clara con el código SAT como dato secundario.
|
|
140
|
+
expect(screen.getAllByText('Pago en una sola exhibición · PUE').length).toBeGreaterThan(0)
|
|
141
|
+
expect(within(main).getByText('Cargar desde…')).toBeTruthy()
|
|
142
|
+
expect(within(main).getByText('Renglones')).toBeTruthy()
|
|
143
|
+
|
|
144
|
+
const toggle = screen.getByRole('button', { name: /Opciones fiscales/ })
|
|
145
|
+
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
|
146
|
+
const panel = document.getElementById(toggle.getAttribute('aria-controls')!)!
|
|
147
|
+
expect(panel.hidden).toBe(true)
|
|
148
|
+
expect(within(panel).getByText('Moneda')).toBeTruthy()
|
|
149
|
+
expect(essentials.queryByText('Moneda')).toBeNull()
|
|
150
|
+
|
|
151
|
+
const aside = within(slot('editor-aside'))
|
|
152
|
+
const totals = slot('editor-aside').querySelector(':scope > div > [data-slot="totals"]') as HTMLElement
|
|
153
|
+
expect(within(totals).getByText('Total')).toBeTruthy()
|
|
154
|
+
const preview = aside.getByRole('button', { name: /Vista previa/ })
|
|
155
|
+
expect(preview.getAttribute('aria-expanded')).toBe('false')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('una sola acción primaria: «Crear factura»', () => {
|
|
159
|
+
renderInvoice(makeApi())
|
|
160
|
+
const primaries = document.querySelectorAll('[data-slot="document-editor"] [data-primary="true"]')
|
|
161
|
+
expect(primaries.length).toBe(1)
|
|
162
|
+
expect(primaries[0].textContent).toBe('Crear factura')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('«Cargar desde» ofrece venta, OT y cotización como opciones', () => {
|
|
166
|
+
renderInvoice(makeApi())
|
|
167
|
+
const group = screen.getByRole('radiogroup', { name: 'Tipo de documento origen' })
|
|
168
|
+
expect(within(group).getAllByRole('radio').map((r) => r.textContent)).toEqual(['Venta', 'Orden de trabajo', 'Cotización'])
|
|
169
|
+
fireEvent.click(within(group).getByRole('radio', { name: 'Orden de trabajo' }))
|
|
170
|
+
expect(within(group).getByRole('radio', { name: 'Orden de trabajo' }).getAttribute('aria-checked')).toBe('true')
|
|
171
|
+
expect(document.querySelector('[data-source="work_order"]')).toBeTruthy()
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('Factura — «Cargar desde» OT y cotización con lo restante', () => {
|
|
176
|
+
it('OT: cliente, renglones con lo pendiente (omite lo ya facturado), IVA de la org y vínculos', async () => {
|
|
177
|
+
const api = makeApi({ work_order: workOrderLines })
|
|
178
|
+
renderInvoice(api, { key: 'work_order', id: 'wo-1' })
|
|
179
|
+
await screen.findByDisplayValue('Válvula de alta presión')
|
|
180
|
+
expect(screen.getByDisplayValue('Alineación eje direccional')).toBeTruthy()
|
|
181
|
+
expect(screen.queryByDisplayValue('Montaje y balanceo')).toBeNull()
|
|
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 pendiente · 1 ya cubiertos se omitieron')).toBeTruthy()
|
|
184
|
+
|
|
185
|
+
// La tarjeta del cliente: datos fiscales con etiquetas del modelo.
|
|
186
|
+
await waitFor(() => expect(slot('party-card')).toBeTruthy())
|
|
187
|
+
const card = slot('party-card')
|
|
188
|
+
expect(within(card).getByText('Transportes del Bajío')).toBeTruthy()
|
|
189
|
+
expect(within(card).getByText('RFC')).toBeTruthy()
|
|
190
|
+
expect(within(card).getByText('TBA850312KJ9')).toBeTruthy()
|
|
191
|
+
expect(within(card).getByText('Uso CFDI')).toBeTruthy()
|
|
192
|
+
expect(within(card).getByText('G03')).toBeTruthy()
|
|
193
|
+
|
|
194
|
+
fireEvent.click(screen.getByRole('button', { name: 'Crear factura' }))
|
|
195
|
+
await waitFor(() => expect(api.post).toHaveBeenCalled())
|
|
196
|
+
const [url, body] = api.post.mock.calls[0] as [string, any]
|
|
197
|
+
expect(url).toBe('/dynamic/customers.Invoice')
|
|
198
|
+
expect(body.customer_id).toBe('c-1')
|
|
199
|
+
expect(body.work_order_id).toBe('wo-1')
|
|
200
|
+
expect(body['fiscal_data.metodo_pago']).toBe('PUE')
|
|
201
|
+
expect(body['fiscal_data.forma_pago']).toBe('01')
|
|
202
|
+
expect(body.items).toEqual([
|
|
203
|
+
expect.objectContaining({ description: 'Alineación eje direccional', quantity: 1, unit_price: 650, tax_rate: 0.16, work_order_item_id: 'woi-1' }),
|
|
204
|
+
expect.objectContaining({ product_id: 'p-valv', quantity: 2, unit_price: 95, tax_rate: 0.16, work_order_item_id: 'woi-2' }),
|
|
205
|
+
])
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('cotización: lo pendiente, su IVA y descuento, moneda y vínculo', async () => {
|
|
209
|
+
const api = makeApi({ quote: quoteLines })
|
|
210
|
+
renderInvoice(api, { key: 'quote', id: 'q-1' })
|
|
211
|
+
await screen.findByDisplayValue('Llanta 295/80R22.5')
|
|
212
|
+
fireEvent.click(screen.getByRole('button', { name: 'Crear factura' }))
|
|
213
|
+
await waitFor(() => expect(api.post).toHaveBeenCalled())
|
|
214
|
+
const [, body] = api.post.mock.calls[0] as [string, any]
|
|
215
|
+
expect(body.quote_id).toBe('q-1')
|
|
216
|
+
expect(body.currency_code).toBe('MXN')
|
|
217
|
+
expect(body.items).toEqual([
|
|
218
|
+
expect.objectContaining({ product_id: 'p-llanta', quantity: 4, unit_price: 5890, discount: 1200, tax_rate: 0.16, quote_item_id: 'qi-1' }),
|
|
219
|
+
])
|
|
220
|
+
expect(body.items[0]).not.toHaveProperty('work_order_item_id')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('exceso: la cantidad no pasa de lo pendiente y el rechazo del servidor se muestra inline', async () => {
|
|
224
|
+
const post = vi.fn().mockRejectedValue({
|
|
225
|
+
response: {
|
|
226
|
+
status: 422,
|
|
227
|
+
data: {
|
|
228
|
+
success: false,
|
|
229
|
+
message: 'validation failed',
|
|
230
|
+
errors: { 'items.1.quantity': [{ code: 'exceeds_remaining', message: 'Renglón 2: la cantidad 2 excede lo pendiente de «Orden de trabajo» (1). Ajusta la cantidad; lo demás ya está registrado en otro documento.' }] },
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
})
|
|
234
|
+
const api = makeApi({ work_order: workOrderLines }, post)
|
|
235
|
+
renderInvoice(api, { key: 'work_order', id: 'wo-1' })
|
|
236
|
+
await screen.findByDisplayValue('Válvula de alta presión')
|
|
237
|
+
// El editor topa la cantidad en lo pendiente (2 de 6).
|
|
238
|
+
const qty = screen.getAllByLabelText('Cant.').filter((el) => el.tagName === 'INPUT')[1] as HTMLInputElement
|
|
239
|
+
fireEvent.change(qty, { target: { value: '5' } })
|
|
240
|
+
expect(qty.value).toBe('2')
|
|
241
|
+
// Si otro documento consumió lo pendiente mientras tanto, el servidor rechaza.
|
|
242
|
+
fireEvent.click(screen.getByRole('button', { name: 'Crear factura' }))
|
|
243
|
+
await waitFor(() => expect(post).toHaveBeenCalled())
|
|
244
|
+
expect(await screen.findByText(/excede lo pendiente de «Orden de trabajo»/)).toBeTruthy()
|
|
245
|
+
expect(slot('document-editor-dialog')).toBeTruthy()
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
describe('reglas puras del editor de factura', () => {
|
|
250
|
+
it('lo obligatorio que el tipo declara queda a la vista aunque sea fiscal_data.*; las extensiones del metadata se pliegan', () => {
|
|
251
|
+
const g = splitEditorFields(invoice.fields, {
|
|
252
|
+
partyField: 'customer_id',
|
|
253
|
+
extensionFields: [{ key: 'fiscal_data.uso_cfdi', label: 'Uso CFDI', type: 'select', required: true, options: [{ value: 'G03', label: 'G03' }] }],
|
|
254
|
+
})
|
|
255
|
+
expect(g.party?.key).toBe('customer_id')
|
|
256
|
+
expect(g.essential.map((f) => f.key)).toEqual(['invoice_date', 'due_date', 'fiscal_data.metodo_pago', 'fiscal_data.forma_pago'])
|
|
257
|
+
expect(g.advanced.map((f) => f.key)).toEqual(['currency_code', 'fiscal_data.uso_cfdi'])
|
|
258
|
+
expect(g.notes.map((f) => f.key)).toEqual(['notes'])
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('un origen sin dato de impuesto toma la tasa de la org; uno con impuesto conserva el suyo', () => {
|
|
262
|
+
const [wo] = linesFromSource([{ id: 'a', description: 'Mano de obra', quantity: 1, unit_price: 100 }], {}, { defaultTaxRate: 0.16 })
|
|
263
|
+
expect(wo.tax_rate).toBe(0.16)
|
|
264
|
+
const [exempt] = linesFromSource([{ id: 'b', description: 'Exento', quantity: 1, unit_price: 100, subtotal: 100, tax_amount: 0 }], {}, { defaultTaxRate: 0.16 })
|
|
265
|
+
expect(exempt.tax_rate).toBe(0)
|
|
266
|
+
})
|
|
267
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// Retest Pitsline r5: «Registrar pago» desde la factura mostraba el UUID en
|
|
3
|
+
// «Factura a abonar», y la lista / la pestaña REP no se refrescaban con lo que
|
|
4
|
+
// el servidor escribe después de la acción (estado tras timbrar, REP tras pagar).
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import { seedOptionFromRecord } from '../action-modal-dispatcher'
|
|
7
|
+
import { emitRecordMutationSettled, subscribeRecordMutations, RECORD_MUTATION_SETTLE_DELAYS } from '../record-mutation-events'
|
|
8
|
+
import type { ActionFieldDef } from '../types'
|
|
9
|
+
|
|
10
|
+
const invoiceField = {
|
|
11
|
+
key: 'invoice_id',
|
|
12
|
+
type: 'dynamic_select',
|
|
13
|
+
ref: 'Invoice',
|
|
14
|
+
options: { source: 'Invoice', value: 'id', label: 'number' },
|
|
15
|
+
} as unknown as ActionFieldDef
|
|
16
|
+
|
|
17
|
+
describe('seedOptionFromRecord — la propia fila', () => {
|
|
18
|
+
it('un selector sembrado con el id de la fila muestra su folio, no el UUID', () => {
|
|
19
|
+
const record = { id: '68a02222-855e-4af9-b086-31526f0dceb5', number: 'FAC-00015', total: 823.6 }
|
|
20
|
+
expect(seedOptionFromRecord(invoiceField, record.id, record)).toEqual(
|
|
21
|
+
expect.objectContaining({ id: record.id, value: record.id, label: 'FAC-00015' }),
|
|
22
|
+
)
|
|
23
|
+
})
|
|
24
|
+
it('sin options.label usa la primera columna de nombre', () => {
|
|
25
|
+
const field = { key: 'invoice_id', type: 'dynamic_select', ref: 'Invoice' } as unknown as ActionFieldDef
|
|
26
|
+
expect(seedOptionFromRecord(field, 'i1', { id: 'i1', folio: 'A-7' })?.label).toBe('A-7')
|
|
27
|
+
})
|
|
28
|
+
it('otro valor (no la propia fila) sigue resolviendo por el objeto hermano', () => {
|
|
29
|
+
const record = { id: 'pay-1', invoice: { value: 'i9', label: 'FAC-00009' } }
|
|
30
|
+
expect(seedOptionFromRecord(invoiceField, 'i9', record)?.label).toBe('FAC-00009')
|
|
31
|
+
expect(seedOptionFromRecord(invoiceField, 'i8', { id: 'pay-1' })).toBeUndefined()
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('emitRecordMutationSettled', () => {
|
|
36
|
+
afterEach(() => vi.useRealTimers())
|
|
37
|
+
it('anuncia ahora y otra vez cuando el servidor ya escribió lo derivado', () => {
|
|
38
|
+
vi.useFakeTimers()
|
|
39
|
+
const seen = vi.fn()
|
|
40
|
+
const off = subscribeRecordMutations('invoices', seen)
|
|
41
|
+
emitRecordMutationSettled('invoices', 'update')
|
|
42
|
+
expect(seen).toHaveBeenCalledTimes(1)
|
|
43
|
+
vi.advanceTimersByTime(RECORD_MUTATION_SETTLE_DELAYS[RECORD_MUTATION_SETTLE_DELAYS.length - 1])
|
|
44
|
+
expect(seen).toHaveBeenCalledTimes(1 + RECORD_MUTATION_SETTLE_DELAYS.length)
|
|
45
|
+
off()
|
|
46
|
+
})
|
|
47
|
+
it('se puede cancelar', () => {
|
|
48
|
+
vi.useFakeTimers()
|
|
49
|
+
const seen = vi.fn()
|
|
50
|
+
const off = subscribeRecordMutations('invoices', seen)
|
|
51
|
+
const cancel = emitRecordMutationSettled('invoices', 'update')
|
|
52
|
+
cancel()
|
|
53
|
+
vi.advanceTimersByTime(10_000)
|
|
54
|
+
expect(seen).toHaveBeenCalledTimes(1)
|
|
55
|
+
off()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
//
|
|
15
15
|
// The host injects its axios-like client via <ApiProvider>; we no longer
|
|
16
16
|
// depend on a bundler alias to `@/lib/api`.
|
|
17
|
-
import {
|
|
17
|
+
import { emitRecordMutationSettled } from './record-mutation-events'
|
|
18
18
|
import { Suspense, useState, useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
|
|
19
19
|
import { useTranslation } from 'react-i18next'
|
|
20
20
|
import {
|
|
@@ -93,6 +93,8 @@ import { requestFederatedAction } from './federated-action-loader'
|
|
|
93
93
|
import { useInstalledAddons } from './installed-addons-context'
|
|
94
94
|
import { federatedModalComponent, resolveFederatedModal } from './primitives/contributions'
|
|
95
95
|
import { installedPredicate, useContributionsVersion } from './primitives/use-contributions'
|
|
96
|
+
import { isTodayToken, todayInZone } from './calendar-date'
|
|
97
|
+
import { useTimeZone } from './org-runtime-context'
|
|
96
98
|
|
|
97
99
|
export type { ActionMetadata, ActionModalProps }
|
|
98
100
|
|
|
@@ -413,9 +415,11 @@ export function ActionModalDispatcher({
|
|
|
413
415
|
}: ActionModalProps) {
|
|
414
416
|
// Every successful action may have created/updated/deleted a record of
|
|
415
417
|
// `model` (federated component, wizard, generic form or confirmation), so
|
|
416
|
-
// announce it: mounted lists of the model reload on their own (#1020)
|
|
418
|
+
// announce it: mounted lists of the model reload on their own (#1020) —
|
|
419
|
+
// and again once the server's event subscribers had time to write what the
|
|
420
|
+
// action triggers (estado tras timbrar, REP tras un pago).
|
|
417
421
|
const onSuccess = useCallback(() => {
|
|
418
|
-
|
|
422
|
+
emitRecordMutationSettled(model, 'update')
|
|
419
423
|
onSuccessProp?.()
|
|
420
424
|
}, [model, onSuccessProp])
|
|
421
425
|
// Reactive read: a federated remote usually registers AFTER the first
|
|
@@ -1021,6 +1025,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
1021
1025
|
const reasonPrompt = useReasonPrompt()
|
|
1022
1026
|
const supervisor = useSupervisor()
|
|
1023
1027
|
const branchGate = useBranchCreateGate()
|
|
1028
|
+
const orgTimeZone = useTimeZone()
|
|
1024
1029
|
const [formData, setFormData] = useState<Record<string, any>>({})
|
|
1025
1030
|
const [executing, setExecuting] = useState(false)
|
|
1026
1031
|
// Per-field validation errors (localized), shown inline under each input.
|
|
@@ -1058,11 +1063,11 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
1058
1063
|
|
|
1059
1064
|
useEffect(() => {
|
|
1060
1065
|
if (open && action.fields) {
|
|
1061
|
-
setFormData(buildFieldDefaults(action.fields, record, model))
|
|
1066
|
+
setFormData(buildFieldDefaults(action.fields, record, model, { timeZone: orgTimeZone }))
|
|
1062
1067
|
setFieldErrors({})
|
|
1063
1068
|
setFormError(undefined)
|
|
1064
1069
|
}
|
|
1065
|
-
}, [open, action.fields, record, model])
|
|
1070
|
+
}, [open, action.fields, record, model, orgTimeZone])
|
|
1066
1071
|
|
|
1067
1072
|
// Lines the list row does not carry: load them from the declared relation
|
|
1068
1073
|
// (see prefillRelationRequests) and seed the line-items grid.
|
|
@@ -1353,10 +1358,44 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
1353
1358
|
)
|
|
1354
1359
|
}
|
|
1355
1360
|
|
|
1361
|
+
/** Context a declared field default is resolved against. */
|
|
1362
|
+
export interface FieldDefaultContext {
|
|
1363
|
+
/** Org IANA timezone: `$today` is the org's day, not the browser's or UTC's. */
|
|
1364
|
+
timeZone?: string
|
|
1365
|
+
/** Clock (tests). */
|
|
1366
|
+
now?: Date
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
const DATE_FIELD_TYPES = new Set(['date', 'datetime', 'timestamp', 'timestamptz'])
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* The declared scalar default of a field, resolved: `defaultValue` (host
|
|
1373
|
+
* camelCase) or `default` (raw manifest, the kernel serves action/document-form
|
|
1374
|
+
* fields without renaming it). A date field may declare `$today` / `today` and
|
|
1375
|
+
* gets the org's calendar day. An unknown `$token` never lands in a date field
|
|
1376
|
+
* (undefined): a literal "$today" posted as invoice_date is a 422, an empty
|
|
1377
|
+
* field is just a required field to fill.
|
|
1378
|
+
*/
|
|
1379
|
+
export function resolveFieldDefault(field: ActionFieldDef, ctx: FieldDefaultContext = {}): unknown {
|
|
1380
|
+
const f = field as { defaultValue?: unknown; default?: unknown }
|
|
1381
|
+
const raw = f.defaultValue ?? f.default
|
|
1382
|
+
if (raw === undefined || raw === null) return undefined
|
|
1383
|
+
if (DATE_FIELD_TYPES.has(String(field.type ?? '').toLowerCase()) && typeof raw === 'string') {
|
|
1384
|
+
if (isTodayToken(raw)) return todayInZone(ctx.timeZone, ctx.now)
|
|
1385
|
+
if (raw.trim().startsWith('$')) return undefined
|
|
1386
|
+
}
|
|
1387
|
+
return raw
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1356
1390
|
// buildFieldDefaults seeds formData for a set of action fields, honoring the
|
|
1357
1391
|
// same line-items prefill spec + boolean/empty rules GenericActionModal uses, so
|
|
1358
1392
|
// wizard steps and single-page forms initialize identically.
|
|
1359
|
-
export function buildFieldDefaults(
|
|
1393
|
+
export function buildFieldDefaults(
|
|
1394
|
+
fields: ActionFieldDef[],
|
|
1395
|
+
record: any,
|
|
1396
|
+
model?: string,
|
|
1397
|
+
ctx: FieldDefaultContext = {},
|
|
1398
|
+
): Record<string, any> {
|
|
1360
1399
|
const defaults: Record<string, any> = {}
|
|
1361
1400
|
for (const field of fields) {
|
|
1362
1401
|
if (isLineItemsField(field)) {
|
|
@@ -1373,7 +1412,7 @@ export function buildFieldDefaults(fields: ActionFieldDef[], record: any, model?
|
|
|
1373
1412
|
defaults[field.key] = fromRecord
|
|
1374
1413
|
continue
|
|
1375
1414
|
}
|
|
1376
|
-
defaults[field.key] = field
|
|
1415
|
+
defaults[field.key] = resolveFieldDefault(field, ctx) ?? (field.type === 'boolean' ? false : '')
|
|
1377
1416
|
}
|
|
1378
1417
|
return defaults
|
|
1379
1418
|
}
|
|
@@ -1404,6 +1443,7 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
|
|
|
1404
1443
|
const api = useApi()
|
|
1405
1444
|
const reasonPrompt = useReasonPrompt()
|
|
1406
1445
|
const supervisor = useSupervisor()
|
|
1446
|
+
const orgTimeZone = useTimeZone()
|
|
1407
1447
|
const steps = action.steps ?? []
|
|
1408
1448
|
const [stepIndex, setStepIndex] = useState(0)
|
|
1409
1449
|
const [formData, setFormData] = useState<Record<string, any>>({})
|
|
@@ -1416,10 +1456,10 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
|
|
|
1416
1456
|
useEffect(() => {
|
|
1417
1457
|
if (!open) return
|
|
1418
1458
|
const allFields = steps.flatMap((s) => s.fields ?? [])
|
|
1419
|
-
setFormData(buildFieldDefaults(allFields, record, model))
|
|
1459
|
+
setFormData(buildFieldDefaults(allFields, record, model, { timeZone: orgTimeZone }))
|
|
1420
1460
|
setStepIndex(0)
|
|
1421
1461
|
setFormError(undefined)
|
|
1422
|
-
}, [open, action.steps, record, model])
|
|
1462
|
+
}, [open, action.steps, record, model, orgTimeZone])
|
|
1423
1463
|
|
|
1424
1464
|
const updateField = (key: string, value: any) =>
|
|
1425
1465
|
setFormData((prev: Record<string, any>) => ({ ...prev, [key]: value }))
|
|
@@ -1602,12 +1642,39 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
|
|
|
1602
1642
|
)
|
|
1603
1643
|
}
|
|
1604
1644
|
|
|
1605
|
-
|
|
1645
|
+
/** Columnas con las que se nombra una fila cuando el selector no declara `options.label`. */
|
|
1646
|
+
const RECORD_LABEL_COLUMNS = ['number', 'folio', 'name', 'title', 'code', 'label']
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* La etiqueta de la PROPIA fila cuando el campo se sembró con su id
|
|
1650
|
+
* («Factura a abonar» al registrar un pago desde la factura): la columna que el
|
|
1651
|
+
* selector usa de etiqueta (`options.label`) o la primera de nombre que tenga.
|
|
1652
|
+
* Sin esto el selector mostraba el UUID crudo (retest Pitsline r5).
|
|
1653
|
+
*/
|
|
1654
|
+
function selfLabel(field: ActionFieldDef, record: Record<string, any>): string {
|
|
1655
|
+
const opts = (field as { options?: unknown }).options
|
|
1656
|
+
const declared =
|
|
1657
|
+
opts && !Array.isArray(opts) && typeof opts === 'object' ? (opts as { label?: unknown }).label : undefined
|
|
1658
|
+
const cols = typeof declared === 'string' && declared ? [declared, ...RECORD_LABEL_COLUMNS] : RECORD_LABEL_COLUMNS
|
|
1659
|
+
for (const c of cols) {
|
|
1660
|
+
const v = unwrapRecordScalar(record[c])
|
|
1661
|
+
if ((typeof v === 'string' && v.trim()) || typeof v === 'number') return String(v)
|
|
1662
|
+
}
|
|
1663
|
+
return ''
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
export function seedOptionFromRecord(
|
|
1606
1667
|
field: ActionFieldDef,
|
|
1607
1668
|
value: any,
|
|
1608
1669
|
record?: Record<string, any>,
|
|
1609
1670
|
): import('./use-options-resolver').ResolvedOption | undefined {
|
|
1610
|
-
if (!record
|
|
1671
|
+
if (!record) return undefined
|
|
1672
|
+
const selfId = unwrapRecordScalar(record.id)
|
|
1673
|
+
if (value != null && value !== '' && selfId != null && String(selfId) === String(value)) {
|
|
1674
|
+
const label = selfLabel(field, record)
|
|
1675
|
+
if (label) return { id: String(value), value: String(value), label, name: label }
|
|
1676
|
+
}
|
|
1677
|
+
if (!field.key.endsWith('_id')) return undefined
|
|
1611
1678
|
const siblingKey = field.key.replace(/_id$/, '')
|
|
1612
1679
|
const sib = record[siblingKey]
|
|
1613
1680
|
if (!sib || typeof sib !== 'object') return undefined
|
|
@@ -54,7 +54,9 @@ function declaredDefault(f: ActionFieldDef): unknown {
|
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
56
|
* Divulgación progresiva: ¿el campo va plegado en «Opciones fiscales»?
|
|
57
|
-
* - campos de extensión (`fiscal_data.*`) que aportan otros addons
|
|
57
|
+
* - campos de extensión (`fiscal_data.*`) que aportan otros addons (los que
|
|
58
|
+
* el propio tipo declara obligatorios, p. ej. método y forma de pago de la
|
|
59
|
+
* factura, se marcan esenciales en splitEditorFields);
|
|
58
60
|
* - `section: "fiscal" | "advanced"`;
|
|
59
61
|
* - catálogos opcionales que ya traen un default (método, serie, relación…):
|
|
60
62
|
* el usuario no necesita tocarlos para guardar.
|
|
@@ -84,7 +86,10 @@ export function splitEditorFields(
|
|
|
84
86
|
fields: readonly ActionFieldDef[],
|
|
85
87
|
opts: { partyField?: string; essentialKeys?: readonly string[]; extensionFields?: readonly ActionFieldDef[] } = {},
|
|
86
88
|
): EditorFieldGroups {
|
|
87
|
-
|
|
89
|
+
// Lo obligatorio que el tipo declara siempre está a la vista, aunque sea de
|
|
90
|
+
// extensión (`fiscal_data.metodo_pago`): es lo que el usuario debe decidir.
|
|
91
|
+
// Las extensiones que llegan del metadata (no declaradas) siguen plegadas.
|
|
92
|
+
const essentialKeys = new Set([...(opts.essentialKeys ?? []), ...fields.filter((f) => f.required).map((f) => f.key)])
|
|
88
93
|
const out: EditorFieldGroups = { essential: [], advanced: [], notes: [] }
|
|
89
94
|
const seen = new Set<string>()
|
|
90
95
|
for (const f of [...fields, ...(opts.extensionFields ?? [])]) {
|
|
@@ -200,7 +205,7 @@ export function creditStatus(
|
|
|
200
205
|
export function linesFromSource(
|
|
201
206
|
rows: unknown,
|
|
202
207
|
map: Record<string, string> = {},
|
|
203
|
-
opts: { kind?: DocumentFormLines['kind']; discountMode?: DocumentFormLines['discount_mode'] } = {},
|
|
208
|
+
opts: { kind?: DocumentFormLines['kind']; discountMode?: DocumentFormLines['discount_mode']; defaultTaxRate?: number } = {},
|
|
204
209
|
): LineItem[] {
|
|
205
210
|
if (!Array.isArray(rows)) return []
|
|
206
211
|
return rows.filter((raw) => !isFullyConsumed(raw)).map((raw) => {
|
|
@@ -212,13 +217,18 @@ export function linesFromSource(
|
|
|
212
217
|
if (!hasValue(rate)) {
|
|
213
218
|
const sub = toAmount(get('subtotal'))
|
|
214
219
|
const tax = toAmount(get('tax_amount'))
|
|
215
|
-
|
|
220
|
+
// Un origen sin dato de impuesto (p. ej. una OT de taller: solo
|
|
221
|
+
// cantidad y precio) toma la tasa de la org, como un renglón nuevo.
|
|
222
|
+
rate = hasValue(get('tax_amount'))
|
|
223
|
+
? sub > 0 ? Math.round((tax / sub) * 10000) / 10000 : 0
|
|
224
|
+
: opts.defaultTaxRate ?? 0
|
|
216
225
|
}
|
|
217
226
|
const r2 = toAmount(rate)
|
|
227
|
+
const product = get('product_id')
|
|
218
228
|
const line = makeLine({
|
|
219
|
-
product_id:
|
|
229
|
+
product_id: refValue(product),
|
|
220
230
|
sku: get('sku') ?? undefined,
|
|
221
|
-
description: String(get('description') ?? get('product_name') ?? ''),
|
|
231
|
+
description: String(get('description') ?? get('product_name') ?? refLabel(product) ?? refLabel(r.product) ?? ''),
|
|
222
232
|
quantity: qty,
|
|
223
233
|
unit_price: toAmount(get('unit_price')),
|
|
224
234
|
discount: toAmount(get('discount') ?? 0),
|
|
@@ -236,11 +246,40 @@ export function linesFromSource(
|
|
|
236
246
|
})
|
|
237
247
|
}
|
|
238
248
|
|
|
249
|
+
/** Id de una celda de relación (`{value,label}`, `{id,name}` o el id plano). */
|
|
250
|
+
function refValue(v: unknown): string | undefined {
|
|
251
|
+
if (v == null || v === '') return undefined
|
|
252
|
+
if (typeof v === 'object') {
|
|
253
|
+
const o = v as Record<string, unknown>
|
|
254
|
+
const id = o.value ?? o.id
|
|
255
|
+
return id == null || id === '' ? undefined : String(id)
|
|
256
|
+
}
|
|
257
|
+
return String(v)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Nombre de una celda de relación ya resuelta por el host (`{label}`/`{name}`).
|
|
262
|
+
* Un renglón de origen sin descripción propia (InvoiceItem solo lleva el
|
|
263
|
+
* producto) se nombra con su producto en vez de entrar en blanco.
|
|
264
|
+
*/
|
|
265
|
+
function refLabel(v: unknown): string | undefined {
|
|
266
|
+
if (!v || typeof v !== 'object') return undefined
|
|
267
|
+
const o = v as Record<string, unknown>
|
|
268
|
+
const l = o.label ?? o.name
|
|
269
|
+
return typeof l === 'string' && l.trim() ? l : undefined
|
|
270
|
+
}
|
|
271
|
+
|
|
239
272
|
function isFullyConsumed(raw: unknown): boolean {
|
|
240
273
|
const r = (raw ?? {}) as Record<string, any>
|
|
241
274
|
return hasValue(r.remaining_quantity) && toAmount(r.remaining_quantity) <= 0
|
|
242
275
|
}
|
|
243
276
|
|
|
277
|
+
/** El editor ocupa toda la pantalla: documentos comerciales con renglones (factura, cotización, pedido, OC). */
|
|
278
|
+
export function isFullscreenEditor(type: DocumentFormType, forms: Pick<DocumentFormsManifest, 'lines_field'>): boolean {
|
|
279
|
+
const kind = editorLinesConfig(type, forms)?.kind ?? 'sale'
|
|
280
|
+
return !!type.lines && (kind === 'sale' || kind === 'purchase')
|
|
281
|
+
}
|
|
282
|
+
|
|
244
283
|
/** Cuántos renglones del origen se cargaron y cuántos se omitieron por estar ya cubiertos. */
|
|
245
284
|
export function sourceLoadSummary(rows: unknown): { loaded: number; covered: number } {
|
|
246
285
|
if (!Array.isArray(rows)) return { loaded: 0, covered: 0 }
|
|
@@ -267,7 +306,11 @@ export function localIssues(lines: LineItem[], opts: { requireLines?: boolean; r
|
|
|
267
306
|
issues.push({ field: 'lines', severity: 'error', message: 'Agrega al menos un renglón.' })
|
|
268
307
|
}
|
|
269
308
|
items.forEach((l, i) => {
|
|
270
|
-
|
|
309
|
+
// Un renglón con producto se describe con él (el servidor lo nombra
|
|
310
|
+
// desde el catálogo): solo un renglón libre exige texto, como en el
|
|
311
|
+
// editor de renglones (validateLineItems).
|
|
312
|
+
if (!l.description.trim() && !l.product_id)
|
|
313
|
+
issues.push({ field: `lines.${i}`, severity: 'error', message: `Renglón ${i + 1}: elige un producto o escribe la descripción.` })
|
|
271
314
|
if (toAmount(l.unit_price) <= 0) issues.push({ field: `lines.${i}`, severity: 'warning', message: `Renglón ${i + 1}: precio en cero.` })
|
|
272
315
|
if (l.max_quantity != null && toAmount(l.quantity) > toAmount(l.max_quantity)) {
|
|
273
316
|
issues.push({
|