feat(web): gastos adicionales de los documentos de ventas y compras como tabla de varias filas (antes solo el flete)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 12:46:56 -05:00
1 parent 3c1d02a263
commit 6c2cb30d63
10 files changed
+223 -162

No files matched your search

+1 -1
View File
@@ -77,7 +77,7 @@ Pendiente, en orden:
**Lotes y series (entradas, salidas y transferencias de stock):** si el artículo se gestiona por lote (`ManageBatchNumbers`) o por serie (`ManageSerialNumbers`; el maestro de artículos los trae con `extraFields` al elegirlo) la línea muestra un botón «Indicar lotes / series» (columna «Lotes / Series») que abre una ventana con una tabla editable: lotes = `BatchNumber`, `Quantity`, `ExpiryDate`, `ManufacturingDate`; series = `InternalSerialNumber`, `ManufacturerSerialNumber`, fechas (cada serie con `Quantity: 1`). Viajan dentro de la línea como `BatchNumbers` / `SerialNumbers`; solo se envían los campos propios (no los que SAP agrega al leer). Validación: los lotes deben sumar la cantidad de la línea y debe haber una serie por unidad. Al cambiar de artículo se descartan. Un documento contabilizado los muestra en solo lectura. Las solicitudes de traslado, las de compra y los documentos de ventas/compras no los llevan todavía (`layout.details`, `LINE_DETAILS` en `line-details.ts`). Probado: entrada de inventario 156 (1 unidad de `SU01` en el almacén 01, lote `LT-PRUEBA-01`). Pendiente: elegir de una lista los lotes existentes en una salida o transferencia (hoy se escribe el número; SAP valida que exista y haya saldo).
Flete hecho: fila «Flete» en el pie de ventas y compras (gasto adicional + importe; `freightPayload` lo guarda como `DocumentAdditionalExpenses`, reemplazando la colección; el catálogo `AdditionalExpenses` está registrado en el proxy; con varios gastos adicionales cargados en SAP solo se muestra). `freight: false` en `marketing(...)` lo quita de un documento. El toolbar del formulario ocupa todo el ancho del contenido (`<sbo-toolbar bleed>`). Falta: botón PDF. En ventas el flete falla en SBODemoCL ("Define the freight revenue account…"): el gasto adicional «Flete» solo tiene cuenta de gasto, no de ingresos (configuración de SAP, no del código); en compras funciona (orden de compra 2885).
**Gastos adicionales** hechos: tabla «Gastos adicionales» en el pie de ventas y compras (una fila por gasto: código con su ventana de selección sobre `AdditionalExpenses`, nombre, importe, impuesto y comentarios; colección `DocumentAdditionalExpenses`). Se suman al total con su propio impuesto (`computeTotals`); al modificar, si cambian las filas se envía la colección completa con `B1S-ReplaceCollectionsOnPatch` (quitarlas todas envía `[]`); `expenses: false` en `marketing(...)` la quita de un documento; las extensiones pueden ocultarla con el nombre `DocumentAdditionalExpenses`. Falta: botón PDF. En ventas un gasto adicional sin cuenta de ingresos falla en SBODemoCL ("Define the freight revenue account…"; configuración de SAP, no del código); en compras funciona (orden de compra 2885).
2. ~~Pantallas de maestros~~ — hechas (clientes, proveedores y artículos): `features/masters/`, con la misma idea que los documentos pero con clave de texto (`CardCode`, `ItemCode`). Cada pantalla declara su `MasterScreen` (`socios-negocio/socios.screens.ts`, `articulos/articulos.screens.ts`): columnas y filtros de la lista, `baseFilter` (clientes = `CardType eq 'cCustomer'`, proveedores = `cSupplier`; el tipo también se envía al crear con `fixed`), y un `MasterLayout` con código/nombre/estado arriba y los demás campos en **pestañas** (dos columnas, tipos de `HeaderField`: texto, número, fecha, área de texto, casilla `tYES/tNO`, lista de valores numérica o de texto, almacén). El código no se cambia una vez creado (`immutable`); «Inactivo» es `Frozen`. Rutas con `master-paths.ts` + `resolve` diferido; navegación primero/anterior/siguiente/último por clave de texto (`SlMasterApi`). Los campos por cliente van en `socios-negocio/socios.extensions.ts` y `articulos/articulos.extensions.ts` (vacíos en `main`; `header`, `tabs` por `id`, `newTabs`, `listColumns`, y también `hideFields`, `readOnlyFields`, `hideListColumns`; pestaña, campo o ruta inexistente = error). **Tablas en las pestañas de los maestros** (`MasterTab.collection`, `MasterCollection`; componente `sbo-rows-table`): clientes y proveedores tienen **Direcciones** (`BPAddresses`) y **Personas de contacto** (`ContactEmployees`); el artículo, **Precios** (`ItemPrices`) y **Stock por almacén** (`ItemWarehouseInfoCollection`, solo lectura). Cómo se envían (verificado contra SAP con un cliente y un artículo de prueba): `mode: 'replace'` (direcciones, contactos) manda la colección completa —las filas leídas con los cambios aplicados, para conservar sus campos de SAP (`RowNum`, `InternalCode`…), más las nuevas— con `B1S-ReplaceCollectionsOnPatch: true` (así también se puede quitar una fila, si SAP lo permite); `mode: 'merge'` (precios) manda **solo las filas cambiadas**, por `rowKey` (`PriceList`) y sin reemplazo, porque reenviar las demás listas con valores viejos impide que SAP recalcule las derivadas por factor. Las filas vacías (sin `identity`) no se guardan. Probado: cliente `TESTBP01` (dirección de envío añadida) y artículo `TESTITEM01` (precio de la lista 3), ambos de prueba en SBODemoCL. Pendiente en maestros: las extensiones de cliente aún no pueden agregar u ocultar columnas de estas tablas, ni cambiar el tipo de socio. Los catálogos `BusinessPartnerGroups` e `ItemGroups` están registrados en el proxy.
3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`).
4. **Auditoría — en marcha** (`apps/api/src/audit/`, tabla `audit_log` en la misma SQLite): registra inicios de sesión (correctos, fallidos con su motivo —credenciales, usuario inactivo, demasiados intentos— y cierres), cada **creación y modificación** que pasa por el proxy `/api/sl` (recurso, clave, resultado `ok`/`error` con el mensaje de SAP, y **solo los nombres de los campos** y cuántas filas lleva cada colección: nunca valores ni contraseñas), las operaciones **denegadas** por permisos y los cambios de permisos de menú. Las lecturas y las acciones de solo consulta (series) no se auditan. `AuditService.record` nunca lanza (un fallo del registro no cambia la operación del usuario). Consulta solo para administradores: `GET /api/audit` (filtros usuario, acción, recurso, resultado, desde/hasta; 50 por página, máx. 200) y la pantalla `/admin/auditoria` (menú Administración, solo acceso total). Pendiente de afinar: retención (`purgeOlderThan` existe, falta programarlo y su variable de entorno), la IP detrás del proxy de desarrollo (todo llega como loopback; hace falta `xfwd` en el proxy y `trust proxy` solo cuando el backend no esté expuesto), exportar a CSV, y `nestjs-cls` para poner el usuario en cada línea del log.
@@ -9,7 +9,7 @@ import { DOCUMENT_SCREENS } from './document-screens';
const EXPENSES = { value: [{ ExpensCode: 1, Name: 'Seguro' }, { ExpensCode: 2, Name: 'Flete' }] };
describe('flete en el formulario', () => {
describe('gastos adicionales en el formulario', () => {
let fixture: ComponentFixture<DocumentFormPage>;
let ctrl: HttpTestingController;
let navigate: ReturnType<typeof vi.spyOn>;
@@ -60,38 +60,34 @@ describe('flete en el formulario', () => {
beforeEach(() => TestBed.resetTestingModule());
it('muestra la fila Flete con el gasto y el importe que trae SAP, y lo suma al total', async () => {
await open('Orders', order([{ ExpenseCode: 2, LineTotal: 25 }]));
expect([...el().querySelectorAll('label')].map((l) => l.textContent?.trim())).toContain('Flete');
expect(el().querySelector<HTMLSelectElement>('select[aria-label="Gasto adicional del flete"]')?.value).toBe('2');
expect(page().totals()).toMatchObject({ freight: 25, total: 144 });
it('muestra una fila por gasto adicional, con su nombre, y los suma al total', async () => {
await open('Orders', order([{ ExpenseCode: 2, LineTotal: 25 }, { ExpenseCode: 1, LineTotal: 10 }]));
expect(page().expenses()).toHaveLength(2);
expect(el().textContent).toContain('Gastos adicionales');
const values = [...el().querySelectorAll<HTMLInputElement>('input')].map((i) => i.value);
expect(values).toEqual(expect.arrayContaining(['Flete', 'Seguro']));
expect(page().totals()).toMatchObject({ expenses: 35, total: 154 });
});
it('un documento sin flete usa por defecto el gasto llamado flete', async () => {
await open('Orders', order([]));
expect(page().defaultExpense()).toBe('2');
expect(el().querySelector<HTMLSelectElement>('select[aria-label="Gasto adicional del flete"]')?.value).toBe('2');
});
it('crea el documento con DocumentAdditionalExpenses', async () => {
it('crea el documento con todos los gastos en DocumentAdditionalExpenses', async () => {
await open('Orders', null);
page().setHeader('CardCode', 'C1');
page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 100, LineTotal: 100 }]);
page().setHeader('FreightAmount', 1500);
page().setExpenses([{ ExpenseCode: 2, LineTotal: 1500 }, { ExpenseCode: 1, LineTotal: 80, Remarks: 'Seguro mar' }, {}]);
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService'));
const body = req.request.body as Record<string, unknown>;
expect(body['DocumentAdditionalExpenses']).toEqual([{ ExpenseCode: 2, LineTotal: 1500 }]);
expect(body).not.toHaveProperty('FreightAmount');
expect(body).not.toHaveProperty('FreightExpense');
expect((req.request.body as Record<string, unknown>)['DocumentAdditionalExpenses']).toEqual([
{ ExpenseCode: 2, LineTotal: 1500 },
{ ExpenseCode: 1, LineTotal: 80, Remarks: 'Seguro mar' },
]);
req.flush({ DocEntry: 10, DocNum: 10 });
await tick();
expect(navigate).toHaveBeenCalled();
});
it('un documento sin flete no envía la colección', async () => {
it('un documento sin gastos no envía la colección', async () => {
await open('Orders', null);
page().setHeader('CardCode', 'C1');
page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 100, LineTotal: 100 }]);
@@ -102,29 +98,49 @@ describe('flete en el formulario', () => {
expect(body).not.toHaveProperty('DocumentAdditionalExpenses');
});
it('al cambiar el importe envía la colección reemplazándola', async () => {
it('agregar un gasto a un documento guardado envía la colección completa reemplazándola', async () => {
await open('Orders', order([{ ExpenseCode: 2, LineTotal: 25 }]));
page().setHeader('FreightAmount', 40);
expect(page().dirty()).toBe(false);
page().setExpenses([...page().expenses(), { ExpenseCode: 1, LineTotal: 10 }]);
await tick();
expect(page().dirty()).toBe(true);
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'PATCH');
expect(req.request.body).toEqual({ DocumentAdditionalExpenses: [{ ExpenseCode: 2, LineTotal: 25 }, { ExpenseCode: 1, LineTotal: 10 }] });
expect(req.request.headers.get('B1S-ReplaceCollectionsOnPatch')).toBe('true');
req.flush(null);
await tick();
ctrl.expectOne('/api/sl/Orders/9').flush(order([]));
});
it('quitar todos los gastos envía la colección vacía', async () => {
await open('Orders', order([{ ExpenseCode: 2, LineTotal: 25 }]));
page().setExpenses([]);
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'PATCH');
expect(req.request.body).toEqual({ DocumentAdditionalExpenses: [{ ExpenseCode: 2, LineTotal: 40 }] });
expect(req.request.headers.get('B1S-ReplaceCollectionsOnPatch')).toBe('true');
expect(req.request.body).toEqual({ DocumentAdditionalExpenses: [] });
req.flush(null);
await tick();
ctrl.expectOne('/api/sl/Orders/9').flush(order([{ ExpenseCode: 2, LineTotal: 40 }]));
ctrl.expectOne('/api/sl/Orders/9').flush(order([]));
});
it('con varios gastos adicionales en SAP el flete solo se muestra, no se edita', async () => {
await open('Orders', order([{ ExpenseCode: 1, LineTotal: 10 }, { ExpenseCode: 2, LineTotal: 5 }]));
expect(page().freightEditable()).toBe(false);
expect(page().totals().freight).toBe(15);
expect(page().dirty()).toBe(false);
it('pide el gasto y el importe antes de guardar', async () => {
await open('Orders', null);
page().setHeader('CardCode', 'C1');
page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 100, LineTotal: 100 }]);
page().setExpenses([{ LineTotal: 10 }]);
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
ctrl.expectNone((r) => r.method === 'POST' && !r.url.includes('SeriesService'));
expect(el().querySelector('.status')!.textContent).toContain('indique el gasto');
});
it('las entradas de inventario no tienen flete ni piden gastos adicionales', async () => {
it('las entradas de inventario no tienen gastos adicionales', () => {
const screen = DOCUMENT_SCREENS.find((s) => s.resource === 'InventoryGenEntries')!;
expect(JSON.stringify(screen.layout.footer)).not.toContain('freight');
expect(JSON.stringify(screen.layout.footer)).not.toContain('expenses');
});
});
@@ -198,25 +198,21 @@
<sbo-input readonly ariaLabel="Número del documento" [value]="str(doc()?.['DocNum'])" />
</sbo-field>
}
@case ('freight') {
<sbo-field [label]="f.label">
<sbo-select
width="150px"
ariaLabel="Gasto adicional del flete"
placeholder=""
[options]="expenseOptions()"
[readonly]="readOnly() || !freightEditable() || locked(f)"
[value]="str(header()['FreightExpense']) || defaultExpense() || null"
(valueChange)="setHeader('FreightExpense', $event ?? '')"
@case ('expenses') {
<div class="exp">
<h3>{{ f.label }}</h3>
<sbo-rows-table
[columns]="expenseColumns"
[rows]="expenses()"
(rowsChange)="setExpenses($event)"
[readonly]="readOnly() || locked(f)"
[removable]="!readOnly() && !locked(f)"
emptyText="Sin gastos adicionales."
/>
<sbo-number
ariaLabel="Importe del flete"
[decimals]="2"
[readonly]="readOnly() || !freightEditable() || locked(f)"
[value]="+str(header()['FreightAmount'])"
(valueChange)="setHeader('FreightAmount', $event ?? 0)"
/>
</sbo-field>
@if (!readOnly() && !locked(f)) {
<p class="addrow"><button type="button" class="btn" (click)="addExpense()">+ Añadir gasto</button></p>
}
</div>
}
@case ('computed') {
<sbo-text-field [label]="f.label" readonly [value]="computedValue(f.source)" />
@@ -76,7 +76,7 @@ describe('DocumentFormPage', () => {
expect(el().querySelector('.badge.tone-ok')?.textContent?.trim()).toBe('Abierto');
expect(el().querySelector('h1')?.textContent).toContain('1041');
expect(el().querySelectorAll('tbody tr').length).toBeGreaterThanOrEqual(1);
expect(page().totals()).toEqual({ subtotal: 200, discount: 0, net: 200, tax: 38, freight: 0, total: 238 });
expect(page().totals()).toEqual({ subtotal: 200, discount: 0, net: 200, tax: 38, expenses: 0, total: 238 });
expect(buttonText()).toContain('Actualizar');
});
@@ -8,7 +8,7 @@ import { map } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../core/api';
import { formatNumber } from '../../core/format';
import { SlDocumentApi } from '../../core/sl/sl-document';
import { CheckboxField, DateField, LookupField, NumberField, NumberInput, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField } from '../../shared/form';
import { CheckboxField, DateField, LookupField, NumberField, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField } from '../../shared/form';
import type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
import { LinesGrid } from '../../shared/grid/lines-grid';
import type { VhLookup } from '../../shared/value-help/value-help.types';
@@ -19,22 +19,28 @@ import {
type DocRecord,
type NavTarget,
computeTotals,
freightOf,
freightPayload,
EXPENSES_COLLECTION,
type ExpenseRow,
createPayload,
expenseRowsOf,
expensesChanged,
expensesPayload,
isBlankLine,
isDocumentReadOnly,
taxRateMap,
updatePayload,
validateExpenses,
validateLayout,
} from './document-model';
import { SERVICE_DOC_TYPE } from './document-layouts';
import { type ComputedSource, type HeaderField, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasFolio, hasFreight, hasSeries, headerKeysOf, keysOfFields, requiredHeaderOf } from './document-layouts';
import { type ComputedSource, type HeaderField, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasExpenses, hasFolio, hasSeries, headerKeysOf, keysOfFields, requiredHeaderOf } from './document-layouts';
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
import { OBJECT_TYPES } from './document-objects';
import { detailHint, validateLineDetails } from './line-details';
import { ITEMS_MULTI_ADD } from './line-columns';
import { ITEMS_MULTI_ADD, LINE_COLUMNS } from './line-columns';
import { RowsTable } from '../../shared/grid/rows-table';
import type { DetailColumn } from '../../shared/grid/grid.types';
const today = (): string => new Date().toISOString().slice(0, 10);
const DATE_KEYS = new Set(['DocDate', 'DocDueDate', 'RequriedDate', 'TaxDate', 'DueDate']);
@@ -65,13 +71,15 @@ const folioAsNumber = (h: DocRecord): DocRecord => {
@Component({
selector: 'sbo-document-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CheckboxField, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp],
imports: [CheckboxField, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp],
templateUrl: './document-form.page.html',
styles: `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
p { margin: 0; color: var(--quiet); }
a { color: var(--focus); }
.totals { display: grid; gap: 2px; }
.exp h3 { margin: 0 0 4px; font-size: 13px; font-weight: 600; }
.exp .addrow { margin: 6px 0 0; }
.ask { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 4px 0 6px; padding: 4px 8px; background: var(--warn-bg); border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); }
`,
})
@@ -92,12 +100,9 @@ export class DocumentFormPage {
private readonly headerKeys: readonly string[] = headerKeysOf(this.layout);
private readonly hasFolio = hasFolio(this.layout);
private readonly blankHeader: DocRecord = this.layout.newHeader();
private readonly hasFreight = hasFreight(this.layout);
private readonly hasExpenses = hasExpenses(this.layout);
private pick(doc: DocRecord | undefined): DocRecord {
const header = pickHeader(doc, this.headerKeys, this.blankHeader);
if (!doc || !this.hasFreight) return header;
const freight = freightOf(doc);
return { ...header, FreightExpense: freight.expense, FreightAmount: freight.amount };
return pickHeader(doc, this.headerKeys, this.blankHeader);
}
private docLines(doc: DocRecord | undefined): GridLine[] {
return (doc?.[this.lineSpec.key] as GridLine[] | undefined) ?? [];
@@ -120,22 +125,57 @@ export class DocumentFormPage {
: undefined,
);
/** Gastos adicionales de SAP (el flete se registra con uno de ellos). */
/** Gastos adicionales de SAP (flete, seguro…): sus nombres se muestran junto al código de cada fila. */
private readonly expensesResource = httpResource<SlListResponse<DocRecord>>(() =>
this.hasFreight ? { url: `${API_URL}/sl/AdditionalExpenses`, params: { $select: 'ExpensCode,Name', $top: 100 } } : undefined,
this.hasExpenses ? { url: `${API_URL}/sl/AdditionalExpenses`, params: { $select: 'ExpensCode,Name', $top: 200 } } : undefined,
);
protected readonly expenseOptions = computed(() =>
(this.expensesResource.hasValue() ? this.expensesResource.value().value : []).map((e) => ({
value: String(e['ExpensCode']),
label: text(e['Name']) || String(e['ExpensCode']),
})),
private readonly expenseNames = computed(
() => new Map((this.expensesResource.hasValue() ? this.expensesResource.value().value : []).map((e) => [String(e['ExpensCode']), text(e['Name'])])),
);
/** Gasto que se usa si el usuario no elige: el que se llame "flete" o, si no hay, el primero. */
protected readonly defaultExpense = computed(() => {
const options = this.expenseOptions();
return (options.find((o) => /flete|freight/i.test(o.label)) ?? options[0])?.value ?? '';
});
protected readonly freightEditable = computed(() => freightOf(this.doc()).editable);
/** Filas de gastos adicionales; se reinician al cargar o recargar el documento. */
protected readonly expenses = linkedSignal<ExpenseRow[]>(() => expenseRowsOf(this.doc()));
protected readonly expenseColumns: readonly DetailColumn[] = [
{
key: 'ExpenseCode',
title: 'Gasto',
type: 'lookup',
required: true,
width: '90px',
lookup: {
title: 'Gastos adicionales',
resource: 'AdditionalExpenses',
orderby: 'ExpensCode',
valueKey: 'ExpensCode',
valueType: 'number',
columns: [
{ key: 'ExpensCode', title: 'Código', width: '90px', mono: true },
{ key: 'Name', title: 'Nombre' },
],
},
},
{ key: '_Name', title: 'Nombre', type: 'text', width: '170px', computed: (row) => this.expenseNames().get(String(row['ExpenseCode'] ?? '')) ?? '' },
{ key: 'LineTotal', title: 'Importe', type: 'number', decimals: 2, required: true, width: '110px' },
{
key: 'TaxCode',
title: 'Impuesto',
type: 'lookup',
width: '100px',
lookup: {
...(this.layout.columns.find((c) => c.key === 'TaxCode')?.lookup?.config ?? LINE_COLUMNS.tax.lookup!.config),
},
},
{ key: 'Remarks', title: 'Comentarios', type: 'text', width: '180px' },
];
protected setExpenses(rows: ExpenseRow[]): void {
this.expenses.set(rows);
this.message.set(null);
}
protected addExpense(): void {
this.setExpenses([...this.expenses(), {}]);
}
/** Series de numeración de este tipo de documento (acción `SeriesService_GetDocumentSeries` de SAP). */
private readonly hasSeries = hasSeries(this.layout);
@@ -218,9 +258,9 @@ export class DocumentFormPage {
protected readonly dirty = computed(() => {
const d = this.doc();
if (!d) return text(this.header()['CardCode']) !== '' || this.lines().some((l) => !isBlankLine(l, this.serviceMode()));
if (!d) return text(this.header()['CardCode']) !== '' || this.lines().some((l) => !isBlankLine(l, this.serviceMode())) || expensesPayload(this.expenses()).length > 0;
const { body } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.spec());
return Object.keys(body).length > 0;
return Object.keys(body).length > 0 || (this.hasExpenses && expensesChanged(d, this.expenses()));
});
// --- totales -------------------------------------------------------------------------------
@@ -229,7 +269,7 @@ export class DocumentFormPage {
taxRateMap(this.taxResource.hasValue() ? this.taxResource.value().value : []),
);
protected readonly totals = computed(() =>
computeTotals(this.lines(), this.rates(), Number(this.header()['DiscountPercent'] ?? 0), Number(this.header()['FreightAmount'] ?? 0)),
computeTotals(this.lines(), this.rates(), Number(this.header()['DiscountPercent'] ?? 0), this.expenses()),
);
protected readonly fmt = (n: number): string => formatNumber(n, 2);
@@ -374,6 +414,8 @@ export class DocumentFormPage {
return this.partnerTaxId();
case 'subtotal':
return this.fmt(this.totals().subtotal);
case 'expenses':
return this.fmt(this.totals().expenses);
case 'tax':
return this.fmt(this.totals().tax);
case 'total':
@@ -459,6 +501,7 @@ export class DocumentFormPage {
const errors = validateLayout(this.header(), this.lines(), requiredHeaderOf(this.layout), this.layout.requiredLine, this.serviceMode());
if (this.layout.details?.length && !this.serviceMode()) errors.push(...validateLineDetails(this.lines()));
if (this.hasFolio && !/^\d*$/.test(text(this.header()['FolioNumber']).trim())) errors.push('El número de folio debe ser numérico.');
if (this.hasExpenses) errors.push(...validateExpenses(this.expenses()));
if (errors.length) {
this.message.set({ kind: 'err', text: errors[0] });
return;
@@ -469,9 +512,8 @@ export class DocumentFormPage {
const d = this.doc();
if (!d) {
const header = folioAsNumber(this.layout.beforeCreate?.(this.header()) ?? this.header());
const body = this.hasFreight
? freightPayload(createPayload(header, this.lines(), this.spec()), this.header(), this.defaultExpense(), 'create').body
: createPayload(header, this.lines(), this.spec());
const body = createPayload(header, this.lines(), this.spec());
if (this.hasExpenses && expensesPayload(this.expenses()).length) body[EXPENSES_COLLECTION] = expensesPayload(this.expenses());
const created = await this.api.create(this.screen.resource, body);
await this.router.navigate(['..', created['DocEntry']], {
relativeTo: this.route,
@@ -480,12 +522,11 @@ export class DocumentFormPage {
return;
}
const update = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.spec());
let body = folioAsNumber(update.body);
const body = folioAsNumber(update.body);
let replaceLines = update.replaceLines;
if (this.hasFreight) {
const freight = freightPayload(body, this.header(), this.defaultExpense(), 'update', freightOf(d).amount > 0);
body = freight.body;
replaceLines = replaceLines || freight.replace;
if (this.hasExpenses && expensesChanged(d, this.expenses())) {
body[EXPENSES_COLLECTION] = expensesPayload(this.expenses());
replaceLines = true; // SAP reemplaza la colección entera
}
if (!Object.keys(body).length) {
this.message.set({ kind: 'info', text: 'No hay cambios que guardar.' });
@@ -233,7 +233,7 @@ describe('columnas de línea por documento', () => {
it('un documento puede traer su cabecera completa en lugar de la base', () => {
const l = marketingLayout({ ...base, header: { left: [], right: [] } });
expect(headerKeysOf(l)).toEqual(['Comments', 'DiscountPercent', 'FreightExpense', 'FreightAmount']);
expect(headerKeysOf(l)).toEqual(['Comments', 'DiscountPercent']);
});
it('los demás documentos no cambian cuando uno elige columnas', () => {
@@ -7,7 +7,7 @@ import { SERIES_SUBTYPE } from './document-objects';
import { ANALYTIC_COLUMNS, DOCUMENT_LINE_COLUMNS, lineColumnsFor, lineFieldsOf, newDocumentLine, newServiceLine, recalcLine, serviceLineColumns, uomEntryOf } from './line-columns';
/** Valores calculados o del socio que una fila de solo lectura puede mostrar. */
export type ComputedSource = 'docNum' | 'partnerName' | 'partnerTaxId' | 'subtotal' | 'tax' | 'total';
export type ComputedSource = 'docNum' | 'partnerName' | 'partnerTaxId' | 'subtotal' | 'expenses' | 'tax' | 'total';
/**
* Un campo de la cabecera (o del pie) del formulario. La pantalla de cada documento declara su lista
@@ -42,7 +42,8 @@ export type HeaderField =
/** Serie de numeración y número del documento en una misma fila (`Series` y `DocNum`). */
| { type: 'series'; key: string; label: string }
/** Flete: código del gasto adicional y su importe (`DocumentAdditionalExpenses`). */
| { type: 'freight'; label: string }
/** Tabla de gastos adicionales del documento (flete, seguro…): una fila por gasto. */
| { type: 'expenses'; label: string }
| { type: 'computed'; label: string; source: ComputedSource };
/** Las listas de valores (ventanas modales) de un formulario. */
@@ -233,7 +234,7 @@ export function marketingLayout(opts: {
/** Unidad de medida que se toma del artículo: la de venta o la de compra. */
uom: 'sales' | 'purchase';
/** Fila del flete en el pie (por defecto sí). */
freight?: boolean;
expenses?: boolean;
/** Valores que se envían siempre al crear (ej. `DocumentSubType: 'bod_Bill'` en las boletas). */
fixed?: DocRecord;
}): DocumentLayout {
@@ -256,11 +257,11 @@ export function marketingLayout(opts: {
right: [FIELDS.docNumber, FIELDS.docDate, FIELDS.docDueDate, ...(opts.right ?? [])],
},
footer: {
left: [FIELDS.comments],
left: [...(opts.expenses === false ? [] : [{ type: 'expenses', label: 'Gastos adicionales' } as const]), FIELDS.comments],
right: [
{ type: 'computed', label: 'Total antes del descuento', source: 'subtotal' },
{ type: 'number', key: 'DiscountPercent', label: 'Descuento %', decimals: 2 },
...(opts.freight === false ? [] : [{ type: 'freight', label: 'Flete' } as const]),
{ type: 'computed', label: 'Gastos adicionales', source: 'expenses' },
{ type: 'computed', label: 'Impuesto', source: 'tax' },
{ type: 'computed', label: 'Total del documento', source: 'total' },
],
@@ -298,8 +299,8 @@ export const keysOfFields = (fields: readonly HeaderField[]): string[] =>
return [];
case 'folio':
return [f.prefixKey, f.numberKey];
case 'freight':
return ['FreightExpense', 'FreightAmount'];
case 'expenses':
return [];
default:
return [f.key];
}
@@ -322,6 +323,6 @@ export function isRequired(f: HeaderField): f is HeaderField & { key: string; re
export const hasSeries = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'series');
export const hasFreight = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'freight');
export const hasExpenses = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'expenses');
export const hasFolio = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'folio');
@@ -9,8 +9,10 @@ import {
taxRateMap,
updatePayload,
validateLayout,
freightOf,
freightPayload,
expenseRowsOf,
expensesChanged,
expensesPayload,
validateExpenses,
} from './document-model';
const line = (over: Record<string, unknown> = {}) => ({
@@ -37,12 +39,12 @@ describe('computeTotals', () => {
it('suma líneas e impuesto por tasa', () => {
const t = computeTotals([line(), line({ TaxCode: 'EXE', LineTotal: 100 })], rates, 0);
expect(t).toEqual({ subtotal: 300, discount: 0, net: 300, tax: 38, freight: 0, total: 338 });
expect(t).toEqual({ subtotal: 300, discount: 0, net: 300, tax: 38, expenses: 0, total: 338 });
});
it('aplica el descuento de cabecera antes del impuesto', () => {
const t = computeTotals([line()], rates, 10);
expect(t).toEqual({ subtotal: 200, discount: 20, net: 180, tax: 34.2, freight: 0, total: 214.2 });
expect(t).toEqual({ subtotal: 200, discount: 20, net: 180, tax: 34.2, expenses: 0, total: 214.2 });
});
it('un código de impuesto desconocido no suma impuesto', () => {
@@ -50,7 +52,7 @@ describe('computeTotals', () => {
});
it('sin líneas, todo en cero', () => {
expect(computeTotals([], rates, 0)).toEqual({ subtotal: 0, discount: 0, net: 0, tax: 0, freight: 0, total: 0 });
expect(computeTotals([], rates, 0)).toEqual({ subtotal: 0, discount: 0, net: 0, tax: 0, expenses: 0, total: 0 });
});
});
@@ -154,40 +156,38 @@ describe('navigationQuery', () => {
});
});
describe('flete (gastos adicionales)', () => {
describe('gastos adicionales', () => {
const doc = (rows: Record<string, unknown>[]) => ({ DocumentAdditionalExpenses: rows });
it('suma el flete al total del documento', () => {
it('suman al total del documento, con el impuesto de cada gasto', () => {
const rates = taxRateMap([{ Code: 'IVA', Rate: 19 }]);
const t = computeTotals([line({ LineTotal: 100, TaxCode: 'IVA' })], rates, 0, 25);
expect(t).toMatchObject({ subtotal: 100, tax: 19, freight: 25, total: 144 });
const t = computeTotals([line({ LineTotal: 100, TaxCode: 'IVA' })], rates, 0, [{ ExpenseCode: 1, LineTotal: 25 }, { ExpenseCode: 2, LineTotal: 100, TaxCode: 'IVA' }]);
expect(t).toMatchObject({ subtotal: 100, expenses: 125, tax: 38, total: 263 });
});
it('lee el flete de SAP: código, importe y si se puede editar', () => {
expect(freightOf(doc([{ ExpenseCode: 2, LineTotal: 1500 }]))).toEqual({ expense: '2', amount: 1500, editable: true });
expect(freightOf(undefined)).toEqual({ expense: '', amount: 0, editable: true });
expect(freightOf(doc([{ ExpenseCode: 1, LineTotal: 10 }, { ExpenseCode: 2, LineTotal: 5 }])).editable).toBe(false);
it('lee las filas de SAP para editarlas', () => {
expect(expenseRowsOf(doc([{ ExpenseCode: 2, LineTotal: 1500 }, { ExpenseCode: 1, LineTotal: 10 }]))).toHaveLength(2);
expect(expenseRowsOf(undefined)).toEqual([]);
});
it('al crear arma DocumentAdditionalExpenses con el gasto por defecto si no se eligió', () => {
const out = freightPayload({ CardCode: 'C1', FreightAmount: 1500, FreightExpense: '' }, { FreightAmount: 1500, FreightExpense: '' }, '2', 'create');
expect(out.body).toEqual({ CardCode: 'C1', DocumentAdditionalExpenses: [{ ExpenseCode: 2, LineTotal: 1500 }] });
expect(out.replace).toBe(true);
it('arma la colección con una fila por gasto, sin filas en blanco ni campos vacíos', () => {
expect(expensesPayload([{ ExpenseCode: '2', LineTotal: 1500.456, TaxCode: '', Remarks: 'Maritimo' }, { ExpenseCode: 1, LineTotal: 10, TaxCode: 'IGV' }, {}, { ExpenseCode: '', LineTotal: 0 }])).toEqual([
{ ExpenseCode: 2, LineTotal: 1500.46, Remarks: 'Maritimo' },
{ ExpenseCode: 1, LineTotal: 10, TaxCode: 'IGV' },
]);
});
it('al crear sin importe no envía flete', () => {
const out = freightPayload({ CardCode: 'C1', FreightAmount: 0 }, { FreightAmount: 0 }, '2', 'create');
expect(out.body).toEqual({ CardCode: 'C1' });
expect(out.replace).toBe(false);
it('detecta si cambiaron respecto del documento', () => {
const original = doc([{ ExpenseCode: 2, LineTotal: 25, LineNum: 0, Status: 'bost_Open' }]);
expect(expensesChanged(original, expenseRowsOf(original))).toBe(false);
expect(expensesChanged(original, [{ ExpenseCode: 2, LineTotal: 40 }])).toBe(true);
expect(expensesChanged(original, [])).toBe(true); // quitar el gasto también es un cambio
expect(expensesChanged(undefined, [{}])).toBe(false);
});
it('al modificar solo envía el flete si cambió, y con importe 0 lo quita', () => {
expect(freightPayload({ Comments: 'x' }, { FreightAmount: 10 }, '2', 'update', true)).toEqual({ body: { Comments: 'x' }, replace: false });
const changed = freightPayload({ FreightAmount: 300 }, { FreightAmount: 300, FreightExpense: '3' }, '2', 'update', true);
expect(changed).toEqual({ body: { DocumentAdditionalExpenses: [{ ExpenseCode: 3, LineTotal: 300 }] }, replace: true });
const cleared = freightPayload({ FreightAmount: 0 }, { FreightAmount: 0, FreightExpense: '3' }, '2', 'update', true);
expect(cleared).toEqual({ body: { DocumentAdditionalExpenses: [] }, replace: true });
expect(freightPayload({ FreightAmount: 0 }, { FreightAmount: 0 }, '2', 'update', false).body).toEqual({});
it('pide el gasto y un importe mayor que cero', () => {
expect(validateExpenses([{ ExpenseCode: 2, LineTotal: 10 }, {}])).toEqual([]);
expect(validateExpenses([{ LineTotal: 10 }, { ExpenseCode: 2, LineTotal: 0 }])).toEqual(['Gasto adicional 1: indique el gasto.', 'Gasto adicional 2: el importe debe ser mayor que cero.']);
});
});
@@ -26,8 +26,8 @@ export function isDocumentReadOnly(doc: DocRecord | null | undefined): boolean {
export const FREIGHT_COLLECTION = 'DocumentAdditionalExpenses';
export interface DocTotals {
/** Flete (gastos adicionales) antes de impuestos. */
freight: number;
/** Gastos adicionales (flete, seguro…) antes de impuestos. */
expenses: number;
/** Suma de los totales de línea (ya con el descuento de cada línea). */
subtotal: number;
/** Descuento del documento (% de cabecera aplicado al subtotal). */
@@ -51,7 +51,7 @@ export function computeTotals(
lines: readonly GridLine[],
rates: ReadonlyMap<string, number>,
discountPercent: number,
freight = 0,
expenseRows: readonly ExpenseRow[] = [],
): DocTotals {
const factor = 1 - discountPercent / 100;
let subtotal = 0;
@@ -61,54 +61,61 @@ export function computeTotals(
subtotal += lineTotal;
tax += lineTotal * factor * (rates.get(String(line['TaxCode'] ?? '')) ?? 0) / 100;
}
let expenses = 0;
for (const e of expenseRows) {
const amount = num(e['LineTotal']);
expenses += amount;
tax += amount * (rates.get(String(e['TaxCode'] ?? '')) ?? 0) / 100;
}
const net = subtotal * factor;
return {
subtotal: round2(subtotal),
discount: round2(subtotal - net),
net: round2(net),
tax: round2(tax),
freight: round2(freight),
total: round2(net + tax + freight),
expenses: round2(expenses),
total: round2(net + tax + expenses),
};
}
// --- flete ---------------------------------------------------------------------------------
// --- gastos adicionales -----------------------------------------------------------------------
/** Flete del documento tal como se edita: el código del gasto y el importe (`FreightExpense`, `FreightAmount`). */
export function freightOf(doc: DocRecord | undefined): { expense: string; amount: number; editable: boolean } {
const rows = (doc?.[FREIGHT_COLLECTION] as DocRecord[] | undefined) ?? [];
return {
expense: rows[0]?.['ExpenseCode'] === undefined || rows[0]?.['ExpenseCode'] === null ? '' : String(rows[0]['ExpenseCode']),
amount: round2(rows.reduce((sum, r) => sum + num(r['LineTotal']), 0)),
// con varios gastos adicionales (cargados en SAP) el flete solo se muestra: no se puede reducir a una fila
editable: rows.length <= 1,
};
/** Gastos adicionales del documento (flete, seguro…): colección `DocumentAdditionalExpenses` de SAP, una fila por gasto. */
export const EXPENSES_COLLECTION = 'DocumentAdditionalExpenses';
/** Una fila de gastos adicionales en edición (código del gasto, importe, impuesto y comentarios). */
export type ExpenseRow = Record<string, unknown>;
/** Las filas de gastos adicionales de un documento de SAP, para editarlas. */
export const expenseRowsOf = (doc: DocRecord | undefined): ExpenseRow[] => ((doc?.[EXPENSES_COLLECTION] as DocRecord[] | undefined) ?? []).map((r) => ({ ...r }));
/** Una fila sin gasto ni importe (la que se añadió y no se llenó) no se envía. */
const isBlankExpense = (r: ExpenseRow): boolean => isEmpty(r['ExpenseCode']) && num(r['LineTotal']) === 0;
/** Filas listas para SAP: sin filas en blanco ni campos vacíos. */
export function expensesPayload(rows: readonly ExpenseRow[]): DocRecord[] {
return rows
.filter((r) => !isBlankExpense(r))
.map((r) => {
const out: DocRecord = { ExpenseCode: Number(r['ExpenseCode']), LineTotal: round2(num(r['LineTotal'])) };
if (!isEmpty(r['TaxCode'])) out['TaxCode'] = r['TaxCode'];
if (!isEmpty(r['Remarks'])) out['Remarks'] = r['Remarks'];
return out;
});
}
const FREIGHT_KEYS = ['FreightExpense', 'FreightAmount'] as const;
const withoutFreight = (r: DocRecord): DocRecord => Object.fromEntries(Object.entries(r).filter(([k]) => !(FREIGHT_KEYS as readonly string[]).includes(k)));
/** Las filas cambiaron respecto de las del documento (lo que ya no se envía no cuenta). */
export const expensesChanged = (original: DocRecord | undefined, rows: readonly ExpenseRow[]): boolean =>
JSON.stringify(expensesPayload(rows)) !== JSON.stringify(expensesPayload(expenseRowsOf(original)));
/**
* Convierte los campos del flete del formulario (`FreightExpense`, `FreightAmount`) en la colección
* `DocumentAdditionalExpenses` que espera SAP. `expense` es el código elegido o, si no eligió, el
* de flete por defecto. Al crear, sin importe no se envía nada; al modificar solo si cambió (con
* `replace`, porque SAP reemplaza la colección entera) y un importe 0 quita el flete.
*/
export function freightPayload(
body: DocRecord,
header: DocRecord,
expense: string,
mode: 'create' | 'update',
hadFreight = false,
): { body: DocRecord; replace: boolean } {
const touched = FREIGHT_KEYS.some((k) => k in body);
if (mode === 'update' && !touched) return { body, replace: false };
const amount = round2(num(header['FreightAmount']));
const code = isEmpty(header['FreightExpense']) ? expense : String(header['FreightExpense']);
const rest = withoutFreight(body);
if (amount > 0 && code !== '') return { body: { ...rest, [FREIGHT_COLLECTION]: [{ ExpenseCode: Number(code), LineTotal: amount }] }, replace: true };
if (mode === 'update' && hadFreight) return { body: { ...rest, [FREIGHT_COLLECTION]: [] }, replace: true };
return { body: rest, replace: false };
/** Errores que se pueden detectar sin preguntarle a SAP. */
export function validateExpenses(rows: readonly ExpenseRow[]): string[] {
const errors: string[] = [];
rows.filter((r) => !isBlankExpense(r)).forEach((r, i) => {
if (isEmpty(r['ExpenseCode']) || !Number.isFinite(Number(r['ExpenseCode']))) errors.push(`Gasto adicional ${i + 1}: indique el gasto.`);
if (num(r['LineTotal']) <= 0) errors.push(`Gasto adicional ${i + 1}: el importe debe ser mayor que cero.`);
});
return errors;
}
// --- guardado -----------------------------------------------------------------------------
@@ -44,7 +44,7 @@ const withFields = (base: FieldColumns, left?: readonly HeaderField[], right?: r
});
/** Nombres con que se puede referir a un campo del formulario: sus claves de SAP y, si es calculado, su origen. */
const namesOf = (f: HeaderField): string[] => [...keysOfFields([f]), ...(f.type === 'computed' ? [f.source] : [])];
const namesOf = (f: HeaderField): string[] => [...keysOfFields([f]), ...(f.type === 'computed' ? [f.source] : []), ...(f.type === 'expenses' ? ['DocumentAdditionalExpenses'] : [])];
const allFieldsOf = (layout: DocumentLayout): HeaderField[] => [
...layout.header.left,