@asteby/metacore-runtime-react 45.2.0 → 45.4.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 +24 -0
- package/dist/action-modal-dispatcher.d.ts +29 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +115 -6
- package/dist/activity-diff.d.ts +2 -0
- package/dist/activity-diff.d.ts.map +1 -1
- package/dist/activity-diff.js +2 -2
- package/dist/business/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +8 -0
- package/dist/business/index.d.ts +2 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +2 -0
- package/dist/business/refund-destination.js +1 -1
- package/dist/business/refund.d.ts +1 -1
- package/dist/business/refund.d.ts.map +1 -1
- package/dist/business/refund.js +1 -0
- package/dist/business/return-wizard.d.ts +23 -0
- package/dist/business/return-wizard.d.ts.map +1 -0
- package/dist/business/return-wizard.js +59 -0
- package/dist/business/return.d.ts +90 -0
- package/dist/business/return.d.ts.map +1 -0
- package/dist/business/return.js +112 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +3 -1
- package/dist/dynamic-kanban.d.ts +62 -4
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +158 -27
- package/dist/dynamic-relation.d.ts +4 -0
- package/dist/dynamic-relation.d.ts.map +1 -1
- package/dist/dynamic-relation.js +10 -1
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +14 -2
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +28 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/reason-prompt.d.ts +59 -0
- package/dist/reason-prompt.d.ts.map +1 -0
- package/dist/reason-prompt.js +111 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-addon-settings.d.ts +17 -3
- package/dist/use-addon-settings.d.ts.map +1 -1
- package/dist/use-addon-settings.js +20 -8
- package/package.json +1 -1
- package/src/__tests__/dynamic-kanban.test.tsx +141 -0
- package/src/__tests__/prefill-from-record.test.ts +69 -0
- package/src/__tests__/reason-prompt.test.tsx +121 -0
- package/src/__tests__/use-addon-settings.test.ts +16 -1
- package/src/action-modal-dispatcher.tsx +145 -3
- package/src/activity-diff.tsx +10 -1
- package/src/business/business.test.ts +46 -0
- package/src/business/catalog.ts +8 -0
- package/src/business/index.ts +2 -0
- package/src/business/refund-destination.tsx +2 -2
- package/src/business/refund.ts +2 -1
- package/src/business/return-wizard.tsx +223 -0
- package/src/business/return.ts +181 -0
- package/src/dynamic-columns.tsx +5 -1
- package/src/dynamic-kanban.tsx +197 -19
- package/src/dynamic-relation.tsx +14 -1
- package/src/dynamic-row-actions.tsx +13 -1
- package/src/dynamic-table.tsx +22 -2
- package/src/index.ts +14 -0
- package/src/reason-prompt.tsx +247 -0
- package/src/types.ts +6 -0
- package/src/use-addon-settings.ts +26 -7
|
@@ -162,3 +162,49 @@ describe('product search', () => {
|
|
|
162
162
|
expect(line).toMatchObject({ product_id: 'v1', unit_price: 1200, tax_rate: 0.16, available: 3, description: 'Llanta X · 205/55R16' })
|
|
163
163
|
})
|
|
164
164
|
})
|
|
165
|
+
|
|
166
|
+
import { canTransitionRma, computeReturnTotals, creditNoteRelation, returnSteps, serializeReturn, validateReturnChoices, type ReturnableLine } from './return'
|
|
167
|
+
|
|
168
|
+
describe('return', () => {
|
|
169
|
+
const lines: ReturnableLine[] = [
|
|
170
|
+
{ key: 'a', product_id: 'p1', description: 'Llanta', sold: 4, alreadyReturned: 1, unit_price: 1000, tax_rate: 0.16 },
|
|
171
|
+
{ key: 'b', description: 'Servicio', sold: 1, unit_price: 200 },
|
|
172
|
+
]
|
|
173
|
+
const ch = (q: number) => ({ a: { quantity: q, condition: 'sellable' as const, destination: 'stock' as const } })
|
|
174
|
+
it('tope por devolver = vendido − ya devuelto', () => {
|
|
175
|
+
expect(validateReturnChoices(lines, ch(4), 'x').errors['a.quantity']).toBeTruthy()
|
|
176
|
+
expect(validateReturnChoices(lines, ch(3), 'x').valid).toBe(true)
|
|
177
|
+
})
|
|
178
|
+
it('exige renglón y motivo; destino coherente con la condición', () => {
|
|
179
|
+
expect(validateReturnChoices(lines, {}, 'x').valid).toBe(false)
|
|
180
|
+
expect(validateReturnChoices(lines, ch(1), '').valid).toBe(false)
|
|
181
|
+
const bad = { a: { quantity: 1, condition: 'sellable' as const, destination: 'scrap' as const } }
|
|
182
|
+
expect(validateReturnChoices(lines, bad, 'x').errors['a.destination']).toBeTruthy()
|
|
183
|
+
})
|
|
184
|
+
it('totales con IVA y relación 03/01', () => {
|
|
185
|
+
expect(computeReturnTotals(lines, ch(2)).total).toBe(2320)
|
|
186
|
+
expect(creditNoteRelation(lines, ch(2))).toBe('03')
|
|
187
|
+
expect(creditNoteRelation(lines, { b: { quantity: 1, condition: 'sellable', destination: 'stock' } })).toBe('01')
|
|
188
|
+
})
|
|
189
|
+
it('sin almacén, Recibir es un paso; con almacén no', () => {
|
|
190
|
+
expect(returnSteps({ warehouseConnected: false })).toContain('receive')
|
|
191
|
+
expect(returnSteps({ warehouseConnected: true })).not.toContain('receive')
|
|
192
|
+
})
|
|
193
|
+
it('serializeReturn omite renglones en 0 y añade NC solo si hay reembolso a NC', () => {
|
|
194
|
+
const p = serializeReturn({ lines, choices: ch(2), reason: ' defecto ', received: true, refund: [{ kind: 'credit_note', amount: 2320 }] })
|
|
195
|
+
expect(p.lines).toHaveLength(1)
|
|
196
|
+
expect(p.reason).toBe('defecto')
|
|
197
|
+
expect(p.credit_note).toEqual({ relation: '03' })
|
|
198
|
+
expect(serializeReturn({ lines, choices: ch(2), reason: 'x', received: true, refund: [{ kind: 'cash', amount: 2320 }] }).credit_note).toBeUndefined()
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe('rma states', () => {
|
|
203
|
+
it('flujo feliz y motivo obligatorio al rechazar/cancelar', () => {
|
|
204
|
+
expect(canTransitionRma('draft', 'authorized').ok).toBe(true)
|
|
205
|
+
expect(canTransitionRma('received', 'settled').ok).toBe(true)
|
|
206
|
+
expect(canTransitionRma('authorized', 'rejected').ok).toBe(false)
|
|
207
|
+
expect(canTransitionRma('authorized', 'rejected', 'no aplica').ok).toBe(true)
|
|
208
|
+
expect(canTransitionRma('closed', 'draft').ok).toBe(false)
|
|
209
|
+
})
|
|
210
|
+
})
|
package/src/business/catalog.ts
CHANGED
|
@@ -57,4 +57,12 @@ export const BUSINESS_COMPONENTS: readonly BusinessComponentSpec[] = [
|
|
|
57
57
|
usedBy: ['ReturnWizard', 'Cancelación de apartado', 'Anticipos'],
|
|
58
58
|
helpers: ['validateRefund', 'singleDestination', 'DEFAULT_REFUND_DESTINATIONS'],
|
|
59
59
|
},
|
|
60
|
+
{
|
|
61
|
+
name: 'ReturnWizard',
|
|
62
|
+
value: 'lines: ReturnableLine[]',
|
|
63
|
+
events: ['onSubmit', 'onCancel'],
|
|
64
|
+
permissions: [],
|
|
65
|
+
usedBy: ['POS', 'Pedido', 'Factura', 'RMA', 'OT'],
|
|
66
|
+
helpers: ['computeReturnTotals', 'validateReturnChoices', 'creditNoteRelation', 'returnSteps', 'serializeReturn'],
|
|
67
|
+
},
|
|
60
68
|
] as const
|
package/src/business/index.ts
CHANGED
|
@@ -103,9 +103,9 @@ export function RefundDestination({
|
|
|
103
103
|
/>
|
|
104
104
|
{errors[`${i}.amount`] && <p className="mt-0.5 text-xs text-destructive">{errors[`${i}.amount`]}</p>}
|
|
105
105
|
</div>
|
|
106
|
-
{(a.kind === 'card' || a.kind === 'credit_note') && (
|
|
106
|
+
{(a.kind === 'card' || a.kind === 'transfer' || a.kind === 'credit_note') && (
|
|
107
107
|
<Input
|
|
108
|
-
placeholder={a.kind === 'card' ? t('refund.cardRef', { defaultValue: 'Referencia' }) : t('refund.ncRef', { defaultValue: 'Folio / UUID relacionado' })}
|
|
108
|
+
placeholder={a.kind === 'card' || a.kind === 'transfer' ? t('refund.cardRef', { defaultValue: 'Referencia' }) : t('refund.ncRef', { defaultValue: 'Folio / UUID relacionado' })}
|
|
109
109
|
value={a.reference ?? ''}
|
|
110
110
|
disabled={disabled}
|
|
111
111
|
onChange={(e) => emit(value.map((x, j) => (j === i ? { ...x, reference: e.target.value } : x)))}
|
package/src/business/refund.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Lógica pura de <RefundDestination>.
|
|
3
3
|
import { roundMoney, toAmount } from './format'
|
|
4
4
|
|
|
5
|
-
export type RefundDestinationKind = 'cash' | 'card' | 'wallet' | 'store_credit' | 'credit_note'
|
|
5
|
+
export type RefundDestinationKind = 'cash' | 'card' | 'transfer' | 'wallet' | 'store_credit' | 'credit_note'
|
|
6
6
|
|
|
7
7
|
export interface RefundDestinationOption {
|
|
8
8
|
kind: RefundDestinationKind
|
|
@@ -25,6 +25,7 @@ export interface RefundAllocation {
|
|
|
25
25
|
export const DEFAULT_REFUND_DESTINATIONS: RefundDestinationOption[] = [
|
|
26
26
|
{ kind: 'cash', label: 'Efectivo' },
|
|
27
27
|
{ kind: 'card', label: 'Tarjeta' },
|
|
28
|
+
{ kind: 'transfer', label: 'Transferencia' },
|
|
28
29
|
{ kind: 'wallet', label: 'Monedero' },
|
|
29
30
|
{ kind: 'store_credit', label: 'Saldo a favor' },
|
|
30
31
|
{ kind: 'credit_note', label: 'Nota de crédito (CFDI)' },
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// ReturnWizard («Devolver mercancía») — un solo componente para POS, Pedido,
|
|
2
|
+
// Factura, RMA y OT (benchmark §7, DEV-2). No conoce endpoints: recibe los
|
|
3
|
+
// renglones devolvibles y entrega un payload por `onSubmit`.
|
|
4
|
+
import { useMemo, useState } from 'react'
|
|
5
|
+
import { useTranslation } from 'react-i18next'
|
|
6
|
+
import { Button, Input } from '@asteby/metacore-ui'
|
|
7
|
+
import { useFormatter } from './format'
|
|
8
|
+
import { RefundDestination } from './refund-destination'
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_REFUND_DESTINATIONS,
|
|
11
|
+
type RefundAllocation,
|
|
12
|
+
type RefundDestinationOption,
|
|
13
|
+
} from './refund'
|
|
14
|
+
import {
|
|
15
|
+
RETURN_CONDITION_DESTINATIONS,
|
|
16
|
+
computeReturnTotals,
|
|
17
|
+
creditNoteRelation,
|
|
18
|
+
defaultStockDestination,
|
|
19
|
+
returnSteps,
|
|
20
|
+
returnableQty,
|
|
21
|
+
serializeReturn,
|
|
22
|
+
validateReturnChoices,
|
|
23
|
+
validateReturnRefund,
|
|
24
|
+
type ReturnChoices,
|
|
25
|
+
type ReturnCondition,
|
|
26
|
+
type ReturnStep,
|
|
27
|
+
type ReturnStockDestination,
|
|
28
|
+
type ReturnSubmitPayload,
|
|
29
|
+
type ReturnableLine,
|
|
30
|
+
} from './return'
|
|
31
|
+
|
|
32
|
+
export interface ReturnWizardProps {
|
|
33
|
+
lines: ReturnableLine[]
|
|
34
|
+
/** ¿Hay un addon de almacén que confirma la recepción? Sin él, «Recibir» es un paso. */
|
|
35
|
+
warehouseConnected?: boolean
|
|
36
|
+
/** La venta está facturada: habilita la NC CFDI con relación 01/03 automática. */
|
|
37
|
+
invoiced?: boolean
|
|
38
|
+
/** Destinos de reembolso; `credit_note` se deshabilita solo si no está facturada. */
|
|
39
|
+
refundOptions?: RefundDestinationOption[]
|
|
40
|
+
reasons?: { value: string; label: string }[]
|
|
41
|
+
currency?: string
|
|
42
|
+
submitting?: boolean
|
|
43
|
+
/** Errores del servidor (`"<key>.<campo>"`). */
|
|
44
|
+
serverErrors?: Record<string, string>
|
|
45
|
+
onSubmit: (payload: ReturnSubmitPayload) => void | Promise<void>
|
|
46
|
+
onCancel?: () => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DEFAULT_REASONS = [
|
|
50
|
+
{ value: 'not_wanted', label: 'No lo quiso' },
|
|
51
|
+
{ value: 'defective', label: 'Defectuoso' },
|
|
52
|
+
{ value: 'wrong_item', label: 'Producto equivocado' },
|
|
53
|
+
{ value: 'warranty', label: 'Garantía' },
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
const STOCK_LABEL: Record<ReturnStockDestination, string> = { stock: 'Regresa a stock', scrap: 'Merma', warranty: 'Garantía con proveedor' }
|
|
57
|
+
const STEP_LABEL: Record<ReturnStep, string> = { lines: 'Renglones', condition: 'Condición', receive: 'Recibir', refund: 'Reembolso' }
|
|
58
|
+
|
|
59
|
+
export function ReturnWizard({
|
|
60
|
+
lines,
|
|
61
|
+
warehouseConnected = false,
|
|
62
|
+
invoiced = false,
|
|
63
|
+
refundOptions = DEFAULT_REFUND_DESTINATIONS,
|
|
64
|
+
reasons = DEFAULT_REASONS,
|
|
65
|
+
currency,
|
|
66
|
+
submitting,
|
|
67
|
+
serverErrors,
|
|
68
|
+
onSubmit,
|
|
69
|
+
onCancel,
|
|
70
|
+
}: ReturnWizardProps) {
|
|
71
|
+
const { t } = useTranslation()
|
|
72
|
+
const fmt = useFormatter({ currency })
|
|
73
|
+
const steps = useMemo(() => returnSteps({ warehouseConnected }), [warehouseConnected])
|
|
74
|
+
const [stepIdx, setStepIdx] = useState(0)
|
|
75
|
+
const [choices, setChoices] = useState<ReturnChoices>({})
|
|
76
|
+
const [reason, setReason] = useState('')
|
|
77
|
+
const [notes, setNotes] = useState('')
|
|
78
|
+
const [received, setReceived] = useState(false)
|
|
79
|
+
const [refund, setRefund] = useState<RefundAllocation[]>([])
|
|
80
|
+
|
|
81
|
+
const step = steps[stepIdx]
|
|
82
|
+
const totals = useMemo(() => computeReturnTotals(lines, choices), [lines, choices])
|
|
83
|
+
const validation = useMemo(() => validateReturnChoices(lines, choices, reason), [lines, choices, reason])
|
|
84
|
+
const relation = useMemo(() => creditNoteRelation(lines, choices), [lines, choices])
|
|
85
|
+
const options = useMemo(
|
|
86
|
+
() =>
|
|
87
|
+
refundOptions.map((o) =>
|
|
88
|
+
o.kind === 'credit_note' && !invoiced
|
|
89
|
+
? { ...o, available: false, disabledReason: 'La venta no está facturada' }
|
|
90
|
+
: o.kind === 'credit_note'
|
|
91
|
+
? { ...o, label: `${o.label} · relación ${relation}` }
|
|
92
|
+
: o,
|
|
93
|
+
),
|
|
94
|
+
[refundOptions, invoiced, relation],
|
|
95
|
+
)
|
|
96
|
+
const refundValidation = useMemo(() => validateReturnRefund(totals.total, refund, options), [totals.total, refund, options])
|
|
97
|
+
const errors = { ...validation.errors, ...serverErrors }
|
|
98
|
+
|
|
99
|
+
const setChoice = (key: string, patch: Partial<ReturnChoices[string]>) =>
|
|
100
|
+
setChoices((prev) => {
|
|
101
|
+
const cur = prev[key] ?? { quantity: 0, condition: 'sellable' as ReturnCondition, destination: 'stock' as ReturnStockDestination }
|
|
102
|
+
const next = { ...cur, ...patch }
|
|
103
|
+
if (patch.condition && !patch.destination) next.destination = defaultStockDestination(patch.condition)
|
|
104
|
+
return { ...prev, [key]: next }
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const canNext =
|
|
108
|
+
step === 'lines' ? validation.valid
|
|
109
|
+
: step === 'condition' ? validation.valid
|
|
110
|
+
: step === 'receive' ? received
|
|
111
|
+
: refundValidation.valid
|
|
112
|
+
|
|
113
|
+
const submit = () =>
|
|
114
|
+
onSubmit(serializeReturn({ lines, choices, reason, notes, received: warehouseConnected ? false : received, refund }))
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div data-slot="return-wizard" className="space-y-4">
|
|
118
|
+
<ol className="flex flex-wrap gap-2 text-xs">
|
|
119
|
+
{steps.map((s, i) => (
|
|
120
|
+
<li key={s} aria-current={i === stepIdx ? 'step' : undefined} className={i === stepIdx ? 'font-semibold text-foreground' : 'text-muted-foreground'}>
|
|
121
|
+
{i + 1}. {t(`returns.step.${s}`, { defaultValue: STEP_LABEL[s] })}
|
|
122
|
+
</li>
|
|
123
|
+
))}
|
|
124
|
+
{warehouseConnected && (
|
|
125
|
+
<li className="text-muted-foreground">{t('returns.step.warehouse', { defaultValue: 'Almacén confirma la recepción' })}</li>
|
|
126
|
+
)}
|
|
127
|
+
</ol>
|
|
128
|
+
|
|
129
|
+
{(step === 'lines' || step === 'condition') && (
|
|
130
|
+
<div className="space-y-2">
|
|
131
|
+
{lines.map((l) => {
|
|
132
|
+
const c = choices[l.key]
|
|
133
|
+
const max = returnableQty(l)
|
|
134
|
+
return (
|
|
135
|
+
<div key={l.key} className="grid gap-2 rounded-md border p-2 sm:grid-cols-[1fr_6rem_auto]">
|
|
136
|
+
<div>
|
|
137
|
+
<p className="text-sm font-medium">{l.description}</p>
|
|
138
|
+
<p className="text-xs text-muted-foreground">
|
|
139
|
+
{t('returns.sold', { defaultValue: 'Vendido' })}: {l.sold} · {t('returns.returnable', { defaultValue: 'Por devolver' })}: {max} · {fmt.money(l.unit_price)}
|
|
140
|
+
</p>
|
|
141
|
+
</div>
|
|
142
|
+
{step === 'lines' ? (
|
|
143
|
+
<div>
|
|
144
|
+
<Input
|
|
145
|
+
inputMode="decimal"
|
|
146
|
+
className="text-right"
|
|
147
|
+
aria-label={l.description}
|
|
148
|
+
disabled={max === 0}
|
|
149
|
+
value={c ? String(c.quantity) : ''}
|
|
150
|
+
placeholder="0"
|
|
151
|
+
onChange={(e) => setChoice(l.key, { quantity: Number(e.target.value.replace(',', '.')) || 0 })}
|
|
152
|
+
/>
|
|
153
|
+
{errors[`${l.key}.quantity`] && <p className="mt-0.5 text-xs text-destructive">{errors[`${l.key}.quantity`]}</p>}
|
|
154
|
+
</div>
|
|
155
|
+
) : (
|
|
156
|
+
(c?.quantity ?? 0) > 0 && (
|
|
157
|
+
<div className="sm:col-span-2 flex flex-wrap items-center gap-2">
|
|
158
|
+
{(['sellable', 'defective'] as ReturnCondition[]).map((cond) => (
|
|
159
|
+
<Button key={cond} type="button" size="sm" variant={c!.condition === cond ? 'default' : 'outline'} onClick={() => setChoice(l.key, { condition: cond })}>
|
|
160
|
+
{cond === 'sellable' ? t('returns.sellable', { defaultValue: 'Vendible' }) : t('returns.defective', { defaultValue: 'Defectuoso' })}
|
|
161
|
+
</Button>
|
|
162
|
+
))}
|
|
163
|
+
{RETURN_CONDITION_DESTINATIONS[c!.condition].length > 1 &&
|
|
164
|
+
RETURN_CONDITION_DESTINATIONS[c!.condition].map((d) => (
|
|
165
|
+
<Button key={d} type="button" size="sm" variant={c!.destination === d ? 'secondary' : 'ghost'} onClick={() => setChoice(l.key, { destination: d })}>
|
|
166
|
+
{STOCK_LABEL[d]}
|
|
167
|
+
</Button>
|
|
168
|
+
))}
|
|
169
|
+
{RETURN_CONDITION_DESTINATIONS[c!.condition].length === 1 && (
|
|
170
|
+
<span className="text-xs text-muted-foreground">{STOCK_LABEL[c!.destination]}</span>
|
|
171
|
+
)}
|
|
172
|
+
{errors[`${l.key}.destination`] && <p className="w-full text-xs text-destructive">{errors[`${l.key}.destination`]}</p>}
|
|
173
|
+
</div>
|
|
174
|
+
)
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
})}
|
|
179
|
+
{step === 'lines' && (
|
|
180
|
+
<div className="grid gap-2 sm:grid-cols-2">
|
|
181
|
+
<select className="h-9 rounded-md border bg-background px-2 text-sm" value={reason} onChange={(e) => setReason(e.target.value)} aria-label={t('returns.reason', { defaultValue: 'Motivo' })}>
|
|
182
|
+
<option value="">{t('returns.reason', { defaultValue: 'Motivo' })}…</option>
|
|
183
|
+
{reasons.map((r) => (
|
|
184
|
+
<option key={r.value} value={r.value}>{r.label}</option>
|
|
185
|
+
))}
|
|
186
|
+
</select>
|
|
187
|
+
<Input value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={t('returns.notes', { defaultValue: 'Notas (opcional)' })} />
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
{validation.form && stepIdx === 0 && Object.keys(choices).length > 0 && <p role="alert" className="text-sm text-destructive">{validation.form}</p>}
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
|
|
194
|
+
{step === 'receive' && (
|
|
195
|
+
<label className="flex items-start gap-2 rounded-md border p-3 text-sm">
|
|
196
|
+
<input type="checkbox" className="mt-1" checked={received} onChange={(e) => setReceived(e.target.checked)} />
|
|
197
|
+
<span>{t('returns.receiveHere', { defaultValue: 'Confirmo que la mercancía ya está en tienda. No hay almacén que la reciba, así que la recepción se registra aquí.' })}</span>
|
|
198
|
+
</label>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
{step === 'refund' && (
|
|
202
|
+
<RefundDestination total={totals.total} value={refund} onChange={setRefund} options={options} currency={currency} allowSplit serverErrors={serverErrors} />
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
<div className="flex items-center justify-between gap-2 border-t pt-3">
|
|
206
|
+
<span className="text-sm">
|
|
207
|
+
{t('returns.total', { defaultValue: 'Total a devolver' })}: <strong>{fmt.money(totals.total)}</strong>
|
|
208
|
+
</span>
|
|
209
|
+
<div className="flex gap-2">
|
|
210
|
+
{onCancel && stepIdx === 0 && <Button type="button" variant="outline" onClick={onCancel}>{t('common.cancel', { defaultValue: 'Cancelar' })}</Button>}
|
|
211
|
+
{stepIdx > 0 && <Button type="button" variant="outline" onClick={() => setStepIdx(stepIdx - 1)}>{t('common.back', { defaultValue: 'Atrás' })}</Button>}
|
|
212
|
+
{stepIdx < steps.length - 1 ? (
|
|
213
|
+
<Button type="button" disabled={!canNext} onClick={() => setStepIdx(stepIdx + 1)}>{t('common.next', { defaultValue: 'Siguiente' })}</Button>
|
|
214
|
+
) : (
|
|
215
|
+
<Button type="button" disabled={!canNext || submitting} onClick={submit}>
|
|
216
|
+
{t('returns.confirm', { defaultValue: 'Confirmar devolución' })} {fmt.money(totals.total)}
|
|
217
|
+
</Button>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Devolución de mercancía (ReturnWizard: POS, Pedido, Factura, RMA, OT, devolución
|
|
2
|
+
// a proveedor). Lógica pura, sin endpoints: cada consumidor cablea el envío.
|
|
3
|
+
// Flujo (benchmark §8.2): renglones → condición y destino → recepción → reembolso o NC.
|
|
4
|
+
import { roundMoney, toAmount } from './format'
|
|
5
|
+
import { validateRefund, type RefundAllocation, type RefundDestinationOption } from './refund'
|
|
6
|
+
|
|
7
|
+
/** Estado físico de lo que regresa. */
|
|
8
|
+
export type ReturnCondition = 'sellable' | 'defective'
|
|
9
|
+
/** A dónde va la mercancía: stock vendible, merma o garantía (reclamo al proveedor). */
|
|
10
|
+
export type ReturnStockDestination = 'stock' | 'scrap' | 'warranty'
|
|
11
|
+
|
|
12
|
+
export const RETURN_CONDITION_DESTINATIONS: Record<ReturnCondition, ReturnStockDestination[]> = {
|
|
13
|
+
sellable: ['stock'],
|
|
14
|
+
defective: ['scrap', 'warranty'],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Destino por defecto de una condición (vendible → stock, defectuoso → merma). */
|
|
18
|
+
export function defaultStockDestination(condition: ReturnCondition): ReturnStockDestination {
|
|
19
|
+
return RETURN_CONDITION_DESTINATIONS[condition][0]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Renglón devolvible: lo vendido menos lo ya devuelto. */
|
|
23
|
+
export interface ReturnableLine {
|
|
24
|
+
key: string
|
|
25
|
+
product_id?: string
|
|
26
|
+
description: string
|
|
27
|
+
sold: number
|
|
28
|
+
alreadyReturned?: number
|
|
29
|
+
/** Precio unitario neto de descuento tal como se vendió (congelado). */
|
|
30
|
+
unit_price: number
|
|
31
|
+
/** Fracción (0.16). */
|
|
32
|
+
tax_rate?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ReturnLineChoice {
|
|
36
|
+
quantity: number
|
|
37
|
+
condition: ReturnCondition
|
|
38
|
+
destination: ReturnStockDestination
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ReturnChoices = Record<string, ReturnLineChoice>
|
|
42
|
+
|
|
43
|
+
export function returnableQty(l: ReturnableLine): number {
|
|
44
|
+
return Math.max(0, roundMoney(l.sold - (l.alreadyReturned ?? 0), 4))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ReturnTotals {
|
|
48
|
+
base: number
|
|
49
|
+
tax: number
|
|
50
|
+
total: number
|
|
51
|
+
goodsLines: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function computeReturnTotals(lines: ReturnableLine[], choices: ReturnChoices): ReturnTotals {
|
|
55
|
+
let base = 0
|
|
56
|
+
let tax = 0
|
|
57
|
+
let goodsLines = 0
|
|
58
|
+
for (const l of lines) {
|
|
59
|
+
const c = choices[l.key]
|
|
60
|
+
const q = c ? toAmount(c.quantity) : 0
|
|
61
|
+
if (q <= 0) continue
|
|
62
|
+
goodsLines += 1
|
|
63
|
+
const b = roundMoney(q * l.unit_price)
|
|
64
|
+
base += b
|
|
65
|
+
tax += roundMoney(b * (l.tax_rate ?? 0))
|
|
66
|
+
}
|
|
67
|
+
base = roundMoney(base)
|
|
68
|
+
tax = roundMoney(tax)
|
|
69
|
+
return { base, tax, total: roundMoney(base + tax), goodsLines }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Relación SAT (c_TipoRelacion) de la NC: 03 «devolución de mercancía» si regresan
|
|
74
|
+
* renglones con producto; 01 «nota de crédito de los documentos relacionados» si es
|
|
75
|
+
* solo ajuste de importe. Misma regla que aplica el backend al proyectar la NC.
|
|
76
|
+
*/
|
|
77
|
+
export function creditNoteRelation(lines: ReturnableLine[], choices: ReturnChoices): '01' | '03' {
|
|
78
|
+
return lines.some((l) => !!l.product_id && toAmount(choices[l.key]?.quantity) > 0) ? '03' : '01'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ReturnValidation {
|
|
82
|
+
errors: Record<string, string>
|
|
83
|
+
form?: string
|
|
84
|
+
valid: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function validateReturnChoices(lines: ReturnableLine[], choices: ReturnChoices, reason?: string, requireReason = true): ReturnValidation {
|
|
88
|
+
const errors: Record<string, string> = {}
|
|
89
|
+
let any = false
|
|
90
|
+
for (const l of lines) {
|
|
91
|
+
const c = choices[l.key]
|
|
92
|
+
const q = c ? toAmount(c.quantity) : 0
|
|
93
|
+
if (q === 0) continue
|
|
94
|
+
if (q < 0) errors[`${l.key}.quantity`] = 'La cantidad no puede ser negativa'
|
|
95
|
+
else if (q > returnableQty(l)) errors[`${l.key}.quantity`] = `Máximo ${returnableQty(l)} por devolver`
|
|
96
|
+
else any = true
|
|
97
|
+
if (c && !RETURN_CONDITION_DESTINATIONS[c.condition]?.includes(c.destination)) {
|
|
98
|
+
errors[`${l.key}.destination`] = 'Ese destino no aplica a la condición elegida'
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
let form: string | undefined
|
|
102
|
+
if (!any && Object.keys(errors).length === 0) form = 'Elige al menos un renglón a devolver'
|
|
103
|
+
else if (requireReason && !(reason ?? '').trim()) form = 'Indica el motivo de la devolución'
|
|
104
|
+
return { errors, form, valid: !form && Object.keys(errors).length === 0 }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Pasos visibles. Sin almacén, «Recibir» es un paso del mismo asistente (PIT-021). */
|
|
108
|
+
export type ReturnStep = 'lines' | 'condition' | 'receive' | 'refund'
|
|
109
|
+
|
|
110
|
+
export function returnSteps(opts: { warehouseConnected: boolean }): ReturnStep[] {
|
|
111
|
+
// Con almacén, la recepción la confirma almacén fuera del asistente: no es paso.
|
|
112
|
+
return opts.warehouseConnected ? ['lines', 'condition', 'refund'] : ['lines', 'condition', 'receive', 'refund']
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface ReturnSubmitPayload {
|
|
116
|
+
reason: string
|
|
117
|
+
notes?: string
|
|
118
|
+
/** true cuando el asistente confirmó la recepción (sin almacén). */
|
|
119
|
+
received: boolean
|
|
120
|
+
lines: { key: string; product_id?: string; quantity: number; condition: ReturnCondition; destination: ReturnStockDestination }[]
|
|
121
|
+
refund: RefundAllocation[]
|
|
122
|
+
/** Presente solo si el reembolso incluye una NC CFDI. */
|
|
123
|
+
credit_note?: { relation: '01' | '03' }
|
|
124
|
+
totals: ReturnTotals
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Única puerta de salida hacia el backend: solo renglones con cantidad > 0. */
|
|
128
|
+
export function serializeReturn(args: {
|
|
129
|
+
lines: ReturnableLine[]
|
|
130
|
+
choices: ReturnChoices
|
|
131
|
+
reason: string
|
|
132
|
+
notes?: string
|
|
133
|
+
received: boolean
|
|
134
|
+
refund: RefundAllocation[]
|
|
135
|
+
}): ReturnSubmitPayload {
|
|
136
|
+
const lines = args.lines
|
|
137
|
+
.filter((l) => toAmount(args.choices[l.key]?.quantity) > 0)
|
|
138
|
+
.map((l) => {
|
|
139
|
+
const c = args.choices[l.key]
|
|
140
|
+
return { key: l.key, ...(l.product_id ? { product_id: l.product_id } : {}), quantity: toAmount(c.quantity), condition: c.condition, destination: c.destination }
|
|
141
|
+
})
|
|
142
|
+
const notes = (args.notes ?? '').trim()
|
|
143
|
+
return {
|
|
144
|
+
reason: args.reason.trim(),
|
|
145
|
+
...(notes ? { notes } : {}),
|
|
146
|
+
received: args.received,
|
|
147
|
+
lines,
|
|
148
|
+
refund: args.refund.map((a) => ({ ...a, amount: roundMoney(a.amount) })),
|
|
149
|
+
...(args.refund.some((a) => a.kind === 'credit_note') ? { credit_note: { relation: creditNoteRelation(args.lines, args.choices) } } : {}),
|
|
150
|
+
totals: computeReturnTotals(args.lines, args.choices),
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Valida el paso de reembolso contra el total de la devolución. */
|
|
155
|
+
export function validateReturnRefund(total: number, refund: RefundAllocation[], options?: RefundDestinationOption[]) {
|
|
156
|
+
return validateRefund(total, refund, options)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---- Estados de la RMA ---------------------------------------------------------
|
|
160
|
+
// Borrador → Autorizada → Recibida (total o parcial) → Liquidada → Cerrada, y
|
|
161
|
+
// Rechazada o Cancelada con motivo obligatorio (benchmark §8.2).
|
|
162
|
+
export type RmaState = 'draft' | 'authorized' | 'received' | 'settled' | 'closed' | 'rejected' | 'cancelled'
|
|
163
|
+
|
|
164
|
+
export const RMA_TRANSITIONS: Record<RmaState, RmaState[]> = {
|
|
165
|
+
draft: ['authorized', 'rejected', 'cancelled'],
|
|
166
|
+
authorized: ['received', 'rejected', 'cancelled'],
|
|
167
|
+
received: ['settled', 'cancelled'],
|
|
168
|
+
settled: ['closed'],
|
|
169
|
+
closed: [],
|
|
170
|
+
rejected: [],
|
|
171
|
+
cancelled: [],
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Estados a los que se llega con motivo obligatorio. */
|
|
175
|
+
export const RMA_REASON_REQUIRED: RmaState[] = ['rejected', 'cancelled']
|
|
176
|
+
|
|
177
|
+
export function canTransitionRma(from: RmaState, to: RmaState, reason?: string): { ok: boolean; error?: string } {
|
|
178
|
+
if (!RMA_TRANSITIONS[from]?.includes(to)) return { ok: false, error: `No se puede pasar de ${from} a ${to}` }
|
|
179
|
+
if (RMA_REASON_REQUIRED.includes(to) && !(reason ?? '').trim()) return { ok: false, error: 'Indica el motivo' }
|
|
180
|
+
return { ok: true }
|
|
181
|
+
}
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -1312,7 +1312,11 @@ export function makeDefaultGetDynamicColumns(
|
|
|
1312
1312
|
|
|
1313
1313
|
case 'tags': {
|
|
1314
1314
|
const list: string[] = Array.isArray(value)
|
|
1315
|
-
? value.map(
|
|
1315
|
+
? value.map((v) =>
|
|
1316
|
+
v && typeof v === 'object'
|
|
1317
|
+
? String((v as { label?: unknown; name?: unknown; key?: unknown }).label ?? (v as { name?: unknown }).name ?? (v as { key?: unknown }).key ?? '')
|
|
1318
|
+
: String(v),
|
|
1319
|
+
).filter(Boolean)
|
|
1316
1320
|
: value
|
|
1317
1321
|
? String(value)
|
|
1318
1322
|
.split(',')
|