feat(web): formulario de pagos recibidos y efectuados (documentos abiertos, medios de pago, cheques, cuentas, anulación)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
369be317ab
commit
3d6f76a1dd
14 files changed
+1213
-1
No files matched your search
@@ -0,0 +1,242 @@
|
||||
import { escapeODataString } from '../../core/sl/odata';
|
||||
import type { PaymentDocType, PaymentScreen } from './payment-types';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const round2 = (n: number): number => Math.round((n + Number.EPSILON) * 100) / 100;
|
||||
const num = (v: unknown): number => {
|
||||
const n = Number(v ?? 0);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
const has = (v: unknown): boolean => !(v === null || v === undefined || (typeof v === 'string' && v.trim() === ''));
|
||||
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
|
||||
|
||||
/** Un documento pendiente de pago del socio (factura o nota de crédito) con lo que se aplica de él. */
|
||||
export interface OpenDoc {
|
||||
/** Único dentro del pago: `Invoices:206`. */
|
||||
id: string;
|
||||
docEntry: number;
|
||||
docNum: number;
|
||||
kind: 'invoice' | 'credit';
|
||||
/** Tipo de documento al que se aplica (`PaymentInvoices.InvoiceType`). */
|
||||
type: string;
|
||||
label: string;
|
||||
date: string;
|
||||
dueDate: string;
|
||||
/** Folio o referencia que ve el usuario. */
|
||||
reference: string;
|
||||
total: number;
|
||||
/** Lo que falta por pagar o aplicar. */
|
||||
balance: number;
|
||||
/** Lo que este pago aplica; en una nota de crédito es lo que se descuenta. */
|
||||
applied: number;
|
||||
}
|
||||
|
||||
/** Medios de pago del borrador. */
|
||||
export interface Means {
|
||||
cashAccount: string;
|
||||
cashSum: number | null;
|
||||
transferAccount: string;
|
||||
transferSum: number | null;
|
||||
transferDate: string;
|
||||
transferReference: string;
|
||||
/** Cuenta de cheques en cartera: SAP la exige a nivel del pago cuando hay cheques. */
|
||||
checkAccount: string;
|
||||
}
|
||||
|
||||
export const EMPTY_MEANS: Means = {
|
||||
cashAccount: '',
|
||||
cashSum: null,
|
||||
transferAccount: '',
|
||||
transferSum: null,
|
||||
transferDate: '',
|
||||
transferReference: '',
|
||||
checkAccount: '',
|
||||
};
|
||||
|
||||
export interface PaymentDraft {
|
||||
docType: PaymentDocType;
|
||||
cardCode: string;
|
||||
docDate: string;
|
||||
taxDate: string;
|
||||
series: number | null;
|
||||
remarks: string;
|
||||
journalRemarks: string;
|
||||
means: Means;
|
||||
checks: Row[];
|
||||
docs: OpenDoc[];
|
||||
accounts: Row[];
|
||||
}
|
||||
|
||||
// --- documentos pendientes ------------------------------------------------------------------
|
||||
|
||||
/** Campos que se piden de cada factura o nota de crédito pendiente. */
|
||||
export const OPEN_DOC_FIELDS = 'DocEntry,DocNum,DocDate,DocDueDate,DocTotal,PaidToDate,NumAtCard,FolioPrefixString,FolioNumber,DocCurrency';
|
||||
|
||||
/** Filtro de los documentos pendientes de un socio: abiertos y en la moneda local. */
|
||||
export function openDocsFilter(cardCode: string, localCurrency: string): string {
|
||||
return `CardCode eq '${escapeODataString(cardCode)}' and DocumentStatus eq 'bost_Open' and DocCurrency eq '${escapeODataString(localCurrency)}'`;
|
||||
}
|
||||
|
||||
/** Una fila de `Invoices`/`CreditNotes` de SAP como documento pendiente. */
|
||||
export function toOpenDoc(screen: PaymentScreen, kind: 'invoice' | 'credit', row: Row): OpenDoc {
|
||||
const spec = kind === 'invoice' ? screen.invoices : screen.credits;
|
||||
const folio = has(row['FolioNumber']) ? `${String(row['FolioPrefixString'] ?? '')}${String(row['FolioNumber'])}` : '';
|
||||
return {
|
||||
id: `${spec.resource}:${String(row['DocEntry'])}`,
|
||||
docEntry: Number(row['DocEntry']),
|
||||
docNum: Number(row['DocNum']),
|
||||
kind,
|
||||
type: spec.type,
|
||||
label: spec.label,
|
||||
date: dateOnly(row['DocDate']),
|
||||
dueDate: dateOnly(row['DocDueDate']),
|
||||
reference: folio || String(row['NumAtCard'] ?? ''),
|
||||
total: round2(num(row['DocTotal'])),
|
||||
balance: round2(num(row['DocTotal']) - num(row['PaidToDate'])),
|
||||
applied: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pagar (o aplicar) todo el saldo de los documentos, o nada. */
|
||||
export const applyAll = (docs: readonly OpenDoc[], on: boolean): OpenDoc[] => docs.map((d) => ({ ...d, applied: on ? d.balance : 0 }));
|
||||
|
||||
// --- totales --------------------------------------------------------------------------------
|
||||
|
||||
export interface PaymentTotals {
|
||||
/** Facturas aplicadas. */
|
||||
invoices: number;
|
||||
/** Notas de crédito descontadas. */
|
||||
credits: number;
|
||||
/** Aplicado neto: facturas menos notas de crédito. */
|
||||
applied: number;
|
||||
cash: number;
|
||||
transfer: number;
|
||||
checks: number;
|
||||
/** Suma de los medios de pago. */
|
||||
means: number;
|
||||
/** Lo que sobra de los medios sobre lo aplicado: queda a cuenta del socio. */
|
||||
onAccount: number;
|
||||
/** Suma de las cuentas contables (pago a cuentas). */
|
||||
accounts: number;
|
||||
}
|
||||
|
||||
export function paymentTotals(d: Pick<PaymentDraft, 'means' | 'checks' | 'docs' | 'accounts'>): PaymentTotals {
|
||||
const invoices = round2(d.docs.filter((x) => x.kind === 'invoice').reduce((s, x) => s + num(x.applied), 0));
|
||||
const credits = round2(d.docs.filter((x) => x.kind === 'credit').reduce((s, x) => s + num(x.applied), 0));
|
||||
const cash = round2(num(d.means.cashSum));
|
||||
const transfer = round2(num(d.means.transferSum));
|
||||
const checks = round2(d.checks.reduce((s, c) => s + num(c['CheckSum']), 0));
|
||||
const means = round2(cash + transfer + checks);
|
||||
const applied = round2(invoices - credits);
|
||||
return {
|
||||
invoices,
|
||||
credits,
|
||||
applied,
|
||||
cash,
|
||||
transfer,
|
||||
checks,
|
||||
means,
|
||||
onAccount: round2(Math.max(0, means - applied)),
|
||||
accounts: round2(d.accounts.reduce((s, a) => s + num(a['SumPaid']), 0)),
|
||||
};
|
||||
}
|
||||
|
||||
// --- validación -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Errores que se pueden detectar sin preguntarle a SAP. Importa uno en especial: SAP **no rechaza** un importe
|
||||
* aplicado mayor que el saldo de la factura, lo recorta en silencio y manda el resto a cuenta, así que aquí no
|
||||
* se permite.
|
||||
*/
|
||||
export function validatePayment(draft: PaymentDraft): string[] {
|
||||
const errors: string[] = [];
|
||||
const t = paymentTotals(draft);
|
||||
const toAccounts = draft.docType === 'rAccount';
|
||||
|
||||
if (!toAccounts && !has(draft.cardCode)) errors.push(draft.docType === 'rSupplier' ? 'Indique el proveedor.' : 'Indique el cliente.');
|
||||
if (!has(draft.docDate)) errors.push('Indique la fecha de contabilización.');
|
||||
if (t.means <= 0) errors.push('Indique al menos un medio de pago con su importe.');
|
||||
if (t.cash > 0 && !has(draft.means.cashAccount)) errors.push('Indique la cuenta del efectivo.');
|
||||
if (t.transfer > 0 && !has(draft.means.transferAccount)) errors.push('Indique la cuenta de la transferencia.');
|
||||
if (t.checks > 0 && !has(draft.means.checkAccount)) errors.push('Indique la cuenta de cheques.');
|
||||
|
||||
draft.checks.forEach((c, i) => {
|
||||
if (!has(c['CheckSum']) && !has(c['CheckNumber']) && !has(c['BankCode'])) return; // fila vacía
|
||||
if (num(c['CheckSum']) <= 0) errors.push(`Cheque ${i + 1}: indique el importe.`);
|
||||
if (!has(c['CheckNumber'])) errors.push(`Cheque ${i + 1}: indique el número.`);
|
||||
if (!has(c['BankCode'])) errors.push(`Cheque ${i + 1}: indique el banco.`);
|
||||
if (!has(c['DueDate'])) errors.push(`Cheque ${i + 1}: indique el vencimiento.`);
|
||||
});
|
||||
|
||||
if (toAccounts) {
|
||||
const rows = draft.accounts.filter((a) => has(a['AccountCode']) || has(a['SumPaid']));
|
||||
if (!rows.length) errors.push('Agregue al menos una cuenta contable.');
|
||||
rows.forEach((a, i) => {
|
||||
if (!has(a['AccountCode'])) errors.push(`Cuenta ${i + 1}: indique la cuenta.`);
|
||||
if (num(a['SumPaid']) <= 0) errors.push(`Cuenta ${i + 1}: indique el importe.`);
|
||||
});
|
||||
if (rows.length && t.means > 0 && t.accounts !== t.means) errors.push(`Los medios de pago (${t.means}) deben igualar el total de las cuentas (${t.accounts}).`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
for (const d of draft.docs) {
|
||||
if (num(d.applied) < 0) errors.push(`${d.label} ${d.docNum}: el importe no puede ser negativo.`);
|
||||
if (num(d.applied) > d.balance) errors.push(`${d.label} ${d.docNum}: no se puede aplicar más que su saldo (${d.balance}).`);
|
||||
}
|
||||
if (t.applied > t.means) errors.push(`Los medios de pago (${t.means}) no cubren lo aplicado a los documentos (${t.applied}).`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
// --- lo que se envía a SAP ------------------------------------------------------------------
|
||||
|
||||
const clean = (r: Row): Row => Object.fromEntries(Object.entries(r).filter(([, v]) => has(v)));
|
||||
|
||||
/**
|
||||
* Cuerpo para crear el pago. Una nota de crédito se aplica con importe **negativo** (SAP rechaza el positivo);
|
||||
* lo que los medios de pago dejan sin aplicar a documentos queda a cuenta del socio sin indicarlo.
|
||||
*/
|
||||
export function paymentPayload(screen: PaymentScreen, d: PaymentDraft): Row {
|
||||
const t = paymentTotals(d);
|
||||
const body: Row = { DocType: d.docType, DocDate: d.docDate };
|
||||
if (d.docType !== 'rAccount') body['CardCode'] = d.cardCode;
|
||||
if (has(d.taxDate)) body['TaxDate'] = d.taxDate;
|
||||
if (d.series) body['Series'] = d.series;
|
||||
if (has(d.remarks)) body['Remarks'] = d.remarks;
|
||||
if (has(d.journalRemarks)) body['JournalRemarks'] = d.journalRemarks;
|
||||
|
||||
if (t.cash > 0) Object.assign(body, { CashAccount: d.means.cashAccount, CashSum: t.cash });
|
||||
if (t.transfer > 0) {
|
||||
Object.assign(body, { TransferAccount: d.means.transferAccount, TransferSum: t.transfer, TransferDate: has(d.means.transferDate) ? d.means.transferDate : d.docDate });
|
||||
if (has(d.means.transferReference)) body['TransferReference'] = d.means.transferReference;
|
||||
}
|
||||
const checks = d.checks.filter((c) => num(c['CheckSum']) > 0);
|
||||
if (checks.length) {
|
||||
body['CheckAccount'] = d.means.checkAccount;
|
||||
body['PaymentChecks'] = checks.map((c) =>
|
||||
clean({
|
||||
CheckNumber: c['CheckNumber'],
|
||||
BankCode: c['BankCode'],
|
||||
Branch: c['Branch'],
|
||||
AccounttNum: c['AccounttNum'],
|
||||
DueDate: c['DueDate'],
|
||||
CheckSum: round2(num(c['CheckSum'])),
|
||||
CountryCode: screen.countryCode,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (d.docType === 'rAccount') {
|
||||
body['PaymentAccounts'] = d.accounts.filter((a) => num(a['SumPaid']) > 0).map((a) => clean({ AccountCode: a['AccountCode'], SumPaid: round2(num(a['SumPaid'])), Decription: a['Decription'] }));
|
||||
} else {
|
||||
const applied = d.docs.filter((x) => num(x.applied) > 0);
|
||||
if (applied.length) {
|
||||
body['PaymentInvoices'] = applied.map((x) => ({
|
||||
DocEntry: x.docEntry,
|
||||
InvoiceType: x.type,
|
||||
SumApplied: x.kind === 'credit' ? -round2(num(x.applied)) : round2(num(x.applied)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
Reference in new issue
Block a user