feat(web): copiar de / a entre documentos de ventas y compras
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cafe4bc035
commit
29e80181fe
8 files changed
+383
-6
No files matched your search
@@ -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<DocRecord> {
|
||||
return firstValueFrom(this.http.get<DocRecord>(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<number | null> {
|
||||
const q = navigationQuery(target, from);
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, number>> = {
|
||||
'/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<Record<string, readonly string[]>> = {
|
||||
'/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,
|
||||
};
|
||||
}
|
||||
@@ -94,4 +94,24 @@
|
||||
<button sboActions class="btn" type="button" (click)="reload()">Recargar</button>
|
||||
}
|
||||
<a sboActions class="btn" routerLink="..">{{ readOnly() ? 'Cerrar' : 'Cancelar' }}</a>
|
||||
@if (partner && copyFromItems().length) {
|
||||
<sbo-menu-button sboActionsEnd label="Copiar de" [items]="copyFromItems()" (picked)="copyFrom($event)" />
|
||||
}
|
||||
@if (partner && copyToItems().length) {
|
||||
<sbo-menu-button sboActionsEnd label="Copiar a" [items]="copyToItems()" (picked)="copyTo($event)" />
|
||||
}
|
||||
</sbo-window>
|
||||
|
||||
@if (copyListScreen(); as src) {
|
||||
<sbo-value-help
|
||||
[open]="true"
|
||||
[title]="'Copiar de: ' + src.createLabel.replace('Nueva ', '').replace('Nuevo ', '')"
|
||||
[resource]="src.resource"
|
||||
[columns]="copyListColumns"
|
||||
[baseFilter]="copyListFilter()"
|
||||
orderby="DocEntry desc"
|
||||
rowKey="DocEntry"
|
||||
(selected)="copyPicked($event)"
|
||||
(closed)="copyListFor.set(null)"
|
||||
/>
|
||||
}
|
||||
@@ -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<string, unknown>) {
|
||||
async function open(docEntry: string | null, doc?: Record<string, unknown>, 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<HTMLButtonElement>('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'");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
/** "Copiar a": solo desde un documento guardado, abierto y sin cambios pendientes. */
|
||||
protected readonly copyToItems = computed<MenuItem[]>(() => {
|
||||
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<MenuItem[]>(() =>
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@@ -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<string, unknown>[];
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user