feat(web): folio y RUT en los formularios de facturas, notas de crédito, entregas y recepciones; RUT en todos los de socio

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-14 14:48:50 -05:00
1 parent 49408de082
commit c734dcbd0a
6 files changed
+186 -8

No files matched your search

+1 -1
View File
@@ -56,7 +56,7 @@ Cada menú abre primero una **lista de los últimos documentos** (barra de filtr
Hecho (commits en `main`): monorepo · API (config, capa SAP con sesión compartida y cola, login, permisos SQLite, proxy `/api/sl`) · web (temas, login, layout con menú por permisos, `sbo-value-help`, campos y filas compuestas, listas con filtros, ventana/toolbar/pestañas/menús, grilla de líneas, `/dev/componentes`).
Pendiente, en orden:
1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Un código escrito a mano (socio, artículo, almacén, impuesto) se resuelve al salir del campo con `SlLookupApi` (mismo recurso y filtro base que la lista "…"); si no existe se avisa. "Copiar de/a" hecho (`document-copy.ts`: flujos estándar y `BaseType/BaseEntry/BaseLine`; probado: orden de compra 2883 → recepción 299, orden de venta 315 → entrega 580, ambas órdenes quedaron cerradas). Formularios sin socio ni totales **hechos** (`document-layouts.ts`, configuración declarativa: cabecera de 2 columnas, columnas de línea, colección `DocumentLines` o `StockTransferLines`): entradas y salidas de inventario, transferencias de stock, solicitudes de traslado y solicitudes de compra. Entradas, salidas y transferencias contabilizadas son de solo lectura (SAP no las modifica); solicitudes de traslado y de compra se crean y modifican. Probado contra `SBODemoCL`: solicitud de compra 2 (la 1 es una prueba por API), solicitud de traslado 472 (cantidad modificada) y entrada de inventario 155 (1 unidad de `3MG30080714 M 10.5` en el almacén 8803). Limitaciones conocidas: los artículos por **lote o serie** no se pueden contabilizar (SAP pide `BatchNumbers`/`SerialNumbers`); las líneas de compra/inventario llevan `UoMEntry` solo si el artículo tiene grupo de unidades (`uomEntryOf`; sin él SAP responde "specify a UoM code"); los formularios de ventas y compras (`marketing`) aún no lo envían, y los de compra fallan con artículos sin unidad de compra por defecto. Falta: botón PDF, fila del flete y toolbar de ancho completo.
1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Un código escrito a mano (socio, artículo, almacén, impuesto) se resuelve al salir del campo con `SlLookupApi` (mismo recurso y filtro base que la lista "…"); si no existe se avisa. "Copiar de/a" hecho (`document-copy.ts`: flujos estándar y `BaseType/BaseEntry/BaseLine`; probado: orden de compra 2883 → recepción 299, orden de venta 315 → entrega 580, ambas órdenes quedaron cerradas). Formularios sin socio ni totales **hechos** (`document-layouts.ts`, configuración declarativa: cabecera de 2 columnas, columnas de línea, colección `DocumentLines` o `StockTransferLines`): entradas y salidas de inventario, transferencias de stock, solicitudes de traslado y solicitudes de compra. Entradas, salidas y transferencias contabilizadas son de solo lectura (SAP no las modifica); solicitudes de traslado y de compra se crean y modifican. Probado contra `SBODemoCL`: solicitud de compra 2 (la 1 es una prueba por API), solicitud de traslado 472 (cantidad modificada) y entrada de inventario 155 (1 unidad de `3MG30080714 M 10.5` en el almacén 8803). Limitaciones conocidas: los artículos por **lote o serie** no se pueden contabilizar (SAP pide `BatchNumbers`/`SerialNumbers`); las líneas de compra/inventario llevan `UoMEntry` solo si el artículo tiene grupo de unidades (`uomEntryOf`; sin él SAP responde "specify a UoM code"); los formularios de ventas y compras (`marketing`) aún no lo envían, y los de compra fallan con artículos sin unidad de compra por defecto. Los formularios con socio muestran `NumAtCard` y el RUT (`FederalTaxID`, solo lectura: lo completa SAP desde el socio); facturas, notas de crédito, entregas y recepciones (`FOLIO_PATHS` en `document-screens.ts`) añaden el folio tributario, que en el Service Layer es `FolioPrefixString` + `FolioNumber` (se edita como texto y se envía como número); las listas de facturas también los muestran. Falta: botón PDF, fila del flete y toolbar de ancho completo.
2. Pantallas de maestros (`Items`, `BusinessPartners`: Clientes/Proveedores, Artículos), hoy "no disponible".
3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`).
4. Auditoría (`audit_log` en SQLite; hoy solo líneas `AUDIT` en el log) y `nestjs-cls` para el usuario en los logs.
@@ -0,0 +1,120 @@
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 invoice = (over: Record<string, unknown> = {}) => ({
'odata.etag': 'W/"5"',
DocEntry: 8,
DocNum: 108,
CardCode: 'P1',
CardName: 'Proveedor Uno',
FederalTaxID: '76543210-K',
NumAtCard: 'REF-1',
FolioPrefixString: 'F001',
FolioNumber: 198837,
DocDate: '2026-09-11T00:00:00Z',
DocDueDate: '2026-09-20T00:00:00Z',
DiscountPercent: 0,
Comments: '',
DocumentStatus: 'bost_Open',
Cancelled: 'tNO',
DocumentLines: [{ LineNum: 0, ItemCode: 'A1', ItemDescription: 'Pieza', Quantity: 1, Price: 10, DiscountPercent: 0, TaxCode: 'IVA', LineTotal: 10, WarehouseCode: '01' }],
...over,
});
describe('folio y RUT en los formularios', () => {
let fixture: ComponentFixture<DocumentFormPage>;
let ctrl: HttpTestingController;
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 labels = () => [...el().querySelectorAll('label')].map((l) => l.textContent?.trim());
async function open(resource: string, doc: Record<string, unknown> | null) {
const screen = DOCUMENT_SCREENS.find((s) => s.resource === resource)!;
const params = doc ? { docEntry: '8' } : {};
const route = {
snapshot: { data: { screen, title: 'Documento' }, paramMap: convertToParamMap(params) },
paramMap: of(convertToParamMap(params)),
};
await TestBed.configureTestingModule({
imports: [DocumentFormPage],
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
fixture = TestBed.createComponent(DocumentFormPage);
await tick();
ctrl.expectOne((r) => r.url === '/api/sl/SalesTaxCodes').flush({ value: [{ Code: 'IVA', Rate: 19 }] });
if (doc) ctrl.expectOne(`/api/sl/${resource}/8`).flush(doc);
await tick();
}
beforeEach(() => TestBed.resetTestingModule());
it('lo llevan facturas, notas de crédito, entregas y recepciones (ventas y compras)', () => {
const withFolio = DOCUMENT_SCREENS.filter((s) => s.folio).map((s) => s.resource);
expect(withFolio.sort()).toEqual(
['CreditNotes', 'DeliveryNotes', 'Invoices', 'PurchaseCreditNotes', 'PurchaseDeliveryNotes', 'PurchaseInvoices'].sort(),
);
});
it('una factura muestra folio, número de folio, referencia y RUT', async () => {
await open('PurchaseInvoices', invoice());
expect(labels()).toEqual(expect.arrayContaining(['Folio', 'Nº referencia', 'RUT']));
const inputs = [...el().querySelectorAll<HTMLInputElement>('input')].map((i) => i.value);
expect(inputs).toEqual(expect.arrayContaining(['F001', '198837', 'REF-1', '76543210-K']));
});
it('una orden muestra referencia y RUT pero no el folio', async () => {
await open('Orders', invoice());
expect(labels()).toEqual(expect.arrayContaining(['Nº referencia', 'RUT']));
expect(labels()).not.toContain('Folio');
});
it('cambiar el folio envía el número como número', async () => {
await open('Invoices', invoice());
page().setHeader('FolioPrefixString', 'F002');
page().setHeader('FolioNumber', '200100');
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'PATCH');
expect(req.request.body).toEqual({ FolioPrefixString: 'F002', FolioNumber: 200100 });
req.flush(null);
await tick();
ctrl.expectOne('/api/sl/Invoices/8').flush(invoice());
});
it('un folio que no es numérico no se envía', async () => {
await open('Invoices', invoice());
page().setHeader('FolioNumber', '12ab');
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
ctrl.expectNone((r) => r.method === 'PATCH');
expect(el().querySelector('.status')?.textContent).toContain('folio debe ser numérico');
});
it('un documento nuevo sin folio no lo envía', async () => {
await open('Invoices', null);
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();
const body = ctrl.expectOne((r) => r.method === 'POST').request.body as Record<string, unknown>;
expect(body).not.toHaveProperty('FolioNumber');
expect(body).not.toHaveProperty('FolioPrefixString');
});
});
@@ -23,6 +23,7 @@
(picked)="onPartner($event)"
/>
<sbo-text-field label="Nombre" readonly [value]="partnerName()" />
<sbo-text-field label="RUT" readonly [value]="partnerTaxId()" />
<sbo-text-field
label="Nº referencia"
[readonly]="readOnly()"
@@ -45,6 +46,23 @@
[value]="str(header()['DocDueDate'])"
(valueChange)="setHeader('DocDueDate', $event)"
/>
@if (folio) {
<sbo-field label="Folio">
<sbo-input
width="90px"
[maxlength]="10"
[readonly]="readOnly()"
[value]="str(header()['FolioPrefixString'])"
(valueChange)="setHeader('FolioPrefixString', $event)"
/>
<sbo-input
ariaLabel="Número de folio"
[readonly]="readOnly()"
[value]="str(header()['FolioNumber'])"
(valueChange)="setHeader('FolioNumber', $event)"
/>
</sbo-field>
}
</div>
</div>
@@ -8,7 +8,7 @@ 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 { DateField, LookupField, NumberField, SboField, TextField, TextInput, TextareaField } from '../../shared/form';
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';
@@ -35,6 +35,8 @@ import { ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine } from './
/** 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;
/** Folio tributario de los documentos que lo llevan. */
const FOLIO_KEYS = ['FolioPrefixString', 'FolioNumber'] as const;
const today = (): string => new Date().toISOString().slice(0, 10);
const DATE_KEYS = new Set(['DocDate', 'DocDueDate', 'RequriedDate']);
@@ -43,10 +45,19 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
const pickHeader = (doc: DocRecord | undefined, keys: readonly string[], blank: DocRecord): DocRecord => {
if (!doc) return blank;
return Object.fromEntries(
keys.map((k) => [k, DATE_KEYS.has(k) ? dateOnly(doc[k]) : (doc[k] ?? (k === 'DiscountPercent' ? 0 : ''))]),
keys.map((k) => [
k,
DATE_KEYS.has(k) ? dateOnly(doc[k]) : k === 'FolioNumber' ? text(doc[k]) : (doc[k] ?? (k === 'DiscountPercent' ? 0 : '')),
]),
);
};
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
/** El folio se edita como texto; SAP lo espera como número. */
const folioAsNumber = (h: DocRecord): DocRecord => {
const folio = h['FolioNumber'];
if (typeof folio !== 'string') return h;
return { ...h, FolioNumber: folio.trim() === '' ? null : Number(folio) };
};
/**
* Formulario de un documento de ventas o compras: `nuevo` (vacío) o `:docEntry` (ver / editar).
@@ -56,7 +67,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, NgTemplateOutlet, NumberField, RouterLink, SboWindow, TextField, TextareaField, Toolbar, ValueHelp],
imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, RouterLink, SboField, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp],
templateUrl: './document-form.page.html',
styles: `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
@@ -76,7 +87,12 @@ export class DocumentFormPage {
/** 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 headerKeys: readonly string[] = this.layout
? headerKeysOf(this.layout)
: this.screen.folio
? [...HEADER_KEYS, ...FOLIO_KEYS]
: HEADER_KEYS;
protected readonly folio = !!this.screen.folio;
private readonly blankHeader: DocRecord = this.layout
? this.layout.newHeader()
: { DocDate: today(), DocDueDate: today(), DiscountPercent: 0 };
@@ -115,6 +131,8 @@ export class DocumentFormPage {
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']));
/** RUT del socio: lo completa SAP desde el maestro; aquí solo se muestra. */
protected readonly partnerTaxId = linkedSignal(() => text(this.doc()?.['FederalTaxID']));
// --- estado --------------------------------------------------------------------------------
@@ -233,6 +251,7 @@ export class DocumentFormPage {
if (!card) {
this.header.update((h) => ({ ...h, ...copied.header }));
this.partnerName.set(text(doc['CardName']));
this.partnerTaxId.set(text(doc['FederalTaxID']));
}
this.lines.update((ls) => [...ls.filter((l) => !isBlankLine(l)), ...copied.lines]);
this.message.set({
@@ -262,6 +281,7 @@ export class DocumentFormPage {
{ key: 'CardCode', title: 'Código', width: '130px', mono: true },
{ key: 'CardName', title: 'Nombre' },
],
extraFields: ['FederalTaxID'],
};
protected str = text;
@@ -280,6 +300,7 @@ export class DocumentFormPage {
if (code === text(this.header()['CardCode'])) return;
this.setHeader('CardCode', code);
this.partnerName.set('');
this.partnerTaxId.set('');
}
protected notify(textMessage: string): void {
@@ -288,6 +309,7 @@ export class DocumentFormPage {
protected onPartner(row: Record<string, unknown>): void {
this.partnerName.set(text(row['CardName']));
this.partnerTaxId.set(text(row['FederalTaxID']));
}
protected setLines(lines: readonly GridLine[]): void {
@@ -321,6 +343,7 @@ export class DocumentFormPage {
this.layout.requiredLine,
)
: validateDocument(this.header(), this.lines(), this.partner?.label ?? 'Socio de negocio');
if (this.folio && !/^\d*$/.test(text(this.header()['FolioNumber']).trim())) errors.push('El número de folio debe ser numérico.');
if (errors.length) {
this.message.set({ kind: 'err', text: errors[0] });
return;
@@ -330,7 +353,7 @@ export class DocumentFormPage {
try {
const d = this.doc();
if (!d) {
const header = this.layout?.beforeCreate?.(this.header()) ?? this.header();
const header = folioAsNumber(this.layout?.beforeCreate?.(this.header()) ?? this.header());
const created = await this.api.create(this.screen.resource, createPayload(header, this.lines(), this.lineSpec));
await this.router.navigate(['..', created['DocEntry']], {
relativeTo: this.route,
@@ -338,7 +361,9 @@ export class DocumentFormPage {
});
return;
}
const { body, replaceLines } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec);
const update = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec);
const body = folioAsNumber(update.body);
const replaceLines = update.replaceLines;
if (!Object.keys(body).length) {
this.message.set({ kind: 'info', text: 'No hay cambios que guardar.' });
return;
@@ -24,6 +24,8 @@ export interface DocumentScreen {
/** Maestro de impuestos (el Service Layer no tiene uno propio de compras: en Chile ambos usan `SalesTaxCodes`). */
taxResource: string;
};
/** El formulario muestra el folio tributario (`FolioPrefixString`, `FolioNumber`) y el RUT del socio. */
folio?: boolean;
/** Formulario de los documentos sin socio ni totales (inventario y solicitudes de compra). */
layout?: DocumentLayout;
}
@@ -165,7 +167,17 @@ const inventory = (
filters: withStatus ? [statusFilter, dateFilter] : [dateFilter],
});
export const DOCUMENT_SCREENS: readonly DocumentScreen[] = [
/** Documentos tributarios: llevan folio y RUT. */
const FOLIO_PATHS: ReadonlySet<string> = new Set([
'/ventas/entrega',
'/ventas/facturas',
'/ventas/notas-credito',
'/compras/recepcion',
'/compras/facturas',
'/compras/notas-credito',
]);
const SCREENS: readonly DocumentScreen[] = [
// Ventas
marketing('/ventas/ofertas', 'Quotations', 'Nueva oferta', 'Cliente'),
marketing('/ventas/ordenes', 'Orders', 'Nueva orden', 'Cliente'),
@@ -202,3 +214,5 @@ export const DOCUMENT_SCREENS: readonly DocumentScreen[] = [
inventory('/inventario/transferencias', 'StockTransfers', 'Nueva transferencia', false, LAYOUTS.transfer),
inventory('/inventario/solicitudes-traslado', 'InventoryTransferRequests', 'Nueva solicitud', true, LAYOUTS.transferRequest),
];
export const DOCUMENT_SCREENS: readonly DocumentScreen[] = SCREENS.map((s) => (FOLIO_PATHS.has(s.path) ? { ...s, folio: true } : s));
@@ -62,6 +62,7 @@ import { SboField } from './sbo-field';
[columns]="lookup().columns"
[baseFilter]="lookup().baseFilter"
[orderby]="lookup().orderby"
[extraFields]="lookup().extraFields ?? []"
(selected)="onPicked($event)"
(closed)="closeList()"
/>