feat(web): formularios de inventario y solicitudes de compra (entradas, salidas, transferencias, solicitudes de traslado)
Configuración declarativa en document-layouts.ts; las líneas de las transferencias van en StockTransferLines y las de compra/inventario llevan UoMEntry si el artículo tiene grupo de unidades. Probado contra SBODemoCL (solicitud de compra 2, solicitud de traslado 472, entrada 155). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
45c0ca536e
commit
155d18fce4
13 files changed
+550
-46
No files matched your search
@@ -20,7 +20,7 @@ export class SlLookupApi {
|
||||
const keyField = lookup.valueKey ?? lookup.columns[0]?.key ?? '';
|
||||
const filter = combineFilters(lookup.baseFilter, `${keyField} eq '${escapeODataString(code.trim())}'`);
|
||||
const params = new HttpParams()
|
||||
.set('$select', [...new Set([keyField, ...lookup.columns.map((c) => c.key)])].join(','))
|
||||
.set('$select', [...new Set([keyField, ...lookup.columns.map((c) => c.key), ...(lookup.extraFields ?? [])])].join(','))
|
||||
.set('$filter', filter ?? '')
|
||||
.set('$top', 1);
|
||||
const res = await firstValueFrom(
|
||||
|
||||
@@ -81,11 +81,46 @@
|
||||
<sbo-text-field label="Total del documento" readonly [value]="fmt(totals().total)" />
|
||||
</div>
|
||||
</div>
|
||||
} @else if (layout; as lay) {
|
||||
<div class="sbo-hdr">
|
||||
<div class="sbo-col">
|
||||
@for (f of lay.left; track f.key) {
|
||||
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
|
||||
}
|
||||
</div>
|
||||
<div class="sbo-col">
|
||||
<sbo-text-field label="Número" readonly [value]="str(doc()?.['DocNum'])" />
|
||||
@for (f of lay.right; track f.key) {
|
||||
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<sbo-lines-grid
|
||||
[columns]="columns"
|
||||
[lines]="lines()"
|
||||
(linesChange)="setLines($event)"
|
||||
(notice)="notify($event)"
|
||||
[readonly]="readOnly()"
|
||||
[newLine]="newLine"
|
||||
[multiAdd]="multi"
|
||||
/>
|
||||
|
||||
<div class="sbo-hdr" style="margin-top: 14px">
|
||||
<div class="sbo-col">
|
||||
<sbo-textarea-field
|
||||
label="Comentarios"
|
||||
[readonly]="readOnly()"
|
||||
[value]="str(header()['Comments'])"
|
||||
(valueChange)="setHeader('Comments', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<p>El formulario de este documento aún no está disponible. <a routerLink="..">Volver a la lista</a></p>
|
||||
}
|
||||
|
||||
@if (partner && !readOnly()) {
|
||||
@if ((partner || layout) && !readOnly()) {
|
||||
<button sboActions class="btn main" type="button" [disabled]="saving() || loading()" (click)="save()">
|
||||
{{ isNew() ? 'Crear' : 'Actualizar' }}
|
||||
</button>
|
||||
@@ -115,3 +150,36 @@
|
||||
(closed)="copyListFor.set(null)"
|
||||
/>
|
||||
}
|
||||
|
||||
<ng-template #field let-f>
|
||||
@switch (f.type) {
|
||||
@case ('date') {
|
||||
<sbo-date-field
|
||||
[label]="f.label"
|
||||
[required]="!!f.required"
|
||||
[readonly]="readOnly()"
|
||||
[value]="str(header()[f.key])"
|
||||
(valueChange)="setHeader(f.key, $event)"
|
||||
/>
|
||||
}
|
||||
@case ('warehouse') {
|
||||
<sbo-lookup-field
|
||||
[label]="f.label"
|
||||
[required]="!!f.required"
|
||||
[readonly]="readOnly()"
|
||||
[value]="str(header()[f.key])"
|
||||
(valueChange)="setHeader(f.key, $event)"
|
||||
[lookup]="warehouseLookup"
|
||||
resolveOnLeave
|
||||
/>
|
||||
}
|
||||
@default {
|
||||
<sbo-text-field
|
||||
[label]="f.label"
|
||||
[readonly]="readOnly()"
|
||||
[value]="str(header()[f.key])"
|
||||
(valueChange)="setHeader(f.key, $event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HttpErrorResponse, httpResource } from '@angular/common/http';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
@@ -8,7 +9,7 @@ import { API_URL, apiErrorMessage } from '../../core/api';
|
||||
import { formatNumber } from '../../core/format';
|
||||
import { SlDocumentApi } from '../../core/sl/sl-document';
|
||||
import { DateField, LookupField, NumberField, TextField, TextareaField } from '../../shared/form';
|
||||
import type { GridLine } from '../../shared/grid/grid.types';
|
||||
import type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
|
||||
import { LinesGrid } from '../../shared/grid/lines-grid';
|
||||
import type { VhLookup } from '../../shared/value-help/value-help.types';
|
||||
import { Icon, MenuButton, type MenuItem, SboWindow, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
|
||||
@@ -18,13 +19,16 @@ import {
|
||||
type DocRecord,
|
||||
type NavTarget,
|
||||
computeTotals,
|
||||
MARKETING_LINES,
|
||||
createPayload,
|
||||
isBlankLine,
|
||||
isDocumentReadOnly,
|
||||
taxRateMap,
|
||||
updatePayload,
|
||||
validateDocument,
|
||||
validateLayout,
|
||||
} from './document-model';
|
||||
import { WAREHOUSE_LOOKUP, headerKeysOf } from './document-layouts';
|
||||
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';
|
||||
@@ -33,15 +37,13 @@ import { ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine } from './
|
||||
const HEADER_KEYS = ['CardCode', 'NumAtCard', 'DocDate', 'DocDueDate', 'DiscountPercent', 'Comments'] as const;
|
||||
|
||||
const today = (): string => new Date().toISOString().slice(0, 10);
|
||||
const DATE_KEYS = new Set(['DocDate', 'DocDueDate', 'RequriedDate']);
|
||||
/** SAP devuelve las fechas como `2026-01-05T00:00:00Z`; el campo de fecha y el guardado usan `2026-01-05`. */
|
||||
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
|
||||
const pickHeader = (doc: DocRecord | undefined): DocRecord => {
|
||||
if (!doc) return { DocDate: today(), DocDueDate: today(), DiscountPercent: 0 };
|
||||
const pickHeader = (doc: DocRecord | undefined, keys: readonly string[], blank: DocRecord): DocRecord => {
|
||||
if (!doc) return blank;
|
||||
return Object.fromEntries(
|
||||
HEADER_KEYS.map((k) => [
|
||||
k,
|
||||
k === 'DocDate' || k === 'DocDueDate' ? dateOnly(doc[k]) : (doc[k] ?? (k === 'DiscountPercent' ? 0 : '')),
|
||||
]),
|
||||
keys.map((k) => [k, DATE_KEYS.has(k) ? dateOnly(doc[k]) : (doc[k] ?? (k === 'DiscountPercent' ? 0 : ''))]),
|
||||
);
|
||||
};
|
||||
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
|
||||
@@ -54,7 +56,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, MenuButton, NumberField, RouterLink, SboWindow, TextField, TextareaField, Toolbar, ValueHelp],
|
||||
imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, RouterLink, SboWindow, TextField, TextareaField, Toolbar, ValueHelp],
|
||||
templateUrl: './document-form.page.html',
|
||||
styles: `
|
||||
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
|
||||
@@ -71,6 +73,19 @@ export class DocumentFormPage {
|
||||
protected readonly title = this.route.snapshot.data['title'] as string;
|
||||
protected readonly screen = this.route.snapshot.data['screen'] as DocumentScreen;
|
||||
protected readonly partner = this.screen.partner;
|
||||
/** Formulario de inventario o de solicitud de compra (sin socio ni totales). */
|
||||
protected readonly layout = this.screen.layout;
|
||||
private readonly lineSpec = this.layout?.lines ?? MARKETING_LINES;
|
||||
private readonly headerKeys: readonly string[] = this.layout ? headerKeysOf(this.layout) : HEADER_KEYS;
|
||||
private readonly blankHeader: DocRecord = this.layout
|
||||
? this.layout.newHeader()
|
||||
: { DocDate: today(), DocDueDate: today(), DiscountPercent: 0 };
|
||||
private pick(doc: DocRecord | undefined): DocRecord {
|
||||
return pickHeader(doc, this.headerKeys, this.blankHeader);
|
||||
}
|
||||
private docLines(doc: DocRecord | undefined): GridLine[] {
|
||||
return (doc?.[this.lineSpec.key] as GridLine[] | undefined) ?? [];
|
||||
}
|
||||
protected readonly docEntry = toSignal(
|
||||
this.route.paramMap.pipe(map((p) => (p.has('docEntry') ? Number(p.get('docEntry')) : null))),
|
||||
{ initialValue: this.route.snapshot.paramMap.has('docEntry') ? Number(this.route.snapshot.paramMap.get('docEntry')) : null },
|
||||
@@ -97,15 +112,13 @@ export class DocumentFormPage {
|
||||
});
|
||||
|
||||
/** Valores editables; se reinician al cargar o recargar el documento. */
|
||||
protected readonly header = linkedSignal<DocRecord>(() => pickHeader(this.doc()));
|
||||
protected readonly lines = linkedSignal<readonly GridLine[]>(() =>
|
||||
((this.doc()?.['DocumentLines'] as GridLine[] | undefined) ?? []).map((l) => ({ ...l })),
|
||||
);
|
||||
protected readonly header = linkedSignal<DocRecord>(() => this.pick(this.doc()));
|
||||
protected readonly lines = linkedSignal<readonly GridLine[]>(() => this.docLines(this.doc()).map((l) => ({ ...l })));
|
||||
protected readonly partnerName = linkedSignal(() => text(this.doc()?.['CardName']));
|
||||
|
||||
// --- estado --------------------------------------------------------------------------------
|
||||
|
||||
protected readonly readOnly = computed(() => isDocumentReadOnly(this.doc()));
|
||||
protected readonly readOnly = computed(() => isDocumentReadOnly(this.doc()) || (!!this.layout?.immutable && !this.isNew()));
|
||||
protected readonly saving = signal(false);
|
||||
private readonly message = signal<StatusMessage | null>(
|
||||
((): StatusMessage | null => {
|
||||
@@ -120,8 +133,13 @@ export class DocumentFormPage {
|
||||
if (this.loadError()) return { kind: 'err', text: this.loadError() };
|
||||
const message = this.message();
|
||||
if (message) return message;
|
||||
if (this.readOnly()) return { kind: 'info', text: 'Documento cerrado o cancelado: solo lectura.' };
|
||||
if (this.isNew()) return { kind: 'info', text: `Indique el ${this.partner?.label.toLowerCase() ?? 'socio'} y las líneas.` };
|
||||
if (this.readOnly()) {
|
||||
const posted = this.layout?.immutable && !isDocumentReadOnly(this.doc());
|
||||
return { kind: 'info', text: posted ? 'Documento contabilizado: solo lectura.' : 'Documento cerrado o cancelado: solo lectura.' };
|
||||
}
|
||||
if (this.isNew()) {
|
||||
return { kind: 'info', text: this.layout ? 'Complete la cabecera y las líneas.' : `Indique el ${this.partner?.label.toLowerCase() ?? 'socio'} y las líneas.` };
|
||||
}
|
||||
return { kind: 'info', text: this.dirty() ? 'Hay cambios sin guardar.' : '' };
|
||||
});
|
||||
protected readonly badge = computed<WindowBadge | undefined>(() => {
|
||||
@@ -137,8 +155,7 @@ 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 original = pickHeader(d);
|
||||
const { body } = updatePayload(original, this.header(), (d['DocumentLines'] as GridLine[]) ?? [], this.lines());
|
||||
const { body } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec);
|
||||
return Object.keys(body).length > 0;
|
||||
});
|
||||
|
||||
@@ -229,10 +246,13 @@ export class DocumentFormPage {
|
||||
|
||||
// --- configuración de la vista --------------------------------------------------------------
|
||||
|
||||
protected readonly columns = lineColumnsFor(this.partner?.taxResource ?? 'SalesTaxCodes');
|
||||
protected readonly newLine = newDocumentLine;
|
||||
protected readonly recalc = recalcLine;
|
||||
protected readonly multi = ITEMS_MULTI_ADD;
|
||||
protected readonly columns = this.layout?.columns ?? lineColumnsFor(this.partner?.taxResource ?? 'SalesTaxCodes');
|
||||
protected readonly newLine = this.layout ? () => this.layout!.newLine(this.header()) : newDocumentLine;
|
||||
protected readonly recalc = this.layout ? undefined : recalcLine;
|
||||
protected readonly multi: GridMultiAdd = this.layout
|
||||
? { ...ITEMS_MULTI_ADD, toLine: (item) => this.layout!.columns[0].lookup!.apply(item, this.layout!.newLine(this.header())) }
|
||||
: ITEMS_MULTI_ADD;
|
||||
protected readonly warehouseLookup = WAREHOUSE_LOOKUP;
|
||||
protected readonly partnerLookup: VhLookup = {
|
||||
title: this.partner?.type === 'cSupplier' ? 'Proveedores' : 'Clientes',
|
||||
resource: 'BusinessPartners',
|
||||
@@ -293,7 +313,14 @@ export class DocumentFormPage {
|
||||
|
||||
protected async save(): Promise<void> {
|
||||
if (this.saving() || this.readOnly()) return;
|
||||
const errors = validateDocument(this.header(), this.lines(), this.partner?.label ?? 'Socio de negocio');
|
||||
const errors = this.layout
|
||||
? validateLayout(
|
||||
this.header(),
|
||||
this.lines(),
|
||||
[...this.layout.left, ...this.layout.right].filter((f) => f.required),
|
||||
this.layout.requiredLine,
|
||||
)
|
||||
: validateDocument(this.header(), this.lines(), this.partner?.label ?? 'Socio de negocio');
|
||||
if (errors.length) {
|
||||
this.message.set({ kind: 'err', text: errors[0] });
|
||||
return;
|
||||
@@ -303,19 +330,15 @@ export class DocumentFormPage {
|
||||
try {
|
||||
const d = this.doc();
|
||||
if (!d) {
|
||||
const created = await this.api.create(this.screen.resource, createPayload(this.header(), this.lines()));
|
||||
const header = this.layout?.beforeCreate?.(this.header()) ?? this.header();
|
||||
const created = await this.api.create(this.screen.resource, createPayload(header, this.lines(), this.lineSpec));
|
||||
await this.router.navigate(['..', created['DocEntry']], {
|
||||
relativeTo: this.route,
|
||||
state: { created: text(created['DocNum']) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { body, replaceLines } = updatePayload(
|
||||
pickHeader(d),
|
||||
this.header(),
|
||||
(d['DocumentLines'] as GridLine[]) ?? [],
|
||||
this.lines(),
|
||||
);
|
||||
const { body, replaceLines } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec);
|
||||
if (!Object.keys(body).length) {
|
||||
this.message.set({ kind: 'info', text: 'No hay cambios que guardar.' });
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { type ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { LAYOUTS, headerKeysOf } from './document-layouts';
|
||||
import { createPayload, updatePayload, validateLayout } from './document-model';
|
||||
import { DocumentFormPage } from './document-form.page';
|
||||
import { DOCUMENT_SCREENS } from './document-screens';
|
||||
|
||||
describe('modelo de los formularios sin socio', () => {
|
||||
const transfer = LAYOUTS.transferRequest;
|
||||
|
||||
it('cada pantalla de inventario y la solicitud de compra tienen su formulario', () => {
|
||||
const withLayout = DOCUMENT_SCREENS.filter((s) => s.layout).map((s) => s.resource);
|
||||
expect(withLayout.sort()).toEqual(
|
||||
['InventoryGenEntries', 'InventoryGenExits', 'InventoryTransferRequests', 'PurchaseRequests', 'StockTransfers'].sort(),
|
||||
);
|
||||
expect(DOCUMENT_SCREENS.filter((s) => s.layout && s.partner)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('la cabecera tiene como máximo dos columnas y no usa campos de usuario', () => {
|
||||
for (const l of Object.values(LAYOUTS)) {
|
||||
for (const k of headerKeysOf(l)) expect(k.startsWith('U_')).toBe(false);
|
||||
for (const c of l.columns) expect(c.key.startsWith('U_')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('las transferencias guardan las líneas en StockTransferLines con sus dos almacenes', () => {
|
||||
const lines = [{ ItemCode: 'A', ItemDescription: 'x', Quantity: 2, FromWarehouseCode: '01', WarehouseCode: '02', Price: 5, LineTotal: 10 }];
|
||||
expect(createPayload({ DocDate: '2026-10-14', FromWarehouse: '01', ToWarehouse: '02', Comments: '' }, lines, transfer.lines)).toEqual({
|
||||
DocDate: '2026-10-14',
|
||||
FromWarehouse: '01',
|
||||
ToWarehouse: '02',
|
||||
StockTransferLines: [{ ItemCode: 'A', ItemDescription: 'x', Quantity: 2, FromWarehouseCode: '01', WarehouseCode: '02' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('modificar una solicitud de traslado envía las líneas con su colección', () => {
|
||||
const before = [{ ItemCode: 'A', Quantity: 1, FromWarehouseCode: '01', WarehouseCode: '02' }];
|
||||
const after = [{ ItemCode: 'A', Quantity: 3, FromWarehouseCode: '01', WarehouseCode: '02' }];
|
||||
const { body, replaceLines } = updatePayload({ DocDate: 'd' }, { DocDate: 'd' }, before, after, transfer.lines);
|
||||
expect(replaceLines).toBe(true);
|
||||
expect(body).toEqual({ StockTransferLines: [{ ItemCode: 'A', Quantity: 3, FromWarehouseCode: '01', WarehouseCode: '02' }] });
|
||||
});
|
||||
|
||||
it('validateLayout pide los campos obligatorios de cabecera y de línea', () => {
|
||||
const req = [{ key: 'FromWarehouse', label: 'Almacén origen' }];
|
||||
expect(validateLayout({ FromWarehouse: '' }, [], req, [])).toEqual(['Indique almacén origen.', 'Agregue al menos una línea con artículo.']);
|
||||
const bad = [{ ItemCode: 'A', Quantity: 0, WarehouseCode: '' }];
|
||||
expect(validateLayout({ FromWarehouse: '01' }, bad, req, [{ key: 'WarehouseCode', label: 'El almacén' }])).toEqual([
|
||||
'Línea 1: la cantidad debe ser mayor que cero.',
|
||||
'Línea 1: indique el almacén.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('la solicitud de compra marca al solicitante como usuario (ReqType 12) solo si lo indica', () => {
|
||||
const l = LAYOUTS.purchaseRequest;
|
||||
expect(l.beforeCreate!({ Requester: 'manager' })).toEqual({ Requester: 'manager', ReqType: 12 });
|
||||
expect(l.beforeCreate!({ Requester: '' })).toEqual({ Requester: '' });
|
||||
});
|
||||
|
||||
it('una línea nueva de transferencia toma los almacenes de la cabecera', () => {
|
||||
expect(transfer.newLine({ FromWarehouse: '01', ToWarehouse: '02' })).toMatchObject({ FromWarehouseCode: '01', WarehouseCode: '02', Quantity: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DocumentFormPage de inventario', () => {
|
||||
let fixture: ComponentFixture<DocumentFormPage>;
|
||||
let ctrl: HttpTestingController;
|
||||
let navigate: ReturnType<typeof vi.spyOn>;
|
||||
const el = () => fixture.nativeElement as HTMLElement;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const page = () => fixture.componentInstance as any;
|
||||
const tick = async () => {
|
||||
fixture.detectChanges();
|
||||
await new Promise((r) => setTimeout(r));
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
async function open(resource: string, docEntry: string | null, doc?: Record<string, unknown>) {
|
||||
const screen = DOCUMENT_SCREENS.find((s) => s.resource === resource)!;
|
||||
const params = docEntry ? { docEntry } : {};
|
||||
const route = {
|
||||
snapshot: { data: { screen, title: 'Documento' }, paramMap: convertToParamMap(params) },
|
||||
paramMap: of(convertToParamMap(params)),
|
||||
};
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DocumentFormPage],
|
||||
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }],
|
||||
}).compileComponents();
|
||||
ctrl = TestBed.inject(HttpTestingController);
|
||||
navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
|
||||
fixture = TestBed.createComponent(DocumentFormPage);
|
||||
await tick();
|
||||
if (docEntry) ctrl.expectOne(`/api/sl/${resource}/${docEntry}`).flush(doc ?? {});
|
||||
await tick();
|
||||
}
|
||||
|
||||
beforeEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('no pide el maestro de impuestos (no hay totales) y muestra la cabecera de la transferencia', async () => {
|
||||
await open('InventoryTransferRequests', null);
|
||||
ctrl.expectNone((r) => r.url.includes('SalesTaxCodes'));
|
||||
const labels = [...el().querySelectorAll('label')].map((l) => l.textContent?.trim());
|
||||
expect(labels).toEqual(expect.arrayContaining(['Almacén origen', 'Almacén destino']));
|
||||
expect(el().querySelector('.status')?.textContent).toContain('Complete la cabecera');
|
||||
});
|
||||
|
||||
it('crea una solicitud de traslado con las líneas en StockTransferLines', async () => {
|
||||
await open('InventoryTransferRequests', null);
|
||||
page().setHeader('FromWarehouse', '01');
|
||||
page().setHeader('ToWarehouse', '02');
|
||||
page().setLines([{ ItemCode: 'A1', Quantity: 2, FromWarehouseCode: '01', WarehouseCode: '02' }]);
|
||||
await tick();
|
||||
(el().querySelector('.actions .btn.main') as HTMLElement).click();
|
||||
await tick();
|
||||
const req = ctrl.expectOne((r) => r.method === 'POST');
|
||||
expect(req.request.url).toBe('/api/sl/InventoryTransferRequests');
|
||||
expect(req.request.body).toEqual({
|
||||
DocDate: page().header().DocDate,
|
||||
FromWarehouse: '01',
|
||||
ToWarehouse: '02',
|
||||
StockTransferLines: [{ ItemCode: 'A1', Quantity: 2, FromWarehouseCode: '01', WarehouseCode: '02' }],
|
||||
});
|
||||
req.flush({ DocEntry: 7, DocNum: 7 });
|
||||
await tick();
|
||||
expect(navigate).toHaveBeenCalledWith(['..', 7], expect.objectContaining({ state: { created: '7' } }));
|
||||
});
|
||||
|
||||
it('no envía una transferencia sin almacenes', async () => {
|
||||
await open('InventoryTransferRequests', null);
|
||||
page().setLines([{ ItemCode: 'A1', Quantity: 2, FromWarehouseCode: '', WarehouseCode: '' }]);
|
||||
await tick();
|
||||
(el().querySelector('.actions .btn.main') as HTMLElement).click();
|
||||
await tick();
|
||||
ctrl.expectNone((r) => r.method === 'POST');
|
||||
expect(el().querySelector('.status')?.textContent).toContain('almacén origen');
|
||||
});
|
||||
|
||||
it('una entrada de mercancía contabilizada se abre en solo lectura', async () => {
|
||||
await open('InventoryGenEntries', '154', {
|
||||
DocEntry: 154,
|
||||
DocNum: 154,
|
||||
DocDate: '2026-09-30T00:00:00Z',
|
||||
Comments: 'Stock',
|
||||
DocumentStatus: 'bost_Open',
|
||||
Cancelled: 'tNO',
|
||||
DocumentLines: [{ LineNum: 0, ItemCode: 'A', ItemDescription: 'Pieza', Quantity: 10, WarehouseCode: '01', Price: 5 }],
|
||||
});
|
||||
expect(page().readOnly()).toBe(true);
|
||||
expect([...el().querySelectorAll('.actions .btn')].map((b) => b.textContent?.trim())).not.toContain('Actualizar');
|
||||
expect(el().querySelector('.status')?.textContent).toContain('contabilizado');
|
||||
expect(el().querySelectorAll('tbody tr')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('una solicitud de traslado abierta se puede modificar y envía solo el cambio', async () => {
|
||||
await open('InventoryTransferRequests', '471', {
|
||||
'odata.etag': 'W/"1"',
|
||||
DocEntry: 471,
|
||||
DocNum: 471,
|
||||
DocDate: '2026-09-30T00:00:00Z',
|
||||
FromWarehouse: '01',
|
||||
ToWarehouse: '02',
|
||||
Comments: '',
|
||||
DocumentStatus: 'bost_Open',
|
||||
Cancelled: 'tNO',
|
||||
StockTransferLines: [{ LineNum: 0, ItemCode: 'A', ItemDescription: 'Pieza', Quantity: 1, FromWarehouseCode: '01', WarehouseCode: '02' }],
|
||||
});
|
||||
expect(page().readOnly()).toBe(false);
|
||||
page().setHeader('Comments', 'urgente');
|
||||
await tick();
|
||||
(el().querySelector('.actions .btn.main') as HTMLElement).click();
|
||||
await tick();
|
||||
const req = ctrl.expectOne((r) => r.method === 'PATCH');
|
||||
expect(req.request.body).toEqual({ Comments: 'urgente' });
|
||||
expect(req.request.headers.get('If-Match')).toBe('W/"1"');
|
||||
req.flush(null);
|
||||
await tick();
|
||||
ctrl.expectOne('/api/sl/InventoryTransferRequests/471').flush({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { GridColumn, GridLine } from '../../shared/grid/grid.types';
|
||||
import type { VhLookup } from '../../shared/value-help/value-help.types';
|
||||
import type { DocRecord, LinesSpec, RequiredField } from './document-model';
|
||||
import { DOCUMENT_LINE_COLUMNS, uomEntryOf } from './line-columns';
|
||||
|
||||
export interface HeaderField {
|
||||
key: string;
|
||||
label: string;
|
||||
/** `warehouse` abre la lista de almacenes. */
|
||||
type: 'text' | 'date' | 'warehouse';
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formulario de los documentos sin socio de negocio ni totales (inventario y solicitudes de
|
||||
* compra): qué campos de cabecera y de línea tienen, y cómo se guardan.
|
||||
*/
|
||||
export interface DocumentLayout {
|
||||
lines: LinesSpec;
|
||||
/** Dos columnas como máximo (regla del proyecto base); el número y los comentarios van aparte. */
|
||||
left: readonly HeaderField[];
|
||||
right: readonly HeaderField[];
|
||||
columns: readonly GridColumn[];
|
||||
/** Cabecera de un documento nuevo (siempre lleva `DocDate`). */
|
||||
newHeader: () => DocRecord;
|
||||
/** Línea nueva; las transferencias toman los almacenes de la cabecera. */
|
||||
newLine: (header: DocRecord) => GridLine;
|
||||
/** Una vez contabilizado ya no se modifica (SAP no permite cambiar entradas, salidas ni transferencias). */
|
||||
immutable?: boolean;
|
||||
/** Ajustes a la cabecera antes de crear. */
|
||||
beforeCreate?: (header: DocRecord) => DocRecord;
|
||||
requiredLine: readonly RequiredField[];
|
||||
}
|
||||
|
||||
const today = (): string => new Date().toISOString().slice(0, 10);
|
||||
|
||||
const [baseItemColumn, descriptionColumn, quantityColumn, warehouseColumn, priceColumn] = DOCUMENT_LINE_COLUMNS;
|
||||
|
||||
/** Artículo que además completa la unidad de medida de la línea (`UoMEntry`). */
|
||||
const itemColumn: GridColumn = {
|
||||
...baseItemColumn,
|
||||
lookup: {
|
||||
...baseItemColumn.lookup!,
|
||||
apply: (item, line) => ({ ...baseItemColumn.lookup!.apply(item, line), UoMEntry: uomEntryOf(item) ?? line['UoMEntry'] }),
|
||||
},
|
||||
};
|
||||
|
||||
/** Lista de almacenes de la cabecera (la misma de las líneas). */
|
||||
export const WAREHOUSE_LOOKUP: VhLookup = warehouseColumn.lookup!.config;
|
||||
|
||||
const blankLine = (): GridLine => ({ ItemCode: '', ItemDescription: '', Quantity: 1, WarehouseCode: '' });
|
||||
|
||||
/** Entradas y salidas de mercancía: artículo, cantidad, almacén y precio (opcional: si falta, SAP usa el costo). */
|
||||
const goods: DocumentLayout = {
|
||||
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'Price'], base: false },
|
||||
left: [{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true }],
|
||||
right: [],
|
||||
columns: [itemColumn, descriptionColumn, quantityColumn, { ...warehouseColumn, required: true }, priceColumn],
|
||||
newHeader: () => ({ DocDate: today() }),
|
||||
newLine: () => ({ ...blankLine(), Price: null }),
|
||||
immutable: true,
|
||||
requiredLine: [{ key: 'WarehouseCode', label: 'el almacén' }],
|
||||
};
|
||||
|
||||
/** Transferencias de stock y solicitudes de traslado: almacén origen y destino en la cabecera y en cada línea. */
|
||||
const transfer = (immutable: boolean): DocumentLayout => ({
|
||||
lines: { key: 'StockTransferLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'FromWarehouseCode', 'WarehouseCode'], base: false },
|
||||
left: [
|
||||
{ key: 'FromWarehouse', label: 'Almacén origen', type: 'warehouse', required: true },
|
||||
{ key: 'ToWarehouse', label: 'Almacén destino', type: 'warehouse', required: true },
|
||||
],
|
||||
right: [{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true }],
|
||||
columns: [
|
||||
itemColumn,
|
||||
descriptionColumn,
|
||||
quantityColumn,
|
||||
{
|
||||
...warehouseColumn,
|
||||
key: 'FromWarehouseCode',
|
||||
title: 'Almacén origen',
|
||||
required: true,
|
||||
lookup: { ...warehouseColumn.lookup!, apply: (wh, line) => ({ ...line, FromWarehouseCode: wh['WarehouseCode'] }) },
|
||||
},
|
||||
{ ...warehouseColumn, title: 'Almacén destino', required: true },
|
||||
],
|
||||
newHeader: () => ({ DocDate: today() }),
|
||||
newLine: (header) => ({
|
||||
...blankLine(),
|
||||
FromWarehouseCode: header['FromWarehouse'] ?? '',
|
||||
WarehouseCode: header['ToWarehouse'] ?? '',
|
||||
}),
|
||||
immutable,
|
||||
requiredLine: [
|
||||
{ key: 'FromWarehouseCode', label: 'el almacén origen' },
|
||||
{ key: 'WarehouseCode', label: 'el almacén destino' },
|
||||
],
|
||||
});
|
||||
|
||||
/** Solicitud de compra: solicitante (usuario de SAP), fecha requerida y, por línea, proveedor sugerido. */
|
||||
const purchaseRequest: DocumentLayout = {
|
||||
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'LineVendor'], base: false },
|
||||
left: [{ key: 'Requester', label: 'Solicitante (usuario)', type: 'text' }],
|
||||
right: [
|
||||
{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true },
|
||||
{ key: 'RequriedDate', label: 'Fecha requerida', type: 'date', required: true },
|
||||
],
|
||||
columns: [
|
||||
itemColumn,
|
||||
descriptionColumn,
|
||||
quantityColumn,
|
||||
warehouseColumn,
|
||||
{
|
||||
key: 'LineVendor',
|
||||
title: 'Proveedor',
|
||||
width: '130px',
|
||||
mono: true,
|
||||
lookup: {
|
||||
config: {
|
||||
title: 'Proveedores',
|
||||
resource: 'BusinessPartners',
|
||||
baseFilter: `CardType eq 'cSupplier'`,
|
||||
orderby: 'CardCode',
|
||||
columns: [
|
||||
{ key: 'CardCode', title: 'Código', width: '130px', mono: true },
|
||||
{ key: 'CardName', title: 'Nombre' },
|
||||
],
|
||||
},
|
||||
apply: (bp, line) => ({ ...line, LineVendor: bp['CardCode'] }),
|
||||
},
|
||||
},
|
||||
],
|
||||
newHeader: () => ({ DocDate: today(), RequriedDate: today() }),
|
||||
newLine: blankLine,
|
||||
// El solicitante es un usuario de SAP (`ReqType` 12).
|
||||
beforeCreate: (h) => (h['Requester'] ? { ...h, ReqType: 12 } : h),
|
||||
requiredLine: [],
|
||||
};
|
||||
|
||||
export const LAYOUTS = {
|
||||
goods,
|
||||
transfer: transfer(true),
|
||||
transferRequest: transfer(false),
|
||||
purchaseRequest,
|
||||
} as const;
|
||||
|
||||
/** Campos de cabecera que se editan (y se envían): los de las columnas del formulario y los comentarios. */
|
||||
export const headerKeysOf = (layout: DocumentLayout): readonly string[] => [
|
||||
...layout.left.map((f) => f.key),
|
||||
...layout.right.map((f) => f.key),
|
||||
'Comments',
|
||||
];
|
||||
@@ -68,6 +68,16 @@ 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;
|
||||
|
||||
/** Colección de líneas de un documento y los campos de cada línea que se envían a SAP. */
|
||||
export interface LinesSpec {
|
||||
/** `DocumentLines`, o `StockTransferLines` en las transferencias de stock. */
|
||||
key: string;
|
||||
fields: readonly string[];
|
||||
/** Los documentos de ventas y compras referencian el origen de una línea copiada (`BaseType`…). */
|
||||
base: boolean;
|
||||
}
|
||||
export const MARKETING_LINES: LinesSpec = { key: 'DocumentLines', fields: LINE_FIELDS, base: true };
|
||||
|
||||
/** 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']);
|
||||
@@ -76,13 +86,13 @@ const hasBase = (l: GridLine): boolean => num(l['BaseType']) > 0 && !isEmpty(l['
|
||||
export const isBlankLine = (line: GridLine): boolean => isEmpty(line['ItemCode']);
|
||||
|
||||
/** Líneas listas para SAP: sin filas en blanco ni campos vacíos. */
|
||||
export function linesPayload(lines: readonly GridLine[]): DocRecord[] {
|
||||
export function linesPayload(lines: readonly GridLine[], spec: LinesSpec = MARKETING_LINES): DocRecord[] {
|
||||
return lines
|
||||
.filter((l) => !isBlankLine(l))
|
||||
.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];
|
||||
for (const f of spec.fields) if (!isEmpty(l[f])) out[f] = l[f];
|
||||
if (spec.base && hasBase(l)) for (const f of BASE_FIELDS) out[f] = l[f];
|
||||
return out;
|
||||
});
|
||||
}
|
||||
@@ -100,9 +110,33 @@ export function validateDocument(header: DocRecord, lines: readonly GridLine[],
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Un campo obligatorio y su nombre para el aviso. */
|
||||
export interface RequiredField {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Validación de los documentos sin socio de negocio (inventario, solicitudes de compra). */
|
||||
export function validateLayout(
|
||||
header: DocRecord,
|
||||
lines: readonly GridLine[],
|
||||
requiredHeader: readonly RequiredField[],
|
||||
requiredLine: readonly RequiredField[],
|
||||
): 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.');
|
||||
real.forEach((l, i) => {
|
||||
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()}.`);
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Cuerpo para crear: cabecera sin campos vacíos + líneas. */
|
||||
export function createPayload(header: DocRecord, lines: readonly GridLine[]): DocRecord {
|
||||
return { ...compact(header), DocumentLines: linesPayload(lines) };
|
||||
export function createPayload(header: DocRecord, lines: readonly GridLine[], spec: LinesSpec = MARKETING_LINES): DocRecord {
|
||||
return { ...compact(header), [spec.key]: linesPayload(lines, spec) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,6 +149,7 @@ export function updatePayload(
|
||||
header: DocRecord,
|
||||
originalLines: readonly GridLine[],
|
||||
lines: readonly GridLine[],
|
||||
spec: LinesSpec = MARKETING_LINES,
|
||||
): { body: DocRecord; replaceLines: boolean } {
|
||||
const body: DocRecord = {};
|
||||
for (const [key, value] of Object.entries(header)) {
|
||||
@@ -122,9 +157,9 @@ export function updatePayload(
|
||||
if (isEmpty(value) && isEmpty(before)) continue;
|
||||
if (value !== before) body[key] = isEmpty(value) ? null : value;
|
||||
}
|
||||
const next = linesPayload(lines);
|
||||
const replaceLines = JSON.stringify(next) !== JSON.stringify(linesPayload(originalLines));
|
||||
if (replaceLines) body['DocumentLines'] = next;
|
||||
const next = linesPayload(lines, spec);
|
||||
const replaceLines = JSON.stringify(next) !== JSON.stringify(linesPayload(originalLines, spec));
|
||||
if (replaceLines) body[spec.key] = next;
|
||||
return { body, replaceLines };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { MENU } from '@basic-erp/sap-types';
|
||||
import { formatNumber } from '../../core/format';
|
||||
import type { ListFilter } from '../../core/sl/list-filters';
|
||||
import type { ListColumn, ListRow, Tone } from '../../shared/list/list.types';
|
||||
import { type DocumentLayout, LAYOUTS } from './document-layouts';
|
||||
|
||||
/** Pantalla de un documento: lista de los últimos ingresados + formulario (nuevo / ver-editar). */
|
||||
export interface DocumentScreen {
|
||||
@@ -23,6 +24,8 @@ export interface DocumentScreen {
|
||||
/** Maestro de impuestos (el Service Layer no tiene uno propio de compras: en Chile ambos usan `SalesTaxCodes`). */
|
||||
taxResource: string;
|
||||
};
|
||||
/** Formulario de los documentos sin socio ni totales (inventario y solicitudes de compra). */
|
||||
layout?: DocumentLayout;
|
||||
}
|
||||
|
||||
export const menuLabel = (path: string): string =>
|
||||
@@ -110,8 +113,15 @@ const marketing = (path: string, resource: string, createLabel: string, partner:
|
||||
filters: [statusFilter, currencyFilter, dateFilter],
|
||||
});
|
||||
|
||||
const inventory = (path: string, resource: string, createLabel: string, withStatus: boolean): DocumentScreen => ({
|
||||
const inventory = (
|
||||
path: string,
|
||||
resource: string,
|
||||
createLabel: string,
|
||||
withStatus: boolean,
|
||||
layout: DocumentLayout,
|
||||
): DocumentScreen => ({
|
||||
path,
|
||||
layout,
|
||||
resource,
|
||||
createLabel,
|
||||
orderby: 'DocEntry desc',
|
||||
@@ -142,6 +152,7 @@ export const DOCUMENT_SCREENS: readonly DocumentScreen[] = [
|
||||
// Compras
|
||||
{
|
||||
path: '/compras/solicitudes',
|
||||
layout: LAYOUTS.purchaseRequest,
|
||||
resource: 'PurchaseRequests',
|
||||
createLabel: 'Nueva solicitud',
|
||||
orderby: 'DocEntry desc',
|
||||
@@ -162,8 +173,8 @@ export const DOCUMENT_SCREENS: readonly DocumentScreen[] = [
|
||||
marketing('/compras/devoluciones', 'PurchaseReturns', 'Nueva devolución', 'Proveedor'),
|
||||
marketing('/compras/notas-credito', 'PurchaseCreditNotes', 'Nueva nota de crédito', 'Proveedor'),
|
||||
// Inventario
|
||||
inventory('/inventario/entradas', 'InventoryGenEntries', 'Nueva entrada', true),
|
||||
inventory('/inventario/salidas', 'InventoryGenExits', 'Nueva salida', true),
|
||||
inventory('/inventario/transferencias', 'StockTransfers', 'Nueva transferencia', false),
|
||||
inventory('/inventario/solicitudes-traslado', 'InventoryTransferRequests', 'Nueva solicitud', true),
|
||||
inventory('/inventario/entradas', 'InventoryGenEntries', 'Nueva entrada', true, LAYOUTS.goods),
|
||||
inventory('/inventario/salidas', 'InventoryGenExits', 'Nueva salida', true, LAYOUTS.goods),
|
||||
inventory('/inventario/transferencias', 'StockTransfers', 'Nueva transferencia', false, LAYOUTS.transfer),
|
||||
inventory('/inventario/solicitudes-traslado', 'InventoryTransferRequests', 'Nueva solicitud', true, LAYOUTS.transferRequest),
|
||||
];
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine } from './line-columns';
|
||||
import { DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine, uomEntryOf } from './line-columns';
|
||||
|
||||
describe('recalcLine', () => {
|
||||
it('total = cantidad × precio, redondeado a 2 decimales', () => {
|
||||
@@ -68,3 +68,14 @@ describe('lineColumnsFor', () => {
|
||||
expect(DOCUMENT_LINE_COLUMNS.find((c) => c.key === 'TaxCode')?.lookup?.config.resource).toBe('SalesTaxCodes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uomEntryOf', () => {
|
||||
it('usa la unidad de compra por defecto y, si no hay, la de inventario', () => {
|
||||
expect(uomEntryOf({ UoMGroupEntry: 1, DefaultPurchasingUoMEntry: 3, InventoryUoMEntry: 1 })).toBe(3);
|
||||
expect(uomEntryOf({ UoMGroupEntry: 1, DefaultPurchasingUoMEntry: null, InventoryUoMEntry: 1 })).toBe(1);
|
||||
});
|
||||
it('un artículo de unidad manual (sin grupo) no lleva unidad: SAP no la admite', () => {
|
||||
expect(uomEntryOf({ UoMGroupEntry: -1, InventoryUoMEntry: -1 })).toBeUndefined();
|
||||
expect(uomEntryOf({ InventoryUoMEntry: 1 })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,20 @@ export function recalcLine(line: GridLine): GridLine {
|
||||
* Línea nueva con los valores por defecto de un documento. El precio queda vacío (no 0): si el
|
||||
* usuario no lo escribe no se envía y SAP aplica la lista de precios del socio de negocio.
|
||||
*/
|
||||
/**
|
||||
* Unidad de medida de un artículo con grupo de unidades, para las líneas de compra e inventario: la de
|
||||
* compra por defecto o, si no tiene, la de inventario. Sin ella SAP rechaza el documento ("specify a
|
||||
* UoM code"); y a un artículo de unidad manual (sin grupo) SAP no admite ponérsela.
|
||||
*/
|
||||
export function uomEntryOf(item: Record<string, unknown>): number | undefined {
|
||||
if (!(Number(item['UoMGroupEntry']) > 0)) return undefined;
|
||||
for (const key of ['DefaultPurchasingUoMEntry', 'InventoryUoMEntry']) {
|
||||
const entry = Number(item[key]);
|
||||
if (entry > 0) return entry;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const newDocumentLine = (): GridLine => ({
|
||||
ItemCode: '',
|
||||
ItemDescription: '',
|
||||
@@ -44,6 +58,7 @@ export const DOCUMENT_LINE_COLUMNS: readonly GridColumn[] = [
|
||||
title: 'Artículos',
|
||||
resource: 'Items',
|
||||
orderby: 'ItemCode',
|
||||
extraFields: ['UoMGroupEntry', 'DefaultPurchasingUoMEntry', 'InventoryUoMEntry'],
|
||||
columns: [
|
||||
{ key: 'ItemCode', title: 'Código', width: '150px', mono: true },
|
||||
{ key: 'ItemName', title: 'Descripción' },
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
[baseFilter]="cfg.baseFilter"
|
||||
[orderby]="cfg.orderby"
|
||||
[rowKey]="cfg.valueKey"
|
||||
[extraFields]="cfg.extraFields ?? []"
|
||||
[selection]="lookupMultiple() ? 'multiple' : 'single'"
|
||||
(selected)="lookupPicked($event)"
|
||||
(closed)="lookupClosed()"
|
||||
@@ -106,6 +107,7 @@
|
||||
[columns]="m.lookup.columns"
|
||||
[baseFilter]="m.lookup.baseFilter"
|
||||
[orderby]="m.lookup.orderby"
|
||||
[extraFields]="m.lookup.extraFields ?? []"
|
||||
selection="multiple"
|
||||
(selected)="multiPicked($event)"
|
||||
(closed)="multiOpen.set(false)"
|
||||
|
||||
@@ -28,6 +28,8 @@ export class ValueHelp {
|
||||
/** Filtro fijo (ej. `CardType eq 'cCustomer'`) que se combina con la búsqueda. */
|
||||
readonly baseFilter = input<string>();
|
||||
readonly orderby = input<string>();
|
||||
/** Campos que se piden aunque no sean columna (ver `VhLookup.extraFields`). */
|
||||
readonly extraFields = input<readonly string[]>([]);
|
||||
/** Campo único de cada fila; por defecto, el de la primera columna. */
|
||||
readonly rowKey = input<string>();
|
||||
readonly pageSize = input(20);
|
||||
@@ -41,6 +43,7 @@ export class ValueHelp {
|
||||
resource: () => this.resource(),
|
||||
columns: () => this.columns(),
|
||||
rowKey: () => this.rowKey(),
|
||||
extraFields: () => this.extraFields(),
|
||||
baseFilter: () => this.baseFilter(),
|
||||
orderby: () => this.orderby(),
|
||||
pageSize: () => this.pageSize(),
|
||||
|
||||
@@ -13,4 +13,6 @@ export interface VhLookup {
|
||||
orderby?: string;
|
||||
/** Campo de la fila elegida que se escribe en el campo; por defecto, la primera columna. */
|
||||
valueKey?: string;
|
||||
/** Campos que se piden al Service Layer sin mostrarlos como columna (los usa quien recibe la fila elegida). */
|
||||
extraFields?: readonly string[];
|
||||
}
|
||||
Reference in new issue
Block a user