@asteby/metacore-runtime-react 47.1.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 +29 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +24 -4
- 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 +14 -7
- 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 +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/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -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 +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 +102 -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 +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__/installed-addons-context.test.tsx +50 -0
- package/src/__tests__/line-item-totals.test.ts +28 -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/action-modal-dispatcher.tsx +47 -4
- 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 +29 -6
- package/src/document-form-dialog.tsx +92 -12
- 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 +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/index.ts +21 -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 +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 +101 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// Primitivos federados en React: el dispatcher pinta un modal de
|
|
4
|
+
// registerFederatedModal sin esperar al host, usa el formulario genérico de
|
|
5
|
+
// inmediato si el addon del slug no está instalado, y useDocumentContributions
|
|
6
|
+
// re-pinta al registrar/desmontar un remote.
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
8
|
+
import { act, cleanup, render, screen } from '@testing-library/react'
|
|
9
|
+
|
|
10
|
+
const USE_TRANSLATION = {
|
|
11
|
+
t: (_k: string, opts?: { defaultValue?: string }) => opts?.defaultValue ?? _k,
|
|
12
|
+
i18n: { language: 'es' },
|
|
13
|
+
}
|
|
14
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => USE_TRANSLATION }))
|
|
15
|
+
vi.mock('@tanstack/react-router', () => ({ useNavigate: () => () => {} }))
|
|
16
|
+
|
|
17
|
+
import { ActionModalDispatcher } from '../action-modal-dispatcher'
|
|
18
|
+
import { setFederatedActionLoader } from '../federated-action-loader'
|
|
19
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
20
|
+
import { InstalledAddonsProvider } from '../installed-addons-context'
|
|
21
|
+
import {
|
|
22
|
+
__resetContributions,
|
|
23
|
+
registerDocumentContribution,
|
|
24
|
+
registerFederatedModal,
|
|
25
|
+
type DocumentEditorContext,
|
|
26
|
+
} from '../primitives/contributions'
|
|
27
|
+
import { useDocumentContributions } from '../primitives/use-contributions'
|
|
28
|
+
import type { ActionMetadata, ActionModalProps } from '@asteby/metacore-sdk'
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
cleanup()
|
|
32
|
+
vi.restoreAllMocks()
|
|
33
|
+
setFederatedActionLoader(null)
|
|
34
|
+
__resetContributions()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const noopApi = {
|
|
38
|
+
get: vi.fn().mockResolvedValue({ data: {} }),
|
|
39
|
+
post: vi.fn().mockResolvedValue({ data: { success: true } }),
|
|
40
|
+
delete: vi.fn().mockResolvedValue({ data: { success: true } }),
|
|
41
|
+
} as unknown as ApiClient
|
|
42
|
+
|
|
43
|
+
const importAction: ActionMetadata = {
|
|
44
|
+
key: 'import_cfdi',
|
|
45
|
+
label: 'Importar CFDI',
|
|
46
|
+
modal: 'fiscal_mexico.import_cfdi',
|
|
47
|
+
confirm: true,
|
|
48
|
+
confirmMessage: '¿Importar?',
|
|
49
|
+
executable: true,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ImportModal({ action }: ActionModalProps) {
|
|
53
|
+
return <div data-testid="import-modal">{action.modal}</div>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function renderDispatcher(addons?: string[]) {
|
|
57
|
+
const tree = (
|
|
58
|
+
<ActionModalDispatcher
|
|
59
|
+
open
|
|
60
|
+
onOpenChange={() => {}}
|
|
61
|
+
action={importAction}
|
|
62
|
+
model="Invoice"
|
|
63
|
+
record={{ id: '1' } as any}
|
|
64
|
+
endpoint="/data/Invoice"
|
|
65
|
+
onSuccess={() => {}}
|
|
66
|
+
/>
|
|
67
|
+
)
|
|
68
|
+
return render(
|
|
69
|
+
<ApiProvider client={noopApi}>
|
|
70
|
+
{addons ? <InstalledAddonsProvider addons={addons}>{tree}</InstalledAddonsProvider> : tree}
|
|
71
|
+
</ApiProvider>,
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('ActionModalDispatcher + registerFederatedModal', () => {
|
|
76
|
+
it('pinta el modal registrado (lazy) sin pedir el remote al host', async () => {
|
|
77
|
+
const loader = vi.fn()
|
|
78
|
+
setFederatedActionLoader(loader)
|
|
79
|
+
registerFederatedModal({ key: 'fiscal_mexico.import_cfdi', addon: 'fiscal_mexico', load: async () => ({ default: ImportModal }) })
|
|
80
|
+
renderDispatcher(['fiscal_mexico'])
|
|
81
|
+
expect((await screen.findByTestId('import-modal')).textContent).toBe('fiscal_mexico.import_cfdi')
|
|
82
|
+
expect(loader).not.toHaveBeenCalled()
|
|
83
|
+
expect(screen.queryByText('¿Importar?')).toBeNull()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('addon del slug no instalado → formulario genérico de inmediato, sin esperar ni pedir el remote', () => {
|
|
87
|
+
const loader = vi.fn()
|
|
88
|
+
setFederatedActionLoader(loader)
|
|
89
|
+
registerFederatedModal({ key: 'fiscal_mexico.import_cfdi', addon: 'fiscal_mexico', load: async () => ({ default: ImportModal }) })
|
|
90
|
+
renderDispatcher(['customers'])
|
|
91
|
+
expect(screen.getByText('¿Importar?')).toBeTruthy()
|
|
92
|
+
expect(screen.queryByTestId('federated-action-loading')).toBeNull()
|
|
93
|
+
expect(loader).not.toHaveBeenCalled()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('addon instalado pero modal aún sin registrar → espera al remote (no cae al genérico)', async () => {
|
|
97
|
+
renderDispatcher(['fiscal_mexico'])
|
|
98
|
+
expect(screen.getByTestId('federated-action-loading')).toBeTruthy()
|
|
99
|
+
expect(screen.queryByText('¿Importar?')).toBeNull()
|
|
100
|
+
act(() => {
|
|
101
|
+
registerFederatedModal({ key: 'fiscal_mexico.import_cfdi', addon: 'fiscal_mexico', load: async () => ({ default: ImportModal }) })
|
|
102
|
+
})
|
|
103
|
+
expect(await screen.findByTestId('import-modal')).toBeTruthy()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('sin InstalledAddonsProvider se conserva la espera de siempre', () => {
|
|
107
|
+
renderDispatcher()
|
|
108
|
+
expect(screen.getByTestId('federated-action-loading')).toBeTruthy()
|
|
109
|
+
expect(screen.queryByText('¿Importar?')).toBeNull()
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('useDocumentContributions', () => {
|
|
114
|
+
const ctx: DocumentEditorContext = { kind: 'invoice', model: 'customers.Invoice', values: {}, mode: 'create' }
|
|
115
|
+
|
|
116
|
+
function Region() {
|
|
117
|
+
const items = useDocumentContributions(ctx, 'header.fields')
|
|
118
|
+
return (
|
|
119
|
+
<div data-testid="region">
|
|
120
|
+
{items.map(({ id, component: C }) => (
|
|
121
|
+
<C key={id} ctx={ctx} setValue={() => {}} issues={{ report: () => {} }} />
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
it('re-pinta al registrar y al desmontar el remote; filtra por addon instalado', async () => {
|
|
128
|
+
render(
|
|
129
|
+
<InstalledAddonsProvider addons={['fiscal_mexico']}>
|
|
130
|
+
<Region />
|
|
131
|
+
</InstalledAddonsProvider>,
|
|
132
|
+
)
|
|
133
|
+
expect(screen.getByTestId('region').textContent).toBe('')
|
|
134
|
+
let off = () => {}
|
|
135
|
+
act(() => {
|
|
136
|
+
off = registerDocumentContribution({
|
|
137
|
+
id: 'fiscal_mexico.header.cfdi',
|
|
138
|
+
kinds: ['invoice'],
|
|
139
|
+
region: 'header.fields',
|
|
140
|
+
requiresAddon: 'fiscal_mexico',
|
|
141
|
+
load: async () => ({ default: () => <span>Uso del CFDI</span> }),
|
|
142
|
+
})
|
|
143
|
+
registerDocumentContribution({
|
|
144
|
+
id: 'inventory.header.warehouse',
|
|
145
|
+
kinds: '*',
|
|
146
|
+
region: 'header.fields',
|
|
147
|
+
requiresAddon: 'inventory',
|
|
148
|
+
component: () => <span>Almacén</span>,
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
expect(await screen.findByText('Uso del CFDI')).toBeTruthy()
|
|
152
|
+
expect(screen.queryByText('Almacén')).toBeNull()
|
|
153
|
+
act(() => off())
|
|
154
|
+
expect(screen.queryByText('Uso del CFDI')).toBeNull()
|
|
155
|
+
})
|
|
156
|
+
})
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { allocatePayment, validateAllocation, type OpenDocument } from '../primitives/allocation'
|
|
3
|
+
import { bucketFor, computeAging, daysOverdue } from '../primitives/aging'
|
|
4
|
+
import {
|
|
5
|
+
__resetContributions,
|
|
6
|
+
contributionsVersion,
|
|
7
|
+
federatedModalComponent,
|
|
8
|
+
registerDocumentContribution,
|
|
9
|
+
registerFederatedModal,
|
|
10
|
+
resolveContributions,
|
|
11
|
+
resolveFederatedModal,
|
|
12
|
+
slotIdFor,
|
|
13
|
+
subscribeContributions,
|
|
14
|
+
} from '../primitives/contributions'
|
|
15
|
+
import { approvalsClient, groupApprovals, parseJSONField, registerApprovalCategory, type ApprovalRequestDTO } from '../primitives/approvals'
|
|
16
|
+
import { CORE_DOCUMENT_KINDS, getDocumentKind, listDocumentKinds, registerDocumentKind } from '../primitives/document-kinds'
|
|
17
|
+
import { blockingChecks } from '../primitives/panels'
|
|
18
|
+
import { installedPredicate } from '../primitives/use-contributions'
|
|
19
|
+
import { slotRegistry } from '../slot'
|
|
20
|
+
import type { ApiClient } from '../api-context'
|
|
21
|
+
|
|
22
|
+
// Datos del mockup complemento-pago.html (Transportes del Bajío).
|
|
23
|
+
const docs: OpenDocument[] = [
|
|
24
|
+
{ id: 'a1060', number: 'A-1060', issued_at: '2026-09-10', due_at: '2026-10-10', total: 42270.4, balance: 42270.4, currency: 'MXN', payment_method: 'PPD' },
|
|
25
|
+
{ id: 'a1051', number: 'A-1051', issued_at: '2026-08-15', due_at: '2026-09-14', total: 18560, balance: 8500, currency: 'MXN', payment_method: 'PPD', installments_paid: 1 },
|
|
26
|
+
{ id: 'a1066', number: 'A-1066', issued_at: '2026-09-28', due_at: '2026-10-28', total: 23260, balance: 23260, currency: 'MXN', payment_method: 'PPD' },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
describe('allocatePayment', () => {
|
|
30
|
+
it('aplica primero lo más vencido y calcula parcialidad e insoluto', () => {
|
|
31
|
+
const r = allocatePayment(30000, docs)
|
|
32
|
+
expect(r.allocations).toEqual([
|
|
33
|
+
{ document_id: 'a1051', amount: 8500, balance_before: 8500, balance_after: 0, installment: 2 },
|
|
34
|
+
{ document_id: 'a1060', amount: 21500, balance_before: 42270.4, balance_after: 20770.4, installment: 1 },
|
|
35
|
+
])
|
|
36
|
+
expect(r.unapplied).toBe(0)
|
|
37
|
+
})
|
|
38
|
+
it('manual respeta montos y deja sin aplicar el resto', () => {
|
|
39
|
+
const r = allocatePayment(10000, docs, 'manual', { a1066: 4000 })
|
|
40
|
+
expect(r.applied).toBe(4000)
|
|
41
|
+
expect(r.unapplied).toBe(6000)
|
|
42
|
+
expect(validateAllocation(10000, 'MXN', docs, r.allocations).map(i => i.code)).toEqual(['unapplied'])
|
|
43
|
+
expect(validateAllocation(10000, 'MXN', docs, r.allocations, { allowUnapplied: true })[0].severity).toBe('warning')
|
|
44
|
+
})
|
|
45
|
+
it('detecta pago mayor al saldo y moneda distinta', () => {
|
|
46
|
+
const codes = validateAllocation(9000, 'USD', docs, [{ document_id: 'a1051', amount: 9000, balance_before: 8500, balance_after: -500, installment: 2 }]).map(i => i.code)
|
|
47
|
+
expect(codes).toContain('over_balance')
|
|
48
|
+
expect(codes).toContain('currency_mismatch')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('computeAging', () => {
|
|
53
|
+
it('tramos y vencido por cliente al corte', () => {
|
|
54
|
+
expect(daysOverdue('2026-09-14', '2026-10-05')).toBe(21)
|
|
55
|
+
const { rows, totals, total } = computeAging(docs.map(d => ({ party_id: 'tba', due_at: d.due_at ?? null, issued_at: d.issued_at, balance: d.balance })), '2026-10-05')
|
|
56
|
+
expect(totals.current).toBe(65530.4)
|
|
57
|
+
expect(totals.d1_30).toBe(8500)
|
|
58
|
+
expect(total).toBe(74030.4)
|
|
59
|
+
expect(rows[0].oldest_overdue_days).toBe(21)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('contribuciones federadas', () => {
|
|
64
|
+
afterEach(() => __resetContributions())
|
|
65
|
+
const ctx = { kind: 'invoice', model: 'customers.Invoice', values: {}, mode: 'create' as const }
|
|
66
|
+
it('se ocultan si el addon no está instalado y se ordenan por prioridad', () => {
|
|
67
|
+
const Comp = () => null
|
|
68
|
+
registerDocumentContribution({ id: 'fiscal_mexico.header.cfdi', kinds: ['invoice', 'credit_note'], region: 'header.fields', requiresAddon: 'fiscal_mexico', priority: 10, component: Comp })
|
|
69
|
+
registerDocumentContribution({ id: 'inventory.header.warehouse', kinds: '*', region: 'header.fields', requiresAddon: 'inventory', component: Comp })
|
|
70
|
+
expect(resolveContributions(ctx, 'header.fields', a => a === 'inventory').map(c => c.id)).toEqual(['inventory.header.warehouse'])
|
|
71
|
+
expect(resolveContributions(ctx, 'header.fields', () => true).map(c => c.id)).toEqual(['fiscal_mexico.header.cfdi', 'inventory.header.warehouse'])
|
|
72
|
+
})
|
|
73
|
+
it('el disposer la quita (desinstalación en caliente)', () => {
|
|
74
|
+
const off = registerDocumentContribution({ id: 'x', kinds: '*', region: 'side.panels', component: () => null })
|
|
75
|
+
off()
|
|
76
|
+
expect(resolveContributions(ctx, 'side.panels', () => true)).toEqual([])
|
|
77
|
+
})
|
|
78
|
+
it('modal federado sin addon → null (el dispatcher usa el formulario genérico)', () => {
|
|
79
|
+
registerFederatedModal({ key: 'fiscal_mexico.import_cfdi', addon: 'fiscal_mexico', load: async () => ({ default: () => null }) })
|
|
80
|
+
expect(resolveFederatedModal('fiscal_mexico.import_cfdi', () => false)).toBeNull()
|
|
81
|
+
expect(resolveFederatedModal('fiscal_mexico.import_cfdi', () => true)?.addon).toBe('fiscal_mexico')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('groupApprovals', () => {
|
|
86
|
+
it('agrupa por categoría registrada por cada addon', () => {
|
|
87
|
+
const off = registerApprovalCategory({ key: 'cfdi', label: 'Cancelación CFDI', match: r => r.action_key === 'cancel_fiscal' })
|
|
88
|
+
const base = { kind: 'action', status: 'pending' } as ApprovalRequestDTO
|
|
89
|
+
const g = groupApprovals([{ ...base, id: '1', action_key: 'cancel_fiscal' }, { ...base, id: '2', action_key: 'approve_adjustment' }])
|
|
90
|
+
expect(Object.keys(g).sort()).toEqual(['cfdi', 'other'])
|
|
91
|
+
off()
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('allocatePayment (estrategias y bordes)', () => {
|
|
96
|
+
it('oldest_issued_first ordena por emisión', () => {
|
|
97
|
+
const r = allocatePayment(50000, docs, 'oldest_issued_first')
|
|
98
|
+
expect(r.allocations.map(a => a.document_id)).toEqual(['a1051', 'a1060'])
|
|
99
|
+
})
|
|
100
|
+
it('monto negativo o cero no aplica nada y validateAllocation lo marca', () => {
|
|
101
|
+
expect(allocatePayment(-5, docs)).toEqual({ allocations: [], applied: 0, unapplied: 0 })
|
|
102
|
+
expect(validateAllocation(0, 'MXN', docs, []).map(i => i.code)).toContain('non_positive')
|
|
103
|
+
})
|
|
104
|
+
it('pago mayor a todos los saldos deja el excedente sin aplicar', () => {
|
|
105
|
+
const r = allocatePayment(100000, docs)
|
|
106
|
+
expect(r.applied).toBe(74030.4)
|
|
107
|
+
expect(r.unapplied).toBe(25969.6)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('bucketFor', () => {
|
|
112
|
+
it('ubica los bordes de cada tramo', () => {
|
|
113
|
+
expect(bucketFor(-3)).toBe('current')
|
|
114
|
+
expect(bucketFor(0)).toBe('current')
|
|
115
|
+
expect(bucketFor(30)).toBe('d1_30')
|
|
116
|
+
expect(bucketFor(31)).toBe('d31_60')
|
|
117
|
+
expect(bucketFor(400)).toBe('d90_plus')
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('contribuciones federadas (registro)', () => {
|
|
122
|
+
afterEach(() => __resetContributions())
|
|
123
|
+
const ctx = { kind: 'invoice', model: 'customers.Invoice', values: { total: 10 }, mode: 'edit' as const }
|
|
124
|
+
|
|
125
|
+
it('publica en slotRegistry por kind/region y el disposer la retira', () => {
|
|
126
|
+
const off = registerDocumentContribution({ id: 'a.body.x', kinds: ['invoice', 'quote'], region: 'body.sections', component: () => null })
|
|
127
|
+
expect(slotRegistry.get(slotIdFor('invoice', 'body.sections'))).toHaveLength(1)
|
|
128
|
+
expect(slotRegistry.get(slotIdFor('quote', 'body.sections'))).toHaveLength(1)
|
|
129
|
+
off()
|
|
130
|
+
off() // idempotente
|
|
131
|
+
expect(slotRegistry.get(slotIdFor('invoice', 'body.sections'))).toHaveLength(0)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('re-registrar el mismo id reemplaza; el disposer viejo no borra el nuevo', () => {
|
|
135
|
+
const offOld = registerDocumentContribution({ id: 'a.side.p', kinds: '*', region: 'side.panels', priority: 1, component: () => null })
|
|
136
|
+
registerDocumentContribution({ id: 'a.side.p', kinds: '*', region: 'side.panels', priority: 5, component: () => null })
|
|
137
|
+
expect(slotRegistry.get(slotIdFor('*', 'side.panels'))).toHaveLength(1)
|
|
138
|
+
offOld()
|
|
139
|
+
expect(resolveContributions(ctx, 'side.panels', () => true).map(c => c.priority)).toEqual([5])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('filtra por `when` y por kind; con `load` siempre expone un componente', () => {
|
|
143
|
+
registerDocumentContribution({ id: 'a.h.only_edit', kinds: ['invoice'], region: 'header.fields', when: c => c.mode === 'edit', load: async () => ({ default: () => null }) })
|
|
144
|
+
registerDocumentContribution({ id: 'a.h.quote', kinds: ['quote'], region: 'header.fields', component: () => null })
|
|
145
|
+
const [only] = resolveContributions(ctx, 'header.fields', () => true)
|
|
146
|
+
expect(only.id).toBe('a.h.only_edit')
|
|
147
|
+
expect(typeof only.component).toBe('function')
|
|
148
|
+
expect(resolveContributions({ ...ctx, mode: 'create' }, 'header.fields', () => true)).toEqual([])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('exige component o load', () => {
|
|
152
|
+
expect(() => registerDocumentContribution({ id: 'bad', kinds: '*', region: 'side.panels' })).toThrow(/component o load/)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('notifica a los suscriptores en register/dispose', () => {
|
|
156
|
+
const spy = vi.fn()
|
|
157
|
+
const unsub = subscribeContributions(spy)
|
|
158
|
+
const v0 = contributionsVersion()
|
|
159
|
+
const off = registerDocumentContribution({ id: 'n', kinds: '*', region: 'side.panels', component: () => null })
|
|
160
|
+
off()
|
|
161
|
+
expect(spy).toHaveBeenCalledTimes(2)
|
|
162
|
+
expect(contributionsVersion()).toBe(v0 + 2)
|
|
163
|
+
unsub()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('modal federado: no registrado → null; disposer lo retira; componente memoizado', () => {
|
|
167
|
+
expect(resolveFederatedModal('returns.settle_method', () => true)).toBeNull()
|
|
168
|
+
const m = { key: 'returns.settle_method', addon: 'returns', load: async () => ({ default: () => null }) }
|
|
169
|
+
const off = registerFederatedModal(m)
|
|
170
|
+
expect(resolveFederatedModal('returns.settle_method', () => true)).toBe(m)
|
|
171
|
+
expect(federatedModalComponent(m)).toBe(federatedModalComponent(m))
|
|
172
|
+
off()
|
|
173
|
+
expect(resolveFederatedModal('returns.settle_method', () => true)).toBeNull()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('installedPredicate: sin provider = instalado; con provider consulta el set', () => {
|
|
177
|
+
expect(installedPredicate(null)('lo_que_sea')).toBe(true)
|
|
178
|
+
const p = installedPredicate({ addons: new Set(['customers']), capabilities: new Set() })
|
|
179
|
+
expect(p('customers')).toBe(true)
|
|
180
|
+
expect(p('fiscal_mexico')).toBe(false)
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('tipos de documento', () => {
|
|
185
|
+
it('registerDocumentKind con disposer y filtrado por addon instalado', () => {
|
|
186
|
+
const offs = CORE_DOCUMENT_KINDS.map(registerDocumentKind)
|
|
187
|
+
expect(getDocumentKind('invoice')?.model).toBe('customers.Invoice')
|
|
188
|
+
expect(listDocumentKinds(a => a === 'purchases').map(k => k.key)).toEqual(['purchase_order', 'supplier_invoice', 'supplier_payment'])
|
|
189
|
+
offs.forEach(off => off())
|
|
190
|
+
expect(getDocumentKind('invoice')).toBeUndefined()
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe('paneles', () => {
|
|
195
|
+
it('blockingChecks: solo errores sin política de autorización', () => {
|
|
196
|
+
const checks = [
|
|
197
|
+
{ key: 'a', severity: 'error' as const, message: 'Falta RFC' },
|
|
198
|
+
{ key: 'b', severity: 'error' as const, message: 'Sin crédito', approval_policy: 'credit_override' },
|
|
199
|
+
{ key: 'c', severity: 'warning' as const, message: 'Sin correo' },
|
|
200
|
+
]
|
|
201
|
+
expect(blockingChecks(checks).map(c => c.key)).toEqual(['a'])
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('approvalsClient', () => {
|
|
206
|
+
it('arma la consulta y lee lista y conteo', async () => {
|
|
207
|
+
const get = vi.fn(async (url: string) =>
|
|
208
|
+
url.startsWith('/approvals/count') ? { data: { data: { count: 3 } } } : { data: { data: [{ id: '1' }], meta: { total: 1 } } },
|
|
209
|
+
)
|
|
210
|
+
const post = vi.fn(async () => ({ data: {} }))
|
|
211
|
+
const c = approvalsClient({ get, post } as unknown as ApiClient)
|
|
212
|
+
expect(await c.list({ status: 'pending', for_me: true, model: undefined })).toEqual({ items: [{ id: '1' }], total: 1 })
|
|
213
|
+
expect(get).toHaveBeenCalledWith('/approvals?status=pending&for_me=true')
|
|
214
|
+
expect(await c.count()).toBe(3)
|
|
215
|
+
await c.reject('9', 'No procede')
|
|
216
|
+
expect(post).toHaveBeenCalledWith('/approvals/9/reject', { reason: 'No procede' })
|
|
217
|
+
})
|
|
218
|
+
it('parseJSONField tolera string, objeto y basura', () => {
|
|
219
|
+
expect(parseJSONField('{"a":1}')).toEqual({ a: 1 })
|
|
220
|
+
expect(parseJSONField({ a: 2 })).toEqual({ a: 2 })
|
|
221
|
+
expect(parseJSONField('no-json')).toBeNull()
|
|
222
|
+
expect(parseJSONField('')).toBeNull()
|
|
223
|
+
})
|
|
224
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// RecordPrefillBar: pinta los ayudantes registrados para el modelo/modo, los
|
|
4
|
+
// filtra por permiso y solo deja pasar al formulario las claves que declara.
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
7
|
+
import { registerRecordPrefill, unregisterRecordPrefillsByOwner, type RecordPrefillProps } from '@asteby/metacore-sdk'
|
|
8
|
+
import { RecordPrefillBar } from '../record-prefill-bar'
|
|
9
|
+
import { PermissionsProvider } from '../permissions-context'
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
cleanup()
|
|
13
|
+
unregisterRecordPrefillsByOwner('fiscal_mexico')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
function CsfButton({ applyValues }: RecordPrefillProps) {
|
|
17
|
+
return (
|
|
18
|
+
<button type="button" onClick={() => applyValues({ tax_id: 'XAXX010101000', organization_id: 'hack' })}>
|
|
19
|
+
Cargar constancia
|
|
20
|
+
</button>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('RecordPrefillBar', () => {
|
|
25
|
+
it('aplica solo claves permitidas y aparece cuando el remote registra tarde', () => {
|
|
26
|
+
const onApply = vi.fn()
|
|
27
|
+
render(
|
|
28
|
+
<RecordPrefillBar model="customers.Customer" mode="create" values={{}} allowedKeys={new Set(['tax_id'])} onApply={onApply} />,
|
|
29
|
+
)
|
|
30
|
+
expect(screen.queryByText('Cargar constancia')).toBeNull()
|
|
31
|
+
act(() => {
|
|
32
|
+
registerRecordPrefill({ id: 'fiscal_mexico.csf', models: ['Customer'], component: CsfButton }, 'fiscal_mexico')
|
|
33
|
+
})
|
|
34
|
+
fireEvent.click(screen.getByText('Cargar constancia'))
|
|
35
|
+
expect(onApply).toHaveBeenCalledWith({ tax_id: 'XAXX010101000' })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('se oculta sin el permiso declarado', () => {
|
|
39
|
+
registerRecordPrefill(
|
|
40
|
+
{ id: 'fiscal_mexico.csf', models: ['Customer'], component: CsfButton, permission: 'customers.csf' },
|
|
41
|
+
'fiscal_mexico',
|
|
42
|
+
)
|
|
43
|
+
render(
|
|
44
|
+
<PermissionsProvider permissions={[]} isAdmin={false}>
|
|
45
|
+
<RecordPrefillBar model="Customer" mode="create" values={{}} allowedKeys={new Set(['tax_id'])} onApply={() => {}} />
|
|
46
|
+
</PermissionsProvider>,
|
|
47
|
+
)
|
|
48
|
+
expect(screen.queryByText('Cargar constancia')).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -15,7 +15,7 @@
|
|
|
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
17
|
import { emitRecordMutation } from './record-mutation-events'
|
|
18
|
-
import { useState, useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
|
|
18
|
+
import { Suspense, useState, useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
|
|
19
19
|
import { useTranslation } from 'react-i18next'
|
|
20
20
|
import {
|
|
21
21
|
Dialog,
|
|
@@ -88,6 +88,9 @@ import {
|
|
|
88
88
|
subscribeActionComponents,
|
|
89
89
|
} from '@asteby/metacore-sdk'
|
|
90
90
|
import { requestFederatedAction } from './federated-action-loader'
|
|
91
|
+
import { useInstalledAddons } from './installed-addons-context'
|
|
92
|
+
import { federatedModalComponent, resolveFederatedModal } from './primitives/contributions'
|
|
93
|
+
import { installedPredicate, useContributionsVersion } from './primitives/use-contributions'
|
|
91
94
|
|
|
92
95
|
export type { ActionMetadata, ActionModalProps }
|
|
93
96
|
|
|
@@ -375,6 +378,15 @@ export function ActionModalDispatcher({
|
|
|
375
378
|
const readComponent = () => getActionComponent(model, action.key)
|
|
376
379
|
const CustomComponent = useSyncExternalStore(subscribeActionComponents, readComponent, readComponent)
|
|
377
380
|
|
|
381
|
+
// Modal federado declarativo (registerFederatedModal). Si el addon dueño
|
|
382
|
+
// del slug consta como NO instalado, el `modal` se ignora y se usa el
|
|
383
|
+
// formulario genérico de la acción de inmediato (sin esperar el timeout).
|
|
384
|
+
useContributionsVersion()
|
|
385
|
+
const installed = useInstalledAddons()
|
|
386
|
+
const slug = action.modal || ''
|
|
387
|
+
const federatedModal = slug ? resolveFederatedModal(slug, installedPredicate(installed)) : null
|
|
388
|
+
const modalAddonMissing = !!slug && !federatedModal && !!installed && !installed.addons.has(slug.split('.')[0])
|
|
389
|
+
|
|
378
390
|
if (CustomComponent) {
|
|
379
391
|
return (
|
|
380
392
|
<CustomComponent
|
|
@@ -389,10 +401,28 @@ export function ActionModalDispatcher({
|
|
|
389
401
|
)
|
|
390
402
|
}
|
|
391
403
|
|
|
404
|
+
if (federatedModal) {
|
|
405
|
+
const FederatedComponent = federatedModalComponent<ActionModalProps>(federatedModal)
|
|
406
|
+
return (
|
|
407
|
+
<Suspense fallback={<LoadingActionDialog open={open} onOpenChange={onOpenChange} action={action} />}>
|
|
408
|
+
<FederatedComponent
|
|
409
|
+
open={open}
|
|
410
|
+
onOpenChange={onOpenChange}
|
|
411
|
+
action={action}
|
|
412
|
+
model={model}
|
|
413
|
+
record={record}
|
|
414
|
+
endpoint={endpoint}
|
|
415
|
+
onSuccess={onSuccess}
|
|
416
|
+
/>
|
|
417
|
+
</Suspense>
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
|
|
392
421
|
// Declarative custom slot (`modal: "addon.action"`). Wait for the remote,
|
|
393
422
|
// then a hard error — confirm/fields generics look "fine" and hide a
|
|
394
|
-
// missing remote.
|
|
395
|
-
|
|
423
|
+
// missing remote. Exception: the slug's addon is known NOT installed — the
|
|
424
|
+
// remote will never come, so fall through to the generic form now.
|
|
425
|
+
if (action.modal && !modalAddonMissing) {
|
|
396
426
|
return (
|
|
397
427
|
<PendingCustomActionModal
|
|
398
428
|
open={open}
|
|
@@ -479,7 +509,6 @@ function PendingCustomActionModal({
|
|
|
479
509
|
action: ActionMetadata
|
|
480
510
|
model: string
|
|
481
511
|
}) {
|
|
482
|
-
const { t } = useTranslation()
|
|
483
512
|
const slug = action.modal || ''
|
|
484
513
|
const [timedOut, setTimedOut] = useState(false)
|
|
485
514
|
|
|
@@ -522,6 +551,20 @@ function PendingCustomActionModal({
|
|
|
522
551
|
return <MissingCustomActionModal open={open} onOpenChange={onOpenChange} action={action} model={model} />
|
|
523
552
|
}
|
|
524
553
|
|
|
554
|
+
return <LoadingActionDialog open={open} onOpenChange={onOpenChange} action={action} />
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Diálogo «Cargando…» mientras llega el componente federado de la acción. */
|
|
558
|
+
function LoadingActionDialog({
|
|
559
|
+
open,
|
|
560
|
+
onOpenChange,
|
|
561
|
+
action,
|
|
562
|
+
}: {
|
|
563
|
+
open: boolean
|
|
564
|
+
onOpenChange: (open: boolean) => void
|
|
565
|
+
action: ActionMetadata
|
|
566
|
+
}) {
|
|
567
|
+
const { t } = useTranslation()
|
|
525
568
|
return (
|
|
526
569
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
527
570
|
<DialogContent>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// createCatalogProductSearch — buscador de productos POR DEFECTO para
|
|
2
|
+
// DocumentLinesGrid cuando el host no inyecta uno (`searchProducts`).
|
|
3
|
+
//
|
|
4
|
+
// Problema que corrige: DynamicCRUDPage monta DocumentFormDialog SIN
|
|
5
|
+
// `searchProducts`, así que el paso de renglones de «Crear factura» solo ofrecía
|
|
6
|
+
// «Renglón libre» (descripción a mano, precio 0, IVA 0) y la factura se guardaba
|
|
7
|
+
// en $0. Con esto, cualquier documento declarativo busca en el catálogo.
|
|
8
|
+
//
|
|
9
|
+
// Lee el listado canónico GET /data/<model>?search=&per_page= (el mismo que usa
|
|
10
|
+
// DynamicTable). No usa /options: su envelope solo trae id/label, sin precio.
|
|
11
|
+
import type { ProductQuery, ProductResult } from './product-search'
|
|
12
|
+
|
|
13
|
+
interface ApiLike {
|
|
14
|
+
get: (url: string, cfg?: { params?: Record<string, unknown>; signal?: AbortSignal }) => Promise<{ data: any }>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CatalogProductSearchOptions {
|
|
18
|
+
/** Modelo de catálogo. Default `products.Product`. */
|
|
19
|
+
model?: string
|
|
20
|
+
/** Endpoint de listado. Default `/data/<model>`. */
|
|
21
|
+
endpoint?: string
|
|
22
|
+
limit?: number
|
|
23
|
+
/** Tasa de impuesto cuando el producto no trae la suya (config fiscal de la org; p. ej. 0.16). */
|
|
24
|
+
defaultTaxRate?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const num = (v: unknown): number | undefined => {
|
|
28
|
+
const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : typeof v === 'number' ? v : NaN
|
|
29
|
+
return Number.isFinite(n) ? n : undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
|
|
33
|
+
|
|
34
|
+
/** Tasa como fracción: acepta 0.16 o 16. */
|
|
35
|
+
const rate = (v: unknown): number | undefined => {
|
|
36
|
+
const n = num(v)
|
|
37
|
+
if (n == null || n < 0) return undefined
|
|
38
|
+
return n > 1 ? n / 100 : n
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const bag = (v: unknown): Record<string, unknown> => {
|
|
42
|
+
if (typeof v === 'string') {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(v)
|
|
45
|
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
46
|
+
} catch {
|
|
47
|
+
return {}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return v && typeof v === 'object' ? (v as Record<string, unknown>) : {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Proyección pura registro del catálogo → ProductResult (exportada para tests). */
|
|
54
|
+
export function catalogRecordToProduct(rec: Record<string, any>, defaultTaxRate?: number): ProductResult {
|
|
55
|
+
// Campos de extensión de otros addons (p. ej. claves SAT de fiscal_mexico).
|
|
56
|
+
const extensions = Object.fromEntries(
|
|
57
|
+
Object.entries(bag(rec.fiscal_data)).filter(([, v]) => typeof v === 'string' && v !== ''),
|
|
58
|
+
) as Record<string, string>
|
|
59
|
+
const pt = String(rec.product_type ?? '')
|
|
60
|
+
return {
|
|
61
|
+
id: String(rec.id ?? rec.value ?? ''),
|
|
62
|
+
name: String(rec.name ?? rec.label ?? ''),
|
|
63
|
+
sku: str(rec.sku),
|
|
64
|
+
barcode: str(rec.barcode),
|
|
65
|
+
kind: pt === 'service' ? 'service' : 'product',
|
|
66
|
+
price: num(rec.unit_price ?? rec.sale_price ?? rec.price),
|
|
67
|
+
cost: num(rec.cost_price ?? rec.cost),
|
|
68
|
+
tax_rate: rate(rec.tax_rate) ?? defaultTaxRate,
|
|
69
|
+
unit: str(rec.unit_of_measure) ?? str(rec.unit),
|
|
70
|
+
extensions: Object.keys(extensions).length ? extensions : undefined,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createCatalogProductSearch(api: ApiLike, opts: CatalogProductSearchOptions = {}) {
|
|
75
|
+
const model = opts.model ?? 'products.Product'
|
|
76
|
+
const url = opts.endpoint ?? `/data/${model}`
|
|
77
|
+
const limit = opts.limit ?? 20
|
|
78
|
+
return async (query: Exclude<ProductQuery, { kind: 'empty' }>, signal: AbortSignal): Promise<ProductResult[]> => {
|
|
79
|
+
const search = query.kind === 'barcode' ? query.barcode : query.raw.trim()
|
|
80
|
+
const res = await api.get(url, { params: { search, per_page: limit }, signal })
|
|
81
|
+
const rows: unknown = res?.data?.data
|
|
82
|
+
if (!Array.isArray(rows)) return []
|
|
83
|
+
return rows
|
|
84
|
+
.filter((r) => r && typeof r === 'object' && (r as Record<string, unknown>).is_active !== false)
|
|
85
|
+
.map((r) => catalogRecordToProduct(r as Record<string, any>, opts.defaultTaxRate))
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/business/catalog.ts
CHANGED