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
@@ -17,6 +17,12 @@ Monorepo npm workspaces:
|
|||||||
- La fila de un formulario es **etiqueta + uno o varios controles** (input, select, número, fecha, botón); la cabecera tiene **como máximo 2 columnas**.
|
- La fila de un formulario es **etiqueta + uno o varios controles** (input, select, número, fecha, botón); la cabecera tiene **como máximo 2 columnas**.
|
||||||
- Impuesto de las líneas: `TaxCode` con el maestro `SalesTaxCodes` (verificado en el Service Layer; `VatGroups` no tiene `Rate`). Un cliente con `VatGroup` lo cambia en su rama.
|
- Impuesto de las líneas: `TaxCode` con el maestro `SalesTaxCodes` (verificado en el Service Layer; `VatGroups` no tiene `Rate`). Un cliente con `VatGroup` lo cambia en su rama.
|
||||||
|
|
||||||
|
## Bancos (pagos recibidos y efectuados)
|
||||||
|
|
||||||
|
Menú `Bancos` → `/bancos/pagos-recibidos` (`IncomingPayments`) y `/bancos/pagos-efectuados` (`VendorPayments`), en `apps/web/src/app/features/banking/`. Medios: efectivo, transferencia y cheques; se aplican a facturas/notas de crédito abiertas del socio, a cuenta (lo que sobra de los medios queda como anticipo, implícito) o a cuentas contables (`rAccount`). Un pago contabilizado no se edita: se consulta y se anula (`POST Recurso(clave)/Cancel`, acción en la lista blanca de `sl-resources.ts`, auditada como `sl_action`).
|
||||||
|
|
||||||
|
Reglas de SAP verificadas: nota de crédito se aplica con `SumApplied` **negativo**; los cheques exigen `CheckAccount` en la cabecera; pagar de más trunca en silencio (se valida en el cliente); solo moneda local (`localCurrency` de la pantalla, hoy CLP). En `SBODemoCL` los pagos efectuados a facturas fallan por falta de la cuenta de "diferencias de conversión realizadas" (configuración de SAP); los de `rAccount` funcionan. Pendiente: depósitos y conciliación bancaria, moneda extranjera.
|
||||||
|
|
||||||
## Criterios de la interfaz
|
## Criterios de la interfaz
|
||||||
|
|
||||||
Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de componentes (Material/PrimeNG), sin webfonts ni animaciones, componentes propios y densos (13 px), zoneless + `OnPush`, rutas con carga diferida, listas largas paginadas en el servidor (50 por página). Presupuesto del bundle inicial en `angular.json` (aviso 300 kB; hoy ~286 kB, 81 kB comprimido; la configuración de las pantallas de documento se carga en diferido con un `resolve`, y `document-paths.ts` es la única lista que el enrutador necesita al arrancar: `httpResource` aporta ~18 kB al chunk compartido; si hace falta recuperarlos, reemplazarlo por RxJS).
|
Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de componentes (Material/PrimeNG), sin webfonts ni animaciones, componentes propios y densos (13 px), zoneless + `OnPush`, rutas con carga diferida, listas largas paginadas en el servidor (50 por página). Presupuesto del bundle inicial en `angular.json` (aviso 300 kB; hoy ~286 kB, 81 kB comprimido; la configuración de las pantallas de documento se carga en diferido con un `resolve`, y `document-paths.ts` es la única lista que el enrutador necesita al arrancar: `httpResource` aporta ~18 kB al chunk compartido; si hace falta recuperarlos, reemplazarlo por RxJS).
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { MENU } from '@basic-erp/sap-types';
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { permissionGuard } from './core/auth/guards';
|
import { permissionGuard } from './core/auth/guards';
|
||||||
|
import { PAYMENT_PATHS } from './features/banking/payment-paths';
|
||||||
|
import { PAYMENT_SCREENS } from './features/banking/payment-screens';
|
||||||
import { DOCUMENT_PATHS } from './features/documents/document-paths';
|
import { DOCUMENT_PATHS } from './features/documents/document-paths';
|
||||||
import { DOCUMENT_SCREENS } from './features/documents/document-screens';
|
import { DOCUMENT_SCREENS } from './features/documents/document-screens';
|
||||||
import { MASTER_PATHS } from './features/masters/master-paths';
|
import { MASTER_PATHS } from './features/masters/master-paths';
|
||||||
@@ -54,6 +56,17 @@ describe('rutas', () => {
|
|||||||
expect([...MASTER_PATHS].sort()).toEqual(MASTER_SCREENS.map((s) => s.path).sort());
|
expect([...MASTER_PATHS].sort()).toEqual(MASTER_SCREENS.map((s) => s.path).sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cada pago ofrece lista, "nuevo" y "ver/anular" bajo la ruta del menú', async () => {
|
||||||
|
for (const path of PAYMENT_PATHS) {
|
||||||
|
const route = children.find((r) => r.path === path.slice(1))!;
|
||||||
|
expect(route.children?.map((c) => c.path), path).toEqual(['', 'nuevo', ':docEntry']);
|
||||||
|
expect(route.canActivate, path).toContain(permissionGuard);
|
||||||
|
const screen = await (route.resolve!['screen'] as () => Promise<{ path: string }>)();
|
||||||
|
expect(screen.path).toBe(path);
|
||||||
|
}
|
||||||
|
expect([...PAYMENT_PATHS].sort()).toEqual(PAYMENT_SCREENS.map((s) => s.path).sort());
|
||||||
|
});
|
||||||
|
|
||||||
it('la página de componentes está restringida a desarrollo y no está en el menú', () => {
|
it('la página de componentes está restringida a desarrollo y no está en el menú', () => {
|
||||||
const route = children.find((r) => r.path === 'dev/componentes');
|
const route = children.find((r) => r.path === 'dev/componentes');
|
||||||
expect(route?.loadComponent).toBeDefined();
|
expect(route?.loadComponent).toBeDefined();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Route, Routes } from '@angular/router';
|
|||||||
import { MENU } from '@basic-erp/sap-types';
|
import { MENU } from '@basic-erp/sap-types';
|
||||||
import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards';
|
import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards';
|
||||||
import { DOCUMENT_PATHS } from './features/documents/document-paths';
|
import { DOCUMENT_PATHS } from './features/documents/document-paths';
|
||||||
|
import { PAYMENT_PATHS } from './features/banking/payment-paths';
|
||||||
import { MASTER_PATHS } from './features/masters/master-paths';
|
import { MASTER_PATHS } from './features/masters/master-paths';
|
||||||
|
|
||||||
const menuLabel = (path: string): string => MENU.flatMap((m) => m.items).find((i) => i.path === path)?.label ?? path;
|
const menuLabel = (path: string): string => MENU.flatMap((m) => m.items).find((i) => i.path === path)?.label ?? path;
|
||||||
@@ -58,6 +59,23 @@ const masterRoutes: Routes = MASTER_PATHS.map(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Pagos recibidos y efectuados: lista, `nuevo` y `:docEntry` (consulta y anulación). */
|
||||||
|
const paymentRoutes: Routes = PAYMENT_PATHS.map(
|
||||||
|
(path): Route => ({
|
||||||
|
path: path.slice(1),
|
||||||
|
canActivate: [permissionGuard],
|
||||||
|
data: { path, title: menuLabel(path) },
|
||||||
|
resolve: {
|
||||||
|
screen: () => import('./features/banking/payment-screens').then((m) => m.PAYMENT_SCREENS.find((s) => s.path === path)!),
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{ path: '', loadComponent: () => import('./features/banking/payment-list.page').then((m) => m.PaymentListPage) },
|
||||||
|
{ path: 'nuevo', loadComponent: () => import('./features/banking/payment-form.page').then((m) => m.PaymentFormPage) },
|
||||||
|
{ path: ':docEntry', loadComponent: () => import('./features/banking/payment-form.page').then((m) => m.PaymentFormPage) },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Página de componentes: solo coincide al ejecutar `npm run dev:web` (isDevMode se evalúa al navegar);
|
* Página de componentes: solo coincide al ejecutar `npm run dev:web` (isDevMode se evalúa al navegar);
|
||||||
* en producción cae en la ruta comodín. No aparece en el menú.
|
* en producción cae en la ruta comodín. No aparece en el menú.
|
||||||
@@ -87,7 +105,7 @@ const adminRoutes: Routes = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
|
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
|
||||||
const builtPaths = new Set([...DOCUMENT_PATHS, ...MASTER_PATHS, '/admin/permisos', '/admin/auditoria']);
|
const builtPaths = new Set([...DOCUMENT_PATHS, ...MASTER_PATHS, ...PAYMENT_PATHS, '/admin/permisos', '/admin/auditoria']);
|
||||||
const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
|
const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
|
||||||
.filter((item) => !builtPaths.has(item.path))
|
.filter((item) => !builtPaths.has(item.path))
|
||||||
.map(
|
.map(
|
||||||
@@ -118,6 +136,7 @@ export const routes: Routes = [
|
|||||||
...devRoutes,
|
...devRoutes,
|
||||||
...documentRoutes,
|
...documentRoutes,
|
||||||
...masterRoutes,
|
...masterRoutes,
|
||||||
|
...paymentRoutes,
|
||||||
...adminRoutes,
|
...adminRoutes,
|
||||||
...pendingRoutes,
|
...pendingRoutes,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ export class SlDocumentApi {
|
|||||||
return typeof entry === 'number' ? entry : null;
|
return typeof entry === 'number' ? entry : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Invoca una acción de un registro (p. ej. `Cancel` de un pago): `POST Recurso(clave)/Accion`. */
|
||||||
|
action(resource: string, docEntry: number, action: string): Promise<unknown> {
|
||||||
|
return firstValueFrom(this.http.post<unknown>(`${this.url(resource, docEntry)}/${action}`, {}));
|
||||||
|
}
|
||||||
|
|
||||||
/** Crea el documento; SAP devuelve el documento creado (con `DocEntry` y `DocNum`). */
|
/** Crea el documento; SAP devuelve el documento creado (con `DocEntry` y `DocNum`). */
|
||||||
create(resource: string, body: DocRecord): Promise<DocRecord> {
|
create(resource: string, body: DocRecord): Promise<DocRecord> {
|
||||||
return firstValueFrom(this.http.post<DocRecord>(this.url(resource), body));
|
return firstValueFrom(this.http.post<DocRecord>(this.url(resource), body));
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import type { SlListResponse } from '@basic-erp/sap-types';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { API_URL } from '../../core/api';
|
||||||
|
import { OPEN_DOC_FIELDS, type OpenDoc, openDocsFilter, toOpenDoc } from './payment-model';
|
||||||
|
import type { PaymentScreen } from './payment-types';
|
||||||
|
|
||||||
|
/** Como mucho estos documentos pendientes por socio (los más antiguos primero). */
|
||||||
|
const MAX_OPEN = 200;
|
||||||
|
|
||||||
|
/** Facturas y notas de crédito pendientes de un socio, para aplicarles un pago. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class OpenDocsApi {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
private fetch(resource: string, filter: string): Promise<SlListResponse<Record<string, unknown>>> {
|
||||||
|
const params = new HttpParams().set('$select', OPEN_DOC_FIELDS).set('$filter', filter).set('$orderby', 'DocDueDate asc').set('$top', MAX_OPEN);
|
||||||
|
return firstValueFrom(this.http.get<SlListResponse<Record<string, unknown>>>(`${API_URL}/sl/${resource}`, { params }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Solo los que tienen saldo: facturas primero, después las notas de crédito. */
|
||||||
|
async load(screen: PaymentScreen, cardCode: string): Promise<OpenDoc[]> {
|
||||||
|
const filter = openDocsFilter(cardCode, screen.localCurrency);
|
||||||
|
const [invoices, credits] = await Promise.all([this.fetch(screen.invoices.resource, filter), this.fetch(screen.credits.resource, filter)]);
|
||||||
|
return [
|
||||||
|
...invoices.value.map((r) => toOpenDoc(screen, 'invoice', r)),
|
||||||
|
...credits.value.map((r) => toOpenDoc(screen, 'credit', r)),
|
||||||
|
].filter((d) => d.balance > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
:host { display: block; }
|
||||||
|
h3 { margin: 14px 0 4px; font-size: 13px; font-weight: 600; }
|
||||||
|
.row { display: flex; gap: 8px; align-items: center; margin: 4px 0; }
|
||||||
|
.totals { margin: 12px 0 0 auto; width: 320px; border-collapse: collapse; }
|
||||||
|
.totals td { padding: 2px 6px; }
|
||||||
|
.totals td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.totals tr.strong td { font-weight: 600; border-top: 1px solid var(--field-b); }
|
||||||
|
.confirm { color: var(--danger, #b00020); margin-right: 8px; }
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<sbo-toolbar bleed aria-label="Acciones del pago">
|
||||||
|
<a class="tb" routerLink=".." title="Volver a la lista"><sbo-icon name="find" />Lista</a>
|
||||||
|
<a class="tb" [routerLink]="isNew() ? '.' : '../nuevo'" title="Pago nuevo"><sbo-icon name="new" />Nuevo</a>
|
||||||
|
<span class="tb-sep"></span>
|
||||||
|
<button class="tb" type="button" aria-label="Primero" (click)="go('first')"><sbo-icon name="first" /></button>
|
||||||
|
<button class="tb" type="button" aria-label="Anterior" (click)="go('prev')"><sbo-icon name="prev" /></button>
|
||||||
|
<button class="tb" type="button" aria-label="Siguiente" (click)="go('next')"><sbo-icon name="next" /></button>
|
||||||
|
<button class="tb" type="button" aria-label="Último" (click)="go('last')"><sbo-icon name="last" /></button>
|
||||||
|
</sbo-toolbar>
|
||||||
|
|
||||||
|
<sbo-window [title]="windowTitle()" [badge]="badge()" [status]="status()">
|
||||||
|
<div class="sbo-hdr">
|
||||||
|
<div class="sbo-col">
|
||||||
|
<sbo-select-field label="Pago a" [options]="typeOptions" [value]="docType()" (valueChange)="onType($event)" [readonly]="saved()" />
|
||||||
|
@if (!toAccounts()) {
|
||||||
|
<sbo-lookup-field
|
||||||
|
[label]="screen.partner.label"
|
||||||
|
[lookup]="partnerLookup"
|
||||||
|
[value]="cardCode()"
|
||||||
|
[readonly]="saved()"
|
||||||
|
required
|
||||||
|
resolveOnLeave
|
||||||
|
(valueChange)="onPartnerText($event)"
|
||||||
|
(picked)="onPartner($event)"
|
||||||
|
/>
|
||||||
|
<sbo-text-field label="Nombre" [value]="cardName()" readonly />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="sbo-col">
|
||||||
|
@if (isNew()) {
|
||||||
|
<sbo-select-field label="Serie" [options]="seriesOptions()" [value]="series()" (valueChange)="series.set($event === null ? null : +$event)" />
|
||||||
|
} @else {
|
||||||
|
<sbo-text-field label="Número" [value]="'' + (doc()?.['DocNum'] ?? '')" readonly />
|
||||||
|
}
|
||||||
|
<sbo-date-field label="Fecha de contabilización" [(value)]="docDate" [readonly]="saved()" required />
|
||||||
|
<sbo-date-field label="Fecha del documento" [(value)]="taxDate" [readonly]="saved()" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!toAccounts()) {
|
||||||
|
<h3>Documentos pendientes</h3>
|
||||||
|
@if (saved()) {
|
||||||
|
<sbo-rows-table [columns]="appliedColumns" [rows]="docRows()" [readonly]="true" [removable]="false" emptyText="Pago a cuenta: sin documentos aplicados." />
|
||||||
|
} @else {
|
||||||
|
<sbo-rows-table
|
||||||
|
[columns]="docColumns"
|
||||||
|
[rows]="docRows()"
|
||||||
|
(rowsChange)="setDocs($event)"
|
||||||
|
[removable]="false"
|
||||||
|
[emptyText]="loadingDocs() ? 'Cargando documentos…' : cardCode() ? 'Sin documentos pendientes.' : 'Elija el socio para ver sus documentos pendientes.'"
|
||||||
|
/>
|
||||||
|
<p class="row">
|
||||||
|
<button type="button" class="btn" (click)="payAll(true)">Pagar todo</button>
|
||||||
|
<button type="button" class="btn" (click)="payAll(false)">Quitar</button>
|
||||||
|
<button type="button" class="btn" (click)="useApplied()">Transferir lo aplicado</button>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<h3>Cuentas contables</h3>
|
||||||
|
<sbo-rows-table [columns]="accountColumns" [rows]="accounts()" (rowsChange)="setAccounts($event)" [readonly]="saved()" [removable]="!saved()" emptyText="Sin cuentas." />
|
||||||
|
@if (!saved()) {
|
||||||
|
<p class="row"><button type="button" class="btn" (click)="addAccount()">+ Añadir cuenta</button></p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<h3>Medios de pago</h3>
|
||||||
|
<div class="sbo-hdr">
|
||||||
|
<div class="sbo-col">
|
||||||
|
<sbo-lookup-field label="Cuenta de efectivo" [lookup]="accountLookup" [value]="means().cashAccount" (valueChange)="setMeans({ cashAccount: $event })" [readonly]="saved()" />
|
||||||
|
<sbo-number-field label="Efectivo" [decimals]="2" [value]="means().cashSum" (valueChange)="setMeans({ cashSum: $event })" [readonly]="saved()" />
|
||||||
|
<sbo-lookup-field label="Cuenta de transferencia" [lookup]="accountLookup" [value]="means().transferAccount" (valueChange)="setMeans({ transferAccount: $event })" [readonly]="saved()" />
|
||||||
|
<sbo-number-field label="Transferencia" [decimals]="2" [value]="means().transferSum" (valueChange)="setMeans({ transferSum: $event })" [readonly]="saved()" />
|
||||||
|
</div>
|
||||||
|
<div class="sbo-col">
|
||||||
|
<sbo-date-field label="Fecha de transferencia" [value]="means().transferDate" (valueChange)="setMeans({ transferDate: $event })" [readonly]="saved()" />
|
||||||
|
<sbo-text-field label="Referencia" [value]="means().transferReference" (valueChange)="setMeans({ transferReference: $event })" [readonly]="saved()" />
|
||||||
|
<sbo-lookup-field label="Cuenta de cheques" [lookup]="accountLookup" [value]="means().checkAccount" (valueChange)="setMeans({ checkAccount: $event })" [readonly]="saved()" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Cheques</h3>
|
||||||
|
<sbo-rows-table [columns]="checkColumns" [rows]="checks()" (rowsChange)="setChecks($event)" [readonly]="saved()" [removable]="!saved()" emptyText="Sin cheques." />
|
||||||
|
@if (!saved()) {
|
||||||
|
<p class="row"><button type="button" class="btn" (click)="addCheck()">+ Añadir cheque</button></p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="sbo-hdr" style="margin-top: 14px">
|
||||||
|
<div class="sbo-col">
|
||||||
|
<sbo-textarea-field label="Comentarios" [(value)]="remarks" [readonly]="saved()" />
|
||||||
|
<sbo-text-field label="Comentarios del asiento" [(value)]="journalRemarks" [readonly]="saved()" />
|
||||||
|
</div>
|
||||||
|
<div class="sbo-col">
|
||||||
|
@if (!saved()) {
|
||||||
|
<table class="totals">
|
||||||
|
<tbody>
|
||||||
|
@if (!toAccounts()) {
|
||||||
|
<tr><td>Facturas</td><td>{{ fmt(totals().invoices) }}</td></tr>
|
||||||
|
<tr><td>Notas de crédito</td><td>{{ fmt(totals().credits) }}</td></tr>
|
||||||
|
<tr><td>Aplicado</td><td>{{ fmt(totals().applied) }}</td></tr>
|
||||||
|
} @else {
|
||||||
|
<tr><td>Cuentas</td><td>{{ fmt(totals().accounts) }}</td></tr>
|
||||||
|
}
|
||||||
|
<tr><td>Efectivo</td><td>{{ fmt(totals().cash) }}</td></tr>
|
||||||
|
<tr><td>Transferencia</td><td>{{ fmt(totals().transfer) }}</td></tr>
|
||||||
|
<tr><td>Cheques</td><td>{{ fmt(totals().checks) }}</td></tr>
|
||||||
|
<tr class="strong"><td>Total medios de pago</td><td>{{ fmt(totals().means) }}</td></tr>
|
||||||
|
@if (!toAccounts()) {
|
||||||
|
<tr><td>A cuenta del socio</td><td>{{ fmt(totals().onAccount) }}</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isNew()) {
|
||||||
|
<button sboActions class="btn main" type="button" [disabled]="saving() || loading()" (click)="save()">Crear</button>
|
||||||
|
} @else if (!cancelled() && doc()) {
|
||||||
|
@if (confirmCancel()) {
|
||||||
|
<span sboActions class="confirm">¿Anular el pago?</span>
|
||||||
|
<button sboActions class="btn main" type="button" [disabled]="saving()" (click)="cancelPayment()">Sí, anular</button>
|
||||||
|
<button sboActions class="btn" type="button" (click)="confirmCancel.set(false)">No</button>
|
||||||
|
} @else {
|
||||||
|
<button sboActions class="btn" type="button" [disabled]="saving()" (click)="confirmCancel.set(true)">Anular pago</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<a sboActions class="btn" routerLink="..">{{ saved() ? 'Cerrar' : 'Cancelar' }}</a>
|
||||||
|
</sbo-window>
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import { HttpErrorResponse, httpResource } from '@angular/common/http';
|
||||||
|
import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, signal } from '@angular/core';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
import type { SlListResponse } from '@basic-erp/sap-types';
|
||||||
|
import { map } from 'rxjs';
|
||||||
|
import { API_URL, apiErrorMessage } from '../../core/api';
|
||||||
|
import { formatNumber } from '../../core/format';
|
||||||
|
import { SlDocumentApi } from '../../core/sl/sl-document';
|
||||||
|
import { DateField, LookupField, NumberField, SelectField, TextField, TextareaField } from '../../shared/form';
|
||||||
|
import type { DetailColumn } from '../../shared/grid/grid.types';
|
||||||
|
import { RowsTable } from '../../shared/grid/rows-table';
|
||||||
|
import type { VhLookup } from '../../shared/value-help/value-help.types';
|
||||||
|
import { Icon, SboWindow, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
|
||||||
|
import { ACCOUNT_LOOKUP } from '../documents/dimension-lookups';
|
||||||
|
import type { NavTarget } from '../documents/document-model';
|
||||||
|
import { OpenDocsApi } from './open-docs';
|
||||||
|
import { EMPTY_MEANS, type Means, type OpenDoc, applyAll, paymentPayload, paymentTotals, validatePayment } from './payment-model';
|
||||||
|
import type { PaymentDocType, PaymentScreen } from './payment-types';
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
|
||||||
|
const today = (): string => new Date().toISOString().slice(0, 10);
|
||||||
|
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
|
||||||
|
const money = (n: number): string => formatNumber(n, 2);
|
||||||
|
|
||||||
|
const BANKS: VhLookup = {
|
||||||
|
title: 'Bancos',
|
||||||
|
resource: 'Banks',
|
||||||
|
orderby: 'BankCode',
|
||||||
|
columns: [
|
||||||
|
{ key: 'BankCode', title: 'Código', width: '100px', mono: true },
|
||||||
|
{ key: 'BankName', title: 'Nombre' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Qué se muestra de un documento ya aplicado en un pago guardado. */
|
||||||
|
const APPLIED_LABELS: Readonly<Record<string, string>> = {
|
||||||
|
it_Invoice: 'Factura',
|
||||||
|
it_CredItnote: 'Nota de crédito',
|
||||||
|
it_PurchaseInvoice: 'Factura de proveedor',
|
||||||
|
it_PurchaseCreditNote: 'Nota de crédito de compra',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formulario de un pago recibido o efectuado. Un pago nuevo se arma con el socio, sus **documentos pendientes**
|
||||||
|
* (cuánto se paga de cada uno), los **medios de pago** (efectivo, transferencia, cheques) y, si el pago va
|
||||||
|
* directo a cuentas contables, esas cuentas. Lo que los medios dejan sin aplicar queda a cuenta del socio. Un
|
||||||
|
* pago contabilizado no se modifica: se consulta y se puede anular.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'sbo-payment-form-page',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [DateField, Icon, LookupField, NumberField, RouterLink, RowsTable, SboWindow, SelectField, TextField, TextareaField, Toolbar],
|
||||||
|
templateUrl: './payment-form.page.html',
|
||||||
|
styleUrl: './payment-form.page.css',
|
||||||
|
})
|
||||||
|
export class PaymentFormPage {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly api = inject(SlDocumentApi);
|
||||||
|
private readonly openDocs = inject(OpenDocsApi);
|
||||||
|
|
||||||
|
protected readonly screen = this.route.snapshot.data['screen'] as PaymentScreen;
|
||||||
|
protected readonly title = this.route.snapshot.data['title'] as string;
|
||||||
|
protected readonly docEntry = toSignal(this.route.paramMap.pipe(map((p) => (p.has('docEntry') ? Number(p.get('docEntry')) : null))), {
|
||||||
|
initialValue: this.route.snapshot.paramMap.has('docEntry') ? Number(this.route.snapshot.paramMap.get('docEntry')) : null,
|
||||||
|
});
|
||||||
|
protected readonly isNew = computed(() => this.docEntry() === null);
|
||||||
|
|
||||||
|
// --- datos ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private readonly resource = httpResource<Row>(() => {
|
||||||
|
const id = this.docEntry();
|
||||||
|
return id === null ? undefined : `${API_URL}/sl/${this.screen.resource}/${id}`;
|
||||||
|
});
|
||||||
|
protected readonly doc = computed(() => (this.resource.hasValue() ? this.resource.value() : undefined));
|
||||||
|
protected readonly loading = computed(() => this.resource.isLoading());
|
||||||
|
protected readonly loadError = computed(() => {
|
||||||
|
const err = this.resource.error();
|
||||||
|
return err ? (err instanceof HttpErrorResponse && err.status === 404 ? 'El pago no existe.' : apiErrorMessage(err)) : '';
|
||||||
|
});
|
||||||
|
/** Un pago contabilizado no se modifica; se anula. */
|
||||||
|
protected readonly saved = computed(() => !this.isNew());
|
||||||
|
protected readonly cancelled = computed(() => this.doc()?.['Cancelled'] === 'tYES');
|
||||||
|
|
||||||
|
// --- el borrador (o el pago guardado, mostrado) --------------------------------------------
|
||||||
|
|
||||||
|
protected readonly docType = linkedSignal<PaymentDocType>(() => (this.doc()?.['DocType'] as PaymentDocType | undefined) ?? this.screen.partner.docType);
|
||||||
|
protected readonly cardCode = linkedSignal(() => text(this.doc()?.['CardCode']));
|
||||||
|
protected readonly cardName = linkedSignal(() => text(this.doc()?.['CardName']));
|
||||||
|
protected readonly docDate = linkedSignal(() => (this.doc() ? dateOnly(this.doc()!['DocDate']) : today()));
|
||||||
|
protected readonly taxDate = linkedSignal(() => dateOnly(this.doc()?.['TaxDate']));
|
||||||
|
protected readonly series = linkedSignal<number | null>(() => (this.doc() ? Number(this.doc()!['Series']) || null : null));
|
||||||
|
protected readonly remarks = linkedSignal(() => text(this.doc()?.['Remarks']));
|
||||||
|
protected readonly journalRemarks = linkedSignal(() => text(this.doc()?.['JournalRemarks']));
|
||||||
|
protected readonly means = linkedSignal<Means>(() => {
|
||||||
|
const d = this.doc();
|
||||||
|
if (!d) return { ...EMPTY_MEANS };
|
||||||
|
return {
|
||||||
|
cashAccount: text(d['CashAccount']),
|
||||||
|
cashSum: Number(d['CashSum']) || null,
|
||||||
|
transferAccount: text(d['TransferAccount']),
|
||||||
|
transferSum: Number(d['TransferSum']) || null,
|
||||||
|
transferDate: dateOnly(d['TransferDate']),
|
||||||
|
transferReference: text(d['TransferReference']),
|
||||||
|
checkAccount: text(d['CheckAccount']),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
protected readonly checks = linkedSignal<Row[]>(() => ((this.doc()?.['PaymentChecks'] as Row[] | undefined) ?? []).map((c) => ({ ...c, DueDate: dateOnly(c['DueDate']) })));
|
||||||
|
protected readonly accounts = linkedSignal<Row[]>(() => ((this.doc()?.['PaymentAccounts'] as Row[] | undefined) ?? []).map((a) => ({ ...a })));
|
||||||
|
protected readonly docs = linkedSignal<OpenDoc[]>(() =>
|
||||||
|
((this.doc()?.['PaymentInvoices'] as Row[] | undefined) ?? []).map((i) => ({
|
||||||
|
id: `${text(i['InvoiceType'])}:${text(i['DocEntry'])}`,
|
||||||
|
docEntry: Number(i['DocEntry']),
|
||||||
|
docNum: Number(i['DocNum']),
|
||||||
|
kind: text(i['InvoiceType']).includes('Cred') || text(i['InvoiceType']).includes('Credit') ? 'credit' : 'invoice',
|
||||||
|
type: text(i['InvoiceType']),
|
||||||
|
label: APPLIED_LABELS[text(i['InvoiceType'])] ?? text(i['InvoiceType']),
|
||||||
|
date: '',
|
||||||
|
dueDate: '',
|
||||||
|
reference: '',
|
||||||
|
total: 0,
|
||||||
|
balance: 0,
|
||||||
|
applied: Math.abs(Number(i['SumApplied']) || 0),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected readonly loadingDocs = signal(false);
|
||||||
|
protected readonly saving = signal(false);
|
||||||
|
protected readonly confirmCancel = signal(false);
|
||||||
|
private readonly message = signal<StatusMessage | null>(
|
||||||
|
((): StatusMessage | null => {
|
||||||
|
const created = (this.router.currentNavigation()?.extras.state as { created?: string } | undefined)?.created;
|
||||||
|
return created ? { kind: 'ok', text: `${this.screen.singular} ${created} creado.` } : null;
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected readonly totals = computed(() => paymentTotals({ means: this.means(), checks: this.checks(), docs: this.docs(), accounts: this.accounts() }));
|
||||||
|
protected readonly toAccounts = computed(() => this.docType() === 'rAccount');
|
||||||
|
protected readonly fmt = money;
|
||||||
|
|
||||||
|
// --- series de numeración --------------------------------------------------------------------
|
||||||
|
|
||||||
|
private readonly seriesResource = httpResource<SlListResponse<Row>>(() =>
|
||||||
|
this.isNew()
|
||||||
|
? { url: `${API_URL}/sl/SeriesService_GetDocumentSeries`, method: 'POST', body: { DocumentTypeParams: { Document: String(this.screen.objectType) } } }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
protected readonly seriesOptions = computed(() => {
|
||||||
|
const rows = this.seriesResource.hasValue() ? this.seriesResource.value().value : [];
|
||||||
|
const options = rows.filter((r) => r['DocumentSubType'] === '--' && r['Locked'] !== 'tYES').map((r) => ({ value: Number(r['Series']), label: text(r['Name']) || String(r['Series']) }));
|
||||||
|
const current = Number(this.doc()?.['Series']);
|
||||||
|
if (current && !options.some((o) => o.value === current)) options.unshift({ value: current, label: `Serie ${current}` });
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- configuración de la vista ---------------------------------------------------------------
|
||||||
|
|
||||||
|
protected readonly typeOptions = [
|
||||||
|
{ value: this.screen.partner.docType as string, label: this.screen.partner.label },
|
||||||
|
{ value: 'rAccount', label: 'Cuentas contables' },
|
||||||
|
];
|
||||||
|
protected readonly partnerLookup: VhLookup = {
|
||||||
|
title: this.screen.partner.lookupTitle,
|
||||||
|
resource: 'BusinessPartners',
|
||||||
|
baseFilter: `CardType eq '${this.screen.partner.type}'`,
|
||||||
|
orderby: 'CardCode',
|
||||||
|
columns: [
|
||||||
|
{ key: 'CardCode', title: 'Código', width: '130px', mono: true },
|
||||||
|
{ key: 'CardName', title: 'Nombre' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
protected readonly accountLookup = ACCOUNT_LOOKUP;
|
||||||
|
|
||||||
|
/** Documentos pendientes: lo único editable es lo que se paga de cada uno. */
|
||||||
|
protected readonly docColumns: readonly DetailColumn[] = [
|
||||||
|
{ key: 'label', title: 'Tipo', type: 'text', readonly: true, width: '170px' },
|
||||||
|
{ key: 'docNum', title: 'Número', type: 'text', readonly: true, width: '90px' },
|
||||||
|
{ key: 'reference', title: 'Referencia', type: 'text', readonly: true, width: '110px' },
|
||||||
|
{ key: 'date', title: 'Fecha', type: 'date', readonly: true, width: '120px' },
|
||||||
|
{ key: 'dueDate', title: 'Vencimiento', type: 'date', readonly: true, width: '120px' },
|
||||||
|
{ key: 'total', title: 'Total', type: 'number', decimals: 2, readonly: true, width: '120px' },
|
||||||
|
{ key: 'balance', title: 'Saldo', type: 'number', decimals: 2, readonly: true, width: '120px' },
|
||||||
|
{ key: 'applied', title: 'A pagar', type: 'number', decimals: 2, width: '120px' },
|
||||||
|
];
|
||||||
|
/** Documentos de un pago guardado: solo lo aplicado. */
|
||||||
|
protected readonly appliedColumns: readonly DetailColumn[] = [
|
||||||
|
{ key: 'label', title: 'Tipo', type: 'text', readonly: true, width: '200px' },
|
||||||
|
{ key: 'docNum', title: 'Número', type: 'text', readonly: true, width: '110px' },
|
||||||
|
{ key: 'applied', title: 'Importe aplicado', type: 'number', decimals: 2, readonly: true, width: '150px' },
|
||||||
|
];
|
||||||
|
protected readonly checkColumns: readonly DetailColumn[] = [
|
||||||
|
{ key: 'CheckNumber', title: 'Nº de cheque', type: 'text', required: true, width: '120px' },
|
||||||
|
{ key: 'BankCode', title: 'Banco', type: 'lookup', required: true, lookup: BANKS, width: '110px' },
|
||||||
|
{ key: 'Branch', title: 'Sucursal', type: 'text', width: '100px' },
|
||||||
|
{ key: 'AccounttNum', title: 'Cuenta', type: 'text', width: '140px' },
|
||||||
|
{ key: 'DueDate', title: 'Vencimiento', type: 'date', required: true, width: '130px' },
|
||||||
|
{ key: 'CheckSum', title: 'Importe', type: 'number', decimals: 2, required: true, width: '130px' },
|
||||||
|
];
|
||||||
|
protected readonly accountColumns: readonly DetailColumn[] = [
|
||||||
|
{ key: 'AccountCode', title: 'Cuenta', type: 'lookup', required: true, lookup: ACCOUNT_LOOKUP, width: '170px' },
|
||||||
|
{ key: 'Decription', title: 'Descripción', type: 'text', width: '260px' },
|
||||||
|
{ key: 'SumPaid', title: 'Importe', type: 'number', decimals: 2, required: true, width: '130px' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Las filas de documentos como las muestra la tabla. */
|
||||||
|
protected readonly docRows = computed<Row[]>(() => this.docs() as unknown as Row[]);
|
||||||
|
|
||||||
|
// --- estado --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
protected readonly status = computed<StatusMessage>(() => {
|
||||||
|
if (this.loading()) return { kind: 'info', text: 'Cargando…' };
|
||||||
|
if (this.loadError()) return { kind: 'err', text: this.loadError() };
|
||||||
|
const message = this.message();
|
||||||
|
if (message) return message;
|
||||||
|
if (this.cancelled()) return { kind: 'info', text: 'Pago anulado: solo lectura.' };
|
||||||
|
if (this.saved()) return { kind: 'info', text: 'Pago contabilizado: no se modifica; se puede anular.' };
|
||||||
|
return { kind: 'info', text: this.toAccounts() ? 'Indique las cuentas y los medios de pago.' : `Indique el ${this.screen.partner.label.toLowerCase()} y los medios de pago.` };
|
||||||
|
});
|
||||||
|
protected readonly badge = computed<WindowBadge | undefined>(() => {
|
||||||
|
if (this.isNew()) return { label: 'Nuevo', tone: 'info' };
|
||||||
|
if (!this.doc()) return undefined;
|
||||||
|
return this.cancelled() ? { label: 'Anulado', tone: 'muted' } : { label: 'Vigente', tone: 'ok' };
|
||||||
|
});
|
||||||
|
protected readonly windowTitle = computed(() => {
|
||||||
|
const n = this.doc()?.['DocNum'];
|
||||||
|
return n === undefined ? this.title : `${this.title} · ${text(n)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- edición -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
protected onType(value: string | number | null): void {
|
||||||
|
this.docType.set((value ?? this.screen.partner.docType) as PaymentDocType);
|
||||||
|
this.message.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Al escribir otro código el nombre y los documentos anteriores ya no valen. */
|
||||||
|
protected onPartnerText(code: string): void {
|
||||||
|
if (code === this.cardCode()) return;
|
||||||
|
this.cardCode.set(code);
|
||||||
|
this.cardName.set('');
|
||||||
|
this.docs.set([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async onPartner(row: Row): Promise<void> {
|
||||||
|
const code = text(row['CardCode']);
|
||||||
|
this.cardName.set(text(row['CardName']));
|
||||||
|
this.docs.set([]);
|
||||||
|
this.loadingDocs.set(true);
|
||||||
|
try {
|
||||||
|
const open = await this.openDocs.load(this.screen, code);
|
||||||
|
if (this.cardCode() === code) this.docs.set(open);
|
||||||
|
if (!open.length) this.message.set({ kind: 'info', text: `${text(row['CardName']) || code} no tiene documentos pendientes: el pago quedará a cuenta.` });
|
||||||
|
} catch (e) {
|
||||||
|
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
||||||
|
} finally {
|
||||||
|
this.loadingDocs.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setMeans(patch: Partial<Means>): void {
|
||||||
|
this.means.update((m) => ({ ...m, ...patch }));
|
||||||
|
this.message.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setDocs(rows: Row[]): void {
|
||||||
|
this.docs.set(rows as unknown as OpenDoc[]);
|
||||||
|
this.message.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setChecks(rows: Row[]): void {
|
||||||
|
this.checks.set(rows);
|
||||||
|
this.message.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setAccounts(rows: Row[]): void {
|
||||||
|
this.accounts.set(rows);
|
||||||
|
this.message.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected addCheck(): void {
|
||||||
|
this.setChecks([...this.checks(), { DueDate: this.docDate() }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected addAccount(): void {
|
||||||
|
this.setAccounts([...this.accounts(), {}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected payAll(on: boolean): void {
|
||||||
|
this.docs.update((d) => applyAll(d, on));
|
||||||
|
this.message.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pone en la transferencia lo aplicado a los documentos (lo habitual: pagar justo lo que se debe). */
|
||||||
|
protected useApplied(): void {
|
||||||
|
const t = this.totals();
|
||||||
|
this.setMeans({ transferSum: t.applied > 0 ? Math.max(0, t.applied - t.cash - t.checks) : null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- acciones ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
protected async go(target: NavTarget): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entry = await this.api.neighbour(this.screen.resource, target, this.docEntry());
|
||||||
|
if (entry === null) {
|
||||||
|
this.message.set({ kind: 'warn', text: 'No hay más pagos en esa dirección.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.message.set(null);
|
||||||
|
await this.router.navigate(['..', entry], { relativeTo: this.route });
|
||||||
|
} catch (e) {
|
||||||
|
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async save(): Promise<void> {
|
||||||
|
if (this.saving() || this.saved()) return;
|
||||||
|
const draft = {
|
||||||
|
docType: this.docType(),
|
||||||
|
cardCode: this.cardCode(),
|
||||||
|
docDate: this.docDate(),
|
||||||
|
taxDate: this.taxDate(),
|
||||||
|
series: this.series(),
|
||||||
|
remarks: this.remarks(),
|
||||||
|
journalRemarks: this.journalRemarks(),
|
||||||
|
means: this.means(),
|
||||||
|
checks: this.checks(),
|
||||||
|
docs: this.docs(),
|
||||||
|
accounts: this.accounts(),
|
||||||
|
};
|
||||||
|
const errors = validatePayment(draft);
|
||||||
|
if (errors.length) {
|
||||||
|
this.message.set({ kind: 'err', text: errors[0] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.saving.set(true);
|
||||||
|
try {
|
||||||
|
const created = await this.api.create(this.screen.resource, paymentPayload(this.screen, draft));
|
||||||
|
await this.router.navigate(['..', created['DocEntry']], { relativeTo: this.route, state: { created: text(created['DocNum']) } });
|
||||||
|
} catch (e) {
|
||||||
|
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
||||||
|
} finally {
|
||||||
|
this.saving.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async cancelPayment(): Promise<void> {
|
||||||
|
const id = this.docEntry();
|
||||||
|
if (id === null || this.saving()) return;
|
||||||
|
this.saving.set(true);
|
||||||
|
this.confirmCancel.set(false);
|
||||||
|
try {
|
||||||
|
await this.api.action(this.screen.resource, id, 'Cancel');
|
||||||
|
this.message.set({ kind: 'ok', text: `${this.screen.singular} anulado.` });
|
||||||
|
this.resource.reload();
|
||||||
|
} catch (e) {
|
||||||
|
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
||||||
|
} finally {
|
||||||
|
this.saving.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { ListView } from '../../shared/list/list-view';
|
||||||
|
import type { ListRow } from '../../shared/list/list.types';
|
||||||
|
import type { PaymentScreen } from './payment-types';
|
||||||
|
|
||||||
|
/** Lista de los últimos pagos (recibidos o efectuados); abre uno o crea uno nuevo. */
|
||||||
|
@Component({
|
||||||
|
selector: 'sbo-payment-list-page',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [ListView],
|
||||||
|
template: `
|
||||||
|
<sbo-list-view
|
||||||
|
[title]="title"
|
||||||
|
[resource]="screen.resource"
|
||||||
|
[columns]="screen.columns"
|
||||||
|
[extraFields]="['Cancelled']"
|
||||||
|
[filters]="screen.filters"
|
||||||
|
[orderby]="screen.orderby"
|
||||||
|
rowKey="DocEntry"
|
||||||
|
[createLabel]="screen.createLabel"
|
||||||
|
(open)="open($event)"
|
||||||
|
(create)="create()"
|
||||||
|
/>
|
||||||
|
`,
|
||||||
|
styles: ':host { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }',
|
||||||
|
})
|
||||||
|
export class PaymentListPage {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
protected readonly screen = this.route.snapshot.data['screen'] as PaymentScreen;
|
||||||
|
protected readonly title = this.route.snapshot.data['title'] as string;
|
||||||
|
|
||||||
|
protected open(row: ListRow): void {
|
||||||
|
void this.router.navigate([String(row['DocEntry'])], { relativeTo: this.route });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected create(): void {
|
||||||
|
void this.router.navigate(['nuevo'], { relativeTo: this.route });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
EMPTY_MEANS,
|
||||||
|
type OpenDoc,
|
||||||
|
type PaymentDraft,
|
||||||
|
applyAll,
|
||||||
|
openDocsFilter,
|
||||||
|
paymentPayload,
|
||||||
|
paymentTotals,
|
||||||
|
toOpenDoc,
|
||||||
|
validatePayment,
|
||||||
|
} from './payment-model';
|
||||||
|
import { PAYMENT_PATHS } from './payment-paths';
|
||||||
|
import { PAYMENT_SCREENS } from './payment-screens';
|
||||||
|
|
||||||
|
const incoming = PAYMENT_SCREENS.find((s) => s.kind === 'incoming')!;
|
||||||
|
const outgoing = PAYMENT_SCREENS.find((s) => s.kind === 'outgoing')!;
|
||||||
|
|
||||||
|
const doc = (over: Partial<OpenDoc> = {}): OpenDoc => ({
|
||||||
|
id: 'Invoices:206',
|
||||||
|
docEntry: 206,
|
||||||
|
docNum: 206,
|
||||||
|
kind: 'invoice',
|
||||||
|
type: 'it_Invoice',
|
||||||
|
label: 'Factura',
|
||||||
|
date: '2026-09-01',
|
||||||
|
dueDate: '2026-10-01',
|
||||||
|
reference: '',
|
||||||
|
total: 1000,
|
||||||
|
balance: 1000,
|
||||||
|
applied: 0,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const draft = (over: Partial<PaymentDraft> = {}): PaymentDraft => ({
|
||||||
|
docType: 'rCustomer',
|
||||||
|
cardCode: 'C1',
|
||||||
|
docDate: '2026-10-16',
|
||||||
|
taxDate: '',
|
||||||
|
series: null,
|
||||||
|
remarks: '',
|
||||||
|
journalRemarks: '',
|
||||||
|
means: { ...EMPTY_MEANS },
|
||||||
|
checks: [],
|
||||||
|
docs: [],
|
||||||
|
accounts: [],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pantallas de pagos', () => {
|
||||||
|
it('son dos, con sus recursos y facturas de su lado', () => {
|
||||||
|
expect(PAYMENT_SCREENS.map((s) => [s.path, s.resource, s.invoices.resource, s.credits.resource])).toEqual([
|
||||||
|
['/bancos/pagos-recibidos', 'IncomingPayments', 'Invoices', 'CreditNotes'],
|
||||||
|
['/bancos/pagos-efectuados', 'VendorPayments', 'PurchaseInvoices', 'PurchaseCreditNotes'],
|
||||||
|
]);
|
||||||
|
expect(incoming.partner.docType).toBe('rCustomer');
|
||||||
|
expect(outgoing.partner.docType).toBe('rSupplier');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la lista de rutas del enrutador coincide con las pantallas', () => {
|
||||||
|
expect([...PAYMENT_PATHS].sort()).toEqual(PAYMENT_SCREENS.map((s) => s.path).sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('los tipos de objeto de SAP son 24 (pagos recibidos) y 46 (efectuados)', () => {
|
||||||
|
expect([incoming.objectType, outgoing.objectType]).toEqual([24, 46]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('documentos pendientes', () => {
|
||||||
|
it('el filtro pide los abiertos del socio en la moneda local, escapando las comillas', () => {
|
||||||
|
expect(openDocsFilter("O'N", 'CLP')).toBe(`CardCode eq 'O''N' and DocumentStatus eq 'bost_Open' and DocCurrency eq 'CLP'`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('una factura de SAP: el saldo es el total menos lo ya pagado, y el folio es la referencia', () => {
|
||||||
|
const d = toOpenDoc(incoming, 'invoice', { DocEntry: 206, DocNum: 206, DocDate: '2026-09-01T00:00:00Z', DocDueDate: '2026-10-01T00:00:00Z', DocTotal: 7363125, PaidToDate: 363125, FolioPrefixString: 'F', FolioNumber: 77 });
|
||||||
|
expect(d).toMatchObject({ id: 'Invoices:206', docEntry: 206, kind: 'invoice', type: 'it_Invoice', date: '2026-09-01', dueDate: '2026-10-01', total: 7363125, balance: 7000000, applied: 0, reference: 'F77' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('una nota de crédito usa su propio recurso y tipo; sin folio, la referencia del documento', () => {
|
||||||
|
const d = toOpenDoc(incoming, 'credit', { DocEntry: 16, DocNum: 16, DocTotal: 163625, PaidToDate: 0, NumAtCard: 'NC-1' });
|
||||||
|
expect(d).toMatchObject({ id: 'CreditNotes:16', kind: 'credit', type: 'it_CredItnote', label: 'Nota de crédito', balance: 163625, reference: 'NC-1' });
|
||||||
|
expect(toOpenDoc(outgoing, 'credit', { DocEntry: 3, DocNum: 3, DocTotal: 1, PaidToDate: 0 })).toMatchObject({ type: 'it_PurchaseCreditNote' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('«pagar todo» aplica el saldo de cada documento, y quitarlo lo deja en cero', () => {
|
||||||
|
const docs = [doc({ balance: 300 }), doc({ id: 'x', balance: 50.5 })];
|
||||||
|
expect(applyAll(docs, true).map((d) => d.applied)).toEqual([300, 50.5]);
|
||||||
|
expect(applyAll(applyAll(docs, true), false).map((d) => d.applied)).toEqual([0, 0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('totales', () => {
|
||||||
|
it('suma facturas, descuenta notas de crédito y suma los medios de pago, incluidos los cheques', () => {
|
||||||
|
const t = paymentTotals({
|
||||||
|
means: { ...EMPTY_MEANS, cashSum: 100, transferSum: 400 },
|
||||||
|
checks: [{ CheckSum: 250 }, { CheckSum: 50.5 }],
|
||||||
|
docs: [doc({ applied: 700 }), doc({ id: 'b', applied: 100 }), doc({ id: 'c', kind: 'credit', applied: 150 })],
|
||||||
|
accounts: [],
|
||||||
|
});
|
||||||
|
expect(t).toMatchObject({ invoices: 800, credits: 150, applied: 650, cash: 100, transfer: 400, checks: 300.5, means: 800.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lo que sobra de los medios sobre lo aplicado queda a cuenta; nunca negativo', () => {
|
||||||
|
const base = { checks: [], accounts: [] };
|
||||||
|
expect(paymentTotals({ ...base, means: { ...EMPTY_MEANS, transferSum: 1000 }, docs: [doc({ applied: 600 })] }).onAccount).toBe(400);
|
||||||
|
expect(paymentTotals({ ...base, means: { ...EMPTY_MEANS, transferSum: 100 }, docs: [doc({ applied: 600 })] }).onAccount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suma las cuentas contables y redondea a dos decimales', () => {
|
||||||
|
const t = paymentTotals({ means: { ...EMPTY_MEANS, cashSum: 0.1 + 0.2 }, checks: [], docs: [], accounts: [{ SumPaid: 10.005 }, { SumPaid: 5 }] });
|
||||||
|
expect(t.cash).toBe(0.3);
|
||||||
|
expect(t.accounts).toBe(15.01);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validación', () => {
|
||||||
|
const ok = () => draft({ means: { ...EMPTY_MEANS, transferAccount: '1-1-010-20-000', transferSum: 500 }, docs: [doc({ applied: 500 })] });
|
||||||
|
|
||||||
|
it('un pago correcto no tiene errores', () => expect(validatePayment(ok())).toEqual([]));
|
||||||
|
|
||||||
|
it('exige el socio, la fecha y un medio de pago', () => {
|
||||||
|
expect(validatePayment(draft({ cardCode: '', docDate: '' }))).toEqual(['Indique el cliente.', 'Indique la fecha de contabilización.', 'Indique al menos un medio de pago con su importe.']);
|
||||||
|
expect(validatePayment(draft({ docType: 'rSupplier', cardCode: '' }))[0]).toBe('Indique el proveedor.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cada medio con importe exige su cuenta', () => {
|
||||||
|
expect(validatePayment(draft({ means: { ...EMPTY_MEANS, cashSum: 10 } }))).toEqual(['Indique la cuenta del efectivo.']);
|
||||||
|
expect(validatePayment(draft({ means: { ...EMPTY_MEANS, transferSum: 10 } }))).toEqual(['Indique la cuenta de la transferencia.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('los cheques exigen importe, número, banco, vencimiento y la cuenta de cheques del pago', () => {
|
||||||
|
const errors = validatePayment(draft({ checks: [{ CheckSum: 100 }] }));
|
||||||
|
expect(errors).toEqual(['Indique la cuenta de cheques.', 'Cheque 1: indique el número.', 'Cheque 1: indique el banco.', 'Cheque 1: indique el vencimiento.']);
|
||||||
|
const full = draft({ means: { ...EMPTY_MEANS, checkAccount: '1-1-010-20-000' }, checks: [{ CheckSum: 100, CheckNumber: 1, BankCode: '001', DueDate: '2026-11-30' }] });
|
||||||
|
expect(validatePayment(full)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('una fila de cheque vacía se ignora', () => {
|
||||||
|
expect(validatePayment({ ...ok(), checks: [{}] })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no deja aplicar más que el saldo de la factura (SAP lo recortaría en silencio)', () => {
|
||||||
|
const d = draft({ means: { ...EMPTY_MEANS, transferAccount: 'A', transferSum: 9_999_999 }, docs: [doc({ balance: 327250, applied: 9_999_999 })] });
|
||||||
|
expect(validatePayment(d)).toEqual(['Factura 206: no se puede aplicar más que su saldo (327250).']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('los medios de pago deben cubrir lo aplicado', () => {
|
||||||
|
const d = draft({ means: { ...EMPTY_MEANS, transferAccount: 'A', transferSum: 100 }, docs: [doc({ applied: 600 })] });
|
||||||
|
expect(validatePayment(d)).toEqual(['Los medios de pago (100) no cubren lo aplicado a los documentos (600).']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('una nota de crédito aplicada reduce lo que los medios deben cubrir', () => {
|
||||||
|
const d = draft({ means: { ...EMPTY_MEANS, transferAccount: 'A', transferSum: 450 }, docs: [doc({ applied: 600 }), doc({ id: 'n', kind: 'credit', balance: 150, applied: 150 })] });
|
||||||
|
expect(validatePayment(d)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un pago sin documentos queda a cuenta y es válido', () => {
|
||||||
|
expect(validatePayment(draft({ means: { ...EMPTY_MEANS, transferAccount: 'A', transferSum: 50 } }))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a cuentas contables: exige cuentas con importe y que los medios igualen su total', () => {
|
||||||
|
const base = { docType: 'rAccount' as const, cardCode: '', means: { ...EMPTY_MEANS, cashAccount: 'C', cashSum: 5000 } };
|
||||||
|
expect(validatePayment(draft({ ...base, accounts: [] }))).toEqual(['Agregue al menos una cuenta contable.']);
|
||||||
|
expect(validatePayment(draft({ ...base, accounts: [{ AccountCode: '6-1-010-40-000' }] }))).toEqual(['Cuenta 1: indique el importe.', 'Los medios de pago (5000) deben igualar el total de las cuentas (0).']);
|
||||||
|
expect(validatePayment(draft({ ...base, accounts: [{ AccountCode: '6-1-010-40-000', SumPaid: 4000 }] }))).toEqual(['Los medios de pago (5000) deben igualar el total de las cuentas (4000).']);
|
||||||
|
expect(validatePayment(draft({ ...base, accounts: [{ AccountCode: '6-1-010-40-000', SumPaid: 5000 }] }))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cuerpo del pago', () => {
|
||||||
|
it('transferencia aplicada a una factura: solo lo indicado, con la fecha del pago si no hay otra', () => {
|
||||||
|
const body = paymentPayload(incoming, draft({ means: { ...EMPTY_MEANS, transferAccount: '1-1-010-20-000', transferSum: 100000, transferReference: 'REF-1' }, docs: [doc({ docEntry: 819, applied: 100000 })] }));
|
||||||
|
expect(body).toEqual({
|
||||||
|
DocType: 'rCustomer',
|
||||||
|
CardCode: 'C1',
|
||||||
|
DocDate: '2026-10-16',
|
||||||
|
TransferAccount: '1-1-010-20-000',
|
||||||
|
TransferSum: 100000,
|
||||||
|
TransferDate: '2026-10-16',
|
||||||
|
TransferReference: 'REF-1',
|
||||||
|
PaymentInvoices: [{ DocEntry: 819, InvoiceType: 'it_Invoice', SumApplied: 100000 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la nota de crédito se aplica con importe NEGATIVO y las facturas sin aplicar no se envían', () => {
|
||||||
|
const body = paymentPayload(incoming, draft({
|
||||||
|
means: { ...EMPTY_MEANS, transferAccount: 'A', transferSum: 163625 },
|
||||||
|
docs: [doc({ docEntry: 819, applied: 327250 }), doc({ id: 'CreditNotes:16', docEntry: 16, kind: 'credit', type: 'it_CredItnote', applied: 163625 }), doc({ id: 'z', docEntry: 5, applied: 0 })],
|
||||||
|
}));
|
||||||
|
expect(body['PaymentInvoices']).toEqual([
|
||||||
|
{ DocEntry: 819, InvoiceType: 'it_Invoice', SumApplied: 327250 },
|
||||||
|
{ DocEntry: 16, InvoiceType: 'it_CredItnote', SumApplied: -163625 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un pago a cuenta no lleva PaymentInvoices: la diferencia la deja a cuenta SAP', () => {
|
||||||
|
const body = paymentPayload(incoming, draft({ means: { ...EMPTY_MEANS, transferAccount: 'A', transferSum: 50000 } }));
|
||||||
|
expect(body).not.toHaveProperty('PaymentInvoices');
|
||||||
|
expect(body['TransferSum']).toBe(50000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('efectivo y transferencia a la vez; el medio sin importe no se envía', () => {
|
||||||
|
const body = paymentPayload(incoming, draft({ means: { ...EMPTY_MEANS, cashAccount: 'C', cashSum: 20, transferAccount: 'T', transferSum: null } }));
|
||||||
|
expect(body).toMatchObject({ CashAccount: 'C', CashSum: 20 });
|
||||||
|
for (const k of ['TransferAccount', 'TransferSum', 'TransferDate', 'CheckAccount']) expect(body).not.toHaveProperty(k);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('los cheques llevan la cuenta de cheques en el pago, el país del banco y sin campos vacíos', () => {
|
||||||
|
const body = paymentPayload(incoming, draft({
|
||||||
|
means: { ...EMPTY_MEANS, checkAccount: '1-1-010-20-000' },
|
||||||
|
checks: [{ CheckNumber: 8001, BankCode: '001', Branch: '', AccounttNum: '00000001', DueDate: '2026-11-30', CheckSum: 30000 }, { CheckSum: 0 }],
|
||||||
|
}));
|
||||||
|
expect(body['CheckAccount']).toBe('1-1-010-20-000');
|
||||||
|
expect(body['PaymentChecks']).toEqual([{ CheckNumber: 8001, BankCode: '001', AccounttNum: '00000001', DueDate: '2026-11-30', CheckSum: 30000, CountryCode: 'CL' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a cuentas contables: sin socio y con PaymentAccounts', () => {
|
||||||
|
const body = paymentPayload(outgoing, draft({
|
||||||
|
docType: 'rAccount',
|
||||||
|
cardCode: 'ignorado',
|
||||||
|
means: { ...EMPTY_MEANS, cashAccount: '1-1-010-10-001', cashSum: 5000 },
|
||||||
|
accounts: [{ AccountCode: '6-1-010-40-000', SumPaid: 5000, Decription: 'Gasto' }, { AccountCode: '', SumPaid: 0 }],
|
||||||
|
}));
|
||||||
|
expect(body).toEqual({
|
||||||
|
DocType: 'rAccount',
|
||||||
|
DocDate: '2026-10-16',
|
||||||
|
CashAccount: '1-1-010-10-001',
|
||||||
|
CashSum: 5000,
|
||||||
|
PaymentAccounts: [{ AccountCode: '6-1-010-40-000', SumPaid: 5000, Decription: 'Gasto' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serie, comentarios y fecha del documento solo si se indicaron', () => {
|
||||||
|
const body = paymentPayload(outgoing, draft({ docType: 'rSupplier', series: 21, taxDate: '2026-10-15', remarks: 'Pago facturas', journalRemarks: 'Pago', means: { ...EMPTY_MEANS, transferAccount: 'T', transferSum: 1, transferDate: '2026-10-20' } }));
|
||||||
|
expect(body).toMatchObject({ DocType: 'rSupplier', Series: 21, TaxDate: '2026-10-15', Remarks: 'Pago facturas', JournalRemarks: 'Pago', TransferDate: '2026-10-20' });
|
||||||
|
const bare = paymentPayload(outgoing, draft({ docType: 'rSupplier', means: { ...EMPTY_MEANS, transferAccount: 'T', transferSum: 1 } }));
|
||||||
|
for (const k of ['Series', 'TaxDate', 'Remarks', 'JournalRemarks']) expect(bare).not.toHaveProperty(k);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Rutas de menú que son pantallas de pagos. Es lo único que el enrutador necesita al arrancar; la configuración
|
||||||
|
* completa (`payment-screens.ts`) se carga en diferido. Un test comprueba que coincide con `PAYMENT_SCREENS`.
|
||||||
|
*/
|
||||||
|
export const PAYMENT_PATHS: readonly string[] = ['/bancos/pagos-recibidos', '/bancos/pagos-efectuados'];
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { MENU } from '@basic-erp/sap-types';
|
||||||
|
import type { SelectFilter } from '../../core/sl/list-filters';
|
||||||
|
import type { ListColumn } from '../../shared/list/list.types';
|
||||||
|
import type { PaymentScreen } from './payment-types';
|
||||||
|
|
||||||
|
const docNum: ListColumn = { key: 'DocNum', title: 'Número', type: 'number', align: 'left', width: '110px', mono: true, searchable: true, format: (r) => String(r['DocNum'] ?? '') };
|
||||||
|
|
||||||
|
const status: ListColumn = {
|
||||||
|
key: 'Cancelled',
|
||||||
|
title: 'Estado',
|
||||||
|
width: '100px',
|
||||||
|
format: (r) => (r['Cancelled'] === 'tYES' ? 'Anulado' : 'Vigente'),
|
||||||
|
tone: (r) => (r['Cancelled'] === 'tYES' ? 'muted' : 'ok'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusFilter: SelectFilter = {
|
||||||
|
type: 'select',
|
||||||
|
key: 'Cancelled',
|
||||||
|
label: 'Estado',
|
||||||
|
defaultValue: 'tNO',
|
||||||
|
options: [
|
||||||
|
{ value: 'tNO', label: 'Vigente' },
|
||||||
|
{ value: 'tYES', label: 'Anulado' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Columnas de la lista: un pago no trae un total propio, así que se muestran sus medios en efectivo y transferencia. */
|
||||||
|
const columns = (partner: string): readonly ListColumn[] => [
|
||||||
|
docNum,
|
||||||
|
{ key: 'DocDate', title: 'Fecha', type: 'date', align: 'center', width: '110px' },
|
||||||
|
{ key: 'CardCode', title: partner, width: '130px', mono: true, searchable: true },
|
||||||
|
{ key: 'CardName', title: 'Nombre', searchable: true },
|
||||||
|
{ key: 'CashSum', title: 'Efectivo', type: 'currency', width: '120px' },
|
||||||
|
{ key: 'TransferSum', title: 'Transferencia', type: 'currency', width: '130px' },
|
||||||
|
status,
|
||||||
|
];
|
||||||
|
|
||||||
|
const dateFilter = { type: 'date', key: 'DocDate', label: 'Fecha' } as const;
|
||||||
|
|
||||||
|
export const PAYMENT_SCREENS: readonly PaymentScreen[] = [
|
||||||
|
{
|
||||||
|
path: '/bancos/pagos-recibidos',
|
||||||
|
resource: 'IncomingPayments',
|
||||||
|
kind: 'incoming',
|
||||||
|
singular: 'Pago recibido',
|
||||||
|
createLabel: 'Nuevo pago recibido',
|
||||||
|
partner: { label: 'Cliente', type: 'cCustomer', docType: 'rCustomer', lookupTitle: 'Clientes' },
|
||||||
|
invoices: { resource: 'Invoices', type: 'it_Invoice', label: 'Factura' },
|
||||||
|
credits: { resource: 'CreditNotes', type: 'it_CredItnote', label: 'Nota de crédito' },
|
||||||
|
objectType: 24,
|
||||||
|
localCurrency: 'CLP',
|
||||||
|
countryCode: 'CL',
|
||||||
|
columns: columns('Cliente'),
|
||||||
|
filters: [statusFilter, dateFilter],
|
||||||
|
orderby: 'DocEntry desc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/bancos/pagos-efectuados',
|
||||||
|
resource: 'VendorPayments',
|
||||||
|
kind: 'outgoing',
|
||||||
|
singular: 'Pago efectuado',
|
||||||
|
createLabel: 'Nuevo pago efectuado',
|
||||||
|
partner: { label: 'Proveedor', type: 'cSupplier', docType: 'rSupplier', lookupTitle: 'Proveedores' },
|
||||||
|
invoices: { resource: 'PurchaseInvoices', type: 'it_PurchaseInvoice', label: 'Factura de proveedor' },
|
||||||
|
credits: { resource: 'PurchaseCreditNotes', type: 'it_PurchaseCreditNote', label: 'Nota de crédito de compra' },
|
||||||
|
objectType: 46,
|
||||||
|
localCurrency: 'CLP',
|
||||||
|
countryCode: 'CL',
|
||||||
|
columns: columns('Proveedor'),
|
||||||
|
filters: [statusFilter, dateFilter],
|
||||||
|
orderby: 'DocEntry desc',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Nombre de una pantalla de pagos en el menú. */
|
||||||
|
export const paymentTitle = (path: string): string => MENU.flatMap((m) => m.items).find((i) => i.path === path)?.label ?? path;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { ListFilter } from '../../core/sl/list-filters';
|
||||||
|
import type { ListColumn } from '../../shared/list/list.types';
|
||||||
|
|
||||||
|
export type PaymentKind = 'incoming' | 'outgoing';
|
||||||
|
|
||||||
|
/** Tipo de pago de SAP: a un cliente, a un proveedor o directo a cuentas contables. */
|
||||||
|
export type PaymentDocType = 'rCustomer' | 'rSupplier' | 'rAccount';
|
||||||
|
|
||||||
|
/** Tipo de documento al que se aplica un pago (`PaymentInvoices.InvoiceType`). */
|
||||||
|
export type AppliedType = 'it_Invoice' | 'it_CredItnote' | 'it_PurchaseInvoice' | 'it_PurchaseCreditNote';
|
||||||
|
|
||||||
|
/** Pantalla de pagos: recibidos (de clientes) o efectuados (a proveedores). */
|
||||||
|
export interface PaymentScreen {
|
||||||
|
path: string;
|
||||||
|
/** Recurso del proxy `/api/sl`. */
|
||||||
|
resource: 'IncomingPayments' | 'VendorPayments';
|
||||||
|
kind: PaymentKind;
|
||||||
|
/** Nombre en singular, para el título del formulario. */
|
||||||
|
singular: string;
|
||||||
|
createLabel: string;
|
||||||
|
partner: { label: string; type: 'cCustomer' | 'cSupplier'; docType: 'rCustomer' | 'rSupplier'; lookupTitle: string };
|
||||||
|
/** Facturas pendientes del socio y notas de crédito que se pueden aplicar. */
|
||||||
|
invoices: { resource: string; type: AppliedType; label: string };
|
||||||
|
credits: { resource: string; type: AppliedType; label: string };
|
||||||
|
/** Tipo de objeto de SAP del pago (para pedir sus series de numeración). */
|
||||||
|
objectType: number;
|
||||||
|
/** Solo se listan y aplican documentos en esta moneda (la local de la empresa). */
|
||||||
|
localCurrency: string;
|
||||||
|
/** País de los bancos de los cheques. */
|
||||||
|
countryCode: string;
|
||||||
|
columns: readonly ListColumn[];
|
||||||
|
filters: readonly ListFilter[];
|
||||||
|
orderby: string;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user