From 29e80181feba0630b28d4b5e026969c47b24773c Mon Sep 17 00:00:00 2001 From: George Date: Wed, 14 Oct 2026 11:59:07 -0500 Subject: [PATCH] feat(web): copiar de / a entre documentos de ventas y compras Co-Authored-By: Claude Sonnet 5.5 --- apps/web/src/app/core/sl/sl-document.ts | 5 + .../features/documents/document-copy.spec.ts | 62 +++++++++++ .../app/features/documents/document-copy.ts | 98 +++++++++++++++++ .../documents/document-form.page.html | 20 ++++ .../documents/document-form.page.spec.ts | 102 +++++++++++++++++- .../features/documents/document-form.page.ts | 87 ++++++++++++++- .../features/documents/document-model.spec.ts | 10 ++ .../app/features/documents/document-model.ts | 5 + 8 files changed, 383 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/app/features/documents/document-copy.spec.ts create mode 100644 apps/web/src/app/features/documents/document-copy.ts diff --git a/apps/web/src/app/core/sl/sl-document.ts b/apps/web/src/app/core/sl/sl-document.ts index 1aaf437..9cf4732 100644 --- a/apps/web/src/app/core/sl/sl-document.ts +++ b/apps/web/src/app/core/sl/sl-document.ts @@ -14,6 +14,11 @@ export class SlDocumentApi { return `${API_URL}/sl/${resource}${docEntry === undefined ? '' : `/${docEntry}`}`; } + /** El documento completo (cabecera y líneas). */ + get(resource: string, docEntry: number): Promise { + return firstValueFrom(this.http.get(this.url(resource, 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); diff --git a/apps/web/src/app/features/documents/document-copy.spec.ts b/apps/web/src/app/features/documents/document-copy.spec.ts new file mode 100644 index 0000000..e1f9848 --- /dev/null +++ b/apps/web/src/app/features/documents/document-copy.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { COPY_FLOWS, OBJECT_TYPES, copyFromDocument, copySources, copyTargets } from './document-copy'; +import { DOCUMENT_SCREENS } from './document-screens'; + +describe('flujos de copiado', () => { + it('cada documento del flujo existe como pantalla y tiene tipo de objeto', () => { + const paths = new Set(DOCUMENT_SCREENS.map((s) => s.path)); + for (const [from, to] of Object.entries(COPY_FLOWS)) { + for (const p of [from, ...to]) { + expect(paths.has(p), p).toBe(true); + expect(OBJECT_TYPES[p], p).toBeGreaterThan(0); + } + } + }); + + it('solo ventas con ventas y compras con compras', () => { + for (const [from, to] of Object.entries(COPY_FLOWS)) { + for (const p of to) expect(p.split('/')[1], `${from} → ${p}`).toBe(from.split('/')[1]); + } + }); + + it('copiar a / copiar de son los dos lados del mismo flujo', () => { + expect(copyTargets('/ventas/ordenes')).toEqual(['/ventas/entrega', '/ventas/facturas']); + expect(copySources('/ventas/facturas')).toEqual(['/ventas/ofertas', '/ventas/ordenes', '/ventas/entrega']); + expect(copyTargets('/ventas/notas-credito')).toEqual([]); + expect(copySources('/ventas/ofertas')).toEqual([]); + }); +}); + +describe('copyFromDocument', () => { + const source = { + DocEntry: 315, + DocNum: 1315, + CardCode: 'C1', + NumAtCard: 'REF', + DiscountPercent: 5, + DocumentLines: [ + { LineNum: 0, ItemCode: 'A1', ItemDescription: 'Pieza', Quantity: 5, RemainingOpenQuantity: 3, WarehouseCode: '01', Price: 100, DiscountPercent: 0, TaxCode: 'IVA', LineStatus: 'bost_Open' }, + { LineNum: 1, ItemCode: 'B2', Quantity: 2, RemainingOpenQuantity: 0, LineStatus: 'bost_Close' }, + { LineNum: 2, ItemCode: 'C3', ItemDescription: 'Otra', Quantity: 1, Price: 10, LineStatus: 'bost_Open' }, + ], + }; + + it('copia las líneas abiertas con la cantidad pendiente y la referencia al origen', () => { + const c = copyFromDocument(source, '/ventas/ordenes', 'Órdenes de venta'); + expect(c.skipped).toBe(1); + expect(c.lines).toHaveLength(2); + expect(c.lines[0]).toMatchObject({ + ItemCode: 'A1', Quantity: 3, Price: 100, LineTotal: 300, BaseType: 17, BaseEntry: 315, BaseLine: 0, + }); + expect(c.lines[1]).toMatchObject({ ItemCode: 'C3', Quantity: 1, BaseLine: 2 }); + }); + + it('copia socio, referencia y descuento, y deja el origen en el comentario', () => { + expect(copyFromDocument(source, '/ventas/ordenes', 'Órdenes de venta').header).toEqual({ + CardCode: 'C1', + NumAtCard: 'REF', + DiscountPercent: 5, + Comments: 'Basado en órdenes de venta 1315.', + }); + }); +}); diff --git a/apps/web/src/app/features/documents/document-copy.ts b/apps/web/src/app/features/documents/document-copy.ts new file mode 100644 index 0000000..7293f33 --- /dev/null +++ b/apps/web/src/app/features/documents/document-copy.ts @@ -0,0 +1,98 @@ +import type { GridLine } from '../../shared/grid/grid.types'; +import type { DocRecord } from './document-model'; +import { newDocumentLine, recalcLine } from './line-columns'; + +/** Tipo de objeto de SAP de cada documento (`BaseType` de las líneas copiadas). */ +export const OBJECT_TYPES: Readonly> = { + '/ventas/ofertas': 23, + '/ventas/ordenes': 17, + '/ventas/entrega': 15, + '/ventas/devoluciones': 16, + '/ventas/facturas': 13, + '/ventas/notas-credito': 14, + '/compras/ofertas': 540000006, + '/compras/ordenes': 22, + '/compras/recepcion': 20, + '/compras/devoluciones': 21, + '/compras/facturas': 18, + '/compras/notas-credito': 19, +}; + +/** De qué documento se puede copiar a cuáles (el flujo estándar de SAP B1). */ +export const COPY_FLOWS: Readonly> = { + '/ventas/ofertas': ['/ventas/ordenes', '/ventas/entrega', '/ventas/facturas'], + '/ventas/ordenes': ['/ventas/entrega', '/ventas/facturas'], + '/ventas/entrega': ['/ventas/facturas', '/ventas/devoluciones'], + '/ventas/devoluciones': ['/ventas/notas-credito'], + '/ventas/facturas': ['/ventas/notas-credito'], + '/compras/ofertas': ['/compras/ordenes'], + '/compras/ordenes': ['/compras/recepcion', '/compras/facturas'], + '/compras/recepcion': ['/compras/facturas', '/compras/devoluciones'], + '/compras/devoluciones': ['/compras/notas-credito'], + '/compras/facturas': ['/compras/notas-credito'], +}; + +/** Documentos a los que se puede copiar desde `path` ("Copiar a"). */ +export const copyTargets = (path: string): readonly string[] => COPY_FLOWS[path] ?? []; + +/** Documentos desde los que se puede copiar a `path` ("Copiar de"). */ +export const copySources = (path: string): readonly string[] => + Object.entries(COPY_FLOWS) + .filter(([, to]) => to.includes(path)) + .map(([from]) => from); + +/** Origen de un copiado, enviado en el estado de la navegación hacia el documento nuevo. */ +export interface CopyOrigin { + path: string; + docEntry: number; +} + +export interface CopiedDocument { + header: DocRecord; + lines: GridLine[]; + /** Líneas del origen que no se copian porque ya están cerradas. */ + skipped: number; +} + +/** + * Datos de un documento origen para uno nuevo: socio y referencias, y sus líneas abiertas con la + * cantidad pendiente y la referencia al origen (`BaseType`/`BaseEntry`/`BaseLine`), con lo que SAP + * enlaza y cierra las cantidades del origen. + */ +export function copyFromDocument(source: DocRecord, sourcePath: string, sourceLabel: string): CopiedDocument { + const baseType = OBJECT_TYPES[sourcePath]; + const lines: GridLine[] = []; + let skipped = 0; + for (const l of (source['DocumentLines'] as GridLine[] | undefined) ?? []) { + const remaining = Number(l['RemainingOpenQuantity'] ?? l['Quantity'] ?? 0); + if (l['LineStatus'] === 'bost_Close' || !(remaining > 0)) { + skipped++; + continue; + } + lines.push( + recalcLine({ + ...newDocumentLine(), + ItemCode: l['ItemCode'] ?? '', + ItemDescription: l['ItemDescription'] ?? '', + Quantity: remaining, + WarehouseCode: l['WarehouseCode'] ?? '', + Price: l['Price'] ?? null, + DiscountPercent: l['DiscountPercent'] ?? 0, + TaxCode: l['TaxCode'] ?? '', + BaseType: baseType, + BaseEntry: source['DocEntry'], + BaseLine: l['LineNum'], + }), + ); + } + return { + header: { + CardCode: source['CardCode'] ?? '', + NumAtCard: source['NumAtCard'] ?? '', + DiscountPercent: source['DiscountPercent'] ?? 0, + Comments: `Basado en ${sourceLabel.toLowerCase()} ${String(source['DocNum'])}.`, + }, + lines, + skipped, + }; +} 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 b8616df..106c782 100644 --- a/apps/web/src/app/features/documents/document-form.page.html +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -94,4 +94,24 @@ } {{ readOnly() ? 'Cerrar' : 'Cancelar' }} + @if (partner && copyFromItems().length) { + + } + @if (partner && copyToItems().length) { + + } + +@if (copyListScreen(); as src) { + +} 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 64076bb..c2d4d7b 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 @@ -40,9 +40,9 @@ describe('DocumentFormPage', () => { await new Promise((r) => setTimeout(r)); fixture.detectChanges(); }; - const buttonText = () => [...el().querySelectorAll('.actions .btn')].map((b) => b.textContent?.trim()); + const buttonText = () => [...el().querySelectorAll('.actions .start .btn')].map((b) => b.textContent?.trim()); - async function open(docEntry: string | null, doc?: Record) { + async function open(docEntry: string | null, doc?: Record, copyFrom?: unknown, afterCreate?: () => void) { const params = docEntry ? { docEntry } : {}; const route = { snapshot: { data: { screen, title: 'Órdenes de venta' }, paramMap: convertToParamMap(params) }, @@ -53,11 +53,14 @@ describe('DocumentFormPage', () => { providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }], }).compileComponents(); ctrl = TestBed.inject(HttpTestingController); - navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); + const router = TestBed.inject(Router); + navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true); + if (copyFrom) vi.spyOn(router, 'currentNavigation').mockReturnValue({ extras: { state: { copyFrom } } } as never); 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 ?? {}); + afterCreate?.(); await tick(); } @@ -218,4 +221,97 @@ describe('DocumentFormPage', () => { expect(navigate).not.toHaveBeenCalled(); }); }); + + describe('copiar a', () => { + const items = () => [...el().querySelectorAll('sbo-menu-button')].map((m) => m.textContent?.trim()); + + it('un documento abierto y guardado ofrece Copiar a con entrega y factura', async () => { + await open('41', order()); + expect(items().join(' ')).toContain('Copiar a'); + const ids = page().copyToItems().map((i: { id: string; disabled: boolean }) => [i.id, i.disabled]); + expect(ids).toEqual([['/ventas/entrega', false], ['/ventas/facturas', false]]); + }); + + it('abre el documento nuevo de destino llevando el origen', async () => { + await open('41', order()); + const nav = vi.spyOn(TestBed.inject(Router), 'navigateByUrl').mockResolvedValue(true); + page().copyTo('/ventas/entrega'); + expect(nav).toHaveBeenCalledWith('/ventas/entrega/nuevo', { state: { copyFrom: { path: '/ventas/ordenes', docEntry: 41 } } }); + }); + + it('con cambios sin guardar o cerrado no se puede copiar', async () => { + await open('41', order()); + page().setHeader('Comments', 'x'); + await tick(); + expect(page().copyToItems().every((i: { disabled: boolean }) => i.disabled)).toBe(true); + TestBed.resetTestingModule(); + await open('41', order({ DocumentStatus: 'bost_Close' })); + expect(page().copyToItems().every((i: { disabled: boolean }) => i.disabled)).toBe(true); + }); + }); + + describe('copiar de / a: documento nuevo con origen', () => { + const source = { + DocEntry: 7, DocNum: 1007, CardCode: 'C1', CardName: 'ACME', NumAtCard: 'REF', DiscountPercent: 0, + DocumentLines: [ + { LineNum: 0, ItemCode: 'A1', ItemDescription: 'Pieza', Quantity: 4, RemainingOpenQuantity: 3, WarehouseCode: '01', Price: 100, DiscountPercent: 0, TaxCode: 'IVA', LineStatus: 'bost_Open' }, + { LineNum: 1, ItemCode: 'B2', Quantity: 1, RemainingOpenQuantity: 0, LineStatus: 'bost_Close' }, + ], + }; + + it('al llegar con un origen lo carga: socio, líneas abiertas y referencia al origen', async () => { + await open(null, undefined, { path: '/ventas/ordenes', docEntry: 7 }, () => undefined); + ctrl.expectOne('/api/sl/Orders/7').flush(source); + await tick(); + expect(page().header().CardCode).toBe('C1'); + expect(page().partnerName()).toBe('ACME'); + expect(page().lines()).toHaveLength(1); + expect(page().lines()[0]).toMatchObject({ ItemCode: 'A1', Quantity: 3, BaseType: 17, BaseEntry: 7, BaseLine: 0 }); + expect(el().querySelector('.status')?.textContent).toContain('Copiado de órdenes de venta 1007'); + expect(el().querySelector('.status')?.textContent).toContain('1 línea(s) cerradas'); + }); + + it('al crear envía las referencias al origen en las líneas', async () => { + await open(null, undefined, { path: '/ventas/ordenes', docEntry: 7 }); + ctrl.expectOne('/api/sl/Orders/7').flush(source); + await tick(); + (el().querySelector('.actions .btn.main') as HTMLElement).click(); + await tick(); + const req = ctrl.expectOne((r) => r.method === 'POST'); + expect(req.request.body.DocumentLines).toEqual([ + { ItemCode: 'A1', ItemDescription: 'Pieza', Quantity: 3, WarehouseCode: '01', Price: 100, DiscountPercent: 0, TaxCode: 'IVA', BaseType: 17, BaseEntry: 7, BaseLine: 0 }, + ]); + req.flush({ DocEntry: 99, DocNum: 1099 }); + }); + + it('un origen sin líneas abiertas no copia nada y avisa', async () => { + await open(null, undefined, { path: '/ventas/ordenes', docEntry: 7 }); + ctrl.expectOne('/api/sl/Orders/7').flush({ ...source, DocumentLines: [source.DocumentLines[1]] }); + await tick(); + expect(page().lines()).toHaveLength(0); + expect(el().querySelector('.status')?.textContent).toContain('no tiene líneas abiertas'); + }); + + it('Copiar de: no mezcla socios distintos', async () => { + await open(null); + page().setHeader('CardCode', 'C9'); + await tick(); + page().copyListFor.set('/ventas/ordenes'); + const done = page().copyPicked([{ DocEntry: 7 }]); + await Promise.resolve(); + ctrl.expectOne('/api/sl/Orders/7').flush(source); + await done; + await tick(); + expect(page().lines()).toHaveLength(0); + expect(el().querySelector('.status')?.textContent).toContain('es de C1'); + }); + + it('Copiar de: ofrece solo los orígenes del flujo y lista los abiertos del socio', async () => { + await open(null); + expect(page().copyFromItems().map((i: { id: string }) => i.id)).toEqual(['/ventas/ofertas']); + expect(page().copyListFilter()).toBe("DocumentStatus eq 'bost_Open' and Cancelled eq 'tNO'"); + page().setHeader('CardCode', "C'1"); + expect(page().copyListFilter()).toContain("CardCode eq 'C''1'"); + }); + }); }); 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 0401d0c..41b5a1a 100644 --- a/apps/web/src/app/features/documents/document-form.page.ts +++ b/apps/web/src/app/features/documents/document-form.page.ts @@ -11,7 +11,9 @@ import { DateField, LookupField, NumberField, TextField, TextareaField } from '. 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 { Icon, MenuButton, type MenuItem, SboWindow, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window'; +import { ValueHelp } from '../../shared/value-help/value-help'; +import type { ListRow } from '../../shared/list/list.types'; import { type DocRecord, type NavTarget, @@ -23,7 +25,8 @@ import { updatePayload, validateDocument, } from './document-model'; -import { type DocumentScreen, documentStatus } from './document-screens'; +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'; /** Campos de cabecera que se editan en el formulario (el resto lo calcula SAP). */ @@ -51,7 +54,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, NumberField, RouterLink, SboWindow, TextField, TextareaField, Toolbar], + imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NumberField, RouterLink, SboWindow, TextField, TextareaField, Toolbar, ValueHelp], templateUrl: './document-form.page.html', styles: ` h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; } @@ -149,6 +152,80 @@ export class DocumentFormPage { ); protected readonly fmt = (n: number): string => formatNumber(n, 2); + // --- copiar de / a ---------------------------------------------------------------------------- + + private readonly copyOrigin = (this.router.currentNavigation()?.extras.state as { copyFrom?: CopyOrigin } | undefined)?.copyFrom; + protected readonly copyListFor = signal(null); + + /** "Copiar a": solo desde un documento guardado, abierto y sin cambios pendientes. */ + protected readonly copyToItems = computed(() => { + const blocked = this.isNew() || this.readOnly() || this.dirty(); + return copyTargets(this.screen.path).map((p) => ({ id: p, label: menuLabel(p), disabled: blocked })); + }); + protected readonly copyFromItems = computed(() => + copySources(this.screen.path).map((p) => ({ id: p, label: menuLabel(p), disabled: !this.isNew() })), + ); + protected readonly copyListScreen = computed(() => DOCUMENT_SCREENS.find((s) => s.path === this.copyListFor())); + /** Documentos abiertos del origen elegido, del mismo socio si ya se indicó. */ + protected readonly copyListFilter = computed(() => { + const card = text(this.header()['CardCode']); + return [`DocumentStatus eq 'bost_Open'`, `Cancelled eq 'tNO'`, ...(card ? [`CardCode eq '${card.replace(/'/g, "''")}'`] : [])].join(' and '); + }); + protected readonly copyListColumns = [ + docNum, + { key: 'CardCode', title: this.partner?.label ?? 'Socio', width: '120px', mono: true }, + { key: 'CardName', title: 'Nombre' }, + { key: 'DocDate', title: 'Fecha', type: 'date', align: 'center', width: '105px' }, + ] as const; + + protected copyTo(path: string): void { + const docEntry = this.docEntry(); + if (docEntry === null) return; + const origin: CopyOrigin = { path: this.screen.path, docEntry }; + void this.router.navigateByUrl(`${path}/nuevo`, { state: { copyFrom: origin } }); + } + + protected copyFrom(path: string): void { + this.copyListFor.set(path); + } + + protected async copyPicked(rows: ListRow[]): Promise { + const path = this.copyListFor(); + this.copyListFor.set(null); + const docEntry = rows[0]?.['DocEntry']; + if (path && typeof docEntry === 'number') await this.loadCopy({ path, docEntry }); + } + + /** Trae el documento origen y vuelca sus datos y líneas abiertas en el formulario. */ + private async loadCopy(origin: CopyOrigin): Promise { + const source = DOCUMENT_SCREENS.find((s) => s.path === origin.path); + if (!source) return; + try { + const doc = await this.api.get(source.resource, origin.docEntry); + const copied = copyFromDocument(doc, origin.path, menuLabel(origin.path)); + const card = text(this.header()['CardCode']); + if (card && card !== text(copied.header['CardCode'])) { + this.message.set({ kind: 'warn', text: `El documento es de ${text(copied.header['CardCode'])}; este es de ${card}.` }); + return; + } + if (!copied.lines.length) { + this.message.set({ kind: 'warn', text: 'El documento no tiene líneas abiertas para copiar.' }); + return; + } + if (!card) { + this.header.update((h) => ({ ...h, ...copied.header })); + this.partnerName.set(text(doc['CardName'])); + } + this.lines.update((ls) => [...ls.filter((l) => !isBlankLine(l)), ...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.`, + }); + } catch (e) { + this.message.set({ kind: 'err', text: apiErrorMessage(e) }); + } + } + // --- configuración de la vista -------------------------------------------------------------- protected readonly columns = lineColumnsFor(this.partner?.taxResource ?? 'SalesTaxCodes'); @@ -168,6 +245,10 @@ export class DocumentFormPage { protected str = text; + constructor() { + if (this.copyOrigin) void this.loadCopy(this.copyOrigin); + } + protected setHeader(key: string, value: unknown): void { this.header.update((h) => ({ ...h, [key]: value })); this.message.set(null); 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 cfa5281..5cad0d9 100644 --- a/apps/web/src/app/features/documents/document-model.spec.ts +++ b/apps/web/src/app/features/documents/document-model.spec.ts @@ -91,6 +91,16 @@ describe('createPayload', () => { expect(body['DocumentLines']).toEqual([{ ItemCode: 'A1', Quantity: 1, DiscountPercent: 0 }]); }); + it('envía la referencia al origen solo en las líneas copiadas', () => { + const body = createPayload({ CardCode: 'C1' }, [ + line({ BaseType: 17, BaseEntry: 315, BaseLine: 0 }), + line({ BaseType: -1, BaseEntry: null, BaseLine: null }), + ]); + const [copied, own] = body['DocumentLines'] as Record[]; + expect(copied).toMatchObject({ BaseType: 17, BaseEntry: 315, BaseLine: 0 }); + expect(own).not.toHaveProperty('BaseType'); + }); + it('isBlankLine', () => { expect(isBlankLine({ ItemCode: '' })).toBe(true); expect(isBlankLine({ ItemCode: 'A1' })).toBe(false); diff --git a/apps/web/src/app/features/documents/document-model.ts b/apps/web/src/app/features/documents/document-model.ts index aab1202..5f039cf 100644 --- a/apps/web/src/app/features/documents/document-model.ts +++ b/apps/web/src/app/features/documents/document-model.ts @@ -68,6 +68,10 @@ export function computeTotals( /** Campos de línea que se envían a SAP; el total de línea y lo calculado lo resuelve SAP. */ const LINE_FIELDS = ['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'DiscountPercent', 'TaxCode'] as const; +/** Referencia al documento origen de una línea copiada; SAP devuelve `BaseType: -1` y `BaseEntry: null` en las que no tienen. */ +const BASE_FIELDS = ['BaseType', 'BaseEntry', 'BaseLine'] as const; +const hasBase = (l: GridLine): boolean => num(l['BaseType']) > 0 && !isEmpty(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']); @@ -78,6 +82,7 @@ export function linesPayload(lines: readonly GridLine[]): DocRecord[] { .map((l) => { const out: DocRecord = {}; for (const f of LINE_FIELDS) if (!isEmpty(l[f])) out[f] = l[f]; + if (hasBase(l)) for (const f of BASE_FIELDS) out[f] = l[f]; return out; }); }