feat: solicitudes de compra como documento de marketing (solicitante, válido hasta, fecha necesaria, proveedor y fecha por línea) y API de usuarios con campos limitados
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a3bd976bbd
commit
c1f2cea1e1
19 files changed
+342
-62
No files matched your search
@@ -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,
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
(valueChange)="setHeader(f.key, typedLookup(f, $event))"
|
||||
[lookup]="f.lookup"
|
||||
resolveOnLeave
|
||||
(picked)="pickedLookup(f, $event)"
|
||||
/>
|
||||
}
|
||||
@case ('warehouse') {
|
||||
|
||||
@@ -125,8 +125,8 @@ export class DocumentFormPage {
|
||||
return id === null ? undefined : `${API_URL}/sl/${this.screen.resource}/${id}`;
|
||||
});
|
||||
private readonly taxResource = httpResource<SlListResponse<DocRecord>>(() =>
|
||||
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<string | null>(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<string, unknown>) => Record<string, unknown> }, row: Record<string, unknown>): 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);
|
||||
|
||||
@@ -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<string, unknown>) => Record<string, unknown> }
|
||||
| { 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<string, unknown>) => ({
|
||||
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;
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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<DocumentFormPage>;
|
||||
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<string, unknown>;
|
||||
expect(body).toMatchObject({ Requester: 'mmarinos', ReqType: 12, RequesterDepartment: 32, DocType: 'dDocument_Items' });
|
||||
expect(body).not.toHaveProperty('RequesterName');
|
||||
expect((body['DocumentLines'] as Record<string, unknown>[])[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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
} @else {
|
||||
<input
|
||||
class="cell"
|
||||
type="text"
|
||||
[attr.type]="c.type === 'date' ? 'date' : 'text'"
|
||||
autocomplete="off"
|
||||
[style.text-align]="alignOf(c)"
|
||||
[class.mono]="c.mono"
|
||||
|
||||
@@ -662,3 +662,22 @@ describe('LinesGrid: celdas de cuenta', () => {
|
||||
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<HTMLInputElement>('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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user