refactor(web): el formulario base recibe de cada pantalla los campos de cabecera, pie y línea
Todas las pantallas declaran su DocumentLayout (catálogo FIELDS): ventas y compras dejan de tener la cabecera escrita en el HTML; el folio solo lo llevan los documentos tributarios. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c734dcbd0a
commit
8b0b8afc87
9 files changed
+320
-257
No files matched your search
@@ -56,7 +56,7 @@ Cada menú abre primero una **lista de los últimos documentos** (barra de filtr
|
|||||||
Hecho (commits en `main`): monorepo · API (config, capa SAP con sesión compartida y cola, login, permisos SQLite, proxy `/api/sl`) · web (temas, login, layout con menú por permisos, `sbo-value-help`, campos y filas compuestas, listas con filtros, ventana/toolbar/pestañas/menús, grilla de líneas, `/dev/componentes`).
|
Hecho (commits en `main`): monorepo · API (config, capa SAP con sesión compartida y cola, login, permisos SQLite, proxy `/api/sl`) · web (temas, login, layout con menú por permisos, `sbo-value-help`, campos y filas compuestas, listas con filtros, ventana/toolbar/pestañas/menús, grilla de líneas, `/dev/componentes`).
|
||||||
|
|
||||||
Pendiente, en orden:
|
Pendiente, en orden:
|
||||||
1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Un código escrito a mano (socio, artículo, almacén, impuesto) se resuelve al salir del campo con `SlLookupApi` (mismo recurso y filtro base que la lista "…"); si no existe se avisa. "Copiar de/a" hecho (`document-copy.ts`: flujos estándar y `BaseType/BaseEntry/BaseLine`; probado: orden de compra 2883 → recepción 299, orden de venta 315 → entrega 580, ambas órdenes quedaron cerradas). Formularios sin socio ni totales **hechos** (`document-layouts.ts`, configuración declarativa: cabecera de 2 columnas, columnas de línea, colección `DocumentLines` o `StockTransferLines`): entradas y salidas de inventario, transferencias de stock, solicitudes de traslado y solicitudes de compra. Entradas, salidas y transferencias contabilizadas son de solo lectura (SAP no las modifica); solicitudes de traslado y de compra se crean y modifican. Probado contra `SBODemoCL`: solicitud de compra 2 (la 1 es una prueba por API), solicitud de traslado 472 (cantidad modificada) y entrada de inventario 155 (1 unidad de `3MG30080714 M 10.5` en el almacén 8803). Limitaciones conocidas: los artículos por **lote o serie** no se pueden contabilizar (SAP pide `BatchNumbers`/`SerialNumbers`); las líneas de compra/inventario llevan `UoMEntry` solo si el artículo tiene grupo de unidades (`uomEntryOf`; sin él SAP responde "specify a UoM code"); los formularios de ventas y compras (`marketing`) aún no lo envían, y los de compra fallan con artículos sin unidad de compra por defecto. Los formularios con socio muestran `NumAtCard` y el RUT (`FederalTaxID`, solo lectura: lo completa SAP desde el socio); facturas, notas de crédito, entregas y recepciones (`FOLIO_PATHS` en `document-screens.ts`) añaden el folio tributario, que en el Service Layer es `FolioPrefixString` + `FolioNumber` (se edita como texto y se envía como número); las listas de facturas también los muestran. Falta: botón PDF, fila del flete y toolbar de ancho completo.
|
1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Un código escrito a mano (socio, artículo, almacén, impuesto) se resuelve al salir del campo con `SlLookupApi` (mismo recurso y filtro base que la lista "…"); si no existe se avisa. "Copiar de/a" hecho (`document-copy.ts`: flujos estándar y `BaseType/BaseEntry/BaseLine`; probado: orden de compra 2883 → recepción 299, orden de venta 315 → entrega 580, ambas órdenes quedaron cerradas). Formularios sin socio ni totales **hechos** (`document-layouts.ts`, configuración declarativa: cabecera de 2 columnas, columnas de línea, colección `DocumentLines` o `StockTransferLines`): entradas y salidas de inventario, transferencias de stock, solicitudes de traslado y solicitudes de compra. Entradas, salidas y transferencias contabilizadas son de solo lectura (SAP no las modifica); solicitudes de traslado y de compra se crean y modifican. Probado contra `SBODemoCL`: solicitud de compra 2 (la 1 es una prueba por API), solicitud de traslado 472 (cantidad modificada) y entrada de inventario 155 (1 unidad de `3MG30080714 M 10.5` en el almacén 8803). Limitaciones conocidas: los artículos por **lote o serie** no se pueden contabilizar (SAP pide `BatchNumbers`/`SerialNumbers`); las líneas de compra/inventario llevan `UoMEntry` solo si el artículo tiene grupo de unidades (`uomEntryOf`; sin él SAP responde "specify a UoM code"); los formularios de ventas y compras (`marketing`) aún no lo envían, y los de compra fallan con artículos sin unidad de compra por defecto. **Cómo se define un formulario:** el formulario base (`document-form.page`) no sabe de qué documento se trata; recibe de la pantalla su `layout` (`DocumentLayout` en `document-layouts.ts`): `header` y `footer` (cada uno con `left`/`right`, máximo 2 columnas) son arreglos de campos tomados del catálogo `FIELDS` (texto, fecha, número, almacén, socio, folio, calculado), `columns` son las columnas de línea y `lines` la colección que se guarda. Cada pantalla de `document-screens.ts` declara los suyos (la orden de venta no lleva folio; las facturas, notas de crédito, entregas y recepciones sí, con `TAX_DOCUMENT`); agregar o quitar un campo es editar ese arreglo, sin tocar el HTML. Los formularios con socio muestran `NumAtCard` y el RUT (`FederalTaxID`, solo lectura: lo completa SAP desde el socio); facturas, notas de crédito, entregas y recepciones (`FOLIO_PATHS` en `document-screens.ts`) añaden el folio tributario, que en el Service Layer es `FolioPrefixString` + `FolioNumber` (se edita como texto y se envía como número); las listas de facturas también los muestran. Falta: botón PDF, fila del flete y toolbar de ancho completo.
|
||||||
2. Pantallas de maestros (`Items`, `BusinessPartners`: Clientes/Proveedores, Artículos), hoy "no disponible".
|
2. Pantallas de maestros (`Items`, `BusinessPartners`: Clientes/Proveedores, Artículos), hoy "no disponible".
|
||||||
3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`).
|
3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`).
|
||||||
4. Auditoría (`audit_log` en SQLite; hoy solo líneas `AUDIT` en el log) y `nestjs-cls` para el usuario en los logs.
|
4. Auditoría (`audit_log` en SQLite; hoy solo líneas `AUDIT` en el log) y `nestjs-cls` para el usuario en los logs.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angul
|
|||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { DocumentFormPage } from './document-form.page';
|
import { DocumentFormPage } from './document-form.page';
|
||||||
|
import { hasFolio } from './document-layouts';
|
||||||
import { DOCUMENT_SCREENS } from './document-screens';
|
import { DOCUMENT_SCREENS } from './document-screens';
|
||||||
|
|
||||||
const invoice = (over: Record<string, unknown> = {}) => ({
|
const invoice = (over: Record<string, unknown> = {}) => ({
|
||||||
@@ -63,7 +64,7 @@ describe('folio y RUT en los formularios', () => {
|
|||||||
beforeEach(() => TestBed.resetTestingModule());
|
beforeEach(() => TestBed.resetTestingModule());
|
||||||
|
|
||||||
it('lo llevan facturas, notas de crédito, entregas y recepciones (ventas y compras)', () => {
|
it('lo llevan facturas, notas de crédito, entregas y recepciones (ventas y compras)', () => {
|
||||||
const withFolio = DOCUMENT_SCREENS.filter((s) => s.folio).map((s) => s.resource);
|
const withFolio = DOCUMENT_SCREENS.filter((s) => hasFolio(s.layout)).map((s) => s.resource);
|
||||||
expect(withFolio.sort()).toEqual(
|
expect(withFolio.sort()).toEqual(
|
||||||
['CreditNotes', 'DeliveryNotes', 'Invoices', 'PurchaseCreditNotes', 'PurchaseDeliveryNotes', 'PurchaseInvoices'].sort(),
|
['CreditNotes', 'DeliveryNotes', 'Invoices', 'PurchaseCreditNotes', 'PurchaseDeliveryNotes', 'PurchaseInvoices'].sort(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,136 +9,44 @@
|
|||||||
</sbo-toolbar>
|
</sbo-toolbar>
|
||||||
|
|
||||||
<sbo-window [title]="windowTitle()" [badge]="badge()" [status]="status()">
|
<sbo-window [title]="windowTitle()" [badge]="badge()" [status]="status()">
|
||||||
@if (partner) {
|
<div class="sbo-hdr">
|
||||||
<div class="sbo-hdr">
|
<div class="sbo-col">
|
||||||
<div class="sbo-col">
|
@for (f of layout.header.left; track $index) {
|
||||||
<sbo-lookup-field
|
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
|
||||||
[label]="partner.label"
|
}
|
||||||
required
|
|
||||||
[readonly]="readOnly() || !isNew()"
|
|
||||||
[value]="str(header()['CardCode'])"
|
|
||||||
(valueChange)="onPartnerText($event)"
|
|
||||||
[lookup]="partnerLookup"
|
|
||||||
resolveOnLeave
|
|
||||||
(picked)="onPartner($event)"
|
|
||||||
/>
|
|
||||||
<sbo-text-field label="Nombre" readonly [value]="partnerName()" />
|
|
||||||
<sbo-text-field label="RUT" readonly [value]="partnerTaxId()" />
|
|
||||||
<sbo-text-field
|
|
||||||
label="Nº referencia"
|
|
||||||
[readonly]="readOnly()"
|
|
||||||
[value]="str(header()['NumAtCard'])"
|
|
||||||
(valueChange)="setHeader('NumAtCard', $event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="sbo-col">
|
|
||||||
<sbo-text-field label="Número" readonly [value]="str(doc()?.['DocNum'])" />
|
|
||||||
<sbo-date-field
|
|
||||||
label="Fecha de contabilización"
|
|
||||||
required
|
|
||||||
[readonly]="readOnly()"
|
|
||||||
[value]="str(header()['DocDate'])"
|
|
||||||
(valueChange)="setHeader('DocDate', $event)"
|
|
||||||
/>
|
|
||||||
<sbo-date-field
|
|
||||||
label="Fecha de vencimiento"
|
|
||||||
[readonly]="readOnly()"
|
|
||||||
[value]="str(header()['DocDueDate'])"
|
|
||||||
(valueChange)="setHeader('DocDueDate', $event)"
|
|
||||||
/>
|
|
||||||
@if (folio) {
|
|
||||||
<sbo-field label="Folio">
|
|
||||||
<sbo-input
|
|
||||||
width="90px"
|
|
||||||
[maxlength]="10"
|
|
||||||
[readonly]="readOnly()"
|
|
||||||
[value]="str(header()['FolioPrefixString'])"
|
|
||||||
(valueChange)="setHeader('FolioPrefixString', $event)"
|
|
||||||
/>
|
|
||||||
<sbo-input
|
|
||||||
ariaLabel="Número de folio"
|
|
||||||
[readonly]="readOnly()"
|
|
||||||
[value]="str(header()['FolioNumber'])"
|
|
||||||
(valueChange)="setHeader('FolioNumber', $event)"
|
|
||||||
/>
|
|
||||||
</sbo-field>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sbo-col">
|
||||||
<sbo-lines-grid
|
@for (f of layout.header.right; track $index) {
|
||||||
[columns]="columns"
|
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
|
||||||
[lines]="lines()"
|
}
|
||||||
(linesChange)="setLines($event)"
|
|
||||||
(notice)="notify($event)"
|
|
||||||
[readonly]="readOnly()"
|
|
||||||
[newLine]="newLine"
|
|
||||||
[recalc]="recalc"
|
|
||||||
[multiAdd]="multi"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="sbo-hdr" style="margin-top: 14px">
|
|
||||||
<div class="sbo-col">
|
|
||||||
<sbo-textarea-field
|
|
||||||
label="Comentarios"
|
|
||||||
[readonly]="readOnly()"
|
|
||||||
[value]="str(header()['Comments'])"
|
|
||||||
(valueChange)="setHeader('Comments', $event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="sbo-col">
|
|
||||||
<sbo-text-field label="Total antes del descuento" readonly [value]="fmt(totals().subtotal)" />
|
|
||||||
<sbo-number-field
|
|
||||||
label="Descuento %"
|
|
||||||
[decimals]="2"
|
|
||||||
[readonly]="readOnly()"
|
|
||||||
[value]="+str(header()['DiscountPercent'])"
|
|
||||||
(valueChange)="setHeader('DiscountPercent', $event ?? 0)"
|
|
||||||
/>
|
|
||||||
<sbo-text-field label="Impuesto" readonly [value]="fmt(totals().tax)" />
|
|
||||||
<sbo-text-field label="Total del documento" readonly [value]="fmt(totals().total)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
} @else if (layout; as lay) {
|
</div>
|
||||||
<div class="sbo-hdr">
|
|
||||||
<div class="sbo-col">
|
<sbo-lines-grid
|
||||||
@for (f of lay.left; track f.key) {
|
[columns]="columns"
|
||||||
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
|
[lines]="lines()"
|
||||||
}
|
(linesChange)="setLines($event)"
|
||||||
</div>
|
(notice)="notify($event)"
|
||||||
<div class="sbo-col">
|
[readonly]="readOnly()"
|
||||||
<sbo-text-field label="Número" readonly [value]="str(doc()?.['DocNum'])" />
|
[newLine]="newLine"
|
||||||
@for (f of lay.right; track f.key) {
|
[recalc]="recalc"
|
||||||
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
|
[multiAdd]="multi"
|
||||||
}
|
/>
|
||||||
</div>
|
|
||||||
|
<div class="sbo-hdr" style="margin-top: 14px">
|
||||||
|
<div class="sbo-col">
|
||||||
|
@for (f of layout.footer.left; track $index) {
|
||||||
|
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sbo-col">
|
||||||
<sbo-lines-grid
|
@for (f of layout.footer.right; track $index) {
|
||||||
[columns]="columns"
|
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
|
||||||
[lines]="lines()"
|
}
|
||||||
(linesChange)="setLines($event)"
|
|
||||||
(notice)="notify($event)"
|
|
||||||
[readonly]="readOnly()"
|
|
||||||
[newLine]="newLine"
|
|
||||||
[multiAdd]="multi"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="sbo-hdr" style="margin-top: 14px">
|
|
||||||
<div class="sbo-col">
|
|
||||||
<sbo-textarea-field
|
|
||||||
label="Comentarios"
|
|
||||||
[readonly]="readOnly()"
|
|
||||||
[value]="str(header()['Comments'])"
|
|
||||||
(valueChange)="setHeader('Comments', $event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
</div>
|
||||||
<p>El formulario de este documento aún no está disponible. <a routerLink="..">Volver a la lista</a></p>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if ((partner || layout) && !readOnly()) {
|
@if (!readOnly()) {
|
||||||
<button sboActions class="btn main" type="button" [disabled]="saving() || loading()" (click)="save()">
|
<button sboActions class="btn main" type="button" [disabled]="saving() || loading()" (click)="save()">
|
||||||
{{ isNew() ? 'Crear' : 'Actualizar' }}
|
{{ isNew() ? 'Crear' : 'Actualizar' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -171,13 +79,16 @@
|
|||||||
|
|
||||||
<ng-template #field let-f>
|
<ng-template #field let-f>
|
||||||
@switch (f.type) {
|
@switch (f.type) {
|
||||||
@case ('date') {
|
@case ('partner') {
|
||||||
<sbo-date-field
|
<sbo-lookup-field
|
||||||
[label]="f.label"
|
[label]="f.label"
|
||||||
[required]="!!f.required"
|
[required]="!!f.required"
|
||||||
[readonly]="readOnly()"
|
[readonly]="readOnly() || !isNew()"
|
||||||
[value]="str(header()[f.key])"
|
[value]="str(header()[f.key])"
|
||||||
(valueChange)="setHeader(f.key, $event)"
|
(valueChange)="onPartnerText($event)"
|
||||||
|
[lookup]="partnerLookup"
|
||||||
|
resolveOnLeave
|
||||||
|
(picked)="onPartner($event)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@case ('warehouse') {
|
@case ('warehouse') {
|
||||||
@@ -191,13 +102,59 @@
|
|||||||
resolveOnLeave
|
resolveOnLeave
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@default {
|
@case ('date') {
|
||||||
<sbo-text-field
|
<sbo-date-field
|
||||||
|
[label]="f.label"
|
||||||
|
[required]="!!f.required"
|
||||||
|
[readonly]="readOnly()"
|
||||||
|
[value]="str(header()[f.key])"
|
||||||
|
(valueChange)="setHeader(f.key, $event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@case ('number') {
|
||||||
|
<sbo-number-field
|
||||||
|
[label]="f.label"
|
||||||
|
[decimals]="f.decimals"
|
||||||
|
[readonly]="readOnly()"
|
||||||
|
[value]="+str(header()[f.key])"
|
||||||
|
(valueChange)="setHeader(f.key, $event ?? 0)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@case ('textarea') {
|
||||||
|
<sbo-textarea-field
|
||||||
[label]="f.label"
|
[label]="f.label"
|
||||||
[readonly]="readOnly()"
|
[readonly]="readOnly()"
|
||||||
[value]="str(header()[f.key])"
|
[value]="str(header()[f.key])"
|
||||||
(valueChange)="setHeader(f.key, $event)"
|
(valueChange)="setHeader(f.key, $event)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
@case ('folio') {
|
||||||
|
<sbo-field [label]="f.label">
|
||||||
|
<sbo-input
|
||||||
|
width="90px"
|
||||||
|
[maxlength]="10"
|
||||||
|
[readonly]="readOnly()"
|
||||||
|
[value]="str(header()[f.prefixKey])"
|
||||||
|
(valueChange)="setHeader(f.prefixKey, $event)"
|
||||||
|
/>
|
||||||
|
<sbo-input
|
||||||
|
ariaLabel="Número de folio"
|
||||||
|
[readonly]="readOnly()"
|
||||||
|
[value]="str(header()[f.numberKey])"
|
||||||
|
(valueChange)="setHeader(f.numberKey, $event)"
|
||||||
|
/>
|
||||||
|
</sbo-field>
|
||||||
|
}
|
||||||
|
@case ('computed') {
|
||||||
|
<sbo-text-field [label]="f.label" readonly [value]="computedValue(f.source)" />
|
||||||
|
}
|
||||||
|
@default {
|
||||||
|
<sbo-text-field
|
||||||
|
[label]="f.label"
|
||||||
|
[readonly]="readOnly() || !!f.readonly"
|
||||||
|
[value]="str(header()[f.key])"
|
||||||
|
(valueChange)="setHeader(f.key, $event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
@@ -19,24 +19,17 @@ import {
|
|||||||
type DocRecord,
|
type DocRecord,
|
||||||
type NavTarget,
|
type NavTarget,
|
||||||
computeTotals,
|
computeTotals,
|
||||||
MARKETING_LINES,
|
|
||||||
createPayload,
|
createPayload,
|
||||||
isBlankLine,
|
isBlankLine,
|
||||||
isDocumentReadOnly,
|
isDocumentReadOnly,
|
||||||
taxRateMap,
|
taxRateMap,
|
||||||
updatePayload,
|
updatePayload,
|
||||||
validateDocument,
|
|
||||||
validateLayout,
|
validateLayout,
|
||||||
} from './document-model';
|
} from './document-model';
|
||||||
import { WAREHOUSE_LOOKUP, headerKeysOf } from './document-layouts';
|
import { type ComputedSource, WAREHOUSE_LOOKUP, hasFolio, headerKeysOf, requiredHeaderOf } from './document-layouts';
|
||||||
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
|
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
|
||||||
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
|
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
|
||||||
import { ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine } from './line-columns';
|
import { ITEMS_MULTI_ADD } from './line-columns';
|
||||||
|
|
||||||
/** Campos de cabecera que se editan en el formulario (el resto lo calcula SAP). */
|
|
||||||
const HEADER_KEYS = ['CardCode', 'NumAtCard', 'DocDate', 'DocDueDate', 'DiscountPercent', 'Comments'] as const;
|
|
||||||
/** Folio tributario de los documentos que lo llevan. */
|
|
||||||
const FOLIO_KEYS = ['FolioPrefixString', 'FolioNumber'] as const;
|
|
||||||
|
|
||||||
const today = (): string => new Date().toISOString().slice(0, 10);
|
const today = (): string => new Date().toISOString().slice(0, 10);
|
||||||
const DATE_KEYS = new Set(['DocDate', 'DocDueDate', 'RequriedDate']);
|
const DATE_KEYS = new Set(['DocDate', 'DocDueDate', 'RequriedDate']);
|
||||||
@@ -84,18 +77,12 @@ export class DocumentFormPage {
|
|||||||
protected readonly title = this.route.snapshot.data['title'] as string;
|
protected readonly title = this.route.snapshot.data['title'] as string;
|
||||||
protected readonly screen = this.route.snapshot.data['screen'] as DocumentScreen;
|
protected readonly screen = this.route.snapshot.data['screen'] as DocumentScreen;
|
||||||
protected readonly partner = this.screen.partner;
|
protected readonly partner = this.screen.partner;
|
||||||
/** Formulario de inventario o de solicitud de compra (sin socio ni totales). */
|
/** Qué campos y columnas lleva este documento (lo declara su pantalla); el formulario solo lo dibuja. */
|
||||||
protected readonly layout = this.screen.layout;
|
protected readonly layout = this.screen.layout;
|
||||||
private readonly lineSpec = this.layout?.lines ?? MARKETING_LINES;
|
private readonly lineSpec = this.layout.lines;
|
||||||
private readonly headerKeys: readonly string[] = this.layout
|
private readonly headerKeys: readonly string[] = headerKeysOf(this.layout);
|
||||||
? headerKeysOf(this.layout)
|
private readonly hasFolio = hasFolio(this.layout);
|
||||||
: this.screen.folio
|
private readonly blankHeader: DocRecord = this.layout.newHeader();
|
||||||
? [...HEADER_KEYS, ...FOLIO_KEYS]
|
|
||||||
: HEADER_KEYS;
|
|
||||||
protected readonly folio = !!this.screen.folio;
|
|
||||||
private readonly blankHeader: DocRecord = this.layout
|
|
||||||
? this.layout.newHeader()
|
|
||||||
: { DocDate: today(), DocDueDate: today(), DiscountPercent: 0 };
|
|
||||||
private pick(doc: DocRecord | undefined): DocRecord {
|
private pick(doc: DocRecord | undefined): DocRecord {
|
||||||
return pickHeader(doc, this.headerKeys, this.blankHeader);
|
return pickHeader(doc, this.headerKeys, this.blankHeader);
|
||||||
}
|
}
|
||||||
@@ -136,7 +123,7 @@ export class DocumentFormPage {
|
|||||||
|
|
||||||
// --- estado --------------------------------------------------------------------------------
|
// --- estado --------------------------------------------------------------------------------
|
||||||
|
|
||||||
protected readonly readOnly = computed(() => isDocumentReadOnly(this.doc()) || (!!this.layout?.immutable && !this.isNew()));
|
protected readonly readOnly = computed(() => isDocumentReadOnly(this.doc()) || (!!this.layout.immutable && !this.isNew()));
|
||||||
protected readonly saving = signal(false);
|
protected readonly saving = signal(false);
|
||||||
private readonly message = signal<StatusMessage | null>(
|
private readonly message = signal<StatusMessage | null>(
|
||||||
((): StatusMessage | null => {
|
((): StatusMessage | null => {
|
||||||
@@ -152,11 +139,11 @@ export class DocumentFormPage {
|
|||||||
const message = this.message();
|
const message = this.message();
|
||||||
if (message) return message;
|
if (message) return message;
|
||||||
if (this.readOnly()) {
|
if (this.readOnly()) {
|
||||||
const posted = this.layout?.immutable && !isDocumentReadOnly(this.doc());
|
const posted = this.layout.immutable && !isDocumentReadOnly(this.doc());
|
||||||
return { kind: 'info', text: posted ? 'Documento contabilizado: solo lectura.' : 'Documento cerrado o cancelado: solo lectura.' };
|
return { kind: 'info', text: posted ? 'Documento contabilizado: solo lectura.' : 'Documento cerrado o cancelado: solo lectura.' };
|
||||||
}
|
}
|
||||||
if (this.isNew()) {
|
if (this.isNew()) {
|
||||||
return { kind: 'info', text: this.layout ? 'Complete la cabecera y las líneas.' : `Indique el ${this.partner?.label.toLowerCase() ?? 'socio'} y las líneas.` };
|
return { kind: 'info', text: this.partner ? `Indique el ${this.partner.label.toLowerCase()} y las líneas.` : 'Complete la cabecera y las líneas.' };
|
||||||
}
|
}
|
||||||
return { kind: 'info', text: this.dirty() ? 'Hay cambios sin guardar.' : '' };
|
return { kind: 'info', text: this.dirty() ? 'Hay cambios sin guardar.' : '' };
|
||||||
});
|
});
|
||||||
@@ -265,12 +252,13 @@ export class DocumentFormPage {
|
|||||||
|
|
||||||
// --- configuración de la vista --------------------------------------------------------------
|
// --- configuración de la vista --------------------------------------------------------------
|
||||||
|
|
||||||
protected readonly columns = this.layout?.columns ?? lineColumnsFor(this.partner?.taxResource ?? 'SalesTaxCodes');
|
protected readonly columns = this.layout.columns;
|
||||||
protected readonly newLine = this.layout ? () => this.layout!.newLine(this.header()) : newDocumentLine;
|
protected readonly newLine = () => this.layout.newLine(this.header());
|
||||||
protected readonly recalc = this.layout ? undefined : recalcLine;
|
protected readonly recalc = this.layout.recalc;
|
||||||
protected readonly multi: GridMultiAdd = this.layout
|
protected readonly multi: GridMultiAdd = {
|
||||||
? { ...ITEMS_MULTI_ADD, toLine: (item) => this.layout!.columns[0].lookup!.apply(item, this.layout!.newLine(this.header())) }
|
...ITEMS_MULTI_ADD,
|
||||||
: ITEMS_MULTI_ADD;
|
toLine: (item) => this.layout.columns[0].lookup!.apply(item, this.layout.newLine(this.header())),
|
||||||
|
};
|
||||||
protected readonly warehouseLookup = WAREHOUSE_LOOKUP;
|
protected readonly warehouseLookup = WAREHOUSE_LOOKUP;
|
||||||
protected readonly partnerLookup: VhLookup = {
|
protected readonly partnerLookup: VhLookup = {
|
||||||
title: this.partner?.type === 'cSupplier' ? 'Proveedores' : 'Clientes',
|
title: this.partner?.type === 'cSupplier' ? 'Proveedores' : 'Clientes',
|
||||||
@@ -286,6 +274,24 @@ export class DocumentFormPage {
|
|||||||
|
|
||||||
protected str = text;
|
protected str = text;
|
||||||
|
|
||||||
|
/** Valor de una fila de solo lectura (número del documento, datos del socio, totales). */
|
||||||
|
protected computedValue(source: ComputedSource): string {
|
||||||
|
switch (source) {
|
||||||
|
case 'docNum':
|
||||||
|
return text(this.doc()?.['DocNum']);
|
||||||
|
case 'partnerName':
|
||||||
|
return this.partnerName();
|
||||||
|
case 'partnerTaxId':
|
||||||
|
return this.partnerTaxId();
|
||||||
|
case 'subtotal':
|
||||||
|
return this.fmt(this.totals().subtotal);
|
||||||
|
case 'tax':
|
||||||
|
return this.fmt(this.totals().tax);
|
||||||
|
case 'total':
|
||||||
|
return this.fmt(this.totals().total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (this.copyOrigin) void this.loadCopy(this.copyOrigin);
|
if (this.copyOrigin) void this.loadCopy(this.copyOrigin);
|
||||||
}
|
}
|
||||||
@@ -335,15 +341,8 @@ export class DocumentFormPage {
|
|||||||
|
|
||||||
protected async save(): Promise<void> {
|
protected async save(): Promise<void> {
|
||||||
if (this.saving() || this.readOnly()) return;
|
if (this.saving() || this.readOnly()) return;
|
||||||
const errors = this.layout
|
const errors = validateLayout(this.header(), this.lines(), requiredHeaderOf(this.layout), this.layout.requiredLine);
|
||||||
? validateLayout(
|
if (this.hasFolio && !/^\d*$/.test(text(this.header()['FolioNumber']).trim())) errors.push('El número de folio debe ser numérico.');
|
||||||
this.header(),
|
|
||||||
this.lines(),
|
|
||||||
[...this.layout.left, ...this.layout.right].filter((f) => f.required),
|
|
||||||
this.layout.requiredLine,
|
|
||||||
)
|
|
||||||
: validateDocument(this.header(), this.lines(), this.partner?.label ?? 'Socio de negocio');
|
|
||||||
if (this.folio && !/^\d*$/.test(text(this.header()['FolioNumber']).trim())) errors.push('El número de folio debe ser numérico.');
|
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
this.message.set({ kind: 'err', text: errors[0] });
|
this.message.set({ kind: 'err', text: errors[0] });
|
||||||
return;
|
return;
|
||||||
@@ -353,7 +352,7 @@ export class DocumentFormPage {
|
|||||||
try {
|
try {
|
||||||
const d = this.doc();
|
const d = this.doc();
|
||||||
if (!d) {
|
if (!d) {
|
||||||
const header = folioAsNumber(this.layout?.beforeCreate?.(this.header()) ?? this.header());
|
const header = folioAsNumber(this.layout.beforeCreate?.(this.header()) ?? this.header());
|
||||||
const created = await this.api.create(this.screen.resource, createPayload(header, this.lines(), this.lineSpec));
|
const created = await this.api.create(this.screen.resource, createPayload(header, this.lines(), this.lineSpec));
|
||||||
await this.router.navigate(['..', created['DocEntry']], {
|
await this.router.navigate(['..', created['DocEntry']], {
|
||||||
relativeTo: this.route,
|
relativeTo: this.route,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { type ComponentFixture, TestBed } from '@angular/core/testing';
|
|||||||
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { LAYOUTS, headerKeysOf } from './document-layouts';
|
import { LAYOUTS, hasFolio, headerKeysOf } from './document-layouts';
|
||||||
import { createPayload, updatePayload, validateLayout } from './document-model';
|
import { createPayload, updatePayload, validateLayout } from './document-model';
|
||||||
import { DocumentFormPage } from './document-form.page';
|
import { DocumentFormPage } from './document-form.page';
|
||||||
import { DOCUMENT_SCREENS } from './document-screens';
|
import { DOCUMENT_SCREENS } from './document-screens';
|
||||||
@@ -12,18 +12,33 @@ import { DOCUMENT_SCREENS } from './document-screens';
|
|||||||
describe('modelo de los formularios sin socio', () => {
|
describe('modelo de los formularios sin socio', () => {
|
||||||
const transfer = LAYOUTS.transferRequest;
|
const transfer = LAYOUTS.transferRequest;
|
||||||
|
|
||||||
it('cada pantalla de inventario y la solicitud de compra tienen su formulario', () => {
|
it('cada pantalla tiene su formulario, declarado por ella', () => {
|
||||||
const withLayout = DOCUMENT_SCREENS.filter((s) => s.layout).map((s) => s.resource);
|
expect(DOCUMENT_SCREENS.every((s) => s.layout)).toBe(true);
|
||||||
expect(withLayout.sort()).toEqual(
|
const withPartner = DOCUMENT_SCREENS.filter((s) => s.partner).map((s) => s.resource);
|
||||||
|
const without = DOCUMENT_SCREENS.filter((s) => !s.partner).map((s) => s.resource);
|
||||||
|
expect(withPartner).toHaveLength(12);
|
||||||
|
expect(without.sort()).toEqual(
|
||||||
['InventoryGenEntries', 'InventoryGenExits', 'InventoryTransferRequests', 'PurchaseRequests', 'StockTransfers'].sort(),
|
['InventoryGenEntries', 'InventoryGenExits', 'InventoryTransferRequests', 'PurchaseRequests', 'StockTransfers'].sort(),
|
||||||
);
|
);
|
||||||
expect(DOCUMENT_SCREENS.filter((s) => s.layout && s.partner)).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('la cabecera tiene como máximo dos columnas y no usa campos de usuario', () => {
|
it('cada formulario declara solo sus campos: la orden de venta no lleva folio y la factura sí', () => {
|
||||||
for (const l of Object.values(LAYOUTS)) {
|
const by = (r: string) => DOCUMENT_SCREENS.find((s) => s.resource === r)!.layout;
|
||||||
for (const k of headerKeysOf(l)) expect(k.startsWith('U_')).toBe(false);
|
expect(hasFolio(by('Orders'))).toBe(false);
|
||||||
for (const c of l.columns) expect(c.key.startsWith('U_')).toBe(false);
|
expect(hasFolio(by('PurchaseQuotations'))).toBe(false);
|
||||||
|
for (const r of ['Invoices', 'PurchaseInvoices', 'CreditNotes', 'PurchaseCreditNotes', 'DeliveryNotes', 'PurchaseDeliveryNotes']) {
|
||||||
|
expect(hasFolio(by(r))).toBe(true);
|
||||||
|
}
|
||||||
|
expect(headerKeysOf(by('Invoices'))).toEqual(expect.arrayContaining(['FolioPrefixString', 'FolioNumber']));
|
||||||
|
expect(headerKeysOf(by('Orders'))).not.toContain('FolioNumber');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la cabecera y el pie tienen como máximo dos columnas y no usan campos de usuario', () => {
|
||||||
|
for (const s of DOCUMENT_SCREENS) {
|
||||||
|
for (const k of headerKeysOf(s.layout)) expect(k.startsWith('U_')).toBe(false);
|
||||||
|
for (const c of s.layout.columns) expect(c.key.startsWith('U_')).toBe(false);
|
||||||
|
expect(Object.keys(s.layout.header)).toEqual(['left', 'right']);
|
||||||
|
expect(Object.keys(s.layout.footer)).toEqual(['left', 'right']);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,79 @@
|
|||||||
import type { GridColumn, GridLine } from '../../shared/grid/grid.types';
|
import type { GridColumn, GridLine } from '../../shared/grid/grid.types';
|
||||||
import type { VhLookup } from '../../shared/value-help/value-help.types';
|
import type { VhLookup } from '../../shared/value-help/value-help.types';
|
||||||
import type { DocRecord, LinesSpec, RequiredField } from './document-model';
|
import type { DocRecord, LinesSpec } from './document-model';
|
||||||
import { DOCUMENT_LINE_COLUMNS, uomEntryOf } from './line-columns';
|
import { DOCUMENT_LINE_COLUMNS, lineColumnsFor, newDocumentLine, recalcLine, uomEntryOf } from './line-columns';
|
||||||
|
|
||||||
export interface HeaderField {
|
/** Valores calculados o del socio que una fila de solo lectura puede mostrar. */
|
||||||
key: string;
|
export type ComputedSource = 'docNum' | 'partnerName' | 'partnerTaxId' | 'subtotal' | 'tax' | 'total';
|
||||||
label: string;
|
|
||||||
/** `warehouse` abre la lista de almacenes. */
|
/**
|
||||||
type: 'text' | 'date' | 'warehouse';
|
* Un campo de la cabecera (o del pie) del formulario. La pantalla de cada documento declara su lista
|
||||||
required?: boolean;
|
* de campos y el formulario base los dibuja: no hay HTML por documento.
|
||||||
|
*/
|
||||||
|
export type HeaderField =
|
||||||
|
| { type: 'text'; key: string; label: string; readonly?: boolean }
|
||||||
|
| { type: 'date'; key: string; label: string; required?: boolean; missing?: string }
|
||||||
|
| { type: 'number'; key: string; label: string; decimals?: number }
|
||||||
|
| { type: 'textarea'; key: string; label: string }
|
||||||
|
/** Abre la lista de almacenes. */
|
||||||
|
| { type: 'warehouse'; key: string; label: string; required?: boolean; missing?: string }
|
||||||
|
/** Código del socio de negocio (cliente o proveedor) con su lista; solo editable al crear. */
|
||||||
|
| { type: 'partner'; key: string; label: string; required?: boolean; missing?: string }
|
||||||
|
/** Prefijo y número del folio tributario en una misma fila (`FolioPrefixString` y `FolioNumber`). */
|
||||||
|
| { type: 'folio'; label: string; prefixKey: string; numberKey: string }
|
||||||
|
| { type: 'computed'; label: string; source: ComputedSource };
|
||||||
|
|
||||||
|
export interface FieldColumns {
|
||||||
|
/** Dos columnas como máximo (regla del proyecto base). */
|
||||||
|
left: readonly HeaderField[];
|
||||||
|
right: readonly HeaderField[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formulario de los documentos sin socio de negocio ni totales (inventario y solicitudes de
|
* Formulario de un documento: qué campos de cabecera y de pie, qué columnas de línea y cómo se
|
||||||
* compra): qué campos de cabecera y de línea tienen, y cómo se guardan.
|
* guarda. El formulario base lo dibuja sin saber de qué documento se trata.
|
||||||
*/
|
*/
|
||||||
export interface DocumentLayout {
|
export interface DocumentLayout {
|
||||||
lines: LinesSpec;
|
lines: LinesSpec;
|
||||||
/** Dos columnas como máximo (regla del proyecto base); el número y los comentarios van aparte. */
|
header: FieldColumns;
|
||||||
left: readonly HeaderField[];
|
/** Debajo de las líneas: comentarios y, donde los hay, descuento y totales. */
|
||||||
right: readonly HeaderField[];
|
footer: FieldColumns;
|
||||||
columns: readonly GridColumn[];
|
columns: readonly GridColumn[];
|
||||||
/** Cabecera de un documento nuevo (siempre lleva `DocDate`). */
|
/** Cabecera de un documento nuevo (siempre lleva `DocDate`). */
|
||||||
newHeader: () => DocRecord;
|
newHeader: () => DocRecord;
|
||||||
/** Línea nueva; las transferencias toman los almacenes de la cabecera. */
|
/** Línea nueva; las transferencias toman los almacenes de la cabecera. */
|
||||||
newLine: (header: DocRecord) => GridLine;
|
newLine: (header: DocRecord) => GridLine;
|
||||||
|
/** Recalcula una línea tras un cambio (total = cantidad × precio…). */
|
||||||
|
recalc?: (line: GridLine, changedKey: string) => GridLine;
|
||||||
/** Una vez contabilizado ya no se modifica (SAP no permite cambiar entradas, salidas ni transferencias). */
|
/** Una vez contabilizado ya no se modifica (SAP no permite cambiar entradas, salidas ni transferencias). */
|
||||||
immutable?: boolean;
|
immutable?: boolean;
|
||||||
/** Ajustes a la cabecera antes de crear. */
|
/** Ajustes a la cabecera antes de crear. */
|
||||||
beforeCreate?: (header: DocRecord) => DocRecord;
|
beforeCreate?: (header: DocRecord) => DocRecord;
|
||||||
requiredLine: readonly RequiredField[];
|
requiredLine: readonly { key: string; label: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = (): string => new Date().toISOString().slice(0, 10);
|
const today = (): string => new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// --- catálogo de campos ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Catálogo de campos: cada pantalla elige cuáles lleva. */
|
||||||
|
export const FIELDS = {
|
||||||
|
docNum: { type: 'computed', label: 'Número', source: 'docNum' },
|
||||||
|
docDate: { type: 'date', key: 'DocDate', label: 'Fecha de contabilización', required: true, missing: 'la fecha de contabilización' },
|
||||||
|
docDueDate: { type: 'date', key: 'DocDueDate', label: 'Fecha de vencimiento' },
|
||||||
|
requiredDate: { type: 'date', key: 'RequriedDate', label: 'Fecha requerida', required: true, missing: 'la fecha requerida' },
|
||||||
|
comments: { type: 'textarea', key: 'Comments', label: 'Comentarios' },
|
||||||
|
numAtCard: { type: 'text', key: 'NumAtCard', label: 'Nº referencia' },
|
||||||
|
folio: { type: 'folio', label: 'Folio', prefixKey: 'FolioPrefixString', numberKey: 'FolioNumber' },
|
||||||
|
fromWarehouse: { type: 'warehouse', key: 'FromWarehouse', label: 'Almacén origen', required: true, missing: 'el almacén origen' },
|
||||||
|
toWarehouse: { type: 'warehouse', key: 'ToWarehouse', label: 'Almacén destino', required: true, missing: 'el almacén destino' },
|
||||||
|
requester: { type: 'text', key: 'Requester', label: 'Solicitante (usuario)' },
|
||||||
|
} as const satisfies Record<string, HeaderField>;
|
||||||
|
|
||||||
|
const commentsOnly: FieldColumns = { left: [FIELDS.comments], right: [] };
|
||||||
|
|
||||||
|
// --- columnas de línea compartidas ----------------------------------------------------------
|
||||||
|
|
||||||
const [baseItemColumn, descriptionColumn, quantityColumn, warehouseColumn, priceColumn] = DOCUMENT_LINE_COLUMNS;
|
const [baseItemColumn, descriptionColumn, quantityColumn, warehouseColumn, priceColumn] = DOCUMENT_LINE_COLUMNS;
|
||||||
|
|
||||||
/** Artículo que además completa la unidad de medida de la línea (`UoMEntry`). */
|
/** Artículo que además completa la unidad de medida de la línea (`UoMEntry`). */
|
||||||
@@ -50,11 +90,55 @@ export const WAREHOUSE_LOOKUP: VhLookup = warehouseColumn.lookup!.config;
|
|||||||
|
|
||||||
const blankLine = (): GridLine => ({ ItemCode: '', ItemDescription: '', Quantity: 1, WarehouseCode: '' });
|
const blankLine = (): GridLine => ({ ItemCode: '', ItemDescription: '', Quantity: 1, WarehouseCode: '' });
|
||||||
|
|
||||||
|
// --- documentos con socio de negocio (ventas y compras) -------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ventas y compras: socio, referencia, fechas; líneas con precio, descuento e impuesto; pie con
|
||||||
|
* comentarios, descuento y totales. `left` y `right` son los campos que esa pantalla agrega a la
|
||||||
|
* cabecera (por ejemplo `FIELDS.folio` en las facturas); sin ellos, la cabecera base.
|
||||||
|
*/
|
||||||
|
export function marketingLayout(opts: {
|
||||||
|
partnerLabel: string;
|
||||||
|
taxResource: string;
|
||||||
|
left?: readonly HeaderField[];
|
||||||
|
right?: readonly HeaderField[];
|
||||||
|
}): DocumentLayout {
|
||||||
|
return {
|
||||||
|
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'DiscountPercent', 'TaxCode'], base: true },
|
||||||
|
header: {
|
||||||
|
left: [
|
||||||
|
{ type: 'partner', key: 'CardCode', label: opts.partnerLabel, required: true, missing: `el ${opts.partnerLabel.toLowerCase()}` },
|
||||||
|
{ type: 'computed', label: 'Nombre', source: 'partnerName' },
|
||||||
|
{ type: 'computed', label: 'RUT', source: 'partnerTaxId' },
|
||||||
|
FIELDS.numAtCard,
|
||||||
|
...(opts.left ?? []),
|
||||||
|
],
|
||||||
|
right: [FIELDS.docNum, FIELDS.docDate, FIELDS.docDueDate, ...(opts.right ?? [])],
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
left: [FIELDS.comments],
|
||||||
|
right: [
|
||||||
|
{ type: 'computed', label: 'Total antes del descuento', source: 'subtotal' },
|
||||||
|
{ type: 'number', key: 'DiscountPercent', label: 'Descuento %', decimals: 2 },
|
||||||
|
{ type: 'computed', label: 'Impuesto', source: 'tax' },
|
||||||
|
{ type: 'computed', label: 'Total del documento', source: 'total' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
columns: lineColumnsFor(opts.taxResource),
|
||||||
|
newHeader: () => ({ DocDate: today(), DocDueDate: today(), DiscountPercent: 0 }),
|
||||||
|
newLine: newDocumentLine,
|
||||||
|
recalc: recalcLine,
|
||||||
|
requiredLine: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- documentos sin socio ni totales (inventario y solicitudes de compra) -------------------
|
||||||
|
|
||||||
/** Entradas y salidas de mercancía: artículo, cantidad, almacén y precio (opcional: si falta, SAP usa el costo). */
|
/** Entradas y salidas de mercancía: artículo, cantidad, almacén y precio (opcional: si falta, SAP usa el costo). */
|
||||||
const goods: DocumentLayout = {
|
const goods: DocumentLayout = {
|
||||||
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'Price'], base: false },
|
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'Price'], base: false },
|
||||||
left: [{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true }],
|
header: { left: [FIELDS.docDate], right: [FIELDS.docNum] },
|
||||||
right: [],
|
footer: commentsOnly,
|
||||||
columns: [itemColumn, descriptionColumn, quantityColumn, { ...warehouseColumn, required: true }, priceColumn],
|
columns: [itemColumn, descriptionColumn, quantityColumn, { ...warehouseColumn, required: true }, priceColumn],
|
||||||
newHeader: () => ({ DocDate: today() }),
|
newHeader: () => ({ DocDate: today() }),
|
||||||
newLine: () => ({ ...blankLine(), Price: null }),
|
newLine: () => ({ ...blankLine(), Price: null }),
|
||||||
@@ -65,11 +149,8 @@ const goods: DocumentLayout = {
|
|||||||
/** Transferencias de stock y solicitudes de traslado: almacén origen y destino en la cabecera y en cada línea. */
|
/** Transferencias de stock y solicitudes de traslado: almacén origen y destino en la cabecera y en cada línea. */
|
||||||
const transfer = (immutable: boolean): DocumentLayout => ({
|
const transfer = (immutable: boolean): DocumentLayout => ({
|
||||||
lines: { key: 'StockTransferLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'FromWarehouseCode', 'WarehouseCode'], base: false },
|
lines: { key: 'StockTransferLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'FromWarehouseCode', 'WarehouseCode'], base: false },
|
||||||
left: [
|
header: { left: [FIELDS.fromWarehouse, FIELDS.toWarehouse], right: [FIELDS.docNum, FIELDS.docDate] },
|
||||||
{ key: 'FromWarehouse', label: 'Almacén origen', type: 'warehouse', required: true },
|
footer: commentsOnly,
|
||||||
{ key: 'ToWarehouse', label: 'Almacén destino', type: 'warehouse', required: true },
|
|
||||||
],
|
|
||||||
right: [{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true }],
|
|
||||||
columns: [
|
columns: [
|
||||||
itemColumn,
|
itemColumn,
|
||||||
descriptionColumn,
|
descriptionColumn,
|
||||||
@@ -99,11 +180,8 @@ const transfer = (immutable: boolean): DocumentLayout => ({
|
|||||||
/** Solicitud de compra: solicitante (usuario de SAP), fecha requerida y, por línea, proveedor sugerido. */
|
/** Solicitud de compra: solicitante (usuario de SAP), fecha requerida y, por línea, proveedor sugerido. */
|
||||||
const purchaseRequest: DocumentLayout = {
|
const purchaseRequest: DocumentLayout = {
|
||||||
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'LineVendor'], base: false },
|
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'LineVendor'], base: false },
|
||||||
left: [{ key: 'Requester', label: 'Solicitante (usuario)', type: 'text' }],
|
header: { left: [FIELDS.requester], right: [FIELDS.docNum, FIELDS.docDate, FIELDS.requiredDate] },
|
||||||
right: [
|
footer: commentsOnly,
|
||||||
{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true },
|
|
||||||
{ key: 'RequriedDate', label: 'Fecha requerida', type: 'date', required: true },
|
|
||||||
],
|
|
||||||
columns: [
|
columns: [
|
||||||
itemColumn,
|
itemColumn,
|
||||||
descriptionColumn,
|
descriptionColumn,
|
||||||
@@ -143,9 +221,34 @@ export const LAYOUTS = {
|
|||||||
purchaseRequest,
|
purchaseRequest,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Campos de cabecera que se editan (y se envían): los de las columnas del formulario y los comentarios. */
|
// --- consultas sobre un layout --------------------------------------------------------------
|
||||||
export const headerKeysOf = (layout: DocumentLayout): readonly string[] => [
|
|
||||||
...layout.left.map((f) => f.key),
|
const allFields = (layout: DocumentLayout): HeaderField[] => [
|
||||||
...layout.right.map((f) => f.key),
|
...layout.header.left,
|
||||||
'Comments',
|
...layout.header.right,
|
||||||
|
...layout.footer.left,
|
||||||
|
...layout.footer.right,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Campos de cabecera que se editan (y se envían a SAP). */
|
||||||
|
export const headerKeysOf = (layout: DocumentLayout): readonly string[] =>
|
||||||
|
allFields(layout).flatMap((f) => {
|
||||||
|
switch (f.type) {
|
||||||
|
case 'computed':
|
||||||
|
return [];
|
||||||
|
case 'folio':
|
||||||
|
return [f.prefixKey, f.numberKey];
|
||||||
|
default:
|
||||||
|
return [f.key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Campos obligatorios de la cabecera, con el texto para el aviso ("Indique el cliente."). */
|
||||||
|
export const requiredHeaderOf = (layout: DocumentLayout): { key: string; label: string }[] =>
|
||||||
|
allFields(layout).flatMap((f) =>
|
||||||
|
(f.type === 'date' || f.type === 'warehouse' || f.type === 'partner') && f.required
|
||||||
|
? [{ key: f.key, label: f.missing ?? f.label }]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const hasFolio = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'folio');
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
navigationQuery,
|
navigationQuery,
|
||||||
taxRateMap,
|
taxRateMap,
|
||||||
updatePayload,
|
updatePayload,
|
||||||
validateDocument,
|
validateLayout,
|
||||||
} from './document-model';
|
} from './document-model';
|
||||||
|
|
||||||
const line = (over: Record<string, unknown> = {}) => ({
|
const line = (over: Record<string, unknown> = {}) => ({
|
||||||
@@ -52,15 +52,19 @@ describe('computeTotals', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('validateDocument', () => {
|
describe('validateLayout', () => {
|
||||||
const header = { CardCode: 'C1', DocDate: '2026-01-05' };
|
const header = { CardCode: 'C1', DocDate: '2026-01-05' };
|
||||||
|
const required = [
|
||||||
|
{ key: 'CardCode', label: 'el cliente' },
|
||||||
|
{ key: 'DocDate', label: 'la fecha de contabilización' },
|
||||||
|
];
|
||||||
|
|
||||||
it('documento correcto no tiene errores', () => {
|
it('documento correcto no tiene errores', () => {
|
||||||
expect(validateDocument(header, [line()], 'Cliente')).toEqual([]);
|
expect(validateLayout(header, [line()], required, [])).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exige socio, fecha y al menos una línea con artículo', () => {
|
it('exige los campos obligatorios y al menos una línea con artículo', () => {
|
||||||
expect(validateDocument({}, [line({ ItemCode: '' })], 'Cliente')).toEqual([
|
expect(validateLayout({}, [line({ ItemCode: '' })], required, [])).toEqual([
|
||||||
'Indique el cliente.',
|
'Indique el cliente.',
|
||||||
'Indique la fecha de contabilización.',
|
'Indique la fecha de contabilización.',
|
||||||
'Agregue al menos una línea con artículo.',
|
'Agregue al menos una línea con artículo.',
|
||||||
@@ -68,7 +72,7 @@ describe('validateDocument', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('exige cantidad mayor que cero (numerando solo las líneas reales)', () => {
|
it('exige cantidad mayor que cero (numerando solo las líneas reales)', () => {
|
||||||
expect(validateDocument(header, [line({ ItemCode: '' }), line({ Quantity: 0 })], 'Cliente')).toEqual([
|
expect(validateLayout(header, [line({ ItemCode: '' }), line({ Quantity: 0 })], required, [])).toEqual([
|
||||||
'Línea 1: la cantidad debe ser mayor que cero.',
|
'Línea 1: la cantidad debe ser mayor que cero.',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,19 +97,6 @@ export function linesPayload(lines: readonly GridLine[], spec: LinesSpec = MARKE
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Errores que se pueden detectar sin preguntarle a SAP. Lista vacía = se puede guardar. */
|
|
||||||
export function validateDocument(header: DocRecord, lines: readonly GridLine[], partnerLabel: string): string[] {
|
|
||||||
const errors: string[] = [];
|
|
||||||
if (isEmpty(header['CardCode'])) errors.push(`Indique el ${partnerLabel.toLowerCase()}.`);
|
|
||||||
if (isEmpty(header['DocDate'])) errors.push('Indique la fecha de contabilización.');
|
|
||||||
const real = lines.filter((l) => !isBlankLine(l));
|
|
||||||
if (!real.length) errors.push('Agregue al menos una línea con artículo.');
|
|
||||||
real.forEach((l, i) => {
|
|
||||||
if (num(l['Quantity']) <= 0) errors.push(`Línea ${i + 1}: la cantidad debe ser mayor que cero.`);
|
|
||||||
});
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Un campo obligatorio y su nombre para el aviso. */
|
/** Un campo obligatorio y su nombre para el aviso. */
|
||||||
export interface RequiredField {
|
export interface RequiredField {
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { MENU } from '@basic-erp/sap-types';
|
|||||||
import { formatNumber } from '../../core/format';
|
import { formatNumber } from '../../core/format';
|
||||||
import type { ListFilter } from '../../core/sl/list-filters';
|
import type { ListFilter } from '../../core/sl/list-filters';
|
||||||
import type { ListColumn, ListRow, Tone } from '../../shared/list/list.types';
|
import type { ListColumn, ListRow, Tone } from '../../shared/list/list.types';
|
||||||
import { type DocumentLayout, LAYOUTS } from './document-layouts';
|
import { type DocumentLayout, FIELDS, LAYOUTS, type HeaderField, marketingLayout } from './document-layouts';
|
||||||
|
|
||||||
/** Pantalla de un documento: lista de los últimos ingresados + formulario (nuevo / ver-editar). */
|
/** Pantalla de un documento: lista de los últimos ingresados + formulario (nuevo / ver-editar). */
|
||||||
export interface DocumentScreen {
|
export interface DocumentScreen {
|
||||||
@@ -24,10 +24,8 @@ export interface DocumentScreen {
|
|||||||
/** Maestro de impuestos (el Service Layer no tiene uno propio de compras: en Chile ambos usan `SalesTaxCodes`). */
|
/** Maestro de impuestos (el Service Layer no tiene uno propio de compras: en Chile ambos usan `SalesTaxCodes`). */
|
||||||
taxResource: string;
|
taxResource: string;
|
||||||
};
|
};
|
||||||
/** El formulario muestra el folio tributario (`FolioPrefixString`, `FolioNumber`) y el RUT del socio. */
|
/** Campos de la cabecera y del pie del formulario, columnas de línea y cómo se guarda. */
|
||||||
folio?: boolean;
|
layout: DocumentLayout;
|
||||||
/** Formulario de los documentos sin socio ni totales (inventario y solicitudes de compra). */
|
|
||||||
layout?: DocumentLayout;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const menuLabel = (path: string): string =>
|
export const menuLabel = (path: string): string =>
|
||||||
@@ -110,15 +108,26 @@ const currencyFilter: ListFilter = {
|
|||||||
labelField: 'Name',
|
labelField: 'Name',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Campos que una pantalla de ventas o compras agrega a la cabecera base y columnas extra de su lista. */
|
||||||
|
interface MarketingExtras {
|
||||||
|
columns?: readonly ListColumn[];
|
||||||
|
headerLeft?: readonly HeaderField[];
|
||||||
|
headerRight?: readonly HeaderField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Documentos tributarios: la cabecera suma el folio (en el Service Layer `FolioPrefixString` y `FolioNumber`). */
|
||||||
|
const TAX_DOCUMENT: MarketingExtras = { headerRight: [FIELDS.folio] };
|
||||||
|
|
||||||
/** Ventas y compras: documentos con socio de negocio y total. */
|
/** Ventas y compras: documentos con socio de negocio y total. */
|
||||||
const marketing = (
|
const marketing = (
|
||||||
path: string,
|
path: string,
|
||||||
resource: string,
|
resource: string,
|
||||||
createLabel: string,
|
createLabel: string,
|
||||||
partner: string,
|
partner: string,
|
||||||
extraColumns: readonly ListColumn[] = [],
|
extras: MarketingExtras = {},
|
||||||
): DocumentScreen => ({
|
): DocumentScreen => ({
|
||||||
path,
|
path,
|
||||||
|
layout: marketingLayout({ partnerLabel: partner, taxResource: 'SalesTaxCodes', left: extras.headerLeft, right: extras.headerRight }),
|
||||||
resource,
|
resource,
|
||||||
createLabel,
|
createLabel,
|
||||||
orderby: 'DocEntry desc',
|
orderby: 'DocEntry desc',
|
||||||
@@ -129,7 +138,7 @@ const marketing = (
|
|||||||
columns: [
|
columns: [
|
||||||
docNum,
|
docNum,
|
||||||
statusColumn,
|
statusColumn,
|
||||||
...extraColumns,
|
...(extras.columns ?? []),
|
||||||
{ key: 'CardCode', title: partner, width: '130px', mono: true, searchable: true },
|
{ key: 'CardCode', title: partner, width: '130px', mono: true, searchable: true },
|
||||||
{ key: 'CardName', title: 'Nombre' },
|
{ key: 'CardName', title: 'Nombre' },
|
||||||
docDate,
|
docDate,
|
||||||
@@ -167,24 +176,14 @@ const inventory = (
|
|||||||
filters: withStatus ? [statusFilter, dateFilter] : [dateFilter],
|
filters: withStatus ? [statusFilter, dateFilter] : [dateFilter],
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Documentos tributarios: llevan folio y RUT. */
|
export const DOCUMENT_SCREENS: readonly DocumentScreen[] = [
|
||||||
const FOLIO_PATHS: ReadonlySet<string> = new Set([
|
|
||||||
'/ventas/entrega',
|
|
||||||
'/ventas/facturas',
|
|
||||||
'/ventas/notas-credito',
|
|
||||||
'/compras/recepcion',
|
|
||||||
'/compras/facturas',
|
|
||||||
'/compras/notas-credito',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const SCREENS: readonly DocumentScreen[] = [
|
|
||||||
// Ventas
|
// Ventas
|
||||||
marketing('/ventas/ofertas', 'Quotations', 'Nueva oferta', 'Cliente'),
|
marketing('/ventas/ofertas', 'Quotations', 'Nueva oferta', 'Cliente'),
|
||||||
marketing('/ventas/ordenes', 'Orders', 'Nueva orden', 'Cliente'),
|
marketing('/ventas/ordenes', 'Orders', 'Nueva orden', 'Cliente'),
|
||||||
marketing('/ventas/entrega', 'DeliveryNotes', 'Nueva entrega', 'Cliente'),
|
marketing('/ventas/entrega', 'DeliveryNotes', 'Nueva entrega', 'Cliente', TAX_DOCUMENT),
|
||||||
marketing('/ventas/facturas', 'Invoices', 'Nueva factura', 'Cliente', invoiceColumns),
|
marketing('/ventas/facturas', 'Invoices', 'Nueva factura', 'Cliente', { ...TAX_DOCUMENT, columns: invoiceColumns }),
|
||||||
marketing('/ventas/devoluciones', 'Returns', 'Nueva devolución', 'Cliente'),
|
marketing('/ventas/devoluciones', 'Returns', 'Nueva devolución', 'Cliente'),
|
||||||
marketing('/ventas/notas-credito', 'CreditNotes', 'Nueva nota de crédito', 'Cliente'),
|
marketing('/ventas/notas-credito', 'CreditNotes', 'Nueva nota de crédito', 'Cliente', TAX_DOCUMENT),
|
||||||
// Compras
|
// Compras
|
||||||
{
|
{
|
||||||
path: '/compras/solicitudes',
|
path: '/compras/solicitudes',
|
||||||
@@ -204,15 +203,13 @@ const SCREENS: readonly DocumentScreen[] = [
|
|||||||
},
|
},
|
||||||
marketing('/compras/ofertas', 'PurchaseQuotations', 'Nueva oferta', 'Proveedor'),
|
marketing('/compras/ofertas', 'PurchaseQuotations', 'Nueva oferta', 'Proveedor'),
|
||||||
marketing('/compras/ordenes', 'PurchaseOrders', 'Nueva orden', 'Proveedor'),
|
marketing('/compras/ordenes', 'PurchaseOrders', 'Nueva orden', 'Proveedor'),
|
||||||
marketing('/compras/recepcion', 'PurchaseDeliveryNotes', 'Nueva recepción', 'Proveedor'),
|
marketing('/compras/recepcion', 'PurchaseDeliveryNotes', 'Nueva recepción', 'Proveedor', TAX_DOCUMENT),
|
||||||
marketing('/compras/facturas', 'PurchaseInvoices', 'Nueva factura', 'Proveedor', invoiceColumns),
|
marketing('/compras/facturas', 'PurchaseInvoices', 'Nueva factura', 'Proveedor', { ...TAX_DOCUMENT, columns: invoiceColumns }),
|
||||||
marketing('/compras/devoluciones', 'PurchaseReturns', 'Nueva devolución', 'Proveedor'),
|
marketing('/compras/devoluciones', 'PurchaseReturns', 'Nueva devolución', 'Proveedor'),
|
||||||
marketing('/compras/notas-credito', 'PurchaseCreditNotes', 'Nueva nota de crédito', 'Proveedor'),
|
marketing('/compras/notas-credito', 'PurchaseCreditNotes', 'Nueva nota de crédito', 'Proveedor', TAX_DOCUMENT),
|
||||||
// Inventario
|
// Inventario
|
||||||
inventory('/inventario/entradas', 'InventoryGenEntries', 'Nueva entrada', true, LAYOUTS.goods),
|
inventory('/inventario/entradas', 'InventoryGenEntries', 'Nueva entrada', true, LAYOUTS.goods),
|
||||||
inventory('/inventario/salidas', 'InventoryGenExits', 'Nueva salida', true, LAYOUTS.goods),
|
inventory('/inventario/salidas', 'InventoryGenExits', 'Nueva salida', true, LAYOUTS.goods),
|
||||||
inventory('/inventario/transferencias', 'StockTransfers', 'Nueva transferencia', false, LAYOUTS.transfer),
|
inventory('/inventario/transferencias', 'StockTransfers', 'Nueva transferencia', false, LAYOUTS.transfer),
|
||||||
inventory('/inventario/solicitudes-traslado', 'InventoryTransferRequests', 'Nueva solicitud', true, LAYOUTS.transferRequest),
|
inventory('/inventario/solicitudes-traslado', 'InventoryTransferRequests', 'Nueva solicitud', true, LAYOUTS.transferRequest),
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DOCUMENT_SCREENS: readonly DocumentScreen[] = SCREENS.map((s) => (FOLIO_PATHS.has(s.path) ? { ...s, folio: true } : s));
|
|
||||||
Reference in new issue
Block a user