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) { + + } 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'])); });