POS: totales y pago en USD con tipo de cambio
- Equivalente USD en totales del ticket (TC configurable) - Selector MXN/USD al cobrar: recibido en dólares convertido a MXN, cambio en ambas monedas, referencia 'USD X @ TC Y' en el pago - Validación de TC antes de crear la venta (sin ventas huérfanas)
This commit is contained in:
@@ -20,6 +20,9 @@ import { odooApi, type Service, type Patient, type InventoryItem, type PosChecko
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
|
||||
|
||||
const fmtUsd = (n: number) =>
|
||||
`$${(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} USD`;
|
||||
|
||||
const METODO_OPTIONS = [
|
||||
{ value: 'cash', label: 'Efectivo' },
|
||||
{ value: 'card', label: 'Tarjeta' },
|
||||
@@ -62,9 +65,11 @@ const Pos: FC = () => {
|
||||
// Cobro
|
||||
const [cobroOpen, setCobroOpen] = useState(false);
|
||||
const [metodo, setMetodo] = useState('cash');
|
||||
const [moneda, setMoneda] = useState<'MXN' | 'USD'>('MXN');
|
||||
const [recibido, setRecibido] = useState('');
|
||||
const [conPuntos, setConPuntos] = useState(false);
|
||||
const [cobrando, setCobrando] = useState(false);
|
||||
const [tcRate, setTcRate] = useState<number | null>(null);
|
||||
|
||||
// Resultado
|
||||
const [resultado, setResultado] = useState<PosCheckoutResult | null>(null);
|
||||
@@ -118,6 +123,18 @@ const Pos: FC = () => {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Tipo de cambio USD/MXN: al montar y refresco cada 5 min
|
||||
useEffect(() => {
|
||||
const fetchTc = () => {
|
||||
odooApi.getExchangeRate()
|
||||
.then((res) => { if (res.current) setTcRate(res.current.rate); })
|
||||
.catch(() => {});
|
||||
};
|
||||
fetchTc();
|
||||
const interval = setInterval(fetchTc, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Búsqueda server-side de paciente
|
||||
useEffect(() => {
|
||||
if (paciente || altaRapida) return;
|
||||
@@ -183,7 +200,15 @@ const Pos: FC = () => {
|
||||
const numItems = lines.reduce((a, l) => a + l.qty, 0);
|
||||
const puntosPaciente = paciente?.wallet_points ?? 0;
|
||||
const puedePuntos = puntosPaciente >= total && total > 0;
|
||||
const cambio = metodo === 'cash' && recibido ? Math.max(0, (parseFloat(recibido) || 0) - total) : 0;
|
||||
// Equivalentes USD (si hay tipo de cambio configurado)
|
||||
const toUsd = (mxn: number): number | null => (tcRate ? mxn / tcRate : null);
|
||||
const recibidoMxn = recibido
|
||||
? moneda === 'USD' && tcRate
|
||||
? (parseFloat(recibido) || 0) * tcRate
|
||||
: parseFloat(recibido) || 0
|
||||
: 0;
|
||||
const cambio = metodo === 'cash' && recibido ? Math.max(0, recibidoMxn - total) : 0;
|
||||
const cambioUsd = moneda === 'USD' && tcRate ? cambio / tcRate : 0;
|
||||
|
||||
const crearPacienteRapido = async () => {
|
||||
if (!altaForm.name.trim() || !altaForm.phone.trim()) {
|
||||
@@ -214,6 +239,7 @@ const Pos: FC = () => {
|
||||
return;
|
||||
}
|
||||
setMetodo('cash');
|
||||
setMoneda('MXN');
|
||||
setRecibido('');
|
||||
setConPuntos(false);
|
||||
setCobroOpen(true);
|
||||
@@ -235,6 +261,7 @@ const Pos: FC = () => {
|
||||
payment_method: metodo,
|
||||
amount_received: metodo === 'cash' && recibido ? parseFloat(recibido) : undefined,
|
||||
pay_with_points: conPuntos,
|
||||
currency: moneda,
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
setResultado(res);
|
||||
@@ -372,23 +399,41 @@ const Pos: FC = () => {
|
||||
<>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-theme-muted">Subtotal</span>
|
||||
<span className="text-theme-heading">{fmtMoney(subtotal)}</span>
|
||||
<span className="text-theme-heading">
|
||||
{fmtMoney(subtotal)}
|
||||
<span className="text-xs text-theme-muted ml-1.5" title={toUsd(subtotal) === null ? 'Configura el tipo de cambio en Configuración' : undefined}>
|
||||
{toUsd(subtotal) !== null ? `≈ ${fmtUsd(toUsd(subtotal)!)}` : '—'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm gap-2">
|
||||
<span className="text-theme-muted">Descuento</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={50}
|
||||
value={discount}
|
||||
onChange={(e) => setDiscount(e.target.value)}
|
||||
placeholder="0"
|
||||
className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right"
|
||||
/>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{discountNum > 0 && toUsd(discountNum) !== null && (
|
||||
<span className="text-xs text-theme-muted">≈ {fmtUsd(toUsd(discountNum)!)}</span>
|
||||
)}
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={50}
|
||||
value={discount}
|
||||
onChange={(e) => setDiscount(e.target.value)}
|
||||
placeholder="0"
|
||||
className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-base font-semibold text-theme-heading">Total</span>
|
||||
<span className="text-xl font-heading font-bold text-theme-heading">{fmtMoney(total)}</span>
|
||||
<span className="text-right">
|
||||
<span className="text-xl font-heading font-bold text-theme-heading">{fmtMoney(total)}</span>
|
||||
<span
|
||||
className="block text-xs text-theme-muted"
|
||||
title={toUsd(total) === null ? 'Configura el tipo de cambio en Configuración' : undefined}
|
||||
>
|
||||
{toUsd(total) !== null ? `≈ ${fmtUsd(toUsd(total)!)}` : '—'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full mt-1 !py-3 text-base"
|
||||
@@ -591,24 +636,60 @@ const Pos: FC = () => {
|
||||
<Select label="Método de pago" options={METODO_OPTIONS} value={metodo} onChange={(e) => setMetodo(e.target.value)} />
|
||||
{metodo === 'cash' && !conPuntos && (
|
||||
<>
|
||||
<div>
|
||||
<p className="block text-xs font-medium mb-1.5 text-theme-muted">Moneda</p>
|
||||
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMoneda('MXN')}
|
||||
className={`px-4 py-1.5 text-sm font-medium transition ${
|
||||
moneda === 'MXN' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
MXN
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => tcRate && setMoneda('USD')}
|
||||
disabled={!tcRate}
|
||||
title={!tcRate ? 'Configura el tipo de cambio en Configuración' : undefined}
|
||||
className={`px-4 py-1.5 text-sm font-medium transition ${
|
||||
moneda === 'USD' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
||||
} ${!tcRate ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
USD
|
||||
</button>
|
||||
</div>
|
||||
{moneda === 'USD' && tcRate && (
|
||||
<p className="text-xs text-theme-muted mt-1.5">Tipo de cambio: {tcRate.toFixed(2)} MXN por 1 USD</p>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
label="Recibido"
|
||||
label={moneda === 'USD' ? 'Recibido (USD)' : 'Recibido'}
|
||||
type="number"
|
||||
min={0}
|
||||
step={10}
|
||||
value={recibido}
|
||||
onChange={(e) => setRecibido(e.target.value)}
|
||||
placeholder={String(total)}
|
||||
placeholder={moneda === 'USD' && tcRate ? (total / tcRate).toFixed(2) : String(total)}
|
||||
/>
|
||||
{moneda === 'USD' && recibido && tcRate && (
|
||||
<p className="text-sm text-theme-muted">≈ {fmtMoney(recibidoMxn)} MXN</p>
|
||||
)}
|
||||
{recibido && (
|
||||
<p className="text-sm">
|
||||
<span className="text-theme-muted">Cambio: </span>
|
||||
<span className={`font-semibold ${cambio > 0 ? 'text-theme-heading' : 'text-theme-muted'}`}>{fmtMoney(cambio)}</span>
|
||||
<span className={`font-semibold ${cambio > 0 ? 'text-theme-heading' : 'text-theme-muted'}`}>
|
||||
{fmtMoney(cambio)}
|
||||
{moneda === 'USD' && cambio > 0 && (
|
||||
<span className="text-theme-muted font-normal"> (≈ {fmtUsd(cambioUsd)})</span>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{puedePuntos && (
|
||||
{puedePuntos && moneda === 'MXN' && (
|
||||
<label className="flex items-center gap-3 p-3 border border-theme-border-strong rounded-xl cursor-pointer hover:bg-theme-bg">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -676,8 +757,19 @@ const Pos: FC = () => {
|
||||
)}
|
||||
<div className="flex justify-between font-bold text-theme-heading"><span>Total</span><span>{fmtMoney(resultado.sale.total)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-theme-muted">Método</span><span>{resultado.payment ? metodoLabel(resultado.payment.payment_method) : 'Puntos'}</span></div>
|
||||
{resultado.recibido_usd && resultado.recibido_usd > 0 && resultado.rate_used && (
|
||||
<div className="flex justify-between"><span className="text-theme-muted">Recibido USD</span><span>{fmtUsd(resultado.recibido_usd)} (TC {resultado.rate_used.toFixed(2)})</span></div>
|
||||
)}
|
||||
{resultado.cambio > 0 && (
|
||||
<div className="flex justify-between"><span className="text-theme-muted">Cambio</span><span>{fmtMoney(resultado.cambio)}</span></div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-theme-muted">Cambio</span>
|
||||
<span>
|
||||
{fmtMoney(resultado.cambio)}
|
||||
{resultado.cambio_usd && resultado.cambio_usd > 0 && (
|
||||
<span className="text-theme-muted"> · {fmtUsd(resultado.cambio_usd)}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{resultado.puntos_ganados > 0 && (
|
||||
<div className="flex justify-between"><span className="text-theme-muted">Puntos ganados</span><span>+{resultado.puntos_ganados} pts</span></div>
|
||||
|
||||
@@ -690,6 +690,7 @@ export interface PosCheckoutPayload {
|
||||
payment_method?: string;
|
||||
amount_received?: number;
|
||||
pay_with_points?: boolean;
|
||||
currency?: 'MXN' | 'USD';
|
||||
}
|
||||
|
||||
export interface PosCheckoutResult {
|
||||
@@ -697,6 +698,9 @@ export interface PosCheckoutResult {
|
||||
sale: Sale;
|
||||
payment: Payment | null;
|
||||
cambio: number;
|
||||
cambio_usd?: number;
|
||||
rate_used?: number | null;
|
||||
recibido_usd?: number;
|
||||
puntos_usados: number;
|
||||
puntos_ganados: number;
|
||||
wallet_points: number;
|
||||
|
||||
@@ -2016,6 +2016,17 @@ class SkeenFrontendController(http.Controller):
|
||||
}))
|
||||
total_est = max(0.0, subtotal - discount)
|
||||
|
||||
# Moneda y tipo de cambio: validar ANTES de crear nada
|
||||
currency = data.get('currency', 'MXN')
|
||||
rate_used = None
|
||||
if currency == 'USD':
|
||||
tc = request.env['skeen.tipo.cambio'].sudo().search([], order='date desc, id desc', limit=1)
|
||||
if not tc:
|
||||
return json_response({'status': 'error', 'message': 'Configura el tipo de cambio USD en Configuración'}, 400)
|
||||
rate_used = tc.rate
|
||||
elif currency != 'MXN':
|
||||
return json_response({'status': 'error', 'message': 'Moneda no soportada (MXN/USD)'}, 400)
|
||||
|
||||
# Validar saldo de puntos ANTES de crear nada
|
||||
pay_with_points = bool(data.get('pay_with_points'))
|
||||
Monedero = request.env['skeen.monedero'].sudo()
|
||||
@@ -2061,16 +2072,27 @@ class SkeenFrontendController(http.Controller):
|
||||
restante = round(venta.amount_due, 2)
|
||||
pago = None
|
||||
cambio = 0.0
|
||||
cambio_usd = 0.0
|
||||
recibido_usd = 0.0
|
||||
if restante > 0:
|
||||
received = float(data.get('amount_received', restante) or restante)
|
||||
reference = ''
|
||||
if currency == 'USD':
|
||||
# Recibido en dólares: convertir a MXN con el TC vigente (ya validado arriba)
|
||||
recibido_usd = received
|
||||
received = round(recibido_usd * rate_used, 2)
|
||||
reference = f'USD {recibido_usd:.2f} @ TC {rate_used:.2f}'
|
||||
pay_amount = restante if received >= restante - 0.001 else received
|
||||
cambio = max(0.0, round(received - restante, 2)) if received > restante else 0.0
|
||||
if rate_used:
|
||||
cambio_usd = round(cambio / rate_used, 2)
|
||||
pago = request.env['skeen.pago'].sudo().create({
|
||||
'partner_id': partner.id,
|
||||
'amount': pay_amount,
|
||||
'payment_method': method,
|
||||
'state': 'completed',
|
||||
'payment_date': fields.Datetime.now(),
|
||||
'provider_reference': reference,
|
||||
})
|
||||
venta.write({'amount_paid': venta.amount_paid + pay_amount})
|
||||
if venta.amount_paid >= venta.total:
|
||||
@@ -2086,6 +2108,9 @@ class SkeenFrontendController(http.Controller):
|
||||
'sale': self._sale_to_dict(venta),
|
||||
'payment': self._payment_to_dict(pago) if pago else None,
|
||||
'cambio': cambio,
|
||||
'cambio_usd': cambio_usd,
|
||||
'rate_used': rate_used,
|
||||
'recibido_usd': recibido_usd,
|
||||
'puntos_usados': puntos_usados,
|
||||
'puntos_ganados': int(venta.total / 10) if venta.state == 'paid' else 0,
|
||||
'wallet_points': partner.wallet_points,
|
||||
|
||||
Reference in New Issue
Block a user