From c1f2cea1e149b49b6dc5c4412f93b3c56d0cb66c Mon Sep 17 00:00:00 2001 From: George Date: Mon, 19 Oct 2026 14:53:59 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20solicitudes=20de=20compra=20como=20docu?= =?UTF-8?q?mento=20de=20marketing=20(solicitante,=20v=C3=A1lido=20hasta,?= =?UTF-8?q?=20fecha=20necesaria,=20proveedor=20y=20fecha=20por=20l=C3=ADne?= =?UTF-8?q?a)=20y=20API=20de=20usuarios=20con=20campos=20limitados?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 2 +- apps/api/src/sl/sl-proxy.service.ts | 3 + apps/api/src/sl/sl-request.spec.ts | 25 ++++- apps/api/src/sl/sl-request.ts | 11 ++ apps/api/src/sl/sl-resources.spec.ts | 7 ++ apps/api/src/sl/sl-resources.ts | 11 ++ .../documents/compras/compras.layouts.ts | 93 ++++++++-------- .../documents/compras/compras.screens.ts | 2 + .../documents/document-form.page.html | 1 + .../features/documents/document-form.page.ts | 9 +- .../features/documents/document-layouts.ts | 102 ++++++++++++++++-- .../features/documents/document-model.spec.ts | 8 ++ .../app/features/documents/document-model.ts | 3 +- .../documents/document-request.spec.ts | 99 +++++++++++++++++ .../src/app/features/documents/screen-kit.ts | 2 + apps/web/src/app/shared/grid/grid.types.ts | 4 +- apps/web/src/app/shared/grid/lines-grid.html | 2 +- .../src/app/shared/grid/lines-grid.spec.ts | 19 ++++ apps/web/src/app/shared/grid/lines-grid.ts | 1 + 19 files changed, 342 insertions(+), 62 deletions(-) create mode 100644 apps/web/src/app/features/documents/document-request.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index db1441f..a867cc5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,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 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`. **Tipo Artículo / Servicio** (`DocType`): los documentos de ventas y compras con socio llevan en la cabecera la lista «Tipo» (primera de la columna izquierda; no se cambia en uno guardado). Con «Servicio» las líneas pasan a `layout.service` (`serviceLineColumns` en `line-columns.ts`): Cuenta contable (obligatoria), Descripción, Impuesto y Total (se escribe, sin recalcular), dimensiones, proyecto y referencias al origen; sin artículo, cantidad, almacén, precio, lotes ni «Elegir artículos». Se guardan `AccountCode`, `ItemDescription`, `TaxCode`, `LineTotal` (`LinesSpec.service`); cambiar el tipo con líneas escritas pide permiso y las descarta; «Copiar de» trae el tipo y el importe abierto (`OpenAmount`) del origen y no mezcla tipos. Las extensiones de cliente de columnas de línea aplican a las líneas de artículos; las de servicio aún no son extensibles. **Indicador y folio juntos y en ese orden** (`IDENTITY_GROUP`, un `HeaderField` tipo `group` = varias celdas en una fila: Indicador con su lista, prefijo y número del folio) en facturas, boletas, reservas y notas de crédito de ventas y compras (`BILLING_DOCUMENT`) y en el asiento. Una empresa que los sustituye por campos de usuario arma su grupo con `fieldGroup(label, cells)` en `headerRight` de su extensión y oculta el del base con `hideHeaderFields: ['Indicator']` (oculta el grupo completo). Las **tres fechas** de la cabecera de todo documento de ventas y compras van juntas y en este orden, una debajo de la otra: contabilización, vencimiento y del documento (`TaxDate`), las tres con la fecha de hoy al crear. **Pestañas Contenido, Logística y Finanzas** en ventas y compras (`layout.tabs`, `tools/logistica.png` y `tools/finanzas.png`): Logística = destino y dirección de facturación (se eligen de las direcciones del socio, con su texto debajo), forma de envío, número de seguimiento y bloquear reclamaciones; Finanzas = entrada en el diario, cuenta asociada, bloqueo de pago, condiciones y forma de pago (de cobro en ventas, de pago en compras), indicador banco central, plazos, recálculo del vencimiento y proyecto. Al elegir el socio se cargan sus contactos y direcciones (`BusinessPartners(cod)` con `BPAddresses` y `ContactEmployees`) y se prellenan las direcciones, condiciones, forma de pago y cuenta asociada que trae (solo si el campo está vacío). La persona de contacto va bajo el nombre del socio. Las transferencias de stock y las solicitudes de traslado también llevan socio, contacto, destino, lista de precios (`PriceList`) y empleado (`SalesPersonCode`). Los códigos numéricos de las listas se guardan como número (`typedLookup`). Las extensiones de cliente pueden agregar campos a las pestañas (`logisticsLeft/Right`, `financeLeft/Right`). Sin verificar: «Responsable» de Logística (no se identificó su campo del Service Layer), canal del socio, cuota/consolidación de Finanzas. +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`. **Tipo Artículo / Servicio** (`DocType`): los documentos de ventas y compras con socio llevan en la cabecera la lista «Tipo» (primera de la columna izquierda; no se cambia en uno guardado). Con «Servicio» las líneas pasan a `layout.service` (`serviceLineColumns` en `line-columns.ts`): Cuenta contable (obligatoria), Descripción, Impuesto y Total (se escribe, sin recalcular), dimensiones, proyecto y referencias al origen; sin artículo, cantidad, almacén, precio, lotes ni «Elegir artículos». Se guardan `AccountCode`, `ItemDescription`, `TaxCode`, `LineTotal` (`LinesSpec.service`); cambiar el tipo con líneas escritas pide permiso y las descarta; «Copiar de» trae el tipo y el importe abierto (`OpenAmount`) del origen y no mezcla tipos. Las extensiones de cliente de columnas de línea aplican a las líneas de artículos; las de servicio aún no son extensibles. **Indicador y folio juntos y en ese orden** (`IDENTITY_GROUP`, un `HeaderField` tipo `group` = varias celdas en una fila: Indicador con su lista, prefijo y número del folio) en facturas, boletas, reservas y notas de crédito de ventas y compras (`BILLING_DOCUMENT`) y en el asiento. Una empresa que los sustituye por campos de usuario arma su grupo con `fieldGroup(label, cells)` en `headerRight` de su extensión y oculta el del base con `hideHeaderFields: ['Indicator']` (oculta el grupo completo). Las **tres fechas** de la cabecera de todo documento de ventas y compras van juntas y en este orden, una debajo de la otra: contabilización, vencimiento y del documento (`TaxDate`), las tres con la fecha de hoy al crear. **Pestañas Contenido, Logística y Finanzas** en ventas y compras (`layout.tabs`, `tools/logistica.png` y `tools/finanzas.png`): Logística = destino y dirección de facturación (se eligen de las direcciones del socio, con su texto debajo), forma de envío, número de seguimiento y bloquear reclamaciones; Finanzas = entrada en el diario, cuenta asociada, bloqueo de pago, condiciones y forma de pago (de cobro en ventas, de pago en compras), indicador banco central, plazos, recálculo del vencimiento y proyecto. Al elegir el socio se cargan sus contactos y direcciones (`BusinessPartners(cod)` con `BPAddresses` y `ContactEmployees`) y se prellenan las direcciones, condiciones, forma de pago y cuenta asociada que trae (solo si el campo está vacío). La persona de contacto va bajo el nombre del socio. Las transferencias de stock y las solicitudes de traslado también llevan socio, contacto, destino, lista de precios (`PriceList`) y empleado (`SalesPersonCode`). Los códigos numéricos de las listas se guardan como número (`typedLookup`). Las extensiones de cliente pueden agregar campos a las pestañas (`logisticsLeft/Right`, `financeLeft/Right`). Sin verificar: «Responsable» de Logística (no se identificó su campo del Service Layer), canal del socio, cuota/consolidación de Finanzas. **Solicitudes de compra** (`/compras/solicitudes`, `tools/solicitud de compra.png`) son documentos de marketing sin socio: `marketingLayout({ requester: true })` — cabecera con tipo, solicitante (usuario de SAP: al elegirlo se completan nombre, correo, departamento y sucursal; `ReqType` 12), «Válido hasta», fecha necesaria e indicador/proyecto; líneas con proveedor sugerido (`LineVendor`), fecha necesaria por línea (columna `date` de la grilla) y cantidad/precio/descuento/impuesto/total/almacén; pie con gastos adicionales y totales; sin moneda, redondeo ni pestañas. Para el solicitante la API registra `Users` con `selectOnly` (`UserCode`, `UserName`, `eMail`, `Department`, `Branch`: `$select` obligatorio y limitado; la entidad trae contraseña y permisos), más `Departments` y `Branches`. Los documentos sin socio indican su maestro de impuestos con `DocumentScreen.taxResource`. ## Cómo verificar cambios de interfaz diff --git a/apps/api/src/sl/sl-proxy.service.ts b/apps/api/src/sl/sl-proxy.service.ts index d9fedda..e9e633c 100644 --- a/apps/api/src/sl/sl-proxy.service.ts +++ b/apps/api/src/sl/sl-proxy.service.ts @@ -15,6 +15,7 @@ import { type GetQuery, type ListQuery, assertExpandAllowed, + assertSelectAllowed, entityPath, } from './sl-request.js'; import { SL_RESOURCE_MAP, type SlMethod, type SlResource } from './sl-resources.js'; @@ -41,6 +42,7 @@ export class SlProxyService { async list(user: AuthUser, name: string, query: ListQuery, headers: Headers) { const resource = await this.access(user, name, 'GET'); assertExpandAllowed(resource, query.$expand); + assertSelectAllowed(resource, query.$select); const top = query.$top ?? DEFAULT_TOP; const res = await this.sap.request({ method: 'GET', @@ -58,6 +60,7 @@ export class SlProxyService { async get(user: AuthUser, name: string, key: string, query: GetQuery) { const resource = await this.access(user, name, 'GET'); assertExpandAllowed(resource, query.$expand); + assertSelectAllowed(resource, query.$select); const res = await this.sap.request({ method: 'GET', path: entityPath(resource, key), query }); return res.data; } diff --git a/apps/api/src/sl/sl-request.spec.ts b/apps/api/src/sl/sl-request.spec.ts index 3b3c891..a58f593 100644 --- a/apps/api/src/sl/sl-request.spec.ts +++ b/apps/api/src/sl/sl-request.spec.ts @@ -1,7 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import { menuPaths } from '@basic-erp/sap-types'; import { SL_RESOURCES, SL_RESOURCE_MAP } from './sl-resources.js'; -import { entityPath, getQuerySchema, listQuerySchema, pickSlHeaders } from './sl-request.js'; +import { assertSelectAllowed, entityPath, getQuerySchema, listQuerySchema, pickSlHeaders } from './sl-request.js'; describe('registro de recursos', () => { it('no tiene nombres repetidos ni expone EmployeesInfo', () => { @@ -91,3 +91,26 @@ describe('pickSlHeaders', () => { expect(() => pickSlHeaders({ 'b1s-pagesize': '99999' }, 'GET')).toThrow(BadRequestException); }); }); + +describe('recursos con campos limitados ($select obligatorio)', () => { + const users = SL_RESOURCE_MAP.get('Users')!; + + it('acepta solo los campos de la lista', () => { + expect(() => assertSelectAllowed(users, 'UserCode,UserName,eMail')).not.toThrow(); + }); + + it('rechaza un campo fuera de la lista, como la contraseña', () => { + expect(() => assertSelectAllowed(users, 'UserCode,UserPassword')).toThrow(BadRequestException); + expect(() => assertSelectAllowed(users, 'UserPassword')).toThrow(/no permitido: UserPassword/); + }); + + it('rechaza pedir el recurso sin $select (traería todos los campos)', () => { + expect(() => assertSelectAllowed(users, undefined)).toThrow(BadRequestException); + expect(() => assertSelectAllowed(users, '')).toThrow(BadRequestException); + }); + + it('los demás recursos no se limitan', () => { + expect(() => assertSelectAllowed(SL_RESOURCE_MAP.get('Items')!, undefined)).not.toThrow(); + expect(() => assertSelectAllowed(SL_RESOURCE_MAP.get('Items')!, 'ItemCode,Cualquiera')).not.toThrow(); + }); +}); diff --git a/apps/api/src/sl/sl-request.ts b/apps/api/src/sl/sl-request.ts index c7f8979..31df63e 100644 --- a/apps/api/src/sl/sl-request.ts +++ b/apps/api/src/sl/sl-request.ts @@ -102,6 +102,17 @@ export function entityPath(resource: SlResource, key: string): string { return `${resource.name}('${encodeURIComponent(key.replace(/'/g, "''"))}')`; } +/** Un recurso con campos limitados exige `$select` y solo acepta esos campos. */ +export function assertSelectAllowed(resource: SlResource, selectParam: string | undefined): void { + if (!resource.selectOnly) return; + const allowed = new Set(resource.selectOnly); + const fields = (selectParam ?? '').split(',').map((f) => f.trim()).filter(Boolean); + const rejected = fields.filter((f) => !allowed.has(f)); + if (!fields.length || rejected.length) { + throw new BadRequestException(`${resource.name} solo permite $select de: ${resource.selectOnly.join(', ')}${rejected.length ? ` (no permitido: ${rejected.join(', ')})` : ''}`); + } +} + export function assertExpandAllowed(resource: SlResource, expandParam: string | undefined): void { if (!expandParam) return; const allowed = new Set(resource.expand ?? []); diff --git a/apps/api/src/sl/sl-resources.spec.ts b/apps/api/src/sl/sl-resources.spec.ts index 58e52ab..d8a3ad7 100644 --- a/apps/api/src/sl/sl-resources.spec.ts +++ b/apps/api/src/sl/sl-resources.spec.ts @@ -84,4 +84,11 @@ describe('recursos del Service Layer', () => { expect(rate.write).toBeNull(); for (const name of ['ShippingTypes', 'WizardPaymentMethods']) expect(SL_RESOURCE_MAP.get(name)!.methods, name).toEqual(['GET']); }); + + it('los usuarios de SAP solo entregan los campos que necesita el solicitante', () => { + const r = SL_RESOURCE_MAP.get('Users')!; + expect(r.methods).toEqual(['GET']); + expect(r.selectOnly).toEqual(['UserCode', 'UserName', 'eMail', 'Department', 'Branch']); + expect(r.selectOnly).not.toContain('UserPassword'); + }); }); diff --git a/apps/api/src/sl/sl-resources.ts b/apps/api/src/sl/sl-resources.ts index 505424b..4c6c1d6 100644 --- a/apps/api/src/sl/sl-resources.ts +++ b/apps/api/src/sl/sl-resources.ts @@ -15,6 +15,11 @@ export interface SlResource { write: RequiredPath | null; /** Colecciones permitidas en `$expand` (por defecto ninguna). */ expand?: string[]; + /** + * Solo estos campos se pueden pedir (`$select` obligatorio y dentro de la lista): para recursos cuya entidad trae + * más de lo que debe salir (los usuarios de SAP traen contraseña, permisos y actividad). + */ + selectOnly?: readonly string[]; /** Acciones sobre un registro (`POST Entidad(clave)/Accion`) que se pueden invocar, p. ej. `Cancel`. Requieren el permiso de escritura. */ actions?: readonly string[]; } @@ -83,6 +88,12 @@ export const SL_RESOURCES: readonly SlResource[] = [ // Finanzas: los asientos manuales se crean y se cancelan (`Cancel` genera el asiento de reverso), no se modifican. { ...document('JournalEntries', '/finanzas/asientos'), methods: ['GET', 'POST'], actions: ['Cancel'] }, + // Solicitante de una solicitud de compra: usuarios, departamentos y sucursales de SAP. Los usuarios solo entregan + // código, nombre, correo, departamento y sucursal (la entidad trae también contraseña y permisos). + { ...catalog('Users', 'string'), selectOnly: ['UserCode', 'UserName', 'eMail', 'Department', 'Branch'] }, + catalog('Departments', 'number'), + catalog('Branches', 'number'), + // Maestros (consulta para cualquier sesión; escritura según el módulo) catalog('BusinessPartners', 'string', ['/socios/clientes', '/socios/proveedores']), catalog('Items', 'string', '/inventario/articulos'), diff --git a/apps/web/src/app/features/documents/compras/compras.layouts.ts b/apps/web/src/app/features/documents/compras/compras.layouts.ts index f98ad56..c581970 100644 --- a/apps/web/src/app/features/documents/compras/compras.layouts.ts +++ b/apps/web/src/app/features/documents/compras/compras.layouts.ts @@ -1,53 +1,54 @@ -import { - type DocumentLayout, - FIELDS, - blankLine, - commentsOnly, - descriptionColumn, - itemColumn, - quantityColumn, - today, - warehouseColumn, -} from '../document-layouts'; +import { type DocumentLayout, marketingLayout } from '../document-layouts'; import type { GridColumn } from '../../../shared/grid/grid.types'; -import { ANALYTIC_COLUMNS, lineFieldsOf } from '../line-columns'; +import { ANALYTIC_COLUMNS, LINE_COLUMNS } from '../line-columns'; -/** Solicitud de compra: solicitante (usuario de SAP), fecha requerida y, por línea, proveedor sugerido. */ -const purchaseRequestColumns: readonly GridColumn[] = [ - 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'] }), +/** Proveedor sugerido de la línea (`LineVendor`). */ +const vendorColumn: GridColumn = { + 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'] }), }, +}; + +/** Fecha en que se necesita lo pedido en la línea (`RequiredDate`). */ +const requiredDateColumn: GridColumn = { key: 'RequiredDate', title: 'Fecha necesaria', type: 'date', width: '135px' }; + +/** Las columnas de la solicitud de compra, en el orden de SAP: artículo, descripción, proveedor, fecha, cantidad, precio, descuento, impuesto, total y almacén. */ +const requestColumns: readonly GridColumn[] = [ + LINE_COLUMNS.item, + LINE_COLUMNS.description, + vendorColumn, + requiredDateColumn, + { ...LINE_COLUMNS.quantity, title: 'Cantidad necesaria' }, + { ...LINE_COLUMNS.price, title: 'Precio info' }, + LINE_COLUMNS.discount, + LINE_COLUMNS.tax, + LINE_COLUMNS.total, + LINE_COLUMNS.warehouse, ...ANALYTIC_COLUMNS, ]; -export const purchaseRequestLayout: DocumentLayout = { - lines: { key: 'DocumentLines', fields: [...lineFieldsOf(purchaseRequestColumns), 'UoMEntry'], base: false }, - header: { left: [FIELDS.requester, FIELDS.indicator, FIELDS.project], right: [FIELDS.docNumber, FIELDS.docDate, FIELDS.requiredDate] }, - footer: commentsOnly, - columns: purchaseRequestColumns, - 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: [], -}; +/** + * Solicitud de compra: un documento de marketing sin socio de negocio. Lleva al solicitante (usuario de SAP), «Válido + * hasta» y la fecha necesaria; sus líneas llevan el proveedor sugerido y la fecha en que se necesita cada una. + */ +export const purchaseRequestLayout: DocumentLayout = marketingLayout({ + partnerLabel: 'Solicitante', + taxResource: 'SalesTaxCodes', + uom: 'purchase', + requester: true, + lineColumns: requestColumns, +}); diff --git a/apps/web/src/app/features/documents/compras/compras.screens.ts b/apps/web/src/app/features/documents/compras/compras.screens.ts index 4f1f869..2d01cf8 100644 --- a/apps/web/src/app/features/documents/compras/compras.screens.ts +++ b/apps/web/src/app/features/documents/compras/compras.screens.ts @@ -6,6 +6,8 @@ export const COMPRAS_SCREENS: readonly DocumentScreen[] = [ { path: '/compras/solicitudes', layout: purchaseRequestLayout, + // sin socio de negocio: el maestro de impuestos para los totales lo indica la pantalla + taxResource: 'SalesTaxCodes', resource: 'PurchaseRequests', createLabel: 'Nueva solicitud', orderby: 'DocEntry desc', diff --git a/apps/web/src/app/features/documents/document-form.page.html b/apps/web/src/app/features/documents/document-form.page.html index e48b5ae..cda0f5b 100644 --- a/apps/web/src/app/features/documents/document-form.page.html +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -152,6 +152,7 @@ (valueChange)="setHeader(f.key, typedLookup(f, $event))" [lookup]="f.lookup" resolveOnLeave + (picked)="pickedLookup(f, $event)" /> } @case ('warehouse') { diff --git a/apps/web/src/app/features/documents/document-form.page.ts b/apps/web/src/app/features/documents/document-form.page.ts index 8461bcb..95ecaa3 100644 --- a/apps/web/src/app/features/documents/document-form.page.ts +++ b/apps/web/src/app/features/documents/document-form.page.ts @@ -125,8 +125,8 @@ export class DocumentFormPage { return id === null ? undefined : `${API_URL}/sl/${this.screen.resource}/${id}`; }); private readonly taxResource = httpResource>(() => - this.partner - ? { url: `${API_URL}/sl/${this.partner.taxResource}`, params: { $select: 'Code,Rate', $top: 500 } } + this.partner?.taxResource ?? this.screen.taxResource + ? { url: `${API_URL}/sl/${this.partner?.taxResource ?? this.screen.taxResource}`, params: { $select: 'Code,Rate', $top: 500 } } : undefined, ); @@ -508,6 +508,11 @@ export class DocumentFormPage { /** Tipo al que se quiere cambiar y que espera confirmación porque hay líneas escritas que se descartarían. */ protected readonly pendingType = signal(null); + /** Al elegir de una lista de la cabecera, los campos que esa elección completa (el solicitante trae su nombre, correo y departamento). */ + protected pickedLookup(f: { onPick?: (row: Record) => Record }, row: Record): void { + if (f.onPick) this.header.update((h) => ({ ...h, ...f.onPick!(row) })); + } + // --- datos del socio: personas de contacto y direcciones --------------------------------------- private readonly hasPartnerData = hasPartnerData(this.layout); diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts index 0460046..f327da7 100644 --- a/apps/web/src/app/features/documents/document-layouts.ts +++ b/apps/web/src/app/features/documents/document-layouts.ts @@ -38,7 +38,7 @@ export type HeaderField = /** Casilla: en SAP un campo `tYES` / `tNO`. */ | { type: 'checkbox'; key: string; label: string; readonly?: boolean; immutable?: boolean } /** Campo de código con su ventana de selección (grupo, moneda, lista de precios…). */ - | { type: 'lookup'; key: string; label: string; lookup: VhLookup; required?: boolean; missing?: string; immutable?: boolean } + | { type: 'lookup'; key: string; label: string; lookup: VhLookup; required?: boolean; missing?: string; immutable?: boolean; onPick?: (row: Record) => Record } | { type: 'textarea'; key: string; label: string } /** Lista de valores válidos (típico de un campo de usuario con valores válidos en SAP). */ | { type: 'select'; key: string; label: string; options: readonly { value: string | number; label: string }[]; required?: boolean; missing?: string; immutable?: boolean } @@ -199,7 +199,62 @@ export const CONTROL_ACCOUNT_LOOKUP: VhLookup = { baseFilter: `ActiveAccount eq 'tYES' and LockManualTransaction eq 'tYES'`, }; +/** Usuarios de SAP (solicitante de una solicitud de compra): el servidor solo entrega estos campos. */ +export const USER_LOOKUP: VhLookup = { + title: 'Usuarios', + resource: 'Users', + orderby: 'UserCode', + valueKey: 'UserCode', + extraFields: ['eMail', 'Department', 'Branch'], + columns: [ + { key: 'UserCode', title: 'Código', width: '140px', mono: true }, + { key: 'UserName', title: 'Nombre' }, + ], +}; + +export const DEPARTMENT_LOOKUP: VhLookup = { + title: 'Departamentos', + resource: 'Departments', + orderby: 'Code', + valueType: 'number', + columns: [ + { key: 'Code', title: 'Código', width: '90px', mono: true }, + { key: 'Name', title: 'Nombre' }, + ], +}; + +export const BRANCH_LOOKUP: VhLookup = { + title: 'Sucursales', + resource: 'Branches', + orderby: 'Code', + valueType: 'number', + columns: [ + { key: 'Code', title: 'Código', width: '90px', mono: true }, + { key: 'Name', title: 'Nombre' }, + ], +}; + export const FIELDS = { + /** Solicitante de una solicitud de compra: al elegir el usuario se completan su nombre, correo, departamento y sucursal. */ + requesterUser: { + type: 'lookup', + key: 'Requester', + label: 'Solicitante (usuario)', + lookup: USER_LOOKUP, + required: true, + missing: 'el solicitante', + onPick: (u: Record) => ({ + RequesterName: u['UserName'] ?? '', + RequesterEmail: u['eMail'] ?? '', + RequesterDepartment: u['Department'] ?? '', + RequesterBranch: u['Branch'] ?? '', + }), + }, + requesterName: { type: 'text', key: 'RequesterName', label: 'Nombre de solicitante', readonly: true }, + requesterBranch: { type: 'lookup', key: 'RequesterBranch', label: 'Sucursal', lookup: BRANCH_LOOKUP }, + requesterDepartment: { type: 'lookup', key: 'RequesterDepartment', label: 'Departamento', lookup: DEPARTMENT_LOOKUP }, + requesterNotify: { type: 'checkbox', key: 'SendNotification', label: 'Enviar correo electrónico si se agregó pedido o pedido de compra' }, + requesterEmail: { type: 'text', key: 'RequesterEmail', label: 'Dirección de correo electrónico' }, shippingType: { type: 'lookup', key: 'TransportationCode', label: 'Forma de envío', lookup: SHIPPING_TYPE_LOOKUP }, trackingNumber: { type: 'text', key: 'TrackingNumber', label: 'Número de seguimiento' }, blockDunning: { type: 'checkbox', key: 'BlockDunning', label: 'Bloquear reclamaciones' }, @@ -349,6 +404,15 @@ export const blankLine = (): GridLine => ({ ItemCode: '', ItemDescription: '', Q // --- documentos con socio de negocio (ventas y compras) ------------------------------------- +/** + * Cabecera de una solicitud de compra (como en SAP): el solicitante con su sucursal, departamento y correo a la + * izquierda; número, las tres fechas («Válido hasta» en lugar del vencimiento) y la fecha necesaria a la derecha. + */ +const REQUEST_HEADER: FieldColumns = { + left: [FIELDS.docType, FIELDS.requesterUser, FIELDS.requesterName, FIELDS.requesterBranch, FIELDS.requesterDepartment, FIELDS.requesterNotify, FIELDS.requesterEmail, FIELDS.indicator, FIELDS.project], + right: [FIELDS.docNumber, FIELDS.docDate, { ...FIELDS.docDueDate, label: 'Válido hasta' }, FIELDS.taxDate, { type: 'date', key: 'RequriedDate', label: 'Fecha necesaria' }], +}; + /** * Ventas y compras: socio, referencia, fechas; líneas con precio, descuento e impuesto; pie con * comentarios, descuento y totales. Es el punto de partida; cada pantalla ajusta lo suyo: @@ -370,6 +434,8 @@ export function marketingLayout(opts: { uom: 'sales' | 'purchase'; /** Fila del flete en el pie (por defecto sí). */ expenses?: boolean; + /** Solicitud de compra: en lugar del socio de negocio lleva al solicitante (usuario, sucursal, departamento y correo), «Válido hasta» y la fecha necesaria; sin moneda, gastos adicionales ni redondeo. */ + requester?: boolean; /** Indicador y folio juntos en una fila de la derecha (facturas y notas de crédito) en lugar del indicador suelto a la izquierda y el folio. */ identity?: boolean; /** Facturas y notas de crédito: el pie suma el anticipo total, el importe aplicado y el saldo pendiente. */ @@ -382,7 +448,7 @@ export function marketingLayout(opts: { ); const layout: DocumentLayout = { lines: { key: 'DocumentLines', fields: [...lineFieldsOf(columns), 'UoMEntry'], base: true }, - header: opts.header ?? { + header: opts.header ?? (opts.requester ? REQUEST_HEADER : { left: [ FIELDS.docType, { type: 'partner', key: 'CardCode', label: opts.partnerLabel, required: true, missing: `el ${opts.partnerLabel.toLowerCase()}` }, @@ -396,8 +462,18 @@ export function marketingLayout(opts: { ], // las tres fechas juntas, una debajo de la otra: contabilización, vencimiento y del documento right: [FIELDS.docNumber, FIELDS.docDate, FIELDS.docDueDate, FIELDS.taxDate, ...(opts.identity ? [IDENTITY_GROUP] : []), ...(opts.right ?? [])], - }, - footer: { + }), + footer: opts.requester + ? { + left: [{ type: 'expenses', label: 'Gastos adicionales' }, FIELDS.comments], + right: [ + { type: 'computed', label: 'Total antes del descuento', source: 'subtotal' }, + { type: 'computed', label: 'Gastos adicionales', source: 'expenses' }, + { type: 'computed', label: 'Impuesto', source: 'tax' }, + { type: 'computed', label: 'Total del documento', source: 'total' }, + ], + } + : { left: [...(opts.expenses === false ? [] : [{ type: 'expenses', label: 'Gastos adicionales' } as const]), FIELDS.comments], right: [ { type: 'computed', label: 'Total antes del descuento', source: 'subtotal' }, @@ -414,7 +490,7 @@ export function marketingLayout(opts: { ], }, columns, - newHeader: () => ({ DocType: ITEMS_DOC_TYPE, DocDate: today(), DocDueDate: today(), TaxDate: today(), DiscountPercent: 0, Rounding: 'tNO', RoundingDiffAmount: 0 }), + newHeader: () => (opts.requester ? { DocType: ITEMS_DOC_TYPE, DocDate: today(), DocDueDate: today(), TaxDate: today(), RequriedDate: today(), SendNotification: 'tNO' } : { DocType: ITEMS_DOC_TYPE, DocDate: today(), DocDueDate: today(), TaxDate: today(), DiscountPercent: 0, Rounding: 'tNO', RoundingDiffAmount: 0 }), newLine: newDocumentLine, recalc: recalcLine, service: { @@ -423,7 +499,7 @@ export function marketingLayout(opts: { newLine: newServiceLine, }, // pestañas como en SAP: Logística (destino, facturación, envío) y Finanzas (pago, cuenta asociada, proyecto) - tabs: { + ...(opts.requester ? {} : { tabs: { logistics: { left: [FIELDS.shipTo, { ...FIELDS.payTo, label: opts.uom === 'sales' ? 'Destinatario de factura' : 'Dirección de facturación' }, FIELDS.shippingType], right: [FIELDS.trackingNumber, FIELDS.blockDunning], @@ -443,9 +519,19 @@ export function marketingLayout(opts: { ], right: [FIELDS.project], }, - }, + } }), seriesSubType: SERIES_SUBTYPE[String(opts.fixed?.['DocumentSubType'] ?? 'bod_None')] ?? '--', - ...(opts.fixed ? { beforeCreate: (h: DocRecord) => ({ ...h, ...opts.fixed }) } : {}), + ...(opts.requester + ? { + // el solicitante es un usuario de SAP (`ReqType` 12); su nombre lo completa SAP + beforeCreate: (h: DocRecord) => { + const { RequesterName: _name, ...rest } = h; + return rest['Requester'] ? { ...rest, ReqType: 12 } : rest; + }, + } + : opts.fixed + ? { beforeCreate: (h: DocRecord) => ({ ...h, ...opts.fixed }) } + : {}), requiredLine: [], }; return opts.lookups ? withLookupColumns(layout, opts.lookups, 'replace') : layout; diff --git a/apps/web/src/app/features/documents/document-model.spec.ts b/apps/web/src/app/features/documents/document-model.spec.ts index 974db26..64fc64c 100644 --- a/apps/web/src/app/features/documents/document-model.spec.ts +++ b/apps/web/src/app/features/documents/document-model.spec.ts @@ -215,3 +215,11 @@ describe('documentos de servicio', () => { expect(body['DocumentLines']).toEqual([{ AccountCode: '_SYS1', ItemDescription: 'Alquiler', TaxCode: 'IGV', LineTotal: 100 }]); }); }); + +describe('fechas de línea', () => { + it('se envían sin la hora con que las devuelve SAP', () => { + const spec = { key: 'DocumentLines', fields: ['ItemCode', 'RequiredDate'], base: false }; + expect(createPayload({}, [{ ItemCode: 'A', RequiredDate: '2026-10-01T00:00:00Z' }], spec)['DocumentLines']).toEqual([{ ItemCode: 'A', RequiredDate: '2026-10-01' }]); + }); +}); + diff --git a/apps/web/src/app/features/documents/document-model.ts b/apps/web/src/app/features/documents/document-model.ts index b21fd1c..6c38d8d 100644 --- a/apps/web/src/app/features/documents/document-model.ts +++ b/apps/web/src/app/features/documents/document-model.ts @@ -164,7 +164,8 @@ export function linesPayload(lines: readonly GridLine[], spec: LinesSpec = MARKE } if (isEmpty(l[f])) continue; if (f === 'UoMEntry' && !(Number(l[f]) > 0)) continue; // -1 = unidad manual: SAP no admite enviarla - out[f] = l[f]; + // las fechas de línea (`RequiredDate`) vuelven de SAP con hora (`2026-10-01T00:00:00Z`) y se envían solo con el día + out[f] = typeof l[f] === 'string' && /Date$/.test(f) && /^\d{4}-\d{2}-\d{2}T/.test(l[f] as string) ? (l[f] as string).slice(0, 10) : l[f]; } // Precio con y sin impuesto: solo se envía el que el usuario escribió (el otro lo calcula SAP). if ('PriceAfterVAT' in out || spec.fields.includes('PriceAfterVAT')) { diff --git a/apps/web/src/app/features/documents/document-request.spec.ts b/apps/web/src/app/features/documents/document-request.spec.ts new file mode 100644 index 0000000..d5e821f --- /dev/null +++ b/apps/web/src/app/features/documents/document-request.spec.ts @@ -0,0 +1,99 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +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 { DocumentFormPage } from './document-form.page'; +import { DOCUMENT_SCREENS } from './document-screens'; + +describe('solicitud de compra (documento de marketing sin socio)', () => { + const screen = DOCUMENT_SCREENS.find((s) => s.resource === 'PurchaseRequests')!; + let fixture: ComponentFixture; + let ctrl: HttpTestingController; + const el = () => fixture.nativeElement as HTMLElement; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const page = () => fixture.componentInstance as any; + const tick = async () => { + fixture.detectChanges(); + await new Promise((r) => setTimeout(r)); + fixture.detectChanges(); + }; + + async function open() { + const route = { snapshot: { data: { screen, title: 'Solicitud de compra' }, paramMap: convertToParamMap({}) }, paramMap: of(convertToParamMap({})) }; + await TestBed.configureTestingModule({ + imports: [DocumentFormPage], + providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }], + }).compileComponents(); + ctrl = TestBed.inject(HttpTestingController); + vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); + fixture = TestBed.createComponent(DocumentFormPage); + await tick(); + ctrl.expectOne((r) => r.url === '/api/sl/SalesTaxCodes').flush({ value: [{ Code: 'IGV', Rate: 18 }] }); + ctrl.expectOne((r) => r.url === '/api/sl/AdditionalExpenses').flush({ value: [] }); + await tick(); + } + + const labels = () => [...el().querySelectorAll('label')].map((l) => l.textContent?.trim()); + beforeEach(() => TestBed.resetTestingModule()); + + it('la cabecera lleva al solicitante en lugar del socio, con «Válido hasta» y la fecha necesaria', async () => { + await open(); + expect(labels()).toEqual( + expect.arrayContaining(['Tipo', 'Solicitante (usuario)', 'Nombre de solicitante', 'Sucursal', 'Departamento', 'Dirección de correo electrónico', 'Válido hasta', 'Fecha del documento', 'Fecha necesaria']), + ); + expect(labels()).not.toContain('Cliente'); + expect(labels()).not.toContain('Moneda / Tipo de cambio'); + expect(page().layout.tabs).toBeUndefined(); + }); + + it('las columnas de línea van en el orden de SAP, con proveedor y fecha necesaria', async () => { + await open(); + const keys = (page().columns() as { key: string }[]).map((c) => c.key).slice(0, 10); + expect(keys).toEqual(['ItemCode', 'ItemDescription', 'LineVendor', 'RequiredDate', 'Quantity', 'Price', 'DiscountPercent', 'TaxCode', 'LineTotal', 'WarehouseCode']); + }); + + it('elegir el usuario completa nombre, correo, departamento y sucursal', async () => { + await open(); + const f = page().layout.header.left.find((x: { key?: string }) => x.key === 'Requester'); + page().setHeader('Requester', 'mmarinos'); + page().pickedLookup(f, { UserCode: 'mmarinos', UserName: 'Marco Mariños', eMail: 'm@x.pe', Department: 32, Branch: 1 }); + expect(page().header()).toMatchObject({ Requester: 'mmarinos', RequesterName: 'Marco Mariños', RequesterEmail: 'm@x.pe', RequesterDepartment: 32, RequesterBranch: 1 }); + }); + + it('la lista de usuarios pide solo los campos que la API permite', async () => { + await open(); + const f = page().layout.header.left.find((x: { key?: string }) => x.key === 'Requester'); + const fields = [...new Set([f.lookup.valueKey, ...f.lookup.columns.map((c: { key: string }) => c.key), ...f.lookup.extraFields])]; + expect(fields.sort()).toEqual(['Branch', 'Department', 'UserCode', 'UserName', 'eMail']); + }); + + it('crea con el tipo de solicitante usuario (12), sin el nombre que completa SAP, y totaliza con el impuesto', async () => { + await open(); + page().setHeader('Requester', 'mmarinos'); + page().pickedLookup(page().layout.header.left.find((x: { key?: string }) => x.key === 'Requester'), { UserName: 'Marco', eMail: '', Department: 32, Branch: '' }); + page().setLines([{ ItemCode: 'A1', Quantity: 2, Price: 100, TaxCode: 'IGV', LineTotal: 200, RequiredDate: '2026-11-01', LineVendor: 'P1' }]); + await tick(); + expect(page().totals()).toMatchObject({ subtotal: 200, tax: 36, total: 236 }); + (el().querySelector('.actions .btn.main') as HTMLElement).click(); + await tick(); + const req = ctrl.expectOne((r) => r.method === 'POST' && r.url === '/api/sl/PurchaseRequests'); + const body = req.request.body as Record; + expect(body).toMatchObject({ Requester: 'mmarinos', ReqType: 12, RequesterDepartment: 32, DocType: 'dDocument_Items' }); + expect(body).not.toHaveProperty('RequesterName'); + expect((body['DocumentLines'] as Record[])[0]).toMatchObject({ ItemCode: 'A1', Quantity: 2, RequiredDate: '2026-11-01', LineVendor: 'P1' }); + req.flush({ DocEntry: 5, DocNum: 5 }); + await tick(); + }); + + it('exige el solicitante', async () => { + await open(); + page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 1, LineTotal: 1 }]); + await tick(); + (el().querySelector('.actions .btn.main') as HTMLElement).click(); + await tick(); + ctrl.expectNone((r) => r.method === 'POST' && r.url === '/api/sl/PurchaseRequests'); + expect(el().querySelector('.status')!.textContent).toContain('solicitante'); + }); +}); diff --git a/apps/web/src/app/features/documents/screen-kit.ts b/apps/web/src/app/features/documents/screen-kit.ts index 90bd2c7..d36be1a 100644 --- a/apps/web/src/app/features/documents/screen-kit.ts +++ b/apps/web/src/app/features/documents/screen-kit.ts @@ -21,6 +21,8 @@ export interface DocumentScreen { orderby: string; /** Filtro fijo de la lista y de la navegación (ej. las boletas son las `Invoices` con `DocumentSubType = bod_Bill`). */ baseFilter?: string; + /** Maestro de impuestos de un documento sin socio (la solicitud de compra); los que tienen socio lo traen en `partner`. */ + taxResource?: string; /** Formulario de documentos con socio de negocio, líneas y totales (ventas y compras). */ partner?: { label: string; diff --git a/apps/web/src/app/shared/grid/grid.types.ts b/apps/web/src/app/shared/grid/grid.types.ts index 8ff6387..622b0b8 100644 --- a/apps/web/src/app/shared/grid/grid.types.ts +++ b/apps/web/src/app/shared/grid/grid.types.ts @@ -19,8 +19,8 @@ export interface GridColumn { width?: string; /** Alineación del texto de la columna (encabezado y celdas); por defecto, `right` en las numéricas y `left` en el resto. */ align?: 'left' | 'center' | 'right'; - /** `number` se alinea a la derecha, se muestra con formato y se interpreta al salir de la celda. */ - type?: 'text' | 'number'; + /** `number` se alinea a la derecha, se muestra con formato y se interpreta al salir de la celda; `date` es una fecha (`yyyy-MM-dd`, la de SAP sin hora). */ + type?: 'text' | 'number' | 'date'; /** Decimales fijos para `number` (importes: 2); sin él, hasta 3 sin ceros sobrantes. */ decimals?: number; /** No editable (siempre, o según la línea). */ diff --git a/apps/web/src/app/shared/grid/lines-grid.html b/apps/web/src/app/shared/grid/lines-grid.html index e2e6ad5..7e41d0e 100644 --- a/apps/web/src/app/shared/grid/lines-grid.html +++ b/apps/web/src/app/shared/grid/lines-grid.html @@ -51,7 +51,7 @@ } @else { { expect(cell().value).toBe('1071111010'); }); }); + +describe('LinesGrid: columna de fecha', () => { + it('muestra la fecha de SAP sin hora, en un campo de fecha, y guarda la que se elige', async () => { + await TestBed.configureTestingModule({ imports: [Host], providers: [provideHttpClient(), provideHttpClientTesting()] }).compileComponents(); + const fixture = TestBed.createComponent(Host); + fixture.componentInstance.columns = [{ key: 'ItemCode', title: 'Artículo' }, { key: 'RequiredDate', title: 'Fecha necesaria', type: 'date' }]; + fixture.componentInstance.lines.set([{ ItemCode: 'A', RequiredDate: '2026-10-01T00:00:00Z' }]); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const input = (fixture.nativeElement as HTMLElement).querySelector('input[data-r="0"][data-c="1"]')!; + expect(input.type).toBe('date'); + expect(input.value).toBe('2026-10-01'); + input.value = '2026-11-15'; + input.dispatchEvent(new Event('change')); + fixture.detectChanges(); + expect(fixture.componentInstance.lines()[0]['RequiredDate']).toBe('2026-11-15'); + }); +}); diff --git a/apps/web/src/app/shared/grid/lines-grid.ts b/apps/web/src/app/shared/grid/lines-grid.ts index 6f28b7b..8b3d60d 100644 --- a/apps/web/src/app/shared/grid/lines-grid.ts +++ b/apps/web/src/app/shared/grid/lines-grid.ts @@ -116,6 +116,7 @@ export class LinesGrid { if (column.format) return column.format(line); const value = line[column.key]; if (column.type === 'number') return formatNumber(value, column.decimals); + if (column.type === 'date') return typeof value === 'string' ? value.slice(0, 10) : ''; const codes = column.lookup?.config.codes; if (codes && typeof value === 'string' && value !== '') return codes.display(value); return value === null || value === undefined ? '' : String(value);