feat(web): formulario de documentos de ventas y compras (cargar, crear, actualizar con If-Match, navegar)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-13 22:00:54 -05:00
1 parent 51982fd3ab
commit d900a96740
9 files changed
+621 -11

No files matched your search

+1
View File
@@ -69,6 +69,7 @@ export const SL_RESOURCES: readonly SlResource[] = [
catalog('Warehouses', 'string'), catalog('Warehouses', 'string'),
catalog('Currencies', 'string'), catalog('Currencies', 'string'),
catalog('SalesTaxCodes', 'string'), catalog('SalesTaxCodes', 'string'),
catalog('PurchaseTaxCodes', 'string'),
catalog('VatGroups', 'string'), catalog('VatGroups', 'string'),
catalog('PaymentTermsTypes', 'number'), catalog('PaymentTermsTypes', 'number'),
catalog('SalesPersons', 'number'), catalog('SalesPersons', 'number'),
+11
View File
@@ -124,6 +124,17 @@ Cada pantalla de documento del menú abre primero la **lista de los últimos doc
Las tres rutas las gobierna el permiso de menú de la ruta padre. Se generan solas desde `features/documents/document-screens.ts` (`DOCUMENT_SCREENS`): por documento define recurso, columnas, campos extra, filtros y el texto del botón de alta. Agregar un documento = una entrada ahí (más su ruta en `MENU` y su recurso en el proxy del backend). Las tres rutas las gobierna el permiso de menú de la ruta padre. Se generan solas desde `features/documents/document-screens.ts` (`DOCUMENT_SCREENS`): por documento define recurso, columnas, campos extra, filtros y el texto del botón de alta. Agregar un documento = una entrada ahí (más su ruta en `MENU` y su recurso en el proxy del backend).
#### Formulario de documentos de ventas y compras (`document-form.page`)
Lo usan las pantallas con `partner` en su `DocumentScreen` (ventas y compras); el resto de documentos muestra aún un aviso.
- **Cabecera**: socio de negocio (lista de valores filtrada por `CardType`; no se cambia en un documento ya creado), Nº de referencia, fechas y descuento %; **líneas** con `sbo-lines-grid`; **totales** informativos (SAP recalcula al guardar) con las tasas de `SalesTaxCodes` (ventas) o `PurchaseTaxCodes` (compras).
- **Crear**: valida sin SAP (socio, fecha, al menos una línea con artículo, cantidad > 0), quita campos vacíos y filas en blanco, y abre el documento creado.
- **Actualizar**: envía solo la cabecera que cambió y, si cambiaron las líneas, todas con `B1S-ReplaceCollectionsOnPatch: true`. Lleva `If-Match` con el `odata.etag` leído: si otro usuario cambió el documento, SAP responde **412**, no se guarda nada y se ofrece "Recargar".
- **Cerrado o cancelado**: solo lectura (sin botón de guardar).
- **Navegación** primero / anterior / siguiente / último por `DocEntry`.
- La lógica sin Angular está en `document-model.ts` (totales, payload, validación, navegación) y el acceso HTTP en `core/sl/sl-document.ts`.
### `sbo-list-view` y `sbo-filter-bar` ### `sbo-list-view` y `sbo-filter-bar`
Lista con **barra de filtros** y **búsqueda por columna**, igual que el mini-erp: Lista con **barra de filtros** y **búsqueda por columna**, igual que el mini-erp:
+42
View File
@@ -0,0 +1,42 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import type { SlListResponse } from '@basic-erp/sap-types';
import { API_URL } from '../api';
import { type DocRecord, type NavTarget, navigationQuery } from '../../features/documents/document-model';
/** Lectura y escritura de un documento del Service Layer a través del proxy `/api/sl`. */
@Injectable({ providedIn: 'root' })
export class SlDocumentApi {
private readonly http = inject(HttpClient);
private url(resource: string, docEntry?: number): string {
return `${API_URL}/sl/${resource}${docEntry === undefined ? '' : `/${docEntry}`}`;
}
/** DocEntry del documento vecino (primero / anterior / siguiente / último), o `null` si no hay. */
async neighbour(resource: string, target: NavTarget, from: number | null): Promise<number | null> {
const q = navigationQuery(target, from);
let params = new HttpParams().set('$select', 'DocEntry').set('$top', 1).set('$orderby', q.$orderby);
if (q.$filter) params = params.set('$filter', q.$filter);
const res = await firstValueFrom(this.http.get<SlListResponse<DocRecord>>(this.url(resource), { params }));
const entry = res.value[0]?.['DocEntry'];
return typeof entry === 'number' ? entry : null;
}
/** Crea el documento; SAP devuelve el documento creado (con `DocEntry` y `DocNum`). */
create(resource: string, body: DocRecord): Promise<DocRecord> {
return firstValueFrom(this.http.post<DocRecord>(this.url(resource), body));
}
/**
* Modifica el documento. `etag` es el `odata.etag` con que se leyó: si alguien lo cambió antes,
* SAP responde 412 y no se pisa su cambio.
*/
update(resource: string, docEntry: number, body: DocRecord, etag: string | undefined, replaceLines: boolean): Promise<void> {
let headers = new HttpHeaders();
if (etag) headers = headers.set('If-Match', etag);
if (replaceLines) headers = headers.set('B1S-ReplaceCollectionsOnPatch', 'true');
return firstValueFrom(this.http.patch<void>(this.url(resource, docEntry), body, { headers }));
}
}
@@ -0,0 +1,95 @@
<sbo-toolbar aria-label="Acciones del documento">
<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>
<span class="tb-sep"></span>
<button class="tb" type="button" aria-label="Primero" (click)="go('first')"><sbo-icon name="first" /></button>
<button class="tb" type="button" aria-label="Anterior" (click)="go('prev')"><sbo-icon name="prev" /></button>
<button class="tb" type="button" aria-label="Siguiente" (click)="go('next')"><sbo-icon name="next" /></button>
<button class="tb" type="button" aria-label="Último" (click)="go('last')"><sbo-icon name="last" /></button>
</sbo-toolbar>
<sbo-window [title]="windowTitle()" [badge]="badge()" [status]="status()">
@if (partner) {
<div class="sbo-hdr">
<div class="sbo-col">
<sbo-lookup-field
[label]="partner.label"
required
[readonly]="readOnly() || !isNew()"
[value]="str(header()['CardCode'])"
(valueChange)="setHeader('CardCode', $event)"
[lookup]="partnerLookup"
(picked)="onPartner($event)"
/>
<sbo-text-field label="Nombre" readonly [value]="partnerName()" />
<sbo-text-field
label="Nº referencia"
[readonly]="readOnly()"
[value]="str(header()['NumAtCard'])"
(valueChange)="setHeader('NumAtCard', $event)"
/>
</div>
<div class="sbo-col">
<sbo-text-field label="Número" readonly [value]="str(doc()?.['DocNum'])" />
<sbo-date-field
label="Fecha de contabilización"
required
[readonly]="readOnly()"
[value]="str(header()['DocDate'])"
(valueChange)="setHeader('DocDate', $event)"
/>
<sbo-date-field
label="Fecha de vencimiento"
[readonly]="readOnly()"
[value]="str(header()['DocDueDate'])"
(valueChange)="setHeader('DocDueDate', $event)"
/>
</div>
</div>
<sbo-lines-grid
[columns]="columns"
[lines]="lines()"
(linesChange)="setLines($event)"
[readonly]="readOnly()"
[newLine]="newLine"
[recalc]="recalc"
[multiAdd]="multi"
/>
<div class="sbo-hdr" style="margin-top: 14px">
<div class="sbo-col">
<sbo-textarea-field
label="Comentarios"
[readonly]="readOnly()"
[value]="str(header()['Comments'])"
(valueChange)="setHeader('Comments', $event)"
/>
</div>
<div class="sbo-col">
<sbo-text-field label="Total antes del descuento" readonly [value]="fmt(totals().subtotal)" />
<sbo-number-field
label="Descuento %"
[decimals]="2"
[readonly]="readOnly()"
[value]="+str(header()['DiscountPercent'])"
(valueChange)="setHeader('DiscountPercent', $event ?? 0)"
/>
<sbo-text-field label="Impuesto" readonly [value]="fmt(totals().tax)" />
<sbo-text-field label="Total del documento" readonly [value]="fmt(totals().total)" />
</div>
</div>
} @else {
<p>El formulario de este documento aún no está disponible. <a routerLink="..">Volver a la lista</a></p>
}
@if (partner && !readOnly()) {
<button sboActions class="btn main" type="button" [disabled]="saving() || loading()" (click)="save()">
{{ isNew() ? 'Crear' : 'Actualizar' }}
</button>
}
@if (conflict()) {
<button sboActions class="btn" type="button" (click)="reload()">Recargar</button>
}
<a sboActions class="btn" routerLink="..">{{ readOnly() ? 'Cerrar' : 'Cancelar' }}</a>
</sbo-window>
@@ -0,0 +1,216 @@
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 screen = DOCUMENT_SCREENS.find((s) => s.resource === 'Orders')!;
const order = (over: Record<string, unknown> = {}) => ({
'odata.etag': 'W/"77"',
DocEntry: 41,
DocNum: 1041,
CardCode: 'C1',
CardName: 'ACME',
NumAtCard: '',
DocDate: '2026-09-11',
DocDueDate: '2026-09-20',
DiscountPercent: 0,
Comments: 'hola',
DocumentStatus: 'bost_Open',
Cancelled: 'tNO',
DocumentLines: [
{ LineNum: 0, ItemCode: 'A1', ItemDescription: 'Pieza', Quantity: 2, Price: 100, DiscountPercent: 0, TaxCode: 'IVA', LineTotal: 200, WarehouseCode: '01' },
],
...over,
});
describe('DocumentFormPage', () => {
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();
};
const buttonText = () => [...el().querySelectorAll('.actions .btn')].map((b) => b.textContent?.trim());
async function open(docEntry: string | null, doc?: Record<string, unknown>) {
const params = docEntry ? { docEntry } : {};
const route = {
snapshot: { data: { screen, title: 'Órdenes de venta' }, 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('/api/sl/SalesTaxCodes?$select=Code,Rate&$top=500').flush({ value: [{ Code: 'IVA', Rate: 19 }] });
if (docEntry) ctrl.expectOne(`/api/sl/Orders/${docEntry}`).flush(doc ?? {});
await tick();
}
beforeEach(() => TestBed.resetTestingModule());
describe('documento abierto', () => {
beforeEach(() => open('41', order()));
it('muestra la cabecera, las líneas y los totales con la tasa del impuesto', () => {
const inputs = [...el().querySelectorAll('input')].map((i) => i.value);
expect(inputs).toContain('C1');
expect(inputs).toContain('ACME');
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, total: 238 });
expect(buttonText()).toContain('Actualizar');
});
it('sin cambios, Actualizar no envía nada', async () => {
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
ctrl.expectNone((r) => r.method === 'PATCH');
expect(el().querySelector('.status')?.textContent).toContain('No hay cambios');
});
it('envía solo lo que cambió, con If-Match, y recarga el documento', async () => {
page().setHeader('Comments', 'nuevo comentario');
await tick();
expect(el().querySelector('.status')?.textContent).toContain('cambios sin guardar');
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'PATCH');
expect(req.request.url).toBe('/api/sl/Orders/41');
expect(req.request.body).toEqual({ Comments: 'nuevo comentario' });
expect(req.request.headers.get('If-Match')).toBe('W/"77"');
expect(req.request.headers.has('B1S-ReplaceCollectionsOnPatch')).toBe(false);
req.flush(null, { status: 204, statusText: 'No Content' });
await tick();
ctrl.expectOne('/api/sl/Orders/41').flush(order({ Comments: 'nuevo comentario', 'odata.etag': 'W/"78"' }));
await tick();
expect(el().querySelector('.status')?.textContent).toContain('Documento actualizado');
});
it('si cambian las líneas las envía todas con ReplaceCollectionsOnPatch', async () => {
const lines = page().lines();
page().setLines([{ ...lines[0], Quantity: 5, LineTotal: 500 }]);
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'PATCH');
expect(req.request.headers.get('B1S-ReplaceCollectionsOnPatch')).toBe('true');
expect(req.request.body.DocumentLines[0]).toMatchObject({ ItemCode: 'A1', Quantity: 5 });
expect(req.request.body.DocumentLines[0].LineTotal).toBeUndefined();
req.flush(null, { status: 204, statusText: 'No Content' });
});
it('un 412 avisa del conflicto, no guarda y ofrece Recargar', async () => {
page().setHeader('Comments', 'x');
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
ctrl.expectOne((r) => r.method === 'PATCH').flush({ message: 'etag' }, { status: 412, statusText: 'Precondition Failed' });
await tick();
expect(el().querySelector('.status')?.textContent).toContain('Otro usuario modificó');
expect(buttonText()).toContain('Recargar');
(el().querySelectorAll('.actions .btn')[1] as HTMLElement).click();
await tick();
ctrl.expectOne('/api/sl/Orders/41').flush(order({ Comments: 'del otro usuario' }));
await tick();
expect(page().header().Comments).toBe('del otro usuario');
expect(buttonText()).not.toContain('Recargar');
});
it('la navegación pide el documento vecino y abre su DocEntry', async () => {
const done = page().go('next');
await tick();
const req = ctrl.expectOne((r) => r.url === '/api/sl/Orders' && r.params.get('$filter') === 'DocEntry gt 41');
expect(req.request.params.get('$orderby')).toBe('DocEntry asc');
expect(req.request.params.get('$top')).toBe('1');
req.flush({ value: [{ DocEntry: 42 }] });
await done;
expect(navigate).toHaveBeenCalledWith(['..', 42], expect.anything());
});
it('al llegar al extremo avisa y no navega', async () => {
const done = page().go('next');
await tick();
ctrl.expectOne((r) => r.url === '/api/sl/Orders').flush({ value: [] });
await done;
await tick();
expect(navigate).not.toHaveBeenCalled();
expect(el().querySelector('.status')?.textContent).toContain('No hay más documentos');
});
});
describe('documento cerrado', () => {
beforeEach(() => open('41', order({ DocumentStatus: 'bost_Close' })));
it('queda en solo lectura: sin Actualizar y con los campos bloqueados', () => {
expect(buttonText()).toEqual(['Cerrar']);
expect(el().querySelector('.status')?.textContent).toContain('solo lectura');
const comments = el().querySelector('textarea') as HTMLTextAreaElement;
expect(comments.readOnly).toBe(true);
});
});
describe('documento nuevo', () => {
beforeEach(() => open(null));
it('arranca vacío con fecha de hoy y botón Crear', () => {
expect(el().querySelector('.badge.tone-info')?.textContent?.trim()).toBe('Nueva');
expect(buttonText()).toContain('Crear');
expect(page().header().DocDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it('no envía nada si falta el cliente o las líneas', async () => {
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
ctrl.expectNone((r) => r.method === 'POST');
expect(el().querySelector('.status')?.textContent).toContain('Indique el cliente');
});
it('crea el documento sin campos vacíos y abre el creado', async () => {
page().setHeader('CardCode', 'C1');
page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 50, DiscountPercent: 0, TaxCode: '', ItemDescription: '', LineTotal: 50 }]);
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'POST');
expect(req.request.url).toBe('/api/sl/Orders');
expect(req.request.body).toEqual({
CardCode: 'C1',
DocDate: page().header().DocDate,
DocDueDate: page().header().DocDueDate,
DiscountPercent: 0,
DocumentLines: [{ ItemCode: 'A1', Quantity: 1, Price: 50, DiscountPercent: 0 }],
});
req.flush({ DocEntry: 99, DocNum: 1099 });
await tick();
expect(navigate).toHaveBeenCalledWith(['..', 99], expect.objectContaining({ state: { created: '1099' } }));
});
it('muestra el error de SAP si rechaza el documento', async () => {
page().setHeader('CardCode', 'C1');
page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 50, LineTotal: 50 }]);
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
ctrl.expectOne((r) => r.method === 'POST').flush({ message: 'Código de cliente inválido' }, { status: 400, statusText: 'Bad Request' });
await tick();
expect(el().querySelector('.status')?.textContent).toContain('Código de cliente inválido');
expect(navigate).not.toHaveBeenCalled();
});
});
});
@@ -1,25 +1,246 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { HttpErrorResponse, httpResource } from '@angular/common/http';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, signal } from '@angular/core';
import type { DocumentScreen } from './document-screens'; import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import type { SlListResponse } from '@basic-erp/sap-types';
import { map } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../core/api';
import { formatNumber } from '../../core/format';
import { SlDocumentApi } from '../../core/sl/sl-document';
import { DateField, LookupField, NumberField, TextField, TextareaField } from '../../shared/form';
import type { GridLine } from '../../shared/grid/grid.types';
import { LinesGrid } from '../../shared/grid/lines-grid';
import type { VhLookup } from '../../shared/value-help/value-help.types';
import { Icon, SboWindow, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
import {
type DocRecord,
type NavTarget,
computeTotals,
createPayload,
isBlankLine,
isDocumentReadOnly,
taxRateMap,
updatePayload,
validateDocument,
} from './document-model';
import { type DocumentScreen, documentStatus } from './document-screens';
import { ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine } from './line-columns';
/** Formulario del documento: `nuevo` (vacío) o `:docEntry` (ver / editar). Se construye en el paso 9. */ /** Campos de cabecera que se editan en el formulario (el resto lo calcula SAP). */
const HEADER_KEYS = ['CardCode', 'NumAtCard', 'DocDate', 'DocDueDate', 'DiscountPercent', 'Comments'] as const;
const today = (): string => new Date().toISOString().slice(0, 10);
const pickHeader = (doc: DocRecord | undefined): DocRecord => {
if (!doc) return { DocDate: today(), DocDueDate: today(), DiscountPercent: 0 };
return Object.fromEntries(HEADER_KEYS.map((k) => [k, doc[k] ?? (k === 'DiscountPercent' ? 0 : '')]));
};
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
/**
* Formulario de un documento de ventas o compras: `nuevo` (vacío) o `:docEntry` (ver / editar).
* Los cerrados y cancelados quedan en solo lectura. Al modificar se envía el `odata.etag` leído
* (`If-Match`): si otro usuario cambió el documento, SAP responde 412 y se avisa sin pisarlo.
*/
@Component({ @Component({
selector: 'sbo-document-form-page', selector: 'sbo-document-form-page',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink], imports: [DateField, Icon, LinesGrid, LookupField, NumberField, RouterLink, SboWindow, TextField, TextareaField, Toolbar],
template: ` templateUrl: './document-form.page.html',
<h1>{{ title }} — {{ docEntry ? 'documento ' + docEntry : 'nuevo' }}</h1>
<p>El formulario aún no está disponible. <a routerLink="..">Volver a la lista</a></p>
`,
styles: ` styles: `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; } h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
p { margin: 0; color: var(--quiet); } p { margin: 0; color: var(--quiet); }
a { color: var(--focus); } a { color: var(--focus); }
.totals { display: grid; gap: 2px; }
`, `,
}) })
export class DocumentFormPage { export class DocumentFormPage {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly api = inject(SlDocumentApi);
protected readonly title = this.route.snapshot.data['title'] as string; protected readonly title = this.route.snapshot.data['title'] as string;
protected readonly screen = this.route.snapshot.data['screen'] as DocumentScreen; protected readonly screen = this.route.snapshot.data['screen'] as DocumentScreen;
protected readonly docEntry = this.route.snapshot.paramMap.get('docEntry'); protected readonly partner = this.screen.partner;
protected readonly docEntry = toSignal(
this.route.paramMap.pipe(map((p) => (p.has('docEntry') ? Number(p.get('docEntry')) : null))),
{ initialValue: this.route.snapshot.paramMap.has('docEntry') ? Number(this.route.snapshot.paramMap.get('docEntry')) : null },
);
protected readonly isNew = computed(() => this.docEntry() === null);
// --- datos ---------------------------------------------------------------------------------
private readonly docResource = httpResource<DocRecord>(() => {
const id = this.docEntry();
return id === null ? undefined : `${API_URL}/sl/${this.screen.resource}/${id}`;
});
private readonly taxResource = httpResource<SlListResponse<DocRecord>>(() =>
this.partner
? { url: `${API_URL}/sl/${this.partner.taxResource}`, params: { $select: 'Code,Rate', $top: 500 } }
: undefined,
);
protected readonly doc = computed(() => (this.docResource.hasValue() ? this.docResource.value() : undefined));
protected readonly loading = computed(() => this.docResource.isLoading());
protected readonly loadError = computed(() => {
const err = this.docResource.error();
return err ? (err instanceof HttpErrorResponse && err.status === 404 ? 'El documento no existe.' : apiErrorMessage(err)) : '';
});
/** Valores editables; se reinician al cargar o recargar el documento. */
protected readonly header = linkedSignal<DocRecord>(() => pickHeader(this.doc()));
protected readonly lines = linkedSignal<readonly GridLine[]>(() =>
((this.doc()?.['DocumentLines'] as GridLine[] | undefined) ?? []).map((l) => ({ ...l })),
);
protected readonly partnerName = linkedSignal(() => text(this.doc()?.['CardName']));
// --- estado --------------------------------------------------------------------------------
protected readonly readOnly = computed(() => isDocumentReadOnly(this.doc()));
protected readonly saving = signal(false);
private readonly message = signal<StatusMessage | null>(
((): StatusMessage | null => {
const created = (this.router.currentNavigation()?.extras.state as { created?: string } | undefined)?.created;
return created ? { kind: 'ok', text: `Documento ${created} creado.` } : null;
})(),
);
protected readonly conflict = signal(false);
protected readonly status = computed<StatusMessage>(() => {
if (this.loading()) return { kind: 'info', text: 'Cargando…' };
if (this.loadError()) return { kind: 'err', text: this.loadError() };
const message = this.message();
if (message) return message;
if (this.readOnly()) return { kind: 'info', text: 'Documento cerrado o cancelado: solo lectura.' };
if (this.isNew()) return { kind: 'info', text: `Indique el ${this.partner?.label.toLowerCase() ?? 'socio'} y las líneas.` };
return { kind: 'info', text: this.dirty() ? 'Hay cambios sin guardar.' : '' };
});
protected readonly badge = computed<WindowBadge | undefined>(() => {
if (this.isNew()) return { label: 'Nueva', tone: 'info' };
const d = this.doc();
return d ? documentStatus(d) : undefined;
});
protected readonly windowTitle = computed(() => {
const num = this.doc()?.['DocNum'];
return num === undefined ? this.title : `${this.title} · ${text(num)}`;
});
protected readonly dirty = computed(() => {
const d = this.doc();
if (!d) return text(this.header()['CardCode']) !== '' || this.lines().some((l) => !isBlankLine(l));
const original = pickHeader(d);
const { body } = updatePayload(original, this.header(), (d['DocumentLines'] as GridLine[]) ?? [], this.lines());
return Object.keys(body).length > 0;
});
// --- totales -------------------------------------------------------------------------------
private readonly rates = computed(() =>
taxRateMap(this.taxResource.hasValue() ? this.taxResource.value().value : []),
);
protected readonly totals = computed(() =>
computeTotals(this.lines(), this.rates(), Number(this.header()['DiscountPercent'] ?? 0)),
);
protected readonly fmt = (n: number): string => formatNumber(n, 2);
// --- configuración de la vista --------------------------------------------------------------
protected readonly columns = lineColumnsFor(this.partner?.taxResource ?? 'SalesTaxCodes');
protected readonly newLine = newDocumentLine;
protected readonly recalc = recalcLine;
protected readonly multi = ITEMS_MULTI_ADD;
protected readonly partnerLookup: VhLookup = {
title: this.partner?.type === 'cSupplier' ? 'Proveedores' : 'Clientes',
resource: 'BusinessPartners',
baseFilter: `CardType eq '${this.partner?.type ?? 'cCustomer'}'`,
orderby: 'CardCode',
columns: [
{ key: 'CardCode', title: 'Código', width: '130px', mono: true },
{ key: 'CardName', title: 'Nombre' },
],
};
protected str = text;
protected setHeader(key: string, value: unknown): void {
this.header.update((h) => ({ ...h, [key]: value }));
this.message.set(null);
}
protected onPartner(row: Record<string, unknown>): void {
this.partnerName.set(text(row['CardName']));
}
protected setLines(lines: readonly GridLine[]): void {
this.lines.set(lines);
this.message.set(null);
}
// --- acciones ------------------------------------------------------------------------------
protected async go(target: NavTarget): Promise<void> {
try {
const entry = await this.api.neighbour(this.screen.resource, target, this.docEntry());
if (entry === null) {
this.message.set({ kind: 'warn', text: 'No hay más documentos en esa dirección.' });
return;
}
this.message.set(null);
await this.router.navigate(['..', entry], { relativeTo: this.route });
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
}
}
protected async save(): Promise<void> {
if (this.saving() || this.readOnly()) return;
const errors = validateDocument(this.header(), this.lines(), this.partner?.label ?? 'Socio de negocio');
if (errors.length) {
this.message.set({ kind: 'err', text: errors[0] });
return;
}
this.saving.set(true);
this.conflict.set(false);
try {
const d = this.doc();
if (!d) {
const created = await this.api.create(this.screen.resource, createPayload(this.header(), this.lines()));
await this.router.navigate(['..', created['DocEntry']], {
relativeTo: this.route,
state: { created: text(created['DocNum']) },
});
return;
}
const { body, replaceLines } = updatePayload(
pickHeader(d),
this.header(),
(d['DocumentLines'] as GridLine[]) ?? [],
this.lines(),
);
if (!Object.keys(body).length) {
this.message.set({ kind: 'info', text: 'No hay cambios que guardar.' });
return;
}
await this.api.update(this.screen.resource, this.docEntry()!, body, text(d['odata.etag']) || undefined, replaceLines);
this.message.set({ kind: 'ok', text: 'Documento actualizado.' });
this.docResource.reload();
} catch (e) {
if (e instanceof HttpErrorResponse && e.status === 412) {
this.conflict.set(true);
this.message.set({
kind: 'warn',
text: 'Otro usuario modificó este documento. Recárguelo para ver sus cambios; los suyos no se guardaron.',
});
} else {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
}
} finally {
this.saving.set(false);
}
}
protected reload(): void {
this.conflict.set(false);
this.message.set(null);
this.docResource.reload();
}
} }
@@ -16,6 +16,8 @@ export interface DocumentScreen {
filters: readonly ListFilter[]; filters: readonly ListFilter[];
/** Los más recientes primero. */ /** Los más recientes primero. */
orderby: string; orderby: string;
/** Formulario de documentos con socio de negocio, líneas y totales (ventas y compras). */
partner?: { label: string; type: 'cCustomer' | 'cSupplier'; taxResource: 'SalesTaxCodes' | 'PurchaseTaxCodes' };
} }
export const menuLabel = (path: string): string => export const menuLabel = (path: string): string =>
@@ -87,6 +89,9 @@ const marketing = (path: string, resource: string, createLabel: string, partner:
resource, resource,
createLabel, createLabel,
orderby: 'DocEntry desc', orderby: 'DocEntry desc',
partner: path.startsWith('/ventas')
? { label: partner, type: 'cCustomer', taxResource: 'SalesTaxCodes' }
: { label: partner, type: 'cSupplier', taxResource: 'PurchaseTaxCodes' },
extraFields: ['Cancelled', 'DocTotalFc', 'DocCurrency'], extraFields: ['Cancelled', 'DocTotalFc', 'DocCurrency'],
columns: [ columns: [
docNum, docNum,
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, newDocumentLine, recalcLine } from './line-columns'; import { DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine } from './line-columns';
describe('recalcLine', () => { describe('recalcLine', () => {
it('total = cantidad × precio, redondeado a 2 decimales', () => { it('total = cantidad × precio, redondeado a 2 decimales', () => {
@@ -59,3 +59,12 @@ describe('línea nueva y "catálogo completo"', () => {
expect(ITEMS_MULTI_ADD.toLine({ ItemCode: 'I9', ItemName: 'Cable' })).toMatchObject({ ItemCode: 'I9', ItemDescription: 'Cable', Quantity: 1 }); expect(ITEMS_MULTI_ADD.toLine({ ItemCode: 'I9', ItemName: 'Cable' })).toMatchObject({ ItemCode: 'I9', ItemDescription: 'Cable', Quantity: 1 });
}); });
}); });
describe('lineColumnsFor', () => {
it('ventas conserva las columnas; compras cambia el maestro de impuestos', () => {
expect(lineColumnsFor('SalesTaxCodes')).toBe(DOCUMENT_LINE_COLUMNS);
const tax = lineColumnsFor('PurchaseTaxCodes').find((c) => c.key === 'TaxCode');
expect(tax?.lookup?.config.resource).toBe('PurchaseTaxCodes');
expect(DOCUMENT_LINE_COLUMNS.find((c) => c.key === 'TaxCode')?.lookup?.config.resource).toBe('SalesTaxCodes');
});
});
@@ -93,6 +93,16 @@ export const DOCUMENT_LINE_COLUMNS: readonly GridColumn[] = [
{ key: 'LineTotal', title: 'Total', type: 'number', decimals: 2, width: '110px', readonly: true }, { key: 'LineTotal', title: 'Total', type: 'number', decimals: 2, width: '110px', readonly: true },
]; ];
/** Columnas de línea con el maestro de impuestos que corresponde (ventas: `SalesTaxCodes`; compras: `PurchaseTaxCodes`). */
export function lineColumnsFor(taxResource: string): readonly GridColumn[] {
if (taxResource === 'SalesTaxCodes') return DOCUMENT_LINE_COLUMNS;
return DOCUMENT_LINE_COLUMNS.map((c) =>
c.key === 'TaxCode' && c.lookup
? { ...c, lookup: { ...c.lookup, config: { ...c.lookup.config, resource: taxResource } } }
: c,
);
}
/** "Catálogo completo": agrega de una vez varios artículos como líneas nuevas. */ /** "Catálogo completo": agrega de una vez varios artículos como líneas nuevas. */
export const ITEMS_MULTI_ADD: GridMultiAdd = { export const ITEMS_MULTI_ADD: GridMultiAdd = {
label: 'Elegir artículos…', label: 'Elegir artículos…',