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:
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.' });
|
||||
|
||||
Reference in new issue
Block a user