From c5368c7ad07241d490a1a387d049f994e79fdaeb Mon Sep 17 00:00:00 2001 From: George Date: Wed, 14 Oct 2026 15:12:01 -0500 Subject: [PATCH] =?UTF-8?q?refactor(web):=20pantallas=20y=20formularios=20?= =?UTF-8?q?separados=20por=20m=C3=B3dulo=20(ventas,=20compras,=20inventari?= =?UTF-8?q?o)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 4 +- .../documents/compras/compras.layouts.ts | 48 ++++ .../documents/compras/compras.screens.ts | 28 +++ .../documents/document-layouts.spec.ts | 8 +- .../features/documents/document-layouts.ts | 98 +------- .../features/documents/document-screens.ts | 224 +----------------- .../inventario/inventario.layouts.ts | 58 +++++ .../inventario/inventario.screens.ts | 11 + .../src/app/features/documents/screen-kit.ts | 177 ++++++++++++++ .../documents/ventas/ventas.screens.ts | 13 + 10 files changed, 358 insertions(+), 311 deletions(-) create mode 100644 apps/web/src/app/features/documents/compras/compras.layouts.ts create mode 100644 apps/web/src/app/features/documents/compras/compras.screens.ts create mode 100644 apps/web/src/app/features/documents/inventario/inventario.layouts.ts create mode 100644 apps/web/src/app/features/documents/inventario/inventario.screens.ts create mode 100644 apps/web/src/app/features/documents/screen-kit.ts create mode 100644 apps/web/src/app/features/documents/ventas/ventas.screens.ts diff --git a/CLAUDE.md b/CLAUDE.md index 0b102b9..6a05545 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de ## Flujo de las pantallas de documento -Cada menú abre primero una **lista de los últimos documentos** (barra de filtros + búsqueda por columna); desde ahí se crea uno nuevo (`/…/nuevo`, formulario vacío) o se abre uno existente (`/…/:docEntry`, ver/editar; los cerrados/cancelados en solo lectura). Las pantallas se definen en `apps/web/src/app/features/documents/document-screens.ts` (17 documentos: ventas, compras, inventario). Las columnas de línea estándar están en `features/documents/line-columns.ts`. +Cada menú abre primero una **lista de los últimos documentos** (barra de filtros + búsqueda por columna); desde ahí se crea uno nuevo (`/…/nuevo`, formulario vacío) o se abre uno existente (`/…/:docEntry`, ver/editar; los cerrados/cancelados en solo lectura). Las pantallas se definen por módulo en `apps/web/src/app/features/documents/` (17 documentos): `ventas/ventas.screens.ts`, `compras/compras.screens.ts` (+ `compras.layouts.ts`) e `inventario/inventario.screens.ts` (+ `inventario.layouts.ts`); los maestros (socios de negocio, artículos) irán en su propia carpeta. `screen-kit.ts` tiene el tipo `DocumentScreen` y las columnas y filtros comunes; `document-screens.ts` las reúne en `DOCUMENT_SCREENS`. Las columnas de línea estándar están en `features/documents/line-columns.ts`. ## Cómo verificar cambios de interfaz @@ -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`). 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. **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. +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 (en la carpeta de su módulo) 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". 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. diff --git a/apps/web/src/app/features/documents/compras/compras.layouts.ts b/apps/web/src/app/features/documents/compras/compras.layouts.ts new file mode 100644 index 0000000..0dfae03 --- /dev/null +++ b/apps/web/src/app/features/documents/compras/compras.layouts.ts @@ -0,0 +1,48 @@ +import { + type DocumentLayout, + FIELDS, + blankLine, + commentsOnly, + descriptionColumn, + itemColumn, + quantityColumn, + today, + warehouseColumn, +} from '../document-layouts'; + +/** Solicitud de compra: solicitante (usuario de SAP), fecha requerida y, por línea, proveedor sugerido. */ +export const purchaseRequestLayout: DocumentLayout = { + lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'LineVendor'], base: false }, + header: { left: [FIELDS.requester], right: [FIELDS.docNum, FIELDS.docDate, FIELDS.requiredDate] }, + footer: commentsOnly, + columns: [ + itemColumn, + descriptionColumn, + quantityColumn, + warehouseColumn, + { + key: 'LineVendor', + title: 'Proveedor', + width: '130px', + mono: true, + lookup: { + config: { + title: 'Proveedores', + resource: 'BusinessPartners', + baseFilter: `CardType eq 'cSupplier'`, + orderby: 'CardCode', + columns: [ + { key: 'CardCode', title: 'Código', width: '130px', mono: true }, + { key: 'CardName', title: 'Nombre' }, + ], + }, + apply: (bp, line) => ({ ...line, LineVendor: bp['CardCode'] }), + }, + }, + ], + newHeader: () => ({ DocDate: today(), RequriedDate: today() }), + newLine: blankLine, + // El solicitante es un usuario de SAP (`ReqType` 12). + beforeCreate: (h) => (h['Requester'] ? { ...h, ReqType: 12 } : h), + requiredLine: [], +}; diff --git a/apps/web/src/app/features/documents/compras/compras.screens.ts b/apps/web/src/app/features/documents/compras/compras.screens.ts new file mode 100644 index 0000000..78959fc --- /dev/null +++ b/apps/web/src/app/features/documents/compras/compras.screens.ts @@ -0,0 +1,28 @@ +import { TAX_DOCUMENT, type DocumentScreen, docDate, docNum, invoiceColumns, marketing, statusColumn, statusFilter, dateFilter } from '../screen-kit'; +import { purchaseRequestLayout } from './compras.layouts'; + +/** Compras: solicitudes de compra y documentos de proveedor. */ +export const COMPRAS_SCREENS: readonly DocumentScreen[] = [ + { + path: '/compras/solicitudes', + layout: purchaseRequestLayout, + resource: 'PurchaseRequests', + createLabel: 'Nueva solicitud', + orderby: 'DocEntry desc', + extraFields: ['Cancelled'], + columns: [ + docNum, + statusColumn, + docDate, + { key: 'RequriedDate', title: 'Fecha requerida', type: 'date', align: 'center', width: '125px' }, + { key: 'RequesterName', title: 'Solicitante', searchable: true }, + ], + filters: [statusFilter, dateFilter], + }, + marketing('/compras/ofertas', 'PurchaseQuotations', 'Nueva oferta', 'Proveedor'), + marketing('/compras/ordenes', 'PurchaseOrders', 'Nueva orden', 'Proveedor'), + marketing('/compras/recepcion', 'PurchaseDeliveryNotes', 'Nueva recepción', 'Proveedor', TAX_DOCUMENT), + marketing('/compras/facturas', 'PurchaseInvoices', 'Nueva factura', 'Proveedor', { ...TAX_DOCUMENT, columns: invoiceColumns }), + marketing('/compras/devoluciones', 'PurchaseReturns', 'Nueva devolución', 'Proveedor'), + marketing('/compras/notas-credito', 'PurchaseCreditNotes', 'Nueva nota de crédito', 'Proveedor', TAX_DOCUMENT), +]; diff --git a/apps/web/src/app/features/documents/document-layouts.spec.ts b/apps/web/src/app/features/documents/document-layouts.spec.ts index 8930c54..eea6410 100644 --- a/apps/web/src/app/features/documents/document-layouts.spec.ts +++ b/apps/web/src/app/features/documents/document-layouts.spec.ts @@ -4,13 +4,15 @@ import { type ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router'; import { of } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { LAYOUTS, hasFolio, headerKeysOf } from './document-layouts'; +import { purchaseRequestLayout } from './compras/compras.layouts'; +import { hasFolio, headerKeysOf } from './document-layouts'; +import { transferRequestLayout } from './inventario/inventario.layouts'; import { createPayload, updatePayload, validateLayout } from './document-model'; import { DocumentFormPage } from './document-form.page'; import { DOCUMENT_SCREENS } from './document-screens'; describe('modelo de los formularios sin socio', () => { - const transfer = LAYOUTS.transferRequest; + const transfer = transferRequestLayout; it('cada pantalla tiene su formulario, declarado por ella', () => { expect(DOCUMENT_SCREENS.every((s) => s.layout)).toBe(true); @@ -71,7 +73,7 @@ describe('modelo de los formularios sin socio', () => { }); it('la solicitud de compra marca al solicitante como usuario (ReqType 12) solo si lo indica', () => { - const l = LAYOUTS.purchaseRequest; + const l = purchaseRequestLayout; expect(l.beforeCreate!({ Requester: 'manager' })).toEqual({ Requester: 'manager', ReqType: 12 }); expect(l.beforeCreate!({ Requester: '' })).toEqual({ Requester: '' }); }); diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts index 88bebd8..4243298 100644 --- a/apps/web/src/app/features/documents/document-layouts.ts +++ b/apps/web/src/app/features/documents/document-layouts.ts @@ -52,7 +52,7 @@ export interface DocumentLayout { requiredLine: readonly { key: string; label: string }[]; } -const today = (): string => new Date().toISOString().slice(0, 10); +export const today = (): string => new Date().toISOString().slice(0, 10); // --- catálogo de campos --------------------------------------------------------------------- @@ -70,14 +70,15 @@ export const FIELDS = { requester: { type: 'text', key: 'Requester', label: 'Solicitante (usuario)' }, } as const satisfies Record; -const commentsOnly: FieldColumns = { left: [FIELDS.comments], right: [] }; +export const commentsOnly: FieldColumns = { left: [FIELDS.comments], right: [] }; // --- columnas de línea compartidas ---------------------------------------------------------- const [baseItemColumn, descriptionColumn, quantityColumn, warehouseColumn, priceColumn] = DOCUMENT_LINE_COLUMNS; +export { descriptionColumn, priceColumn, quantityColumn, warehouseColumn }; /** Artículo que además completa la unidad de medida de la línea (`UoMEntry`). */ -const itemColumn: GridColumn = { +export const itemColumn: GridColumn = { ...baseItemColumn, lookup: { ...baseItemColumn.lookup!, @@ -88,7 +89,7 @@ const itemColumn: GridColumn = { /** Lista de almacenes de la cabecera (la misma de las líneas). */ export const WAREHOUSE_LOOKUP: VhLookup = warehouseColumn.lookup!.config; -const blankLine = (): GridLine => ({ ItemCode: '', ItemDescription: '', Quantity: 1, WarehouseCode: '' }); +export const blankLine = (): GridLine => ({ ItemCode: '', ItemDescription: '', Quantity: 1, WarehouseCode: '' }); // --- documentos con socio de negocio (ventas y compras) ------------------------------------- @@ -132,95 +133,6 @@ export function marketingLayout(opts: { }; } -// --- 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). */ -const goods: DocumentLayout = { - lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'Price'], base: false }, - header: { left: [FIELDS.docDate], right: [FIELDS.docNum] }, - footer: commentsOnly, - columns: [itemColumn, descriptionColumn, quantityColumn, { ...warehouseColumn, required: true }, priceColumn], - newHeader: () => ({ DocDate: today() }), - newLine: () => ({ ...blankLine(), Price: null }), - immutable: true, - requiredLine: [{ key: 'WarehouseCode', label: 'el almacén' }], -}; - -/** 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 => ({ - lines: { key: 'StockTransferLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'FromWarehouseCode', 'WarehouseCode'], base: false }, - header: { left: [FIELDS.fromWarehouse, FIELDS.toWarehouse], right: [FIELDS.docNum, FIELDS.docDate] }, - footer: commentsOnly, - columns: [ - itemColumn, - descriptionColumn, - quantityColumn, - { - ...warehouseColumn, - key: 'FromWarehouseCode', - title: 'Almacén origen', - required: true, - lookup: { ...warehouseColumn.lookup!, apply: (wh, line) => ({ ...line, FromWarehouseCode: wh['WarehouseCode'] }) }, - }, - { ...warehouseColumn, title: 'Almacén destino', required: true }, - ], - newHeader: () => ({ DocDate: today() }), - newLine: (header) => ({ - ...blankLine(), - FromWarehouseCode: header['FromWarehouse'] ?? '', - WarehouseCode: header['ToWarehouse'] ?? '', - }), - immutable, - requiredLine: [ - { key: 'FromWarehouseCode', label: 'el almacén origen' }, - { key: 'WarehouseCode', label: 'el almacén destino' }, - ], -}); - -/** Solicitud de compra: solicitante (usuario de SAP), fecha requerida y, por línea, proveedor sugerido. */ -const purchaseRequest: DocumentLayout = { - lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'LineVendor'], base: false }, - header: { left: [FIELDS.requester], right: [FIELDS.docNum, FIELDS.docDate, FIELDS.requiredDate] }, - footer: commentsOnly, - columns: [ - itemColumn, - descriptionColumn, - quantityColumn, - warehouseColumn, - { - key: 'LineVendor', - title: 'Proveedor', - width: '130px', - mono: true, - lookup: { - config: { - title: 'Proveedores', - resource: 'BusinessPartners', - baseFilter: `CardType eq 'cSupplier'`, - orderby: 'CardCode', - columns: [ - { key: 'CardCode', title: 'Código', width: '130px', mono: true }, - { key: 'CardName', title: 'Nombre' }, - ], - }, - apply: (bp, line) => ({ ...line, LineVendor: bp['CardCode'] }), - }, - }, - ], - newHeader: () => ({ DocDate: today(), RequriedDate: today() }), - newLine: blankLine, - // El solicitante es un usuario de SAP (`ReqType` 12). - beforeCreate: (h) => (h['Requester'] ? { ...h, ReqType: 12 } : h), - requiredLine: [], -}; - -export const LAYOUTS = { - goods, - transfer: transfer(true), - transferRequest: transfer(false), - purchaseRequest, -} as const; - // --- consultas sobre un layout -------------------------------------------------------------- const allFields = (layout: DocumentLayout): HeaderField[] => [ diff --git a/apps/web/src/app/features/documents/document-screens.ts b/apps/web/src/app/features/documents/document-screens.ts index f63598e..2307b67 100644 --- a/apps/web/src/app/features/documents/document-screens.ts +++ b/apps/web/src/app/features/documents/document-screens.ts @@ -1,215 +1,13 @@ -import { MENU } from '@basic-erp/sap-types'; -import { formatNumber } from '../../core/format'; -import type { ListFilter } from '../../core/sl/list-filters'; -import type { ListColumn, ListRow, Tone } from '../../shared/list/list.types'; -import { type DocumentLayout, FIELDS, LAYOUTS, type HeaderField, marketingLayout } from './document-layouts'; +import type { DocumentScreen } from './screen-kit'; +import { COMPRAS_SCREENS } from './compras/compras.screens'; +import { INVENTARIO_SCREENS } from './inventario/inventario.screens'; +import { VENTAS_SCREENS } from './ventas/ventas.screens'; -/** Pantalla de un documento: lista de los últimos ingresados + formulario (nuevo / ver-editar). */ -export interface DocumentScreen { - /** Ruta de menú (`/ventas/ordenes`); de ella cuelgan `nuevo` y `:docEntry`. */ - path: string; - /** Recurso del proxy `/api/sl`. */ - resource: string; - createLabel: string; - columns: readonly ListColumn[]; - /** Campos necesarios para pintar que no tienen columna propia. */ - extraFields?: readonly string[]; - filters: readonly ListFilter[]; - /** Los más recientes primero. */ - orderby: string; - /** Formulario de documentos con socio de negocio, líneas y totales (ventas y compras). */ - partner?: { - label: string; - type: 'cCustomer' | 'cSupplier'; - /** Maestro de impuestos (el Service Layer no tiene uno propio de compras: en Chile ambos usan `SalesTaxCodes`). */ - taxResource: string; - }; - /** Campos de la cabecera y del pie del formulario, columnas de línea y cómo se guarda. */ - layout: DocumentLayout; -} +// Lo que usan el resto de la aplicación y los tests (tipo, columnas y estados comunes). +export * from './screen-kit'; -export const menuLabel = (path: string): string => - MENU.flatMap((m) => m.items).find((i) => i.path === path)?.label ?? path; - -export function documentStatus(row: ListRow): { label: string; tone: Tone } | undefined { - if (row['Cancelled'] === 'tYES') return { label: 'Cancelado', tone: 'err' }; - switch (row['DocumentStatus']) { - case 'bost_Open': - return { label: 'Abierto', tone: 'ok' }; - case 'bost_Close': - return { label: 'Cerrado', tone: 'muted' }; - default: - return undefined; - } -} - -/** Total en la moneda del documento: SAP guarda en `DocTotalFc` el importe de los documentos en moneda extranjera. */ -export function documentTotal(row: ListRow): string { - const fc = Number(row['DocTotalFc'] ?? 0); - const amount = fc !== 0 ? fc : row['DocTotal']; - if (amount === null || amount === undefined) return ''; - return `${row['DocCurrency'] ?? ''} ${formatNumber(amount, 2)}`.trim(); -} - -const statusColumn: ListColumn = { - key: 'DocumentStatus', - title: 'Estado', - width: '100px', - format: (r) => documentStatus(r)?.label ?? '', - tone: (r) => documentStatus(r)?.tone, -}; -/** Número de documento: sin separador de miles, y buscable por igualdad (es numérico). */ -export const docNum: ListColumn = { - key: 'DocNum', - title: 'Número', - type: 'number', - align: 'left', - width: '110px', - mono: true, - searchable: true, - format: (r) => String(r['DocNum'] ?? ''), -}; -/** Facturas: folio tributario (en el Service Layer `FolioPrefixString` y `FolioNumber`), referencia y RUT. */ -const invoiceColumns: readonly ListColumn[] = [ - { key: 'FolioPrefixString', title: 'Folio pref.', width: '90px', mono: true, searchable: true }, - { - key: 'FolioNumber', - title: 'Folio núm.', - type: 'number', - align: 'left', - width: '110px', - mono: true, - searchable: true, - format: (r) => String(r['FolioNumber'] ?? ''), - }, - { key: 'NumAtCard', title: 'Nº referencia', width: '130px', searchable: true }, - { key: 'FederalTaxID', title: 'RUT', width: '110px', mono: true, searchable: true }, -]; - -const docDate: ListColumn = { key: 'DocDate', title: 'Fecha contab.', type: 'date', align: 'center', width: '115px' }; - -const statusFilter: ListFilter = { - type: 'select', - key: 'DocumentStatus', - label: 'Estado', - defaultValue: 'bost_Open', - options: [ - { value: 'bost_Open', label: 'Abierto' }, - { value: 'bost_Close', label: 'Cerrado' }, - ], -}; -const dateFilter: ListFilter = { type: 'date', key: 'DocDate', label: 'Fecha contab.' }; -const currencyFilter: ListFilter = { - type: 'multi', - key: 'DocCurrency', - label: 'Moneda', - resource: 'Currencies', - valueField: 'Code', - 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. */ -const marketing = ( - path: string, - resource: string, - createLabel: string, - partner: string, - extras: MarketingExtras = {}, -): DocumentScreen => ({ - path, - layout: marketingLayout({ partnerLabel: partner, taxResource: 'SalesTaxCodes', left: extras.headerLeft, right: extras.headerRight }), - resource, - createLabel, - orderby: 'DocEntry desc', - partner: path.startsWith('/ventas') - ? { label: partner, type: 'cCustomer', taxResource: 'SalesTaxCodes' } - : { label: partner, type: 'cSupplier', taxResource: 'SalesTaxCodes' }, - extraFields: ['Cancelled', 'DocTotalFc', 'DocCurrency'], - columns: [ - docNum, - statusColumn, - ...(extras.columns ?? []), - { key: 'CardCode', title: partner, width: '130px', mono: true, searchable: true }, - { key: 'CardName', title: 'Nombre' }, - docDate, - { key: 'DocDueDate', title: 'Fecha venc.', type: 'date', align: 'center', width: '115px' }, - { key: 'DocTotal', title: 'Total', align: 'right', width: '140px', format: documentTotal }, - ], - filters: [statusFilter, currencyFilter, dateFilter], -}); - -const inventory = ( - path: string, - resource: string, - createLabel: string, - withStatus: boolean, - layout: DocumentLayout, -): DocumentScreen => ({ - path, - layout, - resource, - createLabel, - orderby: 'DocEntry desc', - extraFields: withStatus ? ['Cancelled'] : [], - columns: [ - docNum, - ...(withStatus ? [statusColumn] : []), - docDate, - ...(resource === 'StockTransfers' || resource === 'InventoryTransferRequests' - ? [ - { key: 'FromWarehouse', title: 'Almacén origen', width: '130px', mono: true, searchable: true }, - { key: 'ToWarehouse', title: 'Almacén destino', width: '130px', mono: true, searchable: true }, - ] - : []), - { key: 'Comments', title: 'Comentarios', searchable: true }, - ], - filters: withStatus ? [statusFilter, dateFilter] : [dateFilter], -}); - -export const DOCUMENT_SCREENS: readonly DocumentScreen[] = [ - // Ventas - marketing('/ventas/ofertas', 'Quotations', 'Nueva oferta', 'Cliente'), - marketing('/ventas/ordenes', 'Orders', 'Nueva orden', 'Cliente'), - marketing('/ventas/entrega', 'DeliveryNotes', 'Nueva entrega', 'Cliente', TAX_DOCUMENT), - marketing('/ventas/facturas', 'Invoices', 'Nueva factura', 'Cliente', { ...TAX_DOCUMENT, columns: invoiceColumns }), - marketing('/ventas/devoluciones', 'Returns', 'Nueva devolución', 'Cliente'), - marketing('/ventas/notas-credito', 'CreditNotes', 'Nueva nota de crédito', 'Cliente', TAX_DOCUMENT), - // Compras - { - path: '/compras/solicitudes', - layout: LAYOUTS.purchaseRequest, - resource: 'PurchaseRequests', - createLabel: 'Nueva solicitud', - orderby: 'DocEntry desc', - extraFields: ['Cancelled'], - columns: [ - docNum, - statusColumn, - docDate, - { key: 'RequriedDate', title: 'Fecha requerida', type: 'date', align: 'center', width: '125px' }, - { key: 'RequesterName', title: 'Solicitante', searchable: true }, - ], - filters: [statusFilter, dateFilter], - }, - marketing('/compras/ofertas', 'PurchaseQuotations', 'Nueva oferta', 'Proveedor'), - marketing('/compras/ordenes', 'PurchaseOrders', 'Nueva orden', 'Proveedor'), - marketing('/compras/recepcion', 'PurchaseDeliveryNotes', 'Nueva recepción', 'Proveedor', TAX_DOCUMENT), - marketing('/compras/facturas', 'PurchaseInvoices', 'Nueva factura', 'Proveedor', { ...TAX_DOCUMENT, columns: invoiceColumns }), - marketing('/compras/devoluciones', 'PurchaseReturns', 'Nueva devolución', 'Proveedor'), - marketing('/compras/notas-credito', 'PurchaseCreditNotes', 'Nueva nota de crédito', 'Proveedor', TAX_DOCUMENT), - // Inventario - inventory('/inventario/entradas', 'InventoryGenEntries', 'Nueva entrada', true, LAYOUTS.goods), - inventory('/inventario/salidas', 'InventoryGenExits', 'Nueva salida', true, LAYOUTS.goods), - inventory('/inventario/transferencias', 'StockTransfers', 'Nueva transferencia', false, LAYOUTS.transfer), - inventory('/inventario/solicitudes-traslado', 'InventoryTransferRequests', 'Nueva solicitud', true, LAYOUTS.transferRequest), -]; +/** + * Todas las pantallas de documentos, agrupadas por módulo (una carpeta cada uno). Los maestros + * (socios de negocio, artículos) irán en su propia carpeta cuando existan. + */ +export const DOCUMENT_SCREENS: readonly DocumentScreen[] = [...VENTAS_SCREENS, ...COMPRAS_SCREENS, ...INVENTARIO_SCREENS]; diff --git a/apps/web/src/app/features/documents/inventario/inventario.layouts.ts b/apps/web/src/app/features/documents/inventario/inventario.layouts.ts new file mode 100644 index 0000000..178c3f7 --- /dev/null +++ b/apps/web/src/app/features/documents/inventario/inventario.layouts.ts @@ -0,0 +1,58 @@ +import { + type DocumentLayout, + FIELDS, + blankLine, + commentsOnly, + descriptionColumn, + itemColumn, + priceColumn, + quantityColumn, + today, + warehouseColumn, +} from '../document-layouts'; + +/** Entradas y salidas de mercancía: artículo, cantidad, almacén y precio (opcional: si falta, SAP usa el costo). */ +export const goodsLayout: DocumentLayout = { + lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'Price'], base: false }, + header: { left: [FIELDS.docDate], right: [FIELDS.docNum] }, + footer: commentsOnly, + columns: [itemColumn, descriptionColumn, quantityColumn, { ...warehouseColumn, required: true }, priceColumn], + newHeader: () => ({ DocDate: today() }), + newLine: () => ({ ...blankLine(), Price: null }), + immutable: true, + requiredLine: [{ key: 'WarehouseCode', label: 'el almacén' }], +}; + +/** 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 => ({ + lines: { key: 'StockTransferLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'FromWarehouseCode', 'WarehouseCode'], base: false }, + header: { left: [FIELDS.fromWarehouse, FIELDS.toWarehouse], right: [FIELDS.docNum, FIELDS.docDate] }, + footer: commentsOnly, + columns: [ + itemColumn, + descriptionColumn, + quantityColumn, + { + ...warehouseColumn, + key: 'FromWarehouseCode', + title: 'Almacén origen', + required: true, + lookup: { ...warehouseColumn.lookup!, apply: (wh, line) => ({ ...line, FromWarehouseCode: wh['WarehouseCode'] }) }, + }, + { ...warehouseColumn, title: 'Almacén destino', required: true }, + ], + newHeader: () => ({ DocDate: today() }), + newLine: (header) => ({ + ...blankLine(), + FromWarehouseCode: header['FromWarehouse'] ?? '', + WarehouseCode: header['ToWarehouse'] ?? '', + }), + immutable, + requiredLine: [ + { key: 'FromWarehouseCode', label: 'el almacén origen' }, + { key: 'WarehouseCode', label: 'el almacén destino' }, + ], +}); + +export const transferLayout: DocumentLayout = transfer(true); +export const transferRequestLayout: DocumentLayout = transfer(false); diff --git a/apps/web/src/app/features/documents/inventario/inventario.screens.ts b/apps/web/src/app/features/documents/inventario/inventario.screens.ts new file mode 100644 index 0000000..eee93b6 --- /dev/null +++ b/apps/web/src/app/features/documents/inventario/inventario.screens.ts @@ -0,0 +1,11 @@ +import type { DocumentScreen } from '../screen-kit'; +import { inventory } from '../screen-kit'; +import { goodsLayout, transferLayout, transferRequestLayout } from './inventario.layouts'; + +/** Inventario: entradas, salidas, transferencias de stock y solicitudes de traslado. */ +export const INVENTARIO_SCREENS: readonly DocumentScreen[] = [ + inventory('/inventario/entradas', 'InventoryGenEntries', 'Nueva entrada', true, goodsLayout), + inventory('/inventario/salidas', 'InventoryGenExits', 'Nueva salida', true, goodsLayout), + inventory('/inventario/transferencias', 'StockTransfers', 'Nueva transferencia', false, transferLayout), + inventory('/inventario/solicitudes-traslado', 'InventoryTransferRequests', 'Nueva solicitud', true, transferRequestLayout), +]; diff --git a/apps/web/src/app/features/documents/screen-kit.ts b/apps/web/src/app/features/documents/screen-kit.ts new file mode 100644 index 0000000..da52a6e --- /dev/null +++ b/apps/web/src/app/features/documents/screen-kit.ts @@ -0,0 +1,177 @@ +import { MENU } from '@basic-erp/sap-types'; +import { formatNumber } from '../../core/format'; +import type { ListFilter } from '../../core/sl/list-filters'; +import type { ListColumn, ListRow, Tone } from '../../shared/list/list.types'; +import { type DocumentLayout, FIELDS, type HeaderField, marketingLayout } from './document-layouts'; + +/** Pantalla de un documento: lista de los últimos ingresados + formulario (nuevo / ver-editar). */ +export interface DocumentScreen { + /** Ruta de menú (`/ventas/ordenes`); de ella cuelgan `nuevo` y `:docEntry`. */ + path: string; + /** Recurso del proxy `/api/sl`. */ + resource: string; + createLabel: string; + columns: readonly ListColumn[]; + /** Campos necesarios para pintar que no tienen columna propia. */ + extraFields?: readonly string[]; + filters: readonly ListFilter[]; + /** Los más recientes primero. */ + orderby: string; + /** Formulario de documentos con socio de negocio, líneas y totales (ventas y compras). */ + partner?: { + label: string; + type: 'cCustomer' | 'cSupplier'; + /** Maestro de impuestos (el Service Layer no tiene uno propio de compras: en Chile ambos usan `SalesTaxCodes`). */ + taxResource: string; + }; + /** Campos de la cabecera y del pie del formulario, columnas de línea y cómo se guarda. */ + layout: DocumentLayout; +} + +export const menuLabel = (path: string): string => + MENU.flatMap((m) => m.items).find((i) => i.path === path)?.label ?? path; + +export function documentStatus(row: ListRow): { label: string; tone: Tone } | undefined { + if (row['Cancelled'] === 'tYES') return { label: 'Cancelado', tone: 'err' }; + switch (row['DocumentStatus']) { + case 'bost_Open': + return { label: 'Abierto', tone: 'ok' }; + case 'bost_Close': + return { label: 'Cerrado', tone: 'muted' }; + default: + return undefined; + } +} + +/** Total en la moneda del documento: SAP guarda en `DocTotalFc` el importe de los documentos en moneda extranjera. */ +export function documentTotal(row: ListRow): string { + const fc = Number(row['DocTotalFc'] ?? 0); + const amount = fc !== 0 ? fc : row['DocTotal']; + if (amount === null || amount === undefined) return ''; + return `${row['DocCurrency'] ?? ''} ${formatNumber(amount, 2)}`.trim(); +} + +export const statusColumn: ListColumn = { + key: 'DocumentStatus', + title: 'Estado', + width: '100px', + format: (r) => documentStatus(r)?.label ?? '', + tone: (r) => documentStatus(r)?.tone, +}; +/** Número de documento: sin separador de miles, y buscable por igualdad (es numérico). */ +export const docNum: ListColumn = { + key: 'DocNum', + title: 'Número', + type: 'number', + align: 'left', + width: '110px', + mono: true, + searchable: true, + format: (r) => String(r['DocNum'] ?? ''), +}; +/** Facturas: folio tributario (en el Service Layer `FolioPrefixString` y `FolioNumber`), referencia y RUT. */ +export const invoiceColumns: readonly ListColumn[] = [ + { key: 'FolioPrefixString', title: 'Folio pref.', width: '90px', mono: true, searchable: true }, + { + key: 'FolioNumber', + title: 'Folio núm.', + type: 'number', + align: 'left', + width: '110px', + mono: true, + searchable: true, + format: (r) => String(r['FolioNumber'] ?? ''), + }, + { key: 'NumAtCard', title: 'Nº referencia', width: '130px', searchable: true }, + { key: 'FederalTaxID', title: 'RUT', width: '110px', mono: true, searchable: true }, +]; + +export const docDate: ListColumn = { key: 'DocDate', title: 'Fecha contab.', type: 'date', align: 'center', width: '115px' }; + +export const statusFilter: ListFilter = { + type: 'select', + key: 'DocumentStatus', + label: 'Estado', + defaultValue: 'bost_Open', + options: [ + { value: 'bost_Open', label: 'Abierto' }, + { value: 'bost_Close', label: 'Cerrado' }, + ], +}; +export const dateFilter: ListFilter = { type: 'date', key: 'DocDate', label: 'Fecha contab.' }; +export const currencyFilter: ListFilter = { + type: 'multi', + key: 'DocCurrency', + label: 'Moneda', + resource: 'Currencies', + valueField: 'Code', + labelField: 'Name', +}; + +/** Campos que una pantalla de ventas o compras agrega a la cabecera base y columnas extra de su lista. */ +export 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`). */ +export const TAX_DOCUMENT: MarketingExtras = { headerRight: [FIELDS.folio] }; + +/** Ventas y compras: documentos con socio de negocio y total. */ +export const marketing = ( + path: string, + resource: string, + createLabel: string, + partner: string, + extras: MarketingExtras = {}, +): DocumentScreen => ({ + path, + layout: marketingLayout({ partnerLabel: partner, taxResource: 'SalesTaxCodes', left: extras.headerLeft, right: extras.headerRight }), + resource, + createLabel, + orderby: 'DocEntry desc', + partner: path.startsWith('/ventas') + ? { label: partner, type: 'cCustomer', taxResource: 'SalesTaxCodes' } + : { label: partner, type: 'cSupplier', taxResource: 'SalesTaxCodes' }, + extraFields: ['Cancelled', 'DocTotalFc', 'DocCurrency'], + columns: [ + docNum, + statusColumn, + ...(extras.columns ?? []), + { key: 'CardCode', title: partner, width: '130px', mono: true, searchable: true }, + { key: 'CardName', title: 'Nombre' }, + docDate, + { key: 'DocDueDate', title: 'Fecha venc.', type: 'date', align: 'center', width: '115px' }, + { key: 'DocTotal', title: 'Total', align: 'right', width: '140px', format: documentTotal }, + ], + filters: [statusFilter, currencyFilter, dateFilter], +}); + +export const inventory = ( + path: string, + resource: string, + createLabel: string, + withStatus: boolean, + layout: DocumentLayout, +): DocumentScreen => ({ + path, + layout, + resource, + createLabel, + orderby: 'DocEntry desc', + extraFields: withStatus ? ['Cancelled'] : [], + columns: [ + docNum, + ...(withStatus ? [statusColumn] : []), + docDate, + ...(resource === 'StockTransfers' || resource === 'InventoryTransferRequests' + ? [ + { key: 'FromWarehouse', title: 'Almacén origen', width: '130px', mono: true, searchable: true }, + { key: 'ToWarehouse', title: 'Almacén destino', width: '130px', mono: true, searchable: true }, + ] + : []), + { key: 'Comments', title: 'Comentarios', searchable: true }, + ], + filters: withStatus ? [statusFilter, dateFilter] : [dateFilter], +}); diff --git a/apps/web/src/app/features/documents/ventas/ventas.screens.ts b/apps/web/src/app/features/documents/ventas/ventas.screens.ts new file mode 100644 index 0000000..4d01146 --- /dev/null +++ b/apps/web/src/app/features/documents/ventas/ventas.screens.ts @@ -0,0 +1,13 @@ +import { TAX_DOCUMENT, type DocumentScreen, invoiceColumns, marketing } from '../screen-kit'; + +/** Ventas: ofertas, órdenes, entregas, facturas, devoluciones y notas de crédito de cliente. */ +export const VENTAS_SCREENS: readonly DocumentScreen[] = [ +] = [ + // Ventas + marketing('/ventas/ofertas', 'Quotations', 'Nueva oferta', 'Cliente'), + marketing('/ventas/ordenes', 'Orders', 'Nueva orden', 'Cliente'), + marketing('/ventas/entrega', 'DeliveryNotes', 'Nueva entrega', 'Cliente', TAX_DOCUMENT), + marketing('/ventas/facturas', 'Invoices', 'Nueva factura', 'Cliente', { ...TAX_DOCUMENT, columns: invoiceColumns }), + marketing('/ventas/devoluciones', 'Returns', 'Nueva devolución', 'Cliente'), + marketing('/ventas/notas-credito', 'CreditNotes', 'Nueva nota de crédito', 'Cliente', TAX_DOCUMENT), +];