feat(web): formularios de inventario y solicitudes de compra (entradas, salidas, transferencias, solicitudes de traslado)

Configuración declarativa en document-layouts.ts; las líneas de las transferencias van en
StockTransferLines y las de compra/inventario llevan UoMEntry si el artículo tiene grupo de unidades.
Probado contra SBODemoCL (solicitud de compra 2, solicitud de traslado 472, entrada 155).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-14 14:37:01 -05:00
1 parent 45c0ca536e
commit 155d18fce4
13 files changed
+550 -46

No files matched your search

@@ -1,4 +1,5 @@
import { HttpErrorResponse, httpResource } from '@angular/common/http';
import { NgTemplateOutlet } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
@@ -8,7 +9,7 @@ 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 type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
import { LinesGrid } from '../../shared/grid/lines-grid';
import type { VhLookup } from '../../shared/value-help/value-help.types';
import { Icon, MenuButton, type MenuItem, SboWindow, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
@@ -18,13 +19,16 @@ import {
type DocRecord,
type NavTarget,
computeTotals,
MARKETING_LINES,
createPayload,
isBlankLine,
isDocumentReadOnly,
taxRateMap,
updatePayload,
validateDocument,
validateLayout,
} from './document-model';
import { WAREHOUSE_LOOKUP, headerKeysOf } from './document-layouts';
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
import { ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine } from './line-columns';
@@ -33,15 +37,13 @@ import { ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine } from './
const HEADER_KEYS = ['CardCode', 'NumAtCard', 'DocDate', 'DocDueDate', 'DiscountPercent', 'Comments'] as const;
const today = (): string => new Date().toISOString().slice(0, 10);
const DATE_KEYS = new Set(['DocDate', 'DocDueDate', 'RequriedDate']);
/** SAP devuelve las fechas como `2026-01-05T00:00:00Z`; el campo de fecha y el guardado usan `2026-01-05`. */
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
const pickHeader = (doc: DocRecord | undefined): DocRecord => {
if (!doc) return { DocDate: today(), DocDueDate: today(), DiscountPercent: 0 };
const pickHeader = (doc: DocRecord | undefined, keys: readonly string[], blank: DocRecord): DocRecord => {
if (!doc) return blank;
return Object.fromEntries(
HEADER_KEYS.map((k) => [
k,
k === 'DocDate' || k === 'DocDueDate' ? dateOnly(doc[k]) : (doc[k] ?? (k === 'DiscountPercent' ? 0 : '')),
]),
keys.map((k) => [k, DATE_KEYS.has(k) ? dateOnly(doc[k]) : (doc[k] ?? (k === 'DiscountPercent' ? 0 : ''))]),
);
};
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
@@ -54,7 +56,7 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
@Component({
selector: 'sbo-document-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NumberField, RouterLink, SboWindow, TextField, TextareaField, Toolbar, ValueHelp],
imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, RouterLink, SboWindow, TextField, TextareaField, Toolbar, ValueHelp],
templateUrl: './document-form.page.html',
styles: `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
@@ -71,6 +73,19 @@ export class DocumentFormPage {
protected readonly title = this.route.snapshot.data['title'] as string;
protected readonly screen = this.route.snapshot.data['screen'] as DocumentScreen;
protected readonly partner = this.screen.partner;
/** Formulario de inventario o de solicitud de compra (sin socio ni totales). */
protected readonly layout = this.screen.layout;
private readonly lineSpec = this.layout?.lines ?? MARKETING_LINES;
private readonly headerKeys: readonly string[] = this.layout ? headerKeysOf(this.layout) : HEADER_KEYS;
private readonly blankHeader: DocRecord = this.layout
? this.layout.newHeader()
: { DocDate: today(), DocDueDate: today(), DiscountPercent: 0 };
private pick(doc: DocRecord | undefined): DocRecord {
return pickHeader(doc, this.headerKeys, this.blankHeader);
}
private docLines(doc: DocRecord | undefined): GridLine[] {
return (doc?.[this.lineSpec.key] as GridLine[] | undefined) ?? [];
}
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 },
@@ -97,15 +112,13 @@ export class DocumentFormPage {
});
/** 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 header = linkedSignal<DocRecord>(() => this.pick(this.doc()));
protected readonly lines = linkedSignal<readonly GridLine[]>(() => this.docLines(this.doc()).map((l) => ({ ...l })));
protected readonly partnerName = linkedSignal(() => text(this.doc()?.['CardName']));
// --- estado --------------------------------------------------------------------------------
protected readonly readOnly = computed(() => isDocumentReadOnly(this.doc()));
protected readonly readOnly = computed(() => isDocumentReadOnly(this.doc()) || (!!this.layout?.immutable && !this.isNew()));
protected readonly saving = signal(false);
private readonly message = signal<StatusMessage | null>(
((): StatusMessage | null => {
@@ -120,8 +133,13 @@ export class DocumentFormPage {
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.` };
if (this.readOnly()) {
const posted = this.layout?.immutable && !isDocumentReadOnly(this.doc());
return { kind: 'info', text: posted ? 'Documento contabilizado: solo lectura.' : 'Documento cerrado o cancelado: solo lectura.' };
}
if (this.isNew()) {
return { kind: 'info', text: this.layout ? 'Complete la cabecera y las líneas.' : `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>(() => {
@@ -137,8 +155,7 @@ export class DocumentFormPage {
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());
const { body } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec);
return Object.keys(body).length > 0;
});
@@ -229,10 +246,13 @@ export class DocumentFormPage {
// --- 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 columns = this.layout?.columns ?? lineColumnsFor(this.partner?.taxResource ?? 'SalesTaxCodes');
protected readonly newLine = this.layout ? () => this.layout!.newLine(this.header()) : newDocumentLine;
protected readonly recalc = this.layout ? undefined : recalcLine;
protected readonly multi: GridMultiAdd = this.layout
? { ...ITEMS_MULTI_ADD, toLine: (item) => this.layout!.columns[0].lookup!.apply(item, this.layout!.newLine(this.header())) }
: ITEMS_MULTI_ADD;
protected readonly warehouseLookup = WAREHOUSE_LOOKUP;
protected readonly partnerLookup: VhLookup = {
title: this.partner?.type === 'cSupplier' ? 'Proveedores' : 'Clientes',
resource: 'BusinessPartners',
@@ -293,7 +313,14 @@ export class DocumentFormPage {
protected async save(): Promise<void> {
if (this.saving() || this.readOnly()) return;
const errors = validateDocument(this.header(), this.lines(), this.partner?.label ?? 'Socio de negocio');
const errors = this.layout
? validateLayout(
this.header(),
this.lines(),
[...this.layout.left, ...this.layout.right].filter((f) => f.required),
this.layout.requiredLine,
)
: validateDocument(this.header(), this.lines(), this.partner?.label ?? 'Socio de negocio');
if (errors.length) {
this.message.set({ kind: 'err', text: errors[0] });
return;
@@ -303,19 +330,15 @@ export class DocumentFormPage {
try {
const d = this.doc();
if (!d) {
const created = await this.api.create(this.screen.resource, createPayload(this.header(), this.lines()));
const header = this.layout?.beforeCreate?.(this.header()) ?? this.header();
const created = await this.api.create(this.screen.resource, createPayload(header, this.lines(), this.lineSpec));
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(),
);
const { body, replaceLines } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec);
if (!Object.keys(body).length) {
this.message.set({ kind: 'info', text: 'No hay cambios que guardar.' });
return;