feat(web): fila del flete (gastos adicionales) y toolbar de ancho completo; la configuración de pantallas se carga en diferido
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d3ab97cd2e
commit
d6c127627f
14 files changed
+360
-27
No files matched your search
@@ -19,7 +19,7 @@ Monorepo npm workspaces:
|
|||||||
|
|
||||||
## Criterios de la interfaz
|
## Criterios de la interfaz
|
||||||
|
|
||||||
Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de componentes (Material/PrimeNG), sin webfonts ni animaciones, componentes propios y densos (13 px), zoneless + `OnPush`, rutas con carga diferida, listas largas paginadas en el servidor (50 por página). Presupuesto del bundle inicial en `angular.json` (aviso 300 kB; hoy ~287 kB, 81 kB comprimido: `httpResource` aporta ~18 kB al chunk compartido; si hace falta recuperarlos, reemplazarlo por RxJS).
|
Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de componentes (Material/PrimeNG), sin webfonts ni animaciones, componentes propios y densos (13 px), zoneless + `OnPush`, rutas con carga diferida, listas largas paginadas en el servidor (50 por página). Presupuesto del bundle inicial en `angular.json` (aviso 300 kB; hoy ~286 kB, 81 kB comprimido; la configuración de las pantallas de documento se carga en diferido con un `resolve`, y `document-paths.ts` es la única lista que el enrutador necesita al arrancar: `httpResource` aporta ~18 kB al chunk compartido; si hace falta recuperarlos, reemplazarlo por RxJS).
|
||||||
|
|
||||||
## Comandos
|
## Comandos
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ Pendiente, en orden:
|
|||||||
|
|
||||||
**Campos por cliente (estándar adicionales o `U_*`):** no se editan las pantallas base. Cada módulo tiene su archivo de extensiones, vacío en `main`: `ventas/ventas.extensions.ts`, `compras/compras.extensions.ts` e `inventario/inventario.extensions.ts` (reunidos en `client-extensions.ts`). La rama `cliente/<nombre>` declara ahí, por ruta de pantalla, qué suma: `headerLeft`/`headerRight`/`footerLeft`/`footerRight` (campos; tipos `text`, `date`, `number`, `textarea`, `select` con `options`, `warehouse`…), `lineColumns` (se agregan antes del total y se envían a SAP) y `listColumns`. Solo suma, no quita; una ruta inexistente o ajena al módulo lanza un error al arrancar. El test «el proyecto base no usa campos de usuario» revisa `BASE_SCREENS`, no las extensiones. Verificado: el proxy `/api/sl` deja pasar nombres `U_*` en `$select`/`$orderby` y en el cuerpo (el error de un campo inexistente lo da SAP). Límite: una columna de línea de usuario con lista de valores válidos aún no tiene editor desplegable (solo texto/número).
|
**Campos por cliente (estándar adicionales o `U_*`):** no se editan las pantallas base. Cada módulo tiene su archivo de extensiones, vacío en `main`: `ventas/ventas.extensions.ts`, `compras/compras.extensions.ts` e `inventario/inventario.extensions.ts` (reunidos en `client-extensions.ts`). La rama `cliente/<nombre>` declara ahí, por ruta de pantalla, qué suma: `headerLeft`/`headerRight`/`footerLeft`/`footerRight` (campos; tipos `text`, `date`, `number`, `textarea`, `select` con `options`, `warehouse`…), `lineColumns` (se agregan antes del total y se envían a SAP) y `listColumns`. Solo suma, no quita; una ruta inexistente o ajena al módulo lanza un error al arrancar. El test «el proyecto base no usa campos de usuario» revisa `BASE_SCREENS`, no las extensiones. Verificado: el proxy `/api/sl` deja pasar nombres `U_*` en `$select`/`$orderby` y en el cuerpo (el error de un campo inexistente lo da SAP). Límite: una columna de línea de usuario con lista de valores válidos aún no tiene editor desplegable (solo texto/número).
|
||||||
|
|
||||||
**Columnas de las ventanas de selección (cliente, artículo, almacén, impuesto):** son configurables por documento. En una pantalla del base (ventas/compras): `lookups: { item: [...], partner: [...], warehouse: [...], tax: [...] }` en el último parámetro de `marketing(...)` *sustituye* las columnas de esa lista (la primera columna es el código que se escribe en el campo). En la rama del cliente: `lookupColumns` en su archivo de extensiones *suma* columnas al final (por ejemplo `U_Marca` en artículos). El recurso, el filtro y el orden de cada lista no cambian; para los documentos de inventario o solicitudes se usa `withLookupColumns(layout, cols, 'replace' | 'add')`. Las columnas de almacén aplican también al almacén de la cabecera y al de origen de una transferencia. Los formularios con socio muestran `NumAtCard` y el RUT (`FederalTaxID`, solo lectura: lo completa SAP desde el socio); facturas, notas de crédito, entregas y recepciones (`FOLIO_PATHS` en `document-screens.ts`) añaden el folio tributario, que en el Service Layer es `FolioPrefixString` + `FolioNumber` (se edita como texto y se envía como número); las listas de facturas también los muestran. Falta: botón PDF, fila del flete y toolbar de ancho completo.
|
**Columnas de las ventanas de selección (cliente, artículo, almacén, impuesto):** son configurables por documento. En una pantalla del base (ventas/compras): `lookups: { item: [...], partner: [...], warehouse: [...], tax: [...] }` en el último parámetro de `marketing(...)` *sustituye* las columnas de esa lista (la primera columna es el código que se escribe en el campo). En la rama del cliente: `lookupColumns` en su archivo de extensiones *suma* columnas al final (por ejemplo `U_Marca` en artículos). El recurso, el filtro y el orden de cada lista no cambian; para los documentos de inventario o solicitudes se usa `withLookupColumns(layout, cols, 'replace' | 'add')`. Las columnas de almacén aplican también al almacén de la cabecera y al de origen de una transferencia. Los formularios con socio muestran `NumAtCard` y el RUT (`FederalTaxID`, solo lectura: lo completa SAP desde el socio); facturas, notas de crédito, entregas y recepciones (`FOLIO_PATHS` en `document-screens.ts`) añaden el folio tributario, que en el Service Layer es `FolioPrefixString` + `FolioNumber` (se edita como texto y se envía como número); las listas de facturas también los muestran. 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.
|
||||||
2. Pantallas de maestros (`Items`, `BusinessPartners`: Clientes/Proveedores, Artículos), hoy "no disponible".
|
2. Pantallas de maestros (`Items`, `BusinessPartners`: Clientes/Proveedores, Artículos), hoy "no disponible".
|
||||||
3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`).
|
3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`).
|
||||||
4. Auditoría (`audit_log` en SQLite; hoy solo líneas `AUDIT` en el log) y `nestjs-cls` para el usuario en los logs.
|
4. Auditoría (`audit_log` en SQLite; hoy solo líneas `AUDIT` en el log) y `nestjs-cls` para el usuario en los logs.
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export const SL_RESOURCES: readonly SlResource[] = [
|
|||||||
catalog('SalesPersons', 'number'),
|
catalog('SalesPersons', 'number'),
|
||||||
catalog('PriceLists', 'number'),
|
catalog('PriceLists', 'number'),
|
||||||
catalog('UnitOfMeasurements', 'number'),
|
catalog('UnitOfMeasurements', 'number'),
|
||||||
|
// Gastos adicionales de un documento (el flete): se leen para elegir el código de gasto.
|
||||||
|
catalog('AdditionalExpenses', 'number'),
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SL_RESOURCE_MAP: ReadonlyMap<string, SlResource> = new Map(SL_RESOURCES.map((r) => [r.name, r]));
|
export const SL_RESOURCE_MAP: ReadonlyMap<string, SlResource> = new Map(SL_RESOURCES.map((r) => [r.name, r]));
|
||||||
@@ -2,6 +2,7 @@ import { MENU } from '@basic-erp/sap-types';
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { permissionGuard } from './core/auth/guards';
|
import { permissionGuard } from './core/auth/guards';
|
||||||
|
import { DOCUMENT_PATHS } from './features/documents/document-paths';
|
||||||
import { DOCUMENT_SCREENS } from './features/documents/document-screens';
|
import { DOCUMENT_SCREENS } from './features/documents/document-screens';
|
||||||
|
|
||||||
const shell = routes.find((r) => r.path === '')!;
|
const shell = routes.find((r) => r.path === '')!;
|
||||||
@@ -21,7 +22,19 @@ describe('rutas', () => {
|
|||||||
for (const screen of DOCUMENT_SCREENS) {
|
for (const screen of DOCUMENT_SCREENS) {
|
||||||
const route = children.find((r) => r.path === screen.path.slice(1))!;
|
const route = children.find((r) => r.path === screen.path.slice(1))!;
|
||||||
expect(route.children?.map((c) => c.path), screen.path).toEqual(['', 'nuevo', ':docEntry']);
|
expect(route.children?.map((c) => c.path), screen.path).toEqual(['', 'nuevo', ':docEntry']);
|
||||||
expect(route.data?.['screen']).toBe(screen);
|
expect(route.resolve?.['screen'], screen.path).toBeDefined(); // la configuración se carga al abrir la pantalla
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la lista de rutas de documento del enrutador coincide con las pantallas', () => {
|
||||||
|
expect([...DOCUMENT_PATHS].sort()).toEqual(DOCUMENT_SCREENS.map((s) => s.path).sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('el resolver entrega la pantalla de su ruta', async () => {
|
||||||
|
for (const path of ['/ventas/ordenes', '/inventario/entradas']) {
|
||||||
|
const route = children.find((r) => r.path === path.slice(1))!;
|
||||||
|
const screen = await (route.resolve!['screen'] as () => Promise<{ path: string }>)();
|
||||||
|
expect(screen.path).toBe(path);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,24 @@ import { isDevMode } from '@angular/core';
|
|||||||
import type { Route, Routes } from '@angular/router';
|
import type { Route, Routes } from '@angular/router';
|
||||||
import { MENU } from '@basic-erp/sap-types';
|
import { MENU } from '@basic-erp/sap-types';
|
||||||
import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards';
|
import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards';
|
||||||
import { DOCUMENT_SCREENS, menuLabel } from './features/documents/document-screens';
|
import { DOCUMENT_PATHS } from './features/documents/document-paths';
|
||||||
|
|
||||||
|
const menuLabel = (path: string): string => MENU.flatMap((m) => m.items).find((i) => i.path === path)?.label ?? path;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pantallas de documento: la ruta del menú muestra la lista de los últimos documentos;
|
* Pantallas de documento: la ruta del menú muestra la lista de los últimos documentos;
|
||||||
* `nuevo` abre el formulario vacío y `:docEntry` el documento para ver o editar.
|
* `nuevo` abre el formulario vacío y `:docEntry` el documento para ver o editar.
|
||||||
* El permiso de menú gobierna las tres (el guard del padre; `data` se hereda en los hijos).
|
* El permiso de menú gobierna las tres (el guard del padre; `data` se hereda en los hijos).
|
||||||
|
* La configuración de la pantalla (campos, columnas) se carga en diferido: no va en el bundle inicial.
|
||||||
*/
|
*/
|
||||||
const documentRoutes: Routes = DOCUMENT_SCREENS.map(
|
const documentRoutes: Routes = DOCUMENT_PATHS.map(
|
||||||
(screen): Route => ({
|
(path): Route => ({
|
||||||
path: screen.path.slice(1),
|
path: path.slice(1),
|
||||||
canActivate: [permissionGuard],
|
canActivate: [permissionGuard],
|
||||||
data: { path: screen.path, title: menuLabel(screen.path), screen },
|
data: { path, title: menuLabel(path) },
|
||||||
|
resolve: {
|
||||||
|
screen: () => import('./features/documents/document-screens').then((m) => m.DOCUMENT_SCREENS.find((s) => s.path === path)!),
|
||||||
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
@@ -54,7 +60,7 @@ const adminRoutes: Routes = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
|
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
|
||||||
const builtPaths = new Set([...DOCUMENT_SCREENS.map((s) => s.path), '/admin/permisos']);
|
const builtPaths = new Set([...DOCUMENT_PATHS, '/admin/permisos']);
|
||||||
const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
|
const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
|
||||||
.filter((item) => !builtPaths.has(item.path))
|
.filter((item) => !builtPaths.has(item.path))
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<sbo-toolbar aria-label="Acciones del documento">
|
<sbo-toolbar bleed aria-label="Acciones del documento">
|
||||||
<a class="tb" routerLink=".." title="Volver a la lista"><sbo-icon name="find" />Lista</a>
|
<a class="tb" routerLink=".." title="Volver a la lista"><sbo-icon name="find" />Lista</a>
|
||||||
<a class="tb" [routerLink]="isNew() ? '.' : '../nuevo'" title="Documento nuevo"><sbo-icon name="new" />Nuevo</a>
|
<a class="tb" [routerLink]="isNew() ? '.' : '../nuevo'" title="Documento nuevo"><sbo-icon name="new" />Nuevo</a>
|
||||||
<span class="tb-sep"></span>
|
<span class="tb-sep"></span>
|
||||||
@@ -156,6 +156,26 @@
|
|||||||
/>
|
/>
|
||||||
</sbo-field>
|
</sbo-field>
|
||||||
}
|
}
|
||||||
|
@case ('freight') {
|
||||||
|
<sbo-field [label]="f.label">
|
||||||
|
<sbo-select
|
||||||
|
width="150px"
|
||||||
|
ariaLabel="Gasto adicional del flete"
|
||||||
|
placeholder=""
|
||||||
|
[options]="expenseOptions()"
|
||||||
|
[readonly]="readOnly() || !freightEditable()"
|
||||||
|
[value]="str(header()['FreightExpense']) || defaultExpense() || null"
|
||||||
|
(valueChange)="setHeader('FreightExpense', $event ?? '')"
|
||||||
|
/>
|
||||||
|
<sbo-number
|
||||||
|
ariaLabel="Importe del flete"
|
||||||
|
[decimals]="2"
|
||||||
|
[readonly]="readOnly() || !freightEditable()"
|
||||||
|
[value]="+str(header()['FreightAmount'])"
|
||||||
|
(valueChange)="setHeader('FreightAmount', $event ?? 0)"
|
||||||
|
/>
|
||||||
|
</sbo-field>
|
||||||
|
}
|
||||||
@case ('computed') {
|
@case ('computed') {
|
||||||
<sbo-text-field [label]="f.label" readonly [value]="computedValue(f.source)" />
|
<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('.badge.tone-ok')?.textContent?.trim()).toBe('Abierto');
|
||||||
expect(el().querySelector('h1')?.textContent).toContain('1041');
|
expect(el().querySelector('h1')?.textContent).toContain('1041');
|
||||||
expect(el().querySelectorAll('tbody tr').length).toBeGreaterThanOrEqual(1);
|
expect(el().querySelectorAll('tbody tr').length).toBeGreaterThanOrEqual(1);
|
||||||
expect(page().totals()).toEqual({ subtotal: 200, discount: 0, net: 200, tax: 38, total: 238 });
|
expect(page().totals()).toEqual({ subtotal: 200, discount: 0, net: 200, tax: 38, freight: 0, total: 238 });
|
||||||
expect(buttonText()).toContain('Actualizar');
|
expect(buttonText()).toContain('Actualizar');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { map } from 'rxjs';
|
|||||||
import { API_URL, apiErrorMessage } from '../../core/api';
|
import { API_URL, apiErrorMessage } from '../../core/api';
|
||||||
import { formatNumber } from '../../core/format';
|
import { formatNumber } from '../../core/format';
|
||||||
import { SlDocumentApi } from '../../core/sl/sl-document';
|
import { SlDocumentApi } from '../../core/sl/sl-document';
|
||||||
import { DateField, LookupField, NumberField, SboField, SelectField, TextField, TextInput, TextareaField } from '../../shared/form';
|
import { DateField, LookupField, NumberField, NumberInput, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField } from '../../shared/form';
|
||||||
import type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
|
import type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
|
||||||
import { LinesGrid } from '../../shared/grid/lines-grid';
|
import { LinesGrid } from '../../shared/grid/lines-grid';
|
||||||
import type { VhLookup } from '../../shared/value-help/value-help.types';
|
import type { VhLookup } from '../../shared/value-help/value-help.types';
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
type DocRecord,
|
type DocRecord,
|
||||||
type NavTarget,
|
type NavTarget,
|
||||||
computeTotals,
|
computeTotals,
|
||||||
|
freightOf,
|
||||||
|
freightPayload,
|
||||||
createPayload,
|
createPayload,
|
||||||
isBlankLine,
|
isBlankLine,
|
||||||
isDocumentReadOnly,
|
isDocumentReadOnly,
|
||||||
@@ -26,7 +28,7 @@ import {
|
|||||||
updatePayload,
|
updatePayload,
|
||||||
validateLayout,
|
validateLayout,
|
||||||
} from './document-model';
|
} from './document-model';
|
||||||
import { type ComputedSource, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasFolio, headerKeysOf, requiredHeaderOf } from './document-layouts';
|
import { type ComputedSource, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasFolio, hasFreight, headerKeysOf, requiredHeaderOf } from './document-layouts';
|
||||||
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
|
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
|
||||||
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
|
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
|
||||||
import { ITEMS_MULTI_ADD } from './line-columns';
|
import { ITEMS_MULTI_ADD } from './line-columns';
|
||||||
@@ -60,7 +62,7 @@ const folioAsNumber = (h: DocRecord): DocRecord => {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'sbo-document-form-page',
|
selector: 'sbo-document-form-page',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, RouterLink, SboField, SelectField, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp],
|
imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp],
|
||||||
templateUrl: './document-form.page.html',
|
templateUrl: './document-form.page.html',
|
||||||
styles: `
|
styles: `
|
||||||
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
|
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
|
||||||
@@ -83,8 +85,12 @@ export class DocumentFormPage {
|
|||||||
private readonly headerKeys: readonly string[] = headerKeysOf(this.layout);
|
private readonly headerKeys: readonly string[] = headerKeysOf(this.layout);
|
||||||
private readonly hasFolio = hasFolio(this.layout);
|
private readonly hasFolio = hasFolio(this.layout);
|
||||||
private readonly blankHeader: DocRecord = this.layout.newHeader();
|
private readonly blankHeader: DocRecord = this.layout.newHeader();
|
||||||
|
private readonly hasFreight = hasFreight(this.layout);
|
||||||
private pick(doc: DocRecord | undefined): DocRecord {
|
private pick(doc: DocRecord | undefined): DocRecord {
|
||||||
return pickHeader(doc, this.headerKeys, this.blankHeader);
|
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 };
|
||||||
}
|
}
|
||||||
private docLines(doc: DocRecord | undefined): GridLine[] {
|
private docLines(doc: DocRecord | undefined): GridLine[] {
|
||||||
return (doc?.[this.lineSpec.key] as GridLine[] | undefined) ?? [];
|
return (doc?.[this.lineSpec.key] as GridLine[] | undefined) ?? [];
|
||||||
@@ -107,6 +113,23 @@ export class DocumentFormPage {
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Gastos adicionales de SAP (el flete se registra con uno de ellos). */
|
||||||
|
private readonly expensesResource = httpResource<SlListResponse<DocRecord>>(() =>
|
||||||
|
this.hasFreight ? { url: `${API_URL}/sl/AdditionalExpenses`, params: { $select: 'ExpensCode,Name', $top: 100 } } : 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']),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
/** 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);
|
||||||
|
|
||||||
protected readonly doc = computed(() => (this.docResource.hasValue() ? this.docResource.value() : undefined));
|
protected readonly doc = computed(() => (this.docResource.hasValue() ? this.docResource.value() : undefined));
|
||||||
protected readonly loading = computed(() => this.docResource.isLoading());
|
protected readonly loading = computed(() => this.docResource.isLoading());
|
||||||
protected readonly loadError = computed(() => {
|
protected readonly loadError = computed(() => {
|
||||||
@@ -170,7 +193,7 @@ export class DocumentFormPage {
|
|||||||
taxRateMap(this.taxResource.hasValue() ? this.taxResource.value().value : []),
|
taxRateMap(this.taxResource.hasValue() ? this.taxResource.value().value : []),
|
||||||
);
|
);
|
||||||
protected readonly totals = computed(() =>
|
protected readonly totals = computed(() =>
|
||||||
computeTotals(this.lines(), this.rates(), Number(this.header()['DiscountPercent'] ?? 0)),
|
computeTotals(this.lines(), this.rates(), Number(this.header()['DiscountPercent'] ?? 0), Number(this.header()['FreightAmount'] ?? 0)),
|
||||||
);
|
);
|
||||||
protected readonly fmt = (n: number): string => formatNumber(n, 2);
|
protected readonly fmt = (n: number): string => formatNumber(n, 2);
|
||||||
|
|
||||||
@@ -355,7 +378,10 @@ export class DocumentFormPage {
|
|||||||
const d = this.doc();
|
const d = this.doc();
|
||||||
if (!d) {
|
if (!d) {
|
||||||
const header = folioAsNumber(this.layout.beforeCreate?.(this.header()) ?? this.header());
|
const header = folioAsNumber(this.layout.beforeCreate?.(this.header()) ?? this.header());
|
||||||
const created = await this.api.create(this.screen.resource, createPayload(header, this.lines(), this.lineSpec));
|
const body = this.hasFreight
|
||||||
|
? freightPayload(createPayload(header, this.lines(), this.lineSpec), this.header(), this.defaultExpense(), 'create').body
|
||||||
|
: createPayload(header, this.lines(), this.lineSpec);
|
||||||
|
const created = await this.api.create(this.screen.resource, body);
|
||||||
await this.router.navigate(['..', created['DocEntry']], {
|
await this.router.navigate(['..', created['DocEntry']], {
|
||||||
relativeTo: this.route,
|
relativeTo: this.route,
|
||||||
state: { created: text(created['DocNum']) },
|
state: { created: text(created['DocNum']) },
|
||||||
@@ -363,8 +389,13 @@ export class DocumentFormPage {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const update = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec);
|
const update = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec);
|
||||||
const body = folioAsNumber(update.body);
|
let body = folioAsNumber(update.body);
|
||||||
const replaceLines = update.replaceLines;
|
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 (!Object.keys(body).length) {
|
if (!Object.keys(body).length) {
|
||||||
this.message.set({ kind: 'info', text: 'No hay cambios que guardar.' });
|
this.message.set({ kind: 'info', text: 'No hay cambios que guardar.' });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const EXPENSES = { value: [{ ExpensCode: 1, Name: 'Seguro' }, { ExpensCode: 2, Name: 'Flete' }] };
|
||||||
|
|
||||||
|
describe('flete en el formulario', () => {
|
||||||
|
let fixture: ComponentFixture<DocumentFormPage>;
|
||||||
|
let ctrl: HttpTestingController;
|
||||||
|
let navigate: ReturnType<typeof vi.spyOn>;
|
||||||
|
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(resource: string, doc: Record<string, unknown> | null) {
|
||||||
|
const screen = DOCUMENT_SCREENS.find((s) => s.resource === resource)!;
|
||||||
|
const params = doc ? { docEntry: '9' } : {};
|
||||||
|
const route = {
|
||||||
|
snapshot: { data: { screen, title: 'Documento' }, paramMap: convertToParamMap(params) },
|
||||||
|
paramMap: of(convertToParamMap(params)),
|
||||||
|
};
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [DocumentFormPage],
|
||||||
|
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }],
|
||||||
|
}).compileComponents();
|
||||||
|
ctrl = TestBed.inject(HttpTestingController);
|
||||||
|
navigate = 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: 'IVA', Rate: 19 }] });
|
||||||
|
ctrl.expectOne((r) => r.url === '/api/sl/AdditionalExpenses').flush(EXPENSES);
|
||||||
|
if (doc) ctrl.expectOne(`/api/sl/${resource}/9`).flush(doc);
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = (rows: Record<string, unknown>[]) => ({
|
||||||
|
'odata.etag': 'W/"1"',
|
||||||
|
DocEntry: 9,
|
||||||
|
DocNum: 9,
|
||||||
|
CardCode: 'C1',
|
||||||
|
CardName: 'ACME',
|
||||||
|
DocDate: '2026-09-11T00:00:00Z',
|
||||||
|
DocDueDate: '2026-09-20T00:00:00Z',
|
||||||
|
DiscountPercent: 0,
|
||||||
|
DocumentStatus: 'bost_Open',
|
||||||
|
Cancelled: 'tNO',
|
||||||
|
DocumentLines: [{ LineNum: 0, ItemCode: 'A1', ItemDescription: 'Pieza', Quantity: 1, Price: 100, DiscountPercent: 0, TaxCode: 'IVA', LineTotal: 100, WarehouseCode: '01' }],
|
||||||
|
DocumentAdditionalExpenses: rows,
|
||||||
|
});
|
||||||
|
|
||||||
|
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('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 () => {
|
||||||
|
await open('Orders', null);
|
||||||
|
page().setHeader('CardCode', 'C1');
|
||||||
|
page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 100, LineTotal: 100 }]);
|
||||||
|
page().setHeader('FreightAmount', 1500);
|
||||||
|
await tick();
|
||||||
|
(el().querySelector('.actions .btn.main') as HTMLElement).click();
|
||||||
|
await tick();
|
||||||
|
const req = ctrl.expectOne((r) => r.method === 'POST');
|
||||||
|
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');
|
||||||
|
req.flush({ DocEntry: 10, DocNum: 10 });
|
||||||
|
await tick();
|
||||||
|
expect(navigate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un documento sin flete 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 }]);
|
||||||
|
await tick();
|
||||||
|
(el().querySelector('.actions .btn.main') as HTMLElement).click();
|
||||||
|
await tick();
|
||||||
|
const body = ctrl.expectOne((r) => r.method === 'POST').request.body as Record<string, unknown>;
|
||||||
|
expect(body).not.toHaveProperty('DocumentAdditionalExpenses');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('al cambiar el importe envía la colección reemplazándola', async () => {
|
||||||
|
await open('Orders', order([{ ExpenseCode: 2, LineTotal: 25 }]));
|
||||||
|
page().setHeader('FreightAmount', 40);
|
||||||
|
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');
|
||||||
|
req.flush(null);
|
||||||
|
await tick();
|
||||||
|
ctrl.expectOne('/api/sl/Orders/9').flush(order([{ ExpenseCode: 2, LineTotal: 40 }]));
|
||||||
|
});
|
||||||
|
|
||||||
|
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('las entradas de inventario no tienen flete ni piden gastos adicionales', async () => {
|
||||||
|
const screen = DOCUMENT_SCREENS.find((s) => s.resource === 'InventoryGenEntries')!;
|
||||||
|
expect(JSON.stringify(screen.layout.footer)).not.toContain('freight');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -226,7 +226,7 @@ describe('columnas de línea por documento', () => {
|
|||||||
|
|
||||||
it('un documento puede traer su cabecera completa en lugar de la base', () => {
|
it('un documento puede traer su cabecera completa en lugar de la base', () => {
|
||||||
const l = marketingLayout({ ...base, header: { left: [], right: [] } });
|
const l = marketingLayout({ ...base, header: { left: [], right: [] } });
|
||||||
expect(headerKeysOf(l)).toEqual(['Comments', 'DiscountPercent']);
|
expect(headerKeysOf(l)).toEqual(['Comments', 'DiscountPercent', 'FreightExpense', 'FreightAmount']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('los demás documentos no cambian cuando uno elige columnas', () => {
|
it('los demás documentos no cambian cuando uno elige columnas', () => {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export type HeaderField =
|
|||||||
| { type: 'partner'; key: string; label: string; required?: boolean; missing?: string }
|
| { type: 'partner'; key: string; label: string; required?: boolean; missing?: string }
|
||||||
/** Prefijo y número del folio tributario en una misma fila (`FolioPrefixString` y `FolioNumber`). */
|
/** Prefijo y número del folio tributario en una misma fila (`FolioPrefixString` y `FolioNumber`). */
|
||||||
| { type: 'folio'; label: string; prefixKey: string; numberKey: string }
|
| { type: 'folio'; label: string; prefixKey: string; numberKey: string }
|
||||||
|
/** Flete: código del gasto adicional y su importe (`DocumentAdditionalExpenses`). */
|
||||||
|
| { type: 'freight'; label: string }
|
||||||
| { type: 'computed'; label: string; source: ComputedSource };
|
| { type: 'computed'; label: string; source: ComputedSource };
|
||||||
|
|
||||||
/** Las listas de valores (ventanas modales) de un formulario. */
|
/** Las listas de valores (ventanas modales) de un formulario. */
|
||||||
@@ -170,6 +172,8 @@ export function marketingLayout(opts: {
|
|||||||
lookups?: LookupColumns;
|
lookups?: LookupColumns;
|
||||||
/** Unidad de medida que se toma del artículo: la de venta o la de compra. */
|
/** Unidad de medida que se toma del artículo: la de venta o la de compra. */
|
||||||
uom: 'sales' | 'purchase';
|
uom: 'sales' | 'purchase';
|
||||||
|
/** Fila del flete en el pie (por defecto sí). */
|
||||||
|
freight?: boolean;
|
||||||
}): DocumentLayout {
|
}): DocumentLayout {
|
||||||
const columns = lineColumnsFor(opts.taxResource, opts.lineColumns).map((c) =>
|
const columns = lineColumnsFor(opts.taxResource, opts.lineColumns).map((c) =>
|
||||||
c.key === 'ItemCode' && c.lookup ? itemColumnWithUom(c, opts.uom) : c,
|
c.key === 'ItemCode' && c.lookup ? itemColumnWithUom(c, opts.uom) : c,
|
||||||
@@ -191,6 +195,7 @@ export function marketingLayout(opts: {
|
|||||||
right: [
|
right: [
|
||||||
{ type: 'computed', label: 'Total antes del descuento', source: 'subtotal' },
|
{ type: 'computed', label: 'Total antes del descuento', source: 'subtotal' },
|
||||||
{ type: 'number', key: 'DiscountPercent', label: 'Descuento %', decimals: 2 },
|
{ type: 'number', key: 'DiscountPercent', label: 'Descuento %', decimals: 2 },
|
||||||
|
...(opts.freight === false ? [] : [{ type: 'freight', label: 'Flete' } as const]),
|
||||||
{ type: 'computed', label: 'Impuesto', source: 'tax' },
|
{ type: 'computed', label: 'Impuesto', source: 'tax' },
|
||||||
{ type: 'computed', label: 'Total del documento', source: 'total' },
|
{ type: 'computed', label: 'Total del documento', source: 'total' },
|
||||||
],
|
],
|
||||||
@@ -219,6 +224,8 @@ export const headerKeysOf = (layout: DocumentLayout): readonly string[] =>
|
|||||||
switch (f.type) {
|
switch (f.type) {
|
||||||
case 'computed':
|
case 'computed':
|
||||||
return [];
|
return [];
|
||||||
|
case 'freight':
|
||||||
|
return ['FreightExpense', 'FreightAmount'];
|
||||||
case 'folio':
|
case 'folio':
|
||||||
return [f.prefixKey, f.numberKey];
|
return [f.prefixKey, f.numberKey];
|
||||||
default:
|
default:
|
||||||
@@ -234,4 +241,6 @@ export const requiredHeaderOf = (layout: DocumentLayout): { key: string; label:
|
|||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const hasFreight = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'freight');
|
||||||
|
|
||||||
export const hasFolio = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'folio');
|
export const hasFolio = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'folio');
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
taxRateMap,
|
taxRateMap,
|
||||||
updatePayload,
|
updatePayload,
|
||||||
validateLayout,
|
validateLayout,
|
||||||
|
freightOf,
|
||||||
|
freightPayload,
|
||||||
} from './document-model';
|
} from './document-model';
|
||||||
|
|
||||||
const line = (over: Record<string, unknown> = {}) => ({
|
const line = (over: Record<string, unknown> = {}) => ({
|
||||||
@@ -35,12 +37,12 @@ describe('computeTotals', () => {
|
|||||||
|
|
||||||
it('suma líneas e impuesto por tasa', () => {
|
it('suma líneas e impuesto por tasa', () => {
|
||||||
const t = computeTotals([line(), line({ TaxCode: 'EXE', LineTotal: 100 })], rates, 0);
|
const t = computeTotals([line(), line({ TaxCode: 'EXE', LineTotal: 100 })], rates, 0);
|
||||||
expect(t).toEqual({ subtotal: 300, discount: 0, net: 300, tax: 38, total: 338 });
|
expect(t).toEqual({ subtotal: 300, discount: 0, net: 300, tax: 38, freight: 0, total: 338 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aplica el descuento de cabecera antes del impuesto', () => {
|
it('aplica el descuento de cabecera antes del impuesto', () => {
|
||||||
const t = computeTotals([line()], rates, 10);
|
const t = computeTotals([line()], rates, 10);
|
||||||
expect(t).toEqual({ subtotal: 200, discount: 20, net: 180, tax: 34.2, total: 214.2 });
|
expect(t).toEqual({ subtotal: 200, discount: 20, net: 180, tax: 34.2, freight: 0, total: 214.2 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('un código de impuesto desconocido no suma impuesto', () => {
|
it('un código de impuesto desconocido no suma impuesto', () => {
|
||||||
@@ -48,7 +50,7 @@ describe('computeTotals', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sin líneas, todo en cero', () => {
|
it('sin líneas, todo en cero', () => {
|
||||||
expect(computeTotals([], rates, 0)).toEqual({ subtotal: 0, discount: 0, net: 0, tax: 0, total: 0 });
|
expect(computeTotals([], rates, 0)).toEqual({ subtotal: 0, discount: 0, net: 0, tax: 0, freight: 0, total: 0 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,3 +153,40 @@ describe('navigationQuery', () => {
|
|||||||
expect(navigationQuery('next', null)).toEqual({ $orderby: 'DocEntry asc' });
|
expect(navigationQuery('next', null)).toEqual({ $orderby: 'DocEntry asc' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('flete (gastos adicionales)', () => {
|
||||||
|
const doc = (rows: Record<string, unknown>[]) => ({ DocumentAdditionalExpenses: rows });
|
||||||
|
|
||||||
|
it('suma el flete al total del documento', () => {
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
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('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('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('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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,7 +20,12 @@ export function isDocumentReadOnly(doc: DocRecord | null | undefined): boolean {
|
|||||||
|
|
||||||
// --- totales ------------------------------------------------------------------------------
|
// --- totales ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Gasto adicional del documento (el flete): colección `DocumentAdditionalExpenses` de SAP. */
|
||||||
|
export const FREIGHT_COLLECTION = 'DocumentAdditionalExpenses';
|
||||||
|
|
||||||
export interface DocTotals {
|
export interface DocTotals {
|
||||||
|
/** Flete (gastos adicionales) antes de impuestos. */
|
||||||
|
freight: number;
|
||||||
/** Suma de los totales de línea (ya con el descuento de cada línea). */
|
/** Suma de los totales de línea (ya con el descuento de cada línea). */
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
/** Descuento del documento (% de cabecera aplicado al subtotal). */
|
/** Descuento del documento (% de cabecera aplicado al subtotal). */
|
||||||
@@ -44,6 +49,7 @@ export function computeTotals(
|
|||||||
lines: readonly GridLine[],
|
lines: readonly GridLine[],
|
||||||
rates: ReadonlyMap<string, number>,
|
rates: ReadonlyMap<string, number>,
|
||||||
discountPercent: number,
|
discountPercent: number,
|
||||||
|
freight = 0,
|
||||||
): DocTotals {
|
): DocTotals {
|
||||||
const factor = 1 - discountPercent / 100;
|
const factor = 1 - discountPercent / 100;
|
||||||
let subtotal = 0;
|
let subtotal = 0;
|
||||||
@@ -59,10 +65,50 @@ export function computeTotals(
|
|||||||
discount: round2(subtotal - net),
|
discount: round2(subtotal - net),
|
||||||
net: round2(net),
|
net: round2(net),
|
||||||
tax: round2(tax),
|
tax: round2(tax),
|
||||||
total: round2(net + tax),
|
freight: round2(freight),
|
||||||
|
total: round2(net + tax + freight),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- flete ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
|
|
||||||
// --- guardado -----------------------------------------------------------------------------
|
// --- guardado -----------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Campos de línea que se envían a SAP; el total de línea y lo calculado lo resuelve SAP. */
|
/** Campos de línea que se envían a SAP; el total de línea y lo calculado lo resuelve SAP. */
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Rutas de menú que son pantallas de documento. Es la única parte que el enrutador necesita al
|
||||||
|
* arrancar; la configuración completa de cada pantalla (`document-screens.ts`) se carga en
|
||||||
|
* diferido al abrir una. Un test comprueba que esta lista coincide con `DOCUMENT_SCREENS`.
|
||||||
|
*/
|
||||||
|
export const DOCUMENT_PATHS: readonly string[] = [
|
||||||
|
// Ventas
|
||||||
|
'/ventas/ofertas',
|
||||||
|
'/ventas/ordenes',
|
||||||
|
'/ventas/entrega',
|
||||||
|
'/ventas/facturas',
|
||||||
|
'/ventas/devoluciones',
|
||||||
|
'/ventas/notas-credito',
|
||||||
|
// Compras
|
||||||
|
'/compras/solicitudes',
|
||||||
|
'/compras/ofertas',
|
||||||
|
'/compras/ordenes',
|
||||||
|
'/compras/recepcion',
|
||||||
|
'/compras/facturas',
|
||||||
|
'/compras/devoluciones',
|
||||||
|
'/compras/notas-credito',
|
||||||
|
// Inventario
|
||||||
|
'/inventario/entradas',
|
||||||
|
'/inventario/salidas',
|
||||||
|
'/inventario/transferencias',
|
||||||
|
'/inventario/solicitudes-traslado',
|
||||||
|
];
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, booleanAttribute, input } from '@angular/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Barra de herramientas del documento (Nueva, Buscar, navegación...). Los botones son
|
* Barra de herramientas del documento (Nueva, Buscar, navegación...). Los botones son
|
||||||
* `<button class="tb">` con un `<sbo-icon>`; `<span class="tb-sep">` separa grupos.
|
* `<button class="tb">` con un `<sbo-icon>`; `<span class="tb-sep">` separa grupos.
|
||||||
|
*
|
||||||
|
* Con `bleed` ocupa todo el ancho del área de contenido (como en SBO y el mockup): sin el margen de
|
||||||
|
* 16 px de la página ni bordes laterales, pegada a la barra superior.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'sbo-toolbar',
|
selector: 'sbo-toolbar',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
host: { role: 'toolbar' },
|
host: { role: 'toolbar', '[class.bleed]': 'bleed()' },
|
||||||
template: '<ng-content />',
|
template: '<ng-content />',
|
||||||
styles: `
|
styles: `
|
||||||
:host {
|
:host {
|
||||||
@@ -20,6 +23,13 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-bottom-width: 0;
|
border-bottom-width: 0;
|
||||||
}
|
}
|
||||||
|
:host(.bleed) {
|
||||||
|
margin: -16px -16px 16px;
|
||||||
|
border-width: 0 0 1px;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class Toolbar {}
|
export class Toolbar {
|
||||||
|
readonly bleed = input(false, { transform: booleanAttribute });
|
||||||
|
}
|
||||||
Reference in new issue
Block a user