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:
1 parent
3c1d02a263
commit
6c2cb30d63
10 files changed
+223
-162
No files matched your search
@@ -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.
|
||||
|
||||
+47
-31
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user