diff --git a/CLAUDE.md b/CLAUDE.md
index 4f0c89a..bcc4956 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -52,7 +52,7 @@ Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de
## Flujo de las pantallas de documento
-Cada menú abre primero una **lista de los últimos documentos** (barra de filtros + búsqueda por columna); desde ahí se crea uno nuevo (`/…/nuevo`, formulario vacío) o se abre uno existente (`/…/:docEntry`, ver/editar; los cerrados/cancelados en solo lectura). Las pantallas se definen por módulo en `apps/web/src/app/features/documents/` (17 documentos): `ventas/ventas.screens.ts`, `compras/compras.screens.ts` (+ `compras.layouts.ts`) e `inventario/inventario.screens.ts` (+ `inventario.layouts.ts`); los maestros (socios de negocio, artículos) irán en su propia carpeta. `screen-kit.ts` tiene el tipo `DocumentScreen` y las columnas y filtros comunes; `document-screens.ts` las reúne en `DOCUMENT_SCREENS`. Las columnas de línea estándar están en `features/documents/line-columns.ts`.
+Cada menú abre primero una **lista de los últimos documentos** (barra de filtros + búsqueda por columna); desde ahí se crea uno nuevo (`/…/nuevo`, formulario vacío) o se abre uno existente (`/…/:docEntry`, ver/editar; los cerrados/cancelados en solo lectura). Las pantallas se definen por módulo en `apps/web/src/app/features/documents/` (17 documentos): `ventas/ventas.screens.ts`, `compras/compras.screens.ts` (+ `compras.layouts.ts`) e `inventario/inventario.screens.ts` (+ `inventario.layouts.ts`); los maestros (socios de negocio, artículos) irán en su propia carpeta. `screen-kit.ts` tiene el tipo `DocumentScreen` y las columnas y filtros comunes; `document-screens.ts` las reúne en `DOCUMENT_SCREENS`. Las columnas de línea estándar están en `features/documents/line-columns.ts`. **Tipo Artículo / Servicio** (`DocType`): los documentos de ventas y compras con socio llevan en la cabecera la lista «Tipo» (primera de la columna izquierda; no se cambia en uno guardado). Con «Servicio» las líneas pasan a `layout.service` (`serviceLineColumns` en `line-columns.ts`): Cuenta contable (obligatoria), Descripción, Impuesto y Total (se escribe, sin recalcular), dimensiones, proyecto y referencias al origen; sin artículo, cantidad, almacén, precio, lotes ni «Elegir artículos». Se guardan `AccountCode`, `ItemDescription`, `TaxCode`, `LineTotal` (`LinesSpec.service`); cambiar el tipo con líneas escritas pide permiso y las descarta; «Copiar de» trae el tipo y el importe abierto (`OpenAmount`) del origen y no mezcla tipos. Las extensiones de cliente de columnas de línea aplican a las líneas de artículos; las de servicio aún no son extensibles.
## Cómo verificar cambios de interfaz
diff --git a/apps/web/src/app/features/documents/document-copy.spec.ts b/apps/web/src/app/features/documents/document-copy.spec.ts
index 2025602..46228ba 100644
--- a/apps/web/src/app/features/documents/document-copy.spec.ts
+++ b/apps/web/src/app/features/documents/document-copy.spec.ts
@@ -59,6 +59,7 @@ describe('copyFromDocument', () => {
it('copia socio, referencia y descuento, y deja el origen en el comentario', () => {
expect(copyFromDocument(source, '/ventas/ordenes', 'Órdenes de venta').header).toEqual({
+ DocType: 'dDocument_Items',
CardCode: 'C1',
NumAtCard: 'REF',
DiscountPercent: 5,
@@ -68,3 +69,27 @@ describe('copyFromDocument', () => {
});
});
});
+
+describe('copyFromDocument: documento de servicio', () => {
+ const source = {
+ DocEntry: 40,
+ DocNum: 140,
+ DocType: 'dDocument_Service',
+ CardCode: 'P1',
+ DocumentLines: [
+ { LineNum: 0, AccountCode: '_SYS1', ItemDescription: 'Alquiler', TaxCode: 'IGV', LineTotal: 100, OpenAmount: 60, LineStatus: 'bost_Open', CostingCode: 'CC1' },
+ { LineNum: 1, AccountCode: '_SYS2', ItemDescription: 'Cerrada', LineTotal: 20, OpenAmount: 0, LineStatus: 'bost_Close' },
+ ],
+ };
+
+ it('copia el tipo, el importe abierto y la cuenta de cada línea de servicio', () => {
+ const copied = copyFromDocument(source, '/compras/ordenes', 'Órdenes de compra');
+ expect(copied.header['DocType']).toBe('dDocument_Service');
+ expect(copied.skipped).toBe(1);
+ expect(copied.lines).toEqual([
+ expect.objectContaining({ AccountCode: '_SYS1', ItemDescription: 'Alquiler', TaxCode: 'IGV', LineTotal: 60, CostingCode: 'CC1', BaseEntry: 40, BaseLine: 0 }),
+ ]);
+ expect(copied.lines[0]).not.toHaveProperty('ItemCode');
+ });
+});
+
diff --git a/apps/web/src/app/features/documents/document-copy.ts b/apps/web/src/app/features/documents/document-copy.ts
index a0979d8..ca0707a 100644
--- a/apps/web/src/app/features/documents/document-copy.ts
+++ b/apps/web/src/app/features/documents/document-copy.ts
@@ -54,12 +54,32 @@ export function copyFromDocument(source: DocRecord, sourcePath: string, sourceLa
const baseType = OBJECT_TYPES[sourcePath];
const lines: GridLine[] = [];
let skipped = 0;
+ const service = source['DocType'] === 'dDocument_Service';
for (const l of (source['DocumentLines'] as GridLine[] | undefined) ?? []) {
- const remaining = Number(l['RemainingOpenQuantity'] ?? l['Quantity'] ?? 0);
+ // un servicio no tiene cantidad: lo que queda por copiar es su importe abierto
+ const remaining = service ? Number(l['OpenAmount'] ?? l['LineTotal'] ?? 0) : Number(l['RemainingOpenQuantity'] ?? l['Quantity'] ?? 0);
if (l['LineStatus'] === 'bost_Close' || !(remaining > 0)) {
skipped++;
continue;
}
+ if (service) {
+ lines.push({
+ ItemDescription: l['ItemDescription'] ?? '',
+ AccountCode: l['AccountCode'] ?? '',
+ TaxCode: l['TaxCode'] ?? '',
+ LineTotal: remaining,
+ CostingCode: l['CostingCode'] ?? '',
+ CostingCode2: l['CostingCode2'] ?? '',
+ CostingCode3: l['CostingCode3'] ?? '',
+ CostingCode4: l['CostingCode4'] ?? '',
+ CostingCode5: l['CostingCode5'] ?? '',
+ ProjectCode: l['ProjectCode'] ?? '',
+ BaseType: baseType,
+ BaseEntry: source['DocEntry'],
+ BaseLine: l['LineNum'],
+ });
+ continue;
+ }
lines.push(
recalcLine({
...newDocumentLine(),
@@ -84,6 +104,7 @@ export function copyFromDocument(source: DocRecord, sourcePath: string, sourceLa
}
return {
header: {
+ DocType: service ? 'dDocument_Service' : 'dDocument_Items',
CardCode: source['CardCode'] ?? '',
NumAtCard: source['NumAtCard'] ?? '',
DiscountPercent: source['DiscountPercent'] ?? 0,
diff --git a/apps/web/src/app/features/documents/document-form.page.html b/apps/web/src/app/features/documents/document-form.page.html
index d986b37..a8e6dfb 100644
--- a/apps/web/src/app/features/documents/document-form.page.html
+++ b/apps/web/src/app/features/documents/document-form.page.html
@@ -22,16 +22,23 @@
+ @if (pendingType(); as type) {
+
+ Cambiar el tipo a «{{ pendingTypeLabel() }}» descarta {{ lineCount() === 1 ? 'la línea escrita' : 'las ' + lineCount() + ' líneas escritas' }}.
+
+
+
+ }
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
index fb4ae55..686c91e 100644
--- a/apps/web/src/app/features/documents/document-form.page.spec.ts
+++ b/apps/web/src/app/features/documents/document-form.page.spec.ts
@@ -198,6 +198,7 @@ describe('DocumentFormPage', () => {
const req = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService'));
expect(req.request.url).toBe('/api/sl/Orders');
expect(req.request.body).toEqual({
+ DocType: 'dDocument_Items',
CardCode: 'C1',
DocDate: page().header().DocDate,
DocDueDate: page().header().DocDueDate,
@@ -209,6 +210,58 @@ describe('DocumentFormPage', () => {
expect(navigate).toHaveBeenCalledWith(['..', 99], expect.objectContaining({ state: { created: '1099' } }));
});
+ describe('tipo Servicio', () => {
+ const headers = () => [...el().querySelectorAll('.lines-head th, table.grid thead th')].map((t) => t.textContent?.trim());
+
+ it('un documento de servicio cambia las columnas: cuenta contable, descripción, impuesto y total, sin artículo ni cantidad', async () => {
+ page().setHeader('DocType', 'dDocument_Service');
+ await tick();
+ const titles = headers();
+ expect(titles).toEqual(expect.arrayContaining(['Cuenta contable', 'Descripción', 'Impuesto', 'Total']));
+ expect(titles).not.toContain('Nº de artículo');
+ expect(titles).not.toContain('Cantidad');
+ });
+
+ it('crea el documento con DocType de servicio y líneas de cuenta y total', async () => {
+ page().setHeader('DocType', 'dDocument_Service');
+ page().setHeader('CardCode', 'P1');
+ page().setLines([{ AccountCode: '_SYS1', ItemDescription: 'Alquiler', TaxCode: 'IGV', LineTotal: 100, ItemCode: '' }]);
+ await tick();
+ (el().querySelector('.actions .btn.main') as HTMLElement).click();
+ await tick();
+ const req = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService'));
+ expect(req.request.body).toMatchObject({ DocType: 'dDocument_Service', CardCode: 'P1' });
+ expect((req.request.body as { DocumentLines: unknown[] }).DocumentLines).toEqual([{ AccountCode: '_SYS1', ItemDescription: 'Alquiler', TaxCode: 'IGV', LineTotal: 100 }]);
+ req.flush({ DocEntry: 7, DocNum: 1007 });
+ await tick();
+ });
+
+ it('exige cuenta contable y un total mayor que cero', async () => {
+ page().setHeader('DocType', 'dDocument_Service');
+ page().setHeader('CardCode', 'P1');
+ page().setLines([{ ItemDescription: 'Sin cuenta', LineTotal: 10 }]);
+ await tick();
+ (el().querySelector('.actions .btn.main') as HTMLElement).click();
+ await tick();
+ ctrl.expectNone((r) => r.method === 'POST' && !r.url.includes('SeriesService'));
+ expect(el().querySelector('.status')!.textContent).toContain('indique la cuenta contable');
+ });
+
+ it('cambiar el tipo con líneas escritas pide permiso y, si se acepta, las descarta', async () => {
+ page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 50, LineTotal: 50 }]);
+ await tick();
+ page().setHeader('DocType', 'dDocument_Service');
+ await tick();
+ expect(el().querySelector('.ask')!.textContent).toContain('descarta la línea escrita');
+ expect(page().header().DocType).toBe('dDocument_Items'); // todavía no cambió
+ (el().querySelector('.ask .btn.main') as HTMLElement).click();
+ await tick();
+ expect(page().header().DocType).toBe('dDocument_Service');
+ expect(page().lines()).toEqual([]);
+ expect(el().querySelector('.ask')).toBeNull();
+ });
+ });
+
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 }]);
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 9f9f2f9..e429646 100644
--- a/apps/web/src/app/features/documents/document-form.page.ts
+++ b/apps/web/src/app/features/documents/document-form.page.ts
@@ -28,6 +28,7 @@ import {
updatePayload,
validateLayout,
} from './document-model';
+import { SERVICE_DOC_TYPE } from './document-layouts';
import { type ComputedSource, type HeaderField, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasFolio, hasFreight, hasSeries, headerKeysOf, keysOfFields, requiredHeaderOf } from './document-layouts';
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
@@ -71,6 +72,7 @@ const folioAsNumber = (h: DocRecord): DocRecord => {
p { margin: 0; color: var(--quiet); }
a { color: var(--focus); }
.totals { display: grid; gap: 2px; }
+ .ask { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 4px 0 6px; padding: 4px 8px; background: var(--warn-bg); border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); }
`,
})
export class DocumentFormPage {
@@ -84,6 +86,9 @@ export class DocumentFormPage {
/** Qué campos y columnas lleva este documento (lo declara su pantalla); el formulario solo lo dibuja. */
protected readonly layout = this.screen.layout;
private readonly lineSpec = this.layout.lines;
+ /** Si el documento es de servicio, las columnas, los campos que se guardan y las reglas de las líneas son otras. */
+ protected readonly serviceMode = computed(() => !!this.layout.service && this.header()['DocType'] === SERVICE_DOC_TYPE);
+ private readonly spec = computed(() => (this.serviceMode() ? this.layout.service!.lines : this.lineSpec));
private readonly headerKeys: readonly string[] = headerKeysOf(this.layout);
private readonly hasFolio = hasFolio(this.layout);
private readonly blankHeader: DocRecord = this.layout.newHeader();
@@ -213,8 +218,8 @@ 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 { body } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec);
+ if (!d) return text(this.header()['CardCode']) !== '' || this.lines().some((l) => !isBlankLine(l, this.serviceMode()));
+ const { body } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.spec());
return Object.keys(body).length > 0;
});
@@ -296,12 +301,21 @@ export class DocumentFormPage {
this.message.set({ kind: 'warn', text: 'El documento no tiene líneas abiertas para copiar.' });
return;
}
+ // el tipo (artículo / servicio) del origen: si este documento ya tiene líneas de otro tipo no se mezclan
+ const copiedType = text(copied.header['DocType']);
+ if (this.layout.service && copiedType && copiedType !== text(this.header()['DocType'])) {
+ if (this.lines().some((l) => !isBlankLine(l, this.serviceMode()))) {
+ this.message.set({ kind: 'warn', text: `El documento es de ${copiedType === SERVICE_DOC_TYPE ? 'servicio' : 'artículos'} y este tiene líneas de otro tipo.` });
+ return;
+ }
+ this.header.update((h) => ({ ...h, DocType: copiedType }));
+ }
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.lines.update((ls) => [...ls.filter((l) => !isBlankLine(l, this.serviceMode())), ...copied.lines]);
this.message.set({
kind: 'ok',
text: `Copiado de ${menuLabel(origin.path).toLowerCase()} ${text(doc['DocNum'])}${copied.skipped ? ` (${copied.skipped} línea(s) cerradas no se copian)` : ''}. Revise y pulse Crear.`,
@@ -313,12 +327,14 @@ export class DocumentFormPage {
// --- configuración de la vista --------------------------------------------------------------
- protected readonly columns = this.layout.columns;
+ protected readonly columns = computed(() => (this.serviceMode() ? this.layout.service!.columns : this.layout.columns));
private readonly itemColumn = this.layout.columns.find((c) => c.key === 'ItemCode') ?? this.layout.columns[0];
- protected readonly newLine = () => this.layout.newLine(this.header());
+ protected readonly newLine = () => (this.serviceMode() ? this.layout.service!.newLine() : this.layout.newLine(this.header()));
/** Recalcula la línea con la tasa de cada impuesto (precio con y sin impuesto, total). */
protected readonly detailHint = detailHint;
- protected readonly recalc = this.layout.recalc ? (line: GridLine, key: string) => this.layout.recalc!(line, key, this.rates()) : undefined;
+ protected readonly recalc = computed(() =>
+ !this.serviceMode() && this.layout.recalc ? (line: GridLine, key: string) => this.layout.recalc!(line, key, this.rates()) : undefined,
+ );
protected readonly multi: GridMultiAdd = {
...ITEMS_MULTI_ADD,
lookup: this.itemColumn.lookup!.config,
@@ -342,6 +358,8 @@ export class DocumentFormPage {
private readonly lockedKeys = new Set(this.layout.lockedKeys ?? []);
/** El campo se ve pero no se edita (una extensión de cliente lo dejó de solo lectura). */
protected locked(f: HeaderField): boolean {
+ // el tipo (artículo / servicio) no se cambia en un documento ya creado
+ if (f.type === 'select' && f.key === 'DocType' && !this.isNew()) return true;
return this.lockedKeys.size > 0 && (keysOfFields([f]).some((k) => this.lockedKeys.has(k)) || (f.type === 'computed' && this.lockedKeys.has(f.source)));
}
@@ -368,10 +386,34 @@ export class DocumentFormPage {
}
protected setHeader(key: string, value: unknown): void {
+ if (key === 'DocType' && this.layout.service) return this.onDocType(String(value));
this.header.update((h) => ({ ...h, [key]: value }));
this.message.set(null);
}
+ /** Tipo al que se quiere cambiar y que espera confirmación porque hay líneas escritas que se descartarían. */
+ protected readonly pendingType = signal(null);
+
+ /** Cambiar entre artículo y servicio cambia las columnas de línea: las líneas escritas no sirven y se pide permiso para descartarlas. */
+ private onDocType(type: string): void {
+ if (type === text(this.header()['DocType'])) return;
+ if (this.lines().some((l) => !isBlankLine(l, this.serviceMode()))) {
+ this.pendingType.set(type);
+ return;
+ }
+ this.applyDocType(type);
+ }
+
+ protected applyDocType(type: string): void {
+ this.pendingType.set(null);
+ this.header.update((h) => ({ ...h, DocType: type }));
+ this.lines.set([]);
+ this.message.set(null);
+ }
+
+ protected readonly pendingTypeLabel = computed(() => (this.pendingType() === SERVICE_DOC_TYPE ? 'Servicio' : 'Artículo'));
+ protected readonly lineCount = computed(() => this.lines().filter((l) => !isBlankLine(l, this.serviceMode())).length);
+
/** Al escribir otro código el nombre anterior ya no vale; se completa al salir del campo o al elegir de la lista. */
protected onPartnerText(code: string): void {
if (code === text(this.header()['CardCode'])) return;
@@ -412,8 +454,8 @@ export class DocumentFormPage {
protected async save(): Promise {
if (this.saving() || this.readOnly()) return;
- const errors = validateLayout(this.header(), this.lines(), requiredHeaderOf(this.layout), this.layout.requiredLine);
- if (this.layout.details?.length) errors.push(...validateLineDetails(this.lines()));
+ const errors = validateLayout(this.header(), this.lines(), requiredHeaderOf(this.layout), this.layout.requiredLine, this.serviceMode());
+ if (this.layout.details?.length && !this.serviceMode()) errors.push(...validateLineDetails(this.lines()));
if (this.hasFolio && !/^\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] });
@@ -426,8 +468,8 @@ export class DocumentFormPage {
if (!d) {
const header = folioAsNumber(this.layout.beforeCreate?.(this.header()) ?? this.header());
const body = this.hasFreight
- ? freightPayload(createPayload(header, this.lines(), this.lineSpec), this.header(), this.defaultExpense(), 'create').body
- : createPayload(header, this.lines(), this.lineSpec);
+ ? freightPayload(createPayload(header, this.lines(), this.spec()), this.header(), this.defaultExpense(), 'create').body
+ : createPayload(header, this.lines(), this.spec());
const created = await this.api.create(this.screen.resource, body);
await this.router.navigate(['..', created['DocEntry']], {
relativeTo: this.route,
@@ -435,7 +477,7 @@ export class DocumentFormPage {
});
return;
}
- const update = 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.spec());
let body = folioAsNumber(update.body);
let replaceLines = update.replaceLines;
if (this.hasFreight) {
diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts
index 1306ed3..7538948 100644
--- a/apps/web/src/app/features/documents/document-layouts.ts
+++ b/apps/web/src/app/features/documents/document-layouts.ts
@@ -4,7 +4,7 @@ import type { VhLookup } from '../../shared/value-help/value-help.types';
import type { DocRecord, LinesSpec } from './document-model';
import { INDICATOR_LOOKUP, PROJECT_LOOKUP } from './dimension-lookups';
import { SERIES_SUBTYPE } from './document-objects';
-import { ANALYTIC_COLUMNS, DOCUMENT_LINE_COLUMNS, lineColumnsFor, lineFieldsOf, newDocumentLine, recalcLine, uomEntryOf } from './line-columns';
+import { ANALYTIC_COLUMNS, DOCUMENT_LINE_COLUMNS, lineColumnsFor, lineFieldsOf, newDocumentLine, newServiceLine, recalcLine, serviceLineColumns, uomEntryOf } from './line-columns';
/** Valores calculados o del socio que una fila de solo lectura puede mostrar. */
export type ComputedSource = 'docNum' | 'partnerName' | 'partnerTaxId' | 'subtotal' | 'tax' | 'total';
@@ -86,14 +86,34 @@ export interface DocumentLayout {
/** Ajustes a la cabecera antes de crear. */
beforeCreate?: (header: DocRecord) => DocRecord;
requiredLine: readonly { key: string; label: string }[];
+ /**
+ * Cómo cambia el documento cuando es de tipo **Servicio** (`DocType = dDocument_Service`): otras columnas de línea
+ * y otros campos que se guardan. Solo lo tienen los documentos de ventas y compras, que además llevan en la
+ * cabecera la lista «Tipo» (`FIELDS.docType`).
+ */
+ service?: { columns: readonly GridColumn[]; lines: LinesSpec; newLine: () => GridLine };
}
+/** Un documento de servicio (no de artículos): `DocType` de SAP. */
+export const SERVICE_DOC_TYPE = 'dDocument_Service';
+export const ITEMS_DOC_TYPE = 'dDocument_Items';
+
export const today = (): string => new Date().toISOString().slice(0, 10);
// --- catálogo de campos ---------------------------------------------------------------------
/** Catálogo de campos: cada pantalla elige cuáles lleva. */
export const FIELDS = {
+ /** Tipo del documento: de artículos o de servicio (las líneas cambian). */
+ docType: {
+ type: 'select',
+ key: 'DocType',
+ label: 'Tipo',
+ options: [
+ { value: ITEMS_DOC_TYPE, label: 'Artículo' },
+ { value: SERVICE_DOC_TYPE, label: 'Servicio' },
+ ],
+ },
docNum: { type: 'computed', label: 'Número', source: 'docNum' },
/** Número del documento con su serie. */
docNumber: { type: 'series', key: 'Series', label: 'Número' },
@@ -224,6 +244,7 @@ export function marketingLayout(opts: {
lines: { key: 'DocumentLines', fields: [...lineFieldsOf(columns), 'UoMEntry'], base: true },
header: opts.header ?? {
left: [
+ FIELDS.docType,
{ type: 'partner', key: 'CardCode', label: opts.partnerLabel, required: true, missing: `el ${opts.partnerLabel.toLowerCase()}` },
{ type: 'computed', label: 'Nombre', source: 'partnerName' },
{ type: 'computed', label: 'RUT', source: 'partnerTaxId' },
@@ -245,9 +266,14 @@ export function marketingLayout(opts: {
],
},
columns,
- newHeader: () => ({ DocDate: today(), DocDueDate: today(), DiscountPercent: 0 }),
+ newHeader: () => ({ DocType: ITEMS_DOC_TYPE, DocDate: today(), DocDueDate: today(), DiscountPercent: 0 }),
newLine: newDocumentLine,
recalc: recalcLine,
+ service: {
+ columns: serviceLineColumns(opts.taxResource),
+ lines: { key: 'DocumentLines', fields: lineFieldsOf(serviceLineColumns(opts.taxResource)), base: true, service: true },
+ newLine: newServiceLine,
+ },
seriesSubType: SERIES_SUBTYPE[String(opts.fixed?.['DocumentSubType'] ?? 'bod_None')] ?? '--',
...(opts.fixed ? { beforeCreate: (h: DocRecord) => ({ ...h, ...opts.fixed }) } : {}),
requiredLine: [],
diff --git a/apps/web/src/app/features/documents/document-model.spec.ts b/apps/web/src/app/features/documents/document-model.spec.ts
index f6100ae..789ec4c 100644
--- a/apps/web/src/app/features/documents/document-model.spec.ts
+++ b/apps/web/src/app/features/documents/document-model.spec.ts
@@ -190,3 +190,28 @@ describe('flete (gastos adicionales)', () => {
expect(freightPayload({ FreightAmount: 0 }, { FreightAmount: 0 }, '2', 'update', false).body).toEqual({});
});
});
+
+describe('documentos de servicio', () => {
+ const service = { key: 'DocumentLines', fields: ['AccountCode', 'ItemDescription', 'TaxCode', 'LineTotal'], base: true, service: true };
+
+ it('una línea de servicio vacía no tiene cuenta, descripción ni total', () => {
+ expect(isBlankLine({ AccountCode: '', ItemDescription: '', LineTotal: 0 }, true)).toBe(true);
+ expect(isBlankLine({ AccountCode: '_SYS1', LineTotal: 0 }, true)).toBe(false);
+ expect(isBlankLine({ ItemDescription: 'Alquiler', LineTotal: 0 }, true)).toBe(false);
+ expect(isBlankLine({ ItemCode: '', LineTotal: 0 }, false)).toBe(true);
+ });
+
+ it('exige al menos una línea con cuenta y que cada una tenga cuenta y total', () => {
+ expect(validateLayout({}, [], [], [], true)).toEqual(['Agregue al menos una línea con cuenta contable.']);
+ expect(validateLayout({}, [{ ItemDescription: 'x', LineTotal: 0 }], [], [], true)).toEqual([
+ 'Línea 1: indique la cuenta contable.',
+ 'Línea 1: el total debe ser mayor que cero.',
+ ]);
+ expect(validateLayout({}, [{ AccountCode: '_SYS1', LineTotal: 50 }], [], [], true)).toEqual([]);
+ });
+
+ it('envía solo los campos de servicio, sin cantidad ni artículo', () => {
+ const body = createPayload({ DocType: 'dDocument_Service', CardCode: 'P1' }, [{ AccountCode: '_SYS1', ItemDescription: 'Alquiler', TaxCode: 'IGV', LineTotal: 100, Quantity: 1, ItemCode: '' }, { AccountCode: '', LineTotal: 0 }], service);
+ expect(body['DocumentLines']).toEqual([{ AccountCode: '_SYS1', ItemDescription: 'Alquiler', TaxCode: 'IGV', LineTotal: 100 }]);
+ });
+});
diff --git a/apps/web/src/app/features/documents/document-model.ts b/apps/web/src/app/features/documents/document-model.ts
index ff443b5..9d720ff 100644
--- a/apps/web/src/app/features/documents/document-model.ts
+++ b/apps/web/src/app/features/documents/document-model.ts
@@ -123,6 +123,8 @@ export interface LinesSpec {
fields: readonly string[];
/** Los documentos de ventas y compras referencian el origen de una línea copiada (`BaseType`…). */
base: boolean;
+ /** Líneas de un documento de servicio: cuenta contable, descripción, impuesto y total, sin artículo ni cantidad. */
+ service?: boolean;
}
export const MARKETING_LINES: LinesSpec = { key: 'DocumentLines', fields: LINE_FIELDS, base: true };
@@ -130,13 +132,17 @@ export const MARKETING_LINES: LinesSpec = { key: 'DocumentLines', fields: LINE_F
const BASE_FIELDS = ['BaseType', 'BaseEntry', 'BaseLine'] as const;
const hasBase = (l: GridLine): boolean => hasBaseRef(l['BaseType'], l['BaseEntry']);
-/** Una línea vacía (la fila en blanco que deja la grilla) no se envía. */
-export const isBlankLine = (line: GridLine): boolean => isEmpty(line['ItemCode']);
+/**
+ * Una línea vacía (la fila en blanco que deja la grilla) no se envía. En un documento de artículos es la que no
+ * tiene artículo; en uno de servicio, la que no tiene cuenta, descripción ni total.
+ */
+export const isBlankLine = (line: GridLine, service = false): boolean =>
+ service ? isEmpty(line['AccountCode']) && isEmpty(line['ItemDescription']) && num(line['LineTotal']) === 0 : isEmpty(line['ItemCode']);
/** Líneas listas para SAP: sin filas en blanco ni campos vacíos. */
export function linesPayload(lines: readonly GridLine[], spec: LinesSpec = MARKETING_LINES): DocRecord[] {
return lines
- .filter((l) => !isBlankLine(l))
+ .filter((l) => !isBlankLine(l, spec.service))
.map((l) => {
const out: DocRecord = {};
for (const f of spec.fields) {
@@ -171,12 +177,18 @@ export function validateLayout(
lines: readonly GridLine[],
requiredHeader: readonly RequiredField[],
requiredLine: readonly RequiredField[],
+ service = false,
): string[] {
const errors: string[] = [];
for (const f of requiredHeader) if (isEmpty(header[f.key])) errors.push(`Indique ${f.label.toLowerCase()}.`);
- const real = lines.filter((l) => !isBlankLine(l));
- if (!real.length) errors.push('Agregue al menos una línea con artículo.');
+ const real = lines.filter((l) => !isBlankLine(l, service));
+ if (!real.length) errors.push(service ? 'Agregue al menos una línea con cuenta contable.' : 'Agregue al menos una línea con artículo.');
real.forEach((l, i) => {
+ if (service) {
+ if (isEmpty(l['AccountCode'])) errors.push(`Línea ${i + 1}: indique la cuenta contable.`);
+ if (num(l['LineTotal']) <= 0) errors.push(`Línea ${i + 1}: el total debe ser mayor que cero.`);
+ return;
+ }
if (num(l['Quantity']) <= 0) errors.push(`Línea ${i + 1}: la cantidad debe ser mayor que cero.`);
for (const f of requiredLine) if (isEmpty(l[f.key])) errors.push(`Línea ${i + 1}: indique ${f.label.toLowerCase()}.`);
});
diff --git a/apps/web/src/app/features/documents/line-columns.ts b/apps/web/src/app/features/documents/line-columns.ts
index 56938f3..7e77555 100644
--- a/apps/web/src/app/features/documents/line-columns.ts
+++ b/apps/web/src/app/features/documents/line-columns.ts
@@ -206,6 +206,30 @@ export const LINE_COLUMNS = {
total: columnOf('LineTotal'),
} as const;
+/**
+ * Columnas de la línea de un documento de **servicio**: cuenta contable (obligatoria), descripción, impuesto, total
+ * (que aquí se escribe) y analítica; sin artículo, cantidad, almacén ni precio.
+ */
+export const serviceLineColumns = (taxResource = 'SalesTaxCodes'): readonly GridColumn[] =>
+ lineColumnsFor(taxResource, [
+ { ...LINE_COLUMNS.account, title: 'Cuenta contable', width: '170px', required: true },
+ { ...LINE_COLUMNS.description, width: '260px' },
+ LINE_COLUMNS.tax,
+ { ...LINE_COLUMNS.total, readonly: false, required: true },
+ LINE_COLUMNS.costingCode,
+ LINE_COLUMNS.costingCode2,
+ LINE_COLUMNS.costingCode3,
+ LINE_COLUMNS.costingCode4,
+ LINE_COLUMNS.costingCode5,
+ LINE_COLUMNS.project,
+ LINE_COLUMNS.baseType,
+ LINE_COLUMNS.baseEntry,
+ LINE_COLUMNS.baseLine,
+ ]);
+
+/** Línea nueva de un documento de servicio. */
+export const newServiceLine = (): GridLine => ({ ItemDescription: '', AccountCode: '', TaxCode: '', LineTotal: 0 });
+
/** Columnas de dimensión y proyecto: las únicas de contabilidad analítica que también llevan inventario. */
export const ANALYTIC_COLUMNS: readonly GridColumn[] = [
LINE_COLUMNS.costingCode,
diff --git a/apps/web/src/app/features/documents/screen-extensions.spec.ts b/apps/web/src/app/features/documents/screen-extensions.spec.ts
index 881430c..c0392d3 100644
--- a/apps/web/src/app/features/documents/screen-extensions.spec.ts
+++ b/apps/web/src/app/features/documents/screen-extensions.spec.ts
@@ -126,7 +126,7 @@ describe('formulario con campos de cliente', () => {
it('muestra el campo de usuario, el desplegable y la columna de línea con sus valores de SAP', async () => {
await open(order);
expect([...el().querySelectorAll('label')].map((l) => l.textContent?.trim())).toEqual(expect.arrayContaining(['Canal', 'Obra']));
- expect(el().querySelector('select')!.value).toBe('W');
+ expect([...el().querySelectorAll('select')].map((s) => s.value)).toContain('W');
expect([...el().querySelectorAll('input')].map((i) => i.value)).toEqual(expect.arrayContaining(['Torre 3', 'L-77']));
});