feat(web): documentos de ventas y compras con tipo Artículo / Servicio (columnas de línea, validación, guardado y copiado)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 12:19:18 -05:00
1 parent 68d2cfe6d0
commit 2e74bccd65
11 files changed
+260 -25

No files matched your search

@@ -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');
});
});
@@ -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,
@@ -22,16 +22,23 @@
</div>
</div>
@if (pendingType(); as type) {
<p class="ask" role="alert">
Cambiar el tipo a «{{ pendingTypeLabel() }}» descarta {{ lineCount() === 1 ? 'la línea escrita' : 'las ' + lineCount() + ' líneas escritas' }}.
<button type="button" class="btn main" (click)="applyDocType(type)">Sí, cambiar</button>
<button type="button" class="btn" (click)="pendingType.set(null)">No, dejar como está</button>
</p>
}
<sbo-lines-grid
[columns]="columns"
[columns]="columns()"
[lines]="lines()"
(linesChange)="setLines($event)"
(notice)="notify($event)"
[readonly]="readOnly()"
[newLine]="newLine"
[recalc]="recalc"
[multiAdd]="multi"
[details]="layout.details ?? []"
[recalc]="recalc()"
[multiAdd]="serviceMode() ? undefined : multi"
[details]="serviceMode() ? [] : (layout.details ?? [])"
[detailHint]="detailHint"
/>
@@ -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 }]);
@@ -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<string | null>(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<void> {
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) {
@@ -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: [],
@@ -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 }]);
});
});
@@ -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()}.`);
});
@@ -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,
@@ -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<HTMLSelectElement>('select')!.value).toBe('W');
expect([...el().querySelectorAll<HTMLSelectElement>('select')].map((s) => s.value)).toContain('W');
expect([...el().querySelectorAll<HTMLInputElement>('input')].map((i) => i.value)).toEqual(expect.arrayContaining(['Torre 3', 'L-77']));
});