From d900a967404ecada7be3c511887a99a0dfaede15 Mon Sep 17 00:00:00 2001 From: George Date: Tue, 13 Oct 2026 22:00:54 -0500 Subject: [PATCH] feat(web): formulario de documentos de ventas y compras (cargar, crear, actualizar con If-Match, navegar) Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/sl/sl-resources.ts | 1 + apps/web/README.md | 11 + apps/web/src/app/core/sl/sl-document.ts | 42 +++ .../documents/document-form.page.html | 95 +++++++ .../documents/document-form.page.spec.ts | 216 ++++++++++++++++ .../features/documents/document-form.page.ts | 241 +++++++++++++++++- .../features/documents/document-screens.ts | 5 + .../features/documents/line-columns.spec.ts | 11 +- .../app/features/documents/line-columns.ts | 10 + 9 files changed, 621 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/app/core/sl/sl-document.ts create mode 100644 apps/web/src/app/features/documents/document-form.page.html create mode 100644 apps/web/src/app/features/documents/document-form.page.spec.ts diff --git a/apps/api/src/sl/sl-resources.ts b/apps/api/src/sl/sl-resources.ts index b2235ed..96cf325 100644 --- a/apps/api/src/sl/sl-resources.ts +++ b/apps/api/src/sl/sl-resources.ts @@ -69,6 +69,7 @@ export const SL_RESOURCES: readonly SlResource[] = [ catalog('Warehouses', 'string'), catalog('Currencies', 'string'), catalog('SalesTaxCodes', 'string'), + catalog('PurchaseTaxCodes', 'string'), catalog('VatGroups', 'string'), catalog('PaymentTermsTypes', 'number'), catalog('SalesPersons', 'number'), diff --git a/apps/web/README.md b/apps/web/README.md index b98e469..cf34bcd 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -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). +#### 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` Lista con **barra de filtros** y **búsqueda por columna**, igual que el mini-erp: diff --git a/apps/web/src/app/core/sl/sl-document.ts b/apps/web/src/app/core/sl/sl-document.ts new file mode 100644 index 0000000..1aaf437 --- /dev/null +++ b/apps/web/src/app/core/sl/sl-document.ts @@ -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 { + 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>(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 { + return firstValueFrom(this.http.post(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 { + 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(this.url(resource, docEntry), body, { headers })); + } +} diff --git a/apps/web/src/app/features/documents/document-form.page.html b/apps/web/src/app/features/documents/document-form.page.html new file mode 100644 index 0000000..5419d76 --- /dev/null +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -0,0 +1,95 @@ + + Lista + Nuevo + + + + + + + + + @if (partner) { +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+
+ + + + +
+
+ } @else { +

El formulario de este documento aún no está disponible. Volver a la lista

+ } + + @if (partner && !readOnly()) { + + } + @if (conflict()) { + + } + {{ readOnly() ? 'Cerrar' : 'Cancelar' }} +
diff --git a/apps/web/src/app/features/documents/document-form.page.spec.ts b/apps/web/src/app/features/documents/document-form.page.spec.ts new file mode 100644 index 0000000..ece6c1c --- /dev/null +++ b/apps/web/src/app/features/documents/document-form.page.spec.ts @@ -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 = {}) => ({ + '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; + let ctrl: HttpTestingController; + let navigate: ReturnType; + 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) { + 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(); + }); + }); +}); diff --git a/apps/web/src/app/features/documents/document-form.page.ts b/apps/web/src/app/features/documents/document-form.page.ts index be30e35..fdf185d 100644 --- a/apps/web/src/app/features/documents/document-form.page.ts +++ b/apps/web/src/app/features/documents/document-form.page.ts @@ -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: ` -

{{ title }} — {{ docEntry ? 'documento ' + docEntry : 'nuevo' }}

-

El formulario aún no está disponible. Volver a la lista

- `, + 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(() => { + const id = this.docEntry(); + return id === null ? undefined : `${API_URL}/sl/${this.screen.resource}/${id}`; + }); + private readonly taxResource = httpResource>(() => + 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(() => pickHeader(this.doc())); + protected readonly lines = linkedSignal(() => + ((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 => { + 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(() => { + 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(() => { + 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): 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 { + 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 { + 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(); + } } diff --git a/apps/web/src/app/features/documents/document-screens.ts b/apps/web/src/app/features/documents/document-screens.ts index 6e70d7a..29abdc7 100644 --- a/apps/web/src/app/features/documents/document-screens.ts +++ b/apps/web/src/app/features/documents/document-screens.ts @@ -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, diff --git a/apps/web/src/app/features/documents/line-columns.spec.ts b/apps/web/src/app/features/documents/line-columns.spec.ts index 589c2f5..e72de99 100644 --- a/apps/web/src/app/features/documents/line-columns.spec.ts +++ b/apps/web/src/app/features/documents/line-columns.spec.ts @@ -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'); + }); +}); diff --git a/apps/web/src/app/features/documents/line-columns.ts b/apps/web/src/app/features/documents/line-columns.ts index 0709341..fef5aaf 100644 --- a/apps/web/src/app/features/documents/line-columns.ts +++ b/apps/web/src/app/features/documents/line-columns.ts @@ -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…',