feat(web): gastos adicionales de los documentos de ventas y compras como tabla de varias filas (antes solo el flete)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 12:46:56 -05:00
1 parent 3c1d02a263
commit 6c2cb30d63
10 files changed
+223 -162

No files matched your search

@@ -8,7 +8,7 @@ import { map } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../core/api';
import { formatNumber } from '../../core/format';
import { SlDocumentApi } from '../../core/sl/sl-document';
import { CheckboxField, DateField, LookupField, NumberField, NumberInput, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField } from '../../shared/form';
import { CheckboxField, DateField, LookupField, NumberField, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField } from '../../shared/form';
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';
@@ -19,22 +19,28 @@ import {
type DocRecord,
type NavTarget,
computeTotals,
freightOf,
freightPayload,
EXPENSES_COLLECTION,
type ExpenseRow,
createPayload,
expenseRowsOf,
expensesChanged,
expensesPayload,
isBlankLine,
isDocumentReadOnly,
taxRateMap,
updatePayload,
validateExpenses,
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 ComputedSource, type HeaderField, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasExpenses, hasFolio, 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';
import { OBJECT_TYPES } from './document-objects';
import { detailHint, validateLineDetails } from './line-details';
import { ITEMS_MULTI_ADD } from './line-columns';
import { ITEMS_MULTI_ADD, LINE_COLUMNS } from './line-columns';
import { RowsTable } from '../../shared/grid/rows-table';
import type { DetailColumn } from '../../shared/grid/grid.types';
const today = (): string => new Date().toISOString().slice(0, 10);
const DATE_KEYS = new Set(['DocDate', 'DocDueDate', 'RequriedDate', 'TaxDate', 'DueDate']);
@@ -65,13 +71,15 @@ const folioAsNumber = (h: DocRecord): DocRecord => {
@Component({
selector: 'sbo-document-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CheckboxField, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp],
imports: [CheckboxField, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp],
templateUrl: './document-form.page.html',
styles: `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
p { margin: 0; color: var(--quiet); }
a { color: var(--focus); }
.totals { display: grid; gap: 2px; }
.exp h3 { margin: 0 0 4px; font-size: 13px; font-weight: 600; }
.exp .addrow { margin: 6px 0 0; }
.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); }
`,
})
@@ -92,12 +100,9 @@ export class DocumentFormPage {
private readonly headerKeys: readonly string[] = headerKeysOf(this.layout);
private readonly hasFolio = hasFolio(this.layout);
private readonly blankHeader: DocRecord = this.layout.newHeader();
private readonly hasFreight = hasFreight(this.layout);
private readonly hasExpenses = hasExpenses(this.layout);
private pick(doc: DocRecord | undefined): DocRecord {
const header = pickHeader(doc, this.headerKeys, this.blankHeader);
if (!doc || !this.hasFreight) return header;
const freight = freightOf(doc);
return { ...header, FreightExpense: freight.expense, FreightAmount: freight.amount };
return pickHeader(doc, this.headerKeys, this.blankHeader);
}
private docLines(doc: DocRecord | undefined): GridLine[] {
return (doc?.[this.lineSpec.key] as GridLine[] | undefined) ?? [];
@@ -120,22 +125,57 @@ export class DocumentFormPage {
: undefined,
);
/** Gastos adicionales de SAP (el flete se registra con uno de ellos). */
/** Gastos adicionales de SAP (flete, seguro…): sus nombres se muestran junto al código de cada fila. */
private readonly expensesResource = httpResource<SlListResponse<DocRecord>>(() =>
this.hasFreight ? { url: `${API_URL}/sl/AdditionalExpenses`, params: { $select: 'ExpensCode,Name', $top: 100 } } : undefined,
this.hasExpenses ? { url: `${API_URL}/sl/AdditionalExpenses`, params: { $select: 'ExpensCode,Name', $top: 200 } } : undefined,
);
protected readonly expenseOptions = computed(() =>
(this.expensesResource.hasValue() ? this.expensesResource.value().value : []).map((e) => ({
value: String(e['ExpensCode']),
label: text(e['Name']) || String(e['ExpensCode']),
})),
private readonly expenseNames = computed(
() => new Map((this.expensesResource.hasValue() ? this.expensesResource.value().value : []).map((e) => [String(e['ExpensCode']), text(e['Name'])])),
);
/** Gasto que se usa si el usuario no elige: el que se llame "flete" o, si no hay, el primero. */
protected readonly defaultExpense = computed(() => {
const options = this.expenseOptions();
return (options.find((o) => /flete|freight/i.test(o.label)) ?? options[0])?.value ?? '';
});
protected readonly freightEditable = computed(() => freightOf(this.doc()).editable);
/** Filas de gastos adicionales; se reinician al cargar o recargar el documento. */
protected readonly expenses = linkedSignal<ExpenseRow[]>(() => expenseRowsOf(this.doc()));
protected readonly expenseColumns: readonly DetailColumn[] = [
{
key: 'ExpenseCode',
title: 'Gasto',
type: 'lookup',
required: true,
width: '90px',
lookup: {
title: 'Gastos adicionales',
resource: 'AdditionalExpenses',
orderby: 'ExpensCode',
valueKey: 'ExpensCode',
valueType: 'number',
columns: [
{ key: 'ExpensCode', title: 'Código', width: '90px', mono: true },
{ key: 'Name', title: 'Nombre' },
],
},
},
{ key: '_Name', title: 'Nombre', type: 'text', width: '170px', computed: (row) => this.expenseNames().get(String(row['ExpenseCode'] ?? '')) ?? '' },
{ key: 'LineTotal', title: 'Importe', type: 'number', decimals: 2, required: true, width: '110px' },
{
key: 'TaxCode',
title: 'Impuesto',
type: 'lookup',
width: '100px',
lookup: {
...(this.layout.columns.find((c) => c.key === 'TaxCode')?.lookup?.config ?? LINE_COLUMNS.tax.lookup!.config),
},
},
{ key: 'Remarks', title: 'Comentarios', type: 'text', width: '180px' },
];
protected setExpenses(rows: ExpenseRow[]): void {
this.expenses.set(rows);
this.message.set(null);
}
protected addExpense(): void {
this.setExpenses([...this.expenses(), {}]);
}
/** Series de numeración de este tipo de documento (acción `SeriesService_GetDocumentSeries` de SAP). */
private readonly hasSeries = hasSeries(this.layout);
@@ -218,9 +258,9 @@ export class DocumentFormPage {
protected readonly dirty = computed(() => {
const d = this.doc();
if (!d) return text(this.header()['CardCode']) !== '' || this.lines().some((l) => !isBlankLine(l, this.serviceMode()));
if (!d) return text(this.header()['CardCode']) !== '' || this.lines().some((l) => !isBlankLine(l, this.serviceMode())) || expensesPayload(this.expenses()).length > 0;
const { body } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.spec());
return Object.keys(body).length > 0;
return Object.keys(body).length > 0 || (this.hasExpenses && expensesChanged(d, this.expenses()));
});
// --- totales -------------------------------------------------------------------------------
@@ -229,7 +269,7 @@ export class DocumentFormPage {
taxRateMap(this.taxResource.hasValue() ? this.taxResource.value().value : []),
);
protected readonly totals = computed(() =>
computeTotals(this.lines(), this.rates(), Number(this.header()['DiscountPercent'] ?? 0), Number(this.header()['FreightAmount'] ?? 0)),
computeTotals(this.lines(), this.rates(), Number(this.header()['DiscountPercent'] ?? 0), this.expenses()),
);
protected readonly fmt = (n: number): string => formatNumber(n, 2);
@@ -374,6 +414,8 @@ export class DocumentFormPage {
return this.partnerTaxId();
case 'subtotal':
return this.fmt(this.totals().subtotal);
case 'expenses':
return this.fmt(this.totals().expenses);
case 'tax':
return this.fmt(this.totals().tax);
case 'total':
@@ -459,6 +501,7 @@ export class DocumentFormPage {
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 (this.hasExpenses) errors.push(...validateExpenses(this.expenses()));
if (errors.length) {
this.message.set({ kind: 'err', text: errors[0] });
return;
@@ -469,9 +512,8 @@ export class DocumentFormPage {
const d = this.doc();
if (!d) {
const header = folioAsNumber(this.layout.beforeCreate?.(this.header()) ?? this.header());
const body = this.hasFreight
? freightPayload(createPayload(header, this.lines(), this.spec()), this.header(), this.defaultExpense(), 'create').body
: createPayload(header, this.lines(), this.spec());
const body = createPayload(header, this.lines(), this.spec());
if (this.hasExpenses && expensesPayload(this.expenses()).length) body[EXPENSES_COLLECTION] = expensesPayload(this.expenses());
const created = await this.api.create(this.screen.resource, body);
await this.router.navigate(['..', created['DocEntry']], {
relativeTo: this.route,
@@ -480,12 +522,11 @@ export class DocumentFormPage {
return;
}
const update = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.spec());
let body = folioAsNumber(update.body);
const body = folioAsNumber(update.body);
let replaceLines = update.replaceLines;
if (this.hasFreight) {
const freight = freightPayload(body, this.header(), this.defaultExpense(), 'update', freightOf(d).amount > 0);
body = freight.body;
replaceLines = replaceLines || freight.replace;
if (this.hasExpenses && expensesChanged(d, this.expenses())) {
body[EXPENSES_COLLECTION] = expensesPayload(this.expenses());
replaceLines = true; // SAP reemplaza la colección entera
}
if (!Object.keys(body).length) {
this.message.set({ kind: 'info', text: 'No hay cambios que guardar.' });