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:
1 parent
51982fd3ab
commit
d900a96740
9 files changed
+621
-11
No files matched your search
@@ -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 { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import type { DocumentScreen } from './document-screens';
|
||||
import { HttpErrorResponse, httpResource } from '@angular/common/http';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, signal } from '@angular/core';
|
||||
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({
|
||||
selector: 'sbo-document-form-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
template: `
|
||||
<h1>{{ title }} — {{ docEntry ? 'documento ' + docEntry : 'nuevo' }}</h1>
|
||||
<p>El formulario aún no está disponible. <a routerLink="..">Volver a la lista</a></p>
|
||||
`,
|
||||
imports: [DateField, Icon, LinesGrid, LookupField, NumberField, RouterLink, SboWindow, TextField, TextareaField, Toolbar],
|
||||
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; }
|
||||
`,
|
||||
})
|
||||
export class DocumentFormPage {
|
||||
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 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[];
|
||||
/** Los más recientes primero. */
|
||||
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 =>
|
||||
@@ -87,6 +89,9 @@ const marketing = (path: string, resource: string, createLabel: string, partner:
|
||||
resource,
|
||||
createLabel,
|
||||
orderby: 'DocEntry desc',
|
||||
partner: path.startsWith('/ventas')
|
||||
? { label: partner, type: 'cCustomer', taxResource: 'SalesTaxCodes' }
|
||||
: { label: partner, type: 'cSupplier', taxResource: 'PurchaseTaxCodes' },
|
||||
extraFields: ['Cancelled', 'DocTotalFc', 'DocCurrency'],
|
||||
columns: [
|
||||
docNum,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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', () => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
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 },
|
||||
];
|
||||
|
||||
/** 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. */
|
||||
export const ITEMS_MULTI_ADD: GridMultiAdd = {
|
||||
label: 'Elegir artículos…',
|
||||
|
||||
Reference in new issue
Block a user