refactor(web): pantallas y formularios separados por módulo (ventas, compras, inventario)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8b0b8afc87
commit
c5368c7ad0
10 files changed
+358
-311
No files matched your search
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
type DocumentLayout,
|
||||
FIELDS,
|
||||
blankLine,
|
||||
commentsOnly,
|
||||
descriptionColumn,
|
||||
itemColumn,
|
||||
quantityColumn,
|
||||
today,
|
||||
warehouseColumn,
|
||||
} from '../document-layouts';
|
||||
|
||||
/** Solicitud de compra: solicitante (usuario de SAP), fecha requerida y, por línea, proveedor sugerido. */
|
||||
export const purchaseRequestLayout: DocumentLayout = {
|
||||
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'LineVendor'], base: false },
|
||||
header: { left: [FIELDS.requester], right: [FIELDS.docNum, FIELDS.docDate, FIELDS.requiredDate] },
|
||||
footer: commentsOnly,
|
||||
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: [],
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { TAX_DOCUMENT, type DocumentScreen, docDate, docNum, invoiceColumns, marketing, statusColumn, statusFilter, dateFilter } from '../screen-kit';
|
||||
import { purchaseRequestLayout } from './compras.layouts';
|
||||
|
||||
/** Compras: solicitudes de compra y documentos de proveedor. */
|
||||
export const COMPRAS_SCREENS: readonly DocumentScreen[] = [
|
||||
{
|
||||
path: '/compras/solicitudes',
|
||||
layout: purchaseRequestLayout,
|
||||
resource: 'PurchaseRequests',
|
||||
createLabel: 'Nueva solicitud',
|
||||
orderby: 'DocEntry desc',
|
||||
extraFields: ['Cancelled'],
|
||||
columns: [
|
||||
docNum,
|
||||
statusColumn,
|
||||
docDate,
|
||||
{ key: 'RequriedDate', title: 'Fecha requerida', type: 'date', align: 'center', width: '125px' },
|
||||
{ key: 'RequesterName', title: 'Solicitante', searchable: true },
|
||||
],
|
||||
filters: [statusFilter, dateFilter],
|
||||
},
|
||||
marketing('/compras/ofertas', 'PurchaseQuotations', 'Nueva oferta', 'Proveedor'),
|
||||
marketing('/compras/ordenes', 'PurchaseOrders', 'Nueva orden', 'Proveedor'),
|
||||
marketing('/compras/recepcion', 'PurchaseDeliveryNotes', 'Nueva recepción', 'Proveedor', TAX_DOCUMENT),
|
||||
marketing('/compras/facturas', 'PurchaseInvoices', 'Nueva factura', 'Proveedor', { ...TAX_DOCUMENT, columns: invoiceColumns }),
|
||||
marketing('/compras/devoluciones', 'PurchaseReturns', 'Nueva devolución', 'Proveedor'),
|
||||
marketing('/compras/notas-credito', 'PurchaseCreditNotes', 'Nueva nota de crédito', 'Proveedor', TAX_DOCUMENT),
|
||||
];
|
||||
@@ -4,13 +4,15 @@ 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, hasFolio, headerKeysOf } from './document-layouts';
|
||||
import { purchaseRequestLayout } from './compras/compras.layouts';
|
||||
import { hasFolio, headerKeysOf } from './document-layouts';
|
||||
import { transferRequestLayout } from './inventario/inventario.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;
|
||||
const transfer = transferRequestLayout;
|
||||
|
||||
it('cada pantalla tiene su formulario, declarado por ella', () => {
|
||||
expect(DOCUMENT_SCREENS.every((s) => s.layout)).toBe(true);
|
||||
@@ -71,7 +73,7 @@ describe('modelo de los formularios sin socio', () => {
|
||||
});
|
||||
|
||||
it('la solicitud de compra marca al solicitante como usuario (ReqType 12) solo si lo indica', () => {
|
||||
const l = LAYOUTS.purchaseRequest;
|
||||
const l = purchaseRequestLayout;
|
||||
expect(l.beforeCreate!({ Requester: 'manager' })).toEqual({ Requester: 'manager', ReqType: 12 });
|
||||
expect(l.beforeCreate!({ Requester: '' })).toEqual({ Requester: '' });
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ export interface DocumentLayout {
|
||||
requiredLine: readonly { key: string; label: string }[];
|
||||
}
|
||||
|
||||
const today = (): string => new Date().toISOString().slice(0, 10);
|
||||
export const today = (): string => new Date().toISOString().slice(0, 10);
|
||||
|
||||
// --- catálogo de campos ---------------------------------------------------------------------
|
||||
|
||||
@@ -70,14 +70,15 @@ export const FIELDS = {
|
||||
requester: { type: 'text', key: 'Requester', label: 'Solicitante (usuario)' },
|
||||
} as const satisfies Record<string, HeaderField>;
|
||||
|
||||
const commentsOnly: FieldColumns = { left: [FIELDS.comments], right: [] };
|
||||
export const commentsOnly: FieldColumns = { left: [FIELDS.comments], right: [] };
|
||||
|
||||
// --- columnas de línea compartidas ----------------------------------------------------------
|
||||
|
||||
const [baseItemColumn, descriptionColumn, quantityColumn, warehouseColumn, priceColumn] = DOCUMENT_LINE_COLUMNS;
|
||||
export { descriptionColumn, priceColumn, quantityColumn, warehouseColumn };
|
||||
|
||||
/** Artículo que además completa la unidad de medida de la línea (`UoMEntry`). */
|
||||
const itemColumn: GridColumn = {
|
||||
export const itemColumn: GridColumn = {
|
||||
...baseItemColumn,
|
||||
lookup: {
|
||||
...baseItemColumn.lookup!,
|
||||
@@ -88,7 +89,7 @@ const itemColumn: GridColumn = {
|
||||
/** 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: '' });
|
||||
export const blankLine = (): GridLine => ({ ItemCode: '', ItemDescription: '', Quantity: 1, WarehouseCode: '' });
|
||||
|
||||
// --- documentos con socio de negocio (ventas y compras) -------------------------------------
|
||||
|
||||
@@ -132,95 +133,6 @@ export function marketingLayout(opts: {
|
||||
};
|
||||
}
|
||||
|
||||
// --- documentos sin socio ni totales (inventario y solicitudes de compra) -------------------
|
||||
|
||||
/** 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 },
|
||||
header: { left: [FIELDS.docDate], right: [FIELDS.docNum] },
|
||||
footer: commentsOnly,
|
||||
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 },
|
||||
header: { left: [FIELDS.fromWarehouse, FIELDS.toWarehouse], right: [FIELDS.docNum, FIELDS.docDate] },
|
||||
footer: commentsOnly,
|
||||
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 },
|
||||
header: { left: [FIELDS.requester], right: [FIELDS.docNum, FIELDS.docDate, FIELDS.requiredDate] },
|
||||
footer: commentsOnly,
|
||||
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;
|
||||
|
||||
// --- consultas sobre un layout --------------------------------------------------------------
|
||||
|
||||
const allFields = (layout: DocumentLayout): HeaderField[] => [
|
||||
|
||||
@@ -1,215 +1,13 @@
|
||||
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, FIELDS, LAYOUTS, type HeaderField, marketingLayout } from './document-layouts';
|
||||
import type { DocumentScreen } from './screen-kit';
|
||||
import { COMPRAS_SCREENS } from './compras/compras.screens';
|
||||
import { INVENTARIO_SCREENS } from './inventario/inventario.screens';
|
||||
import { VENTAS_SCREENS } from './ventas/ventas.screens';
|
||||
|
||||
/** Pantalla de un documento: lista de los últimos ingresados + formulario (nuevo / ver-editar). */
|
||||
export interface DocumentScreen {
|
||||
/** Ruta de menú (`/ventas/ordenes`); de ella cuelgan `nuevo` y `:docEntry`. */
|
||||
path: string;
|
||||
/** Recurso del proxy `/api/sl`. */
|
||||
resource: string;
|
||||
createLabel: string;
|
||||
columns: readonly ListColumn[];
|
||||
/** Campos necesarios para pintar que no tienen columna propia. */
|
||||
extraFields?: readonly string[];
|
||||
filters: readonly ListFilter[];
|
||||
/** Los más recientes primero. */
|
||||
orderby: string;
|
||||
/** Formulario de documentos con socio de negocio, líneas y totales (ventas y compras). */
|
||||
partner?: {
|
||||
label: string;
|
||||
type: 'cCustomer' | 'cSupplier';
|
||||
/** Maestro de impuestos (el Service Layer no tiene uno propio de compras: en Chile ambos usan `SalesTaxCodes`). */
|
||||
taxResource: string;
|
||||
};
|
||||
/** Campos de la cabecera y del pie del formulario, columnas de línea y cómo se guarda. */
|
||||
layout: DocumentLayout;
|
||||
}
|
||||
// Lo que usan el resto de la aplicación y los tests (tipo, columnas y estados comunes).
|
||||
export * from './screen-kit';
|
||||
|
||||
export const menuLabel = (path: string): string =>
|
||||
MENU.flatMap((m) => m.items).find((i) => i.path === path)?.label ?? path;
|
||||
|
||||
export function documentStatus(row: ListRow): { label: string; tone: Tone } | undefined {
|
||||
if (row['Cancelled'] === 'tYES') return { label: 'Cancelado', tone: 'err' };
|
||||
switch (row['DocumentStatus']) {
|
||||
case 'bost_Open':
|
||||
return { label: 'Abierto', tone: 'ok' };
|
||||
case 'bost_Close':
|
||||
return { label: 'Cerrado', tone: 'muted' };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Total en la moneda del documento: SAP guarda en `DocTotalFc` el importe de los documentos en moneda extranjera. */
|
||||
export function documentTotal(row: ListRow): string {
|
||||
const fc = Number(row['DocTotalFc'] ?? 0);
|
||||
const amount = fc !== 0 ? fc : row['DocTotal'];
|
||||
if (amount === null || amount === undefined) return '';
|
||||
return `${row['DocCurrency'] ?? ''} ${formatNumber(amount, 2)}`.trim();
|
||||
}
|
||||
|
||||
const statusColumn: ListColumn = {
|
||||
key: 'DocumentStatus',
|
||||
title: 'Estado',
|
||||
width: '100px',
|
||||
format: (r) => documentStatus(r)?.label ?? '',
|
||||
tone: (r) => documentStatus(r)?.tone,
|
||||
};
|
||||
/** Número de documento: sin separador de miles, y buscable por igualdad (es numérico). */
|
||||
export const docNum: ListColumn = {
|
||||
key: 'DocNum',
|
||||
title: 'Número',
|
||||
type: 'number',
|
||||
align: 'left',
|
||||
width: '110px',
|
||||
mono: true,
|
||||
searchable: true,
|
||||
format: (r) => String(r['DocNum'] ?? ''),
|
||||
};
|
||||
/** Facturas: folio tributario (en el Service Layer `FolioPrefixString` y `FolioNumber`), referencia y RUT. */
|
||||
const invoiceColumns: readonly ListColumn[] = [
|
||||
{ key: 'FolioPrefixString', title: 'Folio pref.', width: '90px', mono: true, searchable: true },
|
||||
{
|
||||
key: 'FolioNumber',
|
||||
title: 'Folio núm.',
|
||||
type: 'number',
|
||||
align: 'left',
|
||||
width: '110px',
|
||||
mono: true,
|
||||
searchable: true,
|
||||
format: (r) => String(r['FolioNumber'] ?? ''),
|
||||
},
|
||||
{ key: 'NumAtCard', title: 'Nº referencia', width: '130px', searchable: true },
|
||||
{ key: 'FederalTaxID', title: 'RUT', width: '110px', mono: true, searchable: true },
|
||||
];
|
||||
|
||||
const docDate: ListColumn = { key: 'DocDate', title: 'Fecha contab.', type: 'date', align: 'center', width: '115px' };
|
||||
|
||||
const statusFilter: ListFilter = {
|
||||
type: 'select',
|
||||
key: 'DocumentStatus',
|
||||
label: 'Estado',
|
||||
defaultValue: 'bost_Open',
|
||||
options: [
|
||||
{ value: 'bost_Open', label: 'Abierto' },
|
||||
{ value: 'bost_Close', label: 'Cerrado' },
|
||||
],
|
||||
};
|
||||
const dateFilter: ListFilter = { type: 'date', key: 'DocDate', label: 'Fecha contab.' };
|
||||
const currencyFilter: ListFilter = {
|
||||
type: 'multi',
|
||||
key: 'DocCurrency',
|
||||
label: 'Moneda',
|
||||
resource: 'Currencies',
|
||||
valueField: 'Code',
|
||||
labelField: 'Name',
|
||||
};
|
||||
|
||||
/** Campos que una pantalla de ventas o compras agrega a la cabecera base y columnas extra de su lista. */
|
||||
interface MarketingExtras {
|
||||
columns?: readonly ListColumn[];
|
||||
headerLeft?: readonly HeaderField[];
|
||||
headerRight?: readonly HeaderField[];
|
||||
}
|
||||
|
||||
/** Documentos tributarios: la cabecera suma el folio (en el Service Layer `FolioPrefixString` y `FolioNumber`). */
|
||||
const TAX_DOCUMENT: MarketingExtras = { headerRight: [FIELDS.folio] };
|
||||
|
||||
/** Ventas y compras: documentos con socio de negocio y total. */
|
||||
const marketing = (
|
||||
path: string,
|
||||
resource: string,
|
||||
createLabel: string,
|
||||
partner: string,
|
||||
extras: MarketingExtras = {},
|
||||
): DocumentScreen => ({
|
||||
path,
|
||||
layout: marketingLayout({ partnerLabel: partner, taxResource: 'SalesTaxCodes', left: extras.headerLeft, right: extras.headerRight }),
|
||||
resource,
|
||||
createLabel,
|
||||
orderby: 'DocEntry desc',
|
||||
partner: path.startsWith('/ventas')
|
||||
? { label: partner, type: 'cCustomer', taxResource: 'SalesTaxCodes' }
|
||||
: { label: partner, type: 'cSupplier', taxResource: 'SalesTaxCodes' },
|
||||
extraFields: ['Cancelled', 'DocTotalFc', 'DocCurrency'],
|
||||
columns: [
|
||||
docNum,
|
||||
statusColumn,
|
||||
...(extras.columns ?? []),
|
||||
{ key: 'CardCode', title: partner, width: '130px', mono: true, searchable: true },
|
||||
{ key: 'CardName', title: 'Nombre' },
|
||||
docDate,
|
||||
{ key: 'DocDueDate', title: 'Fecha venc.', type: 'date', align: 'center', width: '115px' },
|
||||
{ key: 'DocTotal', title: 'Total', align: 'right', width: '140px', format: documentTotal },
|
||||
],
|
||||
filters: [statusFilter, currencyFilter, dateFilter],
|
||||
});
|
||||
|
||||
const inventory = (
|
||||
path: string,
|
||||
resource: string,
|
||||
createLabel: string,
|
||||
withStatus: boolean,
|
||||
layout: DocumentLayout,
|
||||
): DocumentScreen => ({
|
||||
path,
|
||||
layout,
|
||||
resource,
|
||||
createLabel,
|
||||
orderby: 'DocEntry desc',
|
||||
extraFields: withStatus ? ['Cancelled'] : [],
|
||||
columns: [
|
||||
docNum,
|
||||
...(withStatus ? [statusColumn] : []),
|
||||
docDate,
|
||||
...(resource === 'StockTransfers' || resource === 'InventoryTransferRequests'
|
||||
? [
|
||||
{ key: 'FromWarehouse', title: 'Almacén origen', width: '130px', mono: true, searchable: true },
|
||||
{ key: 'ToWarehouse', title: 'Almacén destino', width: '130px', mono: true, searchable: true },
|
||||
]
|
||||
: []),
|
||||
{ key: 'Comments', title: 'Comentarios', searchable: true },
|
||||
],
|
||||
filters: withStatus ? [statusFilter, dateFilter] : [dateFilter],
|
||||
});
|
||||
|
||||
export const DOCUMENT_SCREENS: readonly DocumentScreen[] = [
|
||||
// Ventas
|
||||
marketing('/ventas/ofertas', 'Quotations', 'Nueva oferta', 'Cliente'),
|
||||
marketing('/ventas/ordenes', 'Orders', 'Nueva orden', 'Cliente'),
|
||||
marketing('/ventas/entrega', 'DeliveryNotes', 'Nueva entrega', 'Cliente', TAX_DOCUMENT),
|
||||
marketing('/ventas/facturas', 'Invoices', 'Nueva factura', 'Cliente', { ...TAX_DOCUMENT, columns: invoiceColumns }),
|
||||
marketing('/ventas/devoluciones', 'Returns', 'Nueva devolución', 'Cliente'),
|
||||
marketing('/ventas/notas-credito', 'CreditNotes', 'Nueva nota de crédito', 'Cliente', TAX_DOCUMENT),
|
||||
// Compras
|
||||
{
|
||||
path: '/compras/solicitudes',
|
||||
layout: LAYOUTS.purchaseRequest,
|
||||
resource: 'PurchaseRequests',
|
||||
createLabel: 'Nueva solicitud',
|
||||
orderby: 'DocEntry desc',
|
||||
extraFields: ['Cancelled'],
|
||||
columns: [
|
||||
docNum,
|
||||
statusColumn,
|
||||
docDate,
|
||||
{ key: 'RequriedDate', title: 'Fecha requerida', type: 'date', align: 'center', width: '125px' },
|
||||
{ key: 'RequesterName', title: 'Solicitante', searchable: true },
|
||||
],
|
||||
filters: [statusFilter, dateFilter],
|
||||
},
|
||||
marketing('/compras/ofertas', 'PurchaseQuotations', 'Nueva oferta', 'Proveedor'),
|
||||
marketing('/compras/ordenes', 'PurchaseOrders', 'Nueva orden', 'Proveedor'),
|
||||
marketing('/compras/recepcion', 'PurchaseDeliveryNotes', 'Nueva recepción', 'Proveedor', TAX_DOCUMENT),
|
||||
marketing('/compras/facturas', 'PurchaseInvoices', 'Nueva factura', 'Proveedor', { ...TAX_DOCUMENT, columns: invoiceColumns }),
|
||||
marketing('/compras/devoluciones', 'PurchaseReturns', 'Nueva devolución', 'Proveedor'),
|
||||
marketing('/compras/notas-credito', 'PurchaseCreditNotes', 'Nueva nota de crédito', 'Proveedor', TAX_DOCUMENT),
|
||||
// Inventario
|
||||
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),
|
||||
];
|
||||
/**
|
||||
* Todas las pantallas de documentos, agrupadas por módulo (una carpeta cada uno). Los maestros
|
||||
* (socios de negocio, artículos) irán en su propia carpeta cuando existan.
|
||||
*/
|
||||
export const DOCUMENT_SCREENS: readonly DocumentScreen[] = [...VENTAS_SCREENS, ...COMPRAS_SCREENS, ...INVENTARIO_SCREENS];
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
type DocumentLayout,
|
||||
FIELDS,
|
||||
blankLine,
|
||||
commentsOnly,
|
||||
descriptionColumn,
|
||||
itemColumn,
|
||||
priceColumn,
|
||||
quantityColumn,
|
||||
today,
|
||||
warehouseColumn,
|
||||
} from '../document-layouts';
|
||||
|
||||
/** Entradas y salidas de mercancía: artículo, cantidad, almacén y precio (opcional: si falta, SAP usa el costo). */
|
||||
export const goodsLayout: DocumentLayout = {
|
||||
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'Price'], base: false },
|
||||
header: { left: [FIELDS.docDate], right: [FIELDS.docNum] },
|
||||
footer: commentsOnly,
|
||||
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 },
|
||||
header: { left: [FIELDS.fromWarehouse, FIELDS.toWarehouse], right: [FIELDS.docNum, FIELDS.docDate] },
|
||||
footer: commentsOnly,
|
||||
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' },
|
||||
],
|
||||
});
|
||||
|
||||
export const transferLayout: DocumentLayout = transfer(true);
|
||||
export const transferRequestLayout: DocumentLayout = transfer(false);
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { DocumentScreen } from '../screen-kit';
|
||||
import { inventory } from '../screen-kit';
|
||||
import { goodsLayout, transferLayout, transferRequestLayout } from './inventario.layouts';
|
||||
|
||||
/** Inventario: entradas, salidas, transferencias de stock y solicitudes de traslado. */
|
||||
export const INVENTARIO_SCREENS: readonly DocumentScreen[] = [
|
||||
inventory('/inventario/entradas', 'InventoryGenEntries', 'Nueva entrada', true, goodsLayout),
|
||||
inventory('/inventario/salidas', 'InventoryGenExits', 'Nueva salida', true, goodsLayout),
|
||||
inventory('/inventario/transferencias', 'StockTransfers', 'Nueva transferencia', false, transferLayout),
|
||||
inventory('/inventario/solicitudes-traslado', 'InventoryTransferRequests', 'Nueva solicitud', true, transferRequestLayout),
|
||||
];
|
||||
@@ -0,0 +1,177 @@
|
||||
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, FIELDS, type HeaderField, marketingLayout } from './document-layouts';
|
||||
|
||||
/** Pantalla de un documento: lista de los últimos ingresados + formulario (nuevo / ver-editar). */
|
||||
export interface DocumentScreen {
|
||||
/** Ruta de menú (`/ventas/ordenes`); de ella cuelgan `nuevo` y `:docEntry`. */
|
||||
path: string;
|
||||
/** Recurso del proxy `/api/sl`. */
|
||||
resource: string;
|
||||
createLabel: string;
|
||||
columns: readonly ListColumn[];
|
||||
/** Campos necesarios para pintar que no tienen columna propia. */
|
||||
extraFields?: readonly string[];
|
||||
filters: readonly ListFilter[];
|
||||
/** Los más recientes primero. */
|
||||
orderby: string;
|
||||
/** Formulario de documentos con socio de negocio, líneas y totales (ventas y compras). */
|
||||
partner?: {
|
||||
label: string;
|
||||
type: 'cCustomer' | 'cSupplier';
|
||||
/** Maestro de impuestos (el Service Layer no tiene uno propio de compras: en Chile ambos usan `SalesTaxCodes`). */
|
||||
taxResource: string;
|
||||
};
|
||||
/** Campos de la cabecera y del pie del formulario, columnas de línea y cómo se guarda. */
|
||||
layout: DocumentLayout;
|
||||
}
|
||||
|
||||
export const menuLabel = (path: string): string =>
|
||||
MENU.flatMap((m) => m.items).find((i) => i.path === path)?.label ?? path;
|
||||
|
||||
export function documentStatus(row: ListRow): { label: string; tone: Tone } | undefined {
|
||||
if (row['Cancelled'] === 'tYES') return { label: 'Cancelado', tone: 'err' };
|
||||
switch (row['DocumentStatus']) {
|
||||
case 'bost_Open':
|
||||
return { label: 'Abierto', tone: 'ok' };
|
||||
case 'bost_Close':
|
||||
return { label: 'Cerrado', tone: 'muted' };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Total en la moneda del documento: SAP guarda en `DocTotalFc` el importe de los documentos en moneda extranjera. */
|
||||
export function documentTotal(row: ListRow): string {
|
||||
const fc = Number(row['DocTotalFc'] ?? 0);
|
||||
const amount = fc !== 0 ? fc : row['DocTotal'];
|
||||
if (amount === null || amount === undefined) return '';
|
||||
return `${row['DocCurrency'] ?? ''} ${formatNumber(amount, 2)}`.trim();
|
||||
}
|
||||
|
||||
export const statusColumn: ListColumn = {
|
||||
key: 'DocumentStatus',
|
||||
title: 'Estado',
|
||||
width: '100px',
|
||||
format: (r) => documentStatus(r)?.label ?? '',
|
||||
tone: (r) => documentStatus(r)?.tone,
|
||||
};
|
||||
/** Número de documento: sin separador de miles, y buscable por igualdad (es numérico). */
|
||||
export const docNum: ListColumn = {
|
||||
key: 'DocNum',
|
||||
title: 'Número',
|
||||
type: 'number',
|
||||
align: 'left',
|
||||
width: '110px',
|
||||
mono: true,
|
||||
searchable: true,
|
||||
format: (r) => String(r['DocNum'] ?? ''),
|
||||
};
|
||||
/** Facturas: folio tributario (en el Service Layer `FolioPrefixString` y `FolioNumber`), referencia y RUT. */
|
||||
export const invoiceColumns: readonly ListColumn[] = [
|
||||
{ key: 'FolioPrefixString', title: 'Folio pref.', width: '90px', mono: true, searchable: true },
|
||||
{
|
||||
key: 'FolioNumber',
|
||||
title: 'Folio núm.',
|
||||
type: 'number',
|
||||
align: 'left',
|
||||
width: '110px',
|
||||
mono: true,
|
||||
searchable: true,
|
||||
format: (r) => String(r['FolioNumber'] ?? ''),
|
||||
},
|
||||
{ key: 'NumAtCard', title: 'Nº referencia', width: '130px', searchable: true },
|
||||
{ key: 'FederalTaxID', title: 'RUT', width: '110px', mono: true, searchable: true },
|
||||
];
|
||||
|
||||
export const docDate: ListColumn = { key: 'DocDate', title: 'Fecha contab.', type: 'date', align: 'center', width: '115px' };
|
||||
|
||||
export const statusFilter: ListFilter = {
|
||||
type: 'select',
|
||||
key: 'DocumentStatus',
|
||||
label: 'Estado',
|
||||
defaultValue: 'bost_Open',
|
||||
options: [
|
||||
{ value: 'bost_Open', label: 'Abierto' },
|
||||
{ value: 'bost_Close', label: 'Cerrado' },
|
||||
],
|
||||
};
|
||||
export const dateFilter: ListFilter = { type: 'date', key: 'DocDate', label: 'Fecha contab.' };
|
||||
export const currencyFilter: ListFilter = {
|
||||
type: 'multi',
|
||||
key: 'DocCurrency',
|
||||
label: 'Moneda',
|
||||
resource: 'Currencies',
|
||||
valueField: 'Code',
|
||||
labelField: 'Name',
|
||||
};
|
||||
|
||||
/** Campos que una pantalla de ventas o compras agrega a la cabecera base y columnas extra de su lista. */
|
||||
export interface MarketingExtras {
|
||||
columns?: readonly ListColumn[];
|
||||
headerLeft?: readonly HeaderField[];
|
||||
headerRight?: readonly HeaderField[];
|
||||
}
|
||||
|
||||
/** Documentos tributarios: la cabecera suma el folio (en el Service Layer `FolioPrefixString` y `FolioNumber`). */
|
||||
export const TAX_DOCUMENT: MarketingExtras = { headerRight: [FIELDS.folio] };
|
||||
|
||||
/** Ventas y compras: documentos con socio de negocio y total. */
|
||||
export const marketing = (
|
||||
path: string,
|
||||
resource: string,
|
||||
createLabel: string,
|
||||
partner: string,
|
||||
extras: MarketingExtras = {},
|
||||
): DocumentScreen => ({
|
||||
path,
|
||||
layout: marketingLayout({ partnerLabel: partner, taxResource: 'SalesTaxCodes', left: extras.headerLeft, right: extras.headerRight }),
|
||||
resource,
|
||||
createLabel,
|
||||
orderby: 'DocEntry desc',
|
||||
partner: path.startsWith('/ventas')
|
||||
? { label: partner, type: 'cCustomer', taxResource: 'SalesTaxCodes' }
|
||||
: { label: partner, type: 'cSupplier', taxResource: 'SalesTaxCodes' },
|
||||
extraFields: ['Cancelled', 'DocTotalFc', 'DocCurrency'],
|
||||
columns: [
|
||||
docNum,
|
||||
statusColumn,
|
||||
...(extras.columns ?? []),
|
||||
{ key: 'CardCode', title: partner, width: '130px', mono: true, searchable: true },
|
||||
{ key: 'CardName', title: 'Nombre' },
|
||||
docDate,
|
||||
{ key: 'DocDueDate', title: 'Fecha venc.', type: 'date', align: 'center', width: '115px' },
|
||||
{ key: 'DocTotal', title: 'Total', align: 'right', width: '140px', format: documentTotal },
|
||||
],
|
||||
filters: [statusFilter, currencyFilter, dateFilter],
|
||||
});
|
||||
|
||||
export const inventory = (
|
||||
path: string,
|
||||
resource: string,
|
||||
createLabel: string,
|
||||
withStatus: boolean,
|
||||
layout: DocumentLayout,
|
||||
): DocumentScreen => ({
|
||||
path,
|
||||
layout,
|
||||
resource,
|
||||
createLabel,
|
||||
orderby: 'DocEntry desc',
|
||||
extraFields: withStatus ? ['Cancelled'] : [],
|
||||
columns: [
|
||||
docNum,
|
||||
...(withStatus ? [statusColumn] : []),
|
||||
docDate,
|
||||
...(resource === 'StockTransfers' || resource === 'InventoryTransferRequests'
|
||||
? [
|
||||
{ key: 'FromWarehouse', title: 'Almacén origen', width: '130px', mono: true, searchable: true },
|
||||
{ key: 'ToWarehouse', title: 'Almacén destino', width: '130px', mono: true, searchable: true },
|
||||
]
|
||||
: []),
|
||||
{ key: 'Comments', title: 'Comentarios', searchable: true },
|
||||
],
|
||||
filters: withStatus ? [statusFilter, dateFilter] : [dateFilter],
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { TAX_DOCUMENT, type DocumentScreen, invoiceColumns, marketing } from '../screen-kit';
|
||||
|
||||
/** Ventas: ofertas, órdenes, entregas, facturas, devoluciones y notas de crédito de cliente. */
|
||||
export const VENTAS_SCREENS: readonly DocumentScreen[] = [
|
||||
] = [
|
||||
// Ventas
|
||||
marketing('/ventas/ofertas', 'Quotations', 'Nueva oferta', 'Cliente'),
|
||||
marketing('/ventas/ordenes', 'Orders', 'Nueva orden', 'Cliente'),
|
||||
marketing('/ventas/entrega', 'DeliveryNotes', 'Nueva entrega', 'Cliente', TAX_DOCUMENT),
|
||||
marketing('/ventas/facturas', 'Invoices', 'Nueva factura', 'Cliente', { ...TAX_DOCUMENT, columns: invoiceColumns }),
|
||||
marketing('/ventas/devoluciones', 'Returns', 'Nueva devolución', 'Cliente'),
|
||||
marketing('/ventas/notas-credito', 'CreditNotes', 'Nueva nota de crédito', 'Cliente', TAX_DOCUMENT),
|
||||
];
|
||||
Reference in new issue
Block a user