diff --git a/apps/web/src/app/features/documents/document-copy.ts b/apps/web/src/app/features/documents/document-copy.ts index ca0707a..33d867e 100644 --- a/apps/web/src/app/features/documents/document-copy.ts +++ b/apps/web/src/app/features/documents/document-copy.ts @@ -152,6 +152,11 @@ export function copyTransferFromDocument(source: DocRecord, sourcePath: string, } return { header: { + CardCode: source['CardCode'] ?? '', + ContactPerson: source['ContactPerson'] ?? '', + ShipToCode: source['ShipToCode'] ?? '', + PriceList: source['PriceList'] ?? '', + SalesPersonCode: source['SalesPersonCode'] ?? '', FromWarehouse: source['FromWarehouse'] ?? '', ToWarehouse: source['ToWarehouse'] ?? '', Project: source['Project'] ?? '', diff --git a/apps/web/src/app/features/documents/document-folio.spec.ts b/apps/web/src/app/features/documents/document-folio.spec.ts index abf0f8d..de31090 100644 --- a/apps/web/src/app/features/documents/document-folio.spec.ts +++ b/apps/web/src/app/features/documents/document-folio.spec.ts @@ -63,12 +63,13 @@ describe('folio y RUT en los formularios', () => { beforeEach(() => TestBed.resetTestingModule()); - it('lo llevan facturas, boletas, notas de crédito, entregas y recepciones (ventas y compras)', () => { + it('lo llevan facturas, boletas, notas de crédito, entregas y recepciones (ventas y compras) y las transferencias de stock', () => { const withFolio = DOCUMENT_SCREENS.filter((s) => hasFolio(s.layout)).map((s) => s.path); expect(withFolio.sort()).toEqual( [ '/ventas/facturas', '/ventas/boletas', '/ventas/facturas-reserva', '/ventas/boletas-reserva', '/ventas/notas-credito', '/ventas/entrega', '/compras/facturas', '/compras/notas-credito', '/compras/recepcion', + '/inventario/transferencias', '/inventario/solicitudes-traslado', ].sort(), ); }); diff --git a/apps/web/src/app/features/documents/document-form.page.html b/apps/web/src/app/features/documents/document-form.page.html index 1f99709..47adff2 100644 --- a/apps/web/src/app/features/documents/document-form.page.html +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -131,7 +131,7 @@ [required]="!!f.required" [readonly]="readOnly() || locked(f)" [value]="str(header()[f.key])" - (valueChange)="setHeader(f.key, $event)" + (valueChange)="setHeader(f.key, typedLookup(f, $event))" [lookup]="f.lookup" resolveOnLeave /> @@ -248,6 +248,27 @@ } } + @case ('contact') { + + } + @case ('address') { + + + } @case ('currency') { ({ ...h, ...copied.header })); this.partnerName.set(text(doc['CardName'])); this.partnerTaxId.set(text(doc['FederalTaxID'])); + this.partnerKey.set(text(copied.header['CardCode'])); } this.lines.update((ls) => [...ls.filter((l) => !isBlankLine(l, this.serviceMode())), ...copied.lines]); this.message.set({ @@ -423,9 +424,10 @@ export class DocumentFormPage { columns: this.layout.lookups?.warehouse ?? WAREHOUSE_LOOKUP.columns, }; protected readonly partnerLookup: VhLookup = { - title: this.partner?.type === 'cSupplier' ? 'Proveedores' : 'Clientes', + title: !this.partner ? 'Socios de negocio' : this.partner.type === 'cSupplier' ? 'Proveedores' : 'Clientes', resource: 'BusinessPartners', - baseFilter: `CardType eq '${this.partner?.type ?? 'cCustomer'}'`, + // un documento con socio de un solo tipo lo filtra; los demás (transferencias de stock) admiten cualquiera + ...(this.partner ? { baseFilter: `CardType eq '${this.partner.type}'` } : {}), orderby: 'CardCode', columns: this.layout.lookups?.partner ?? PARTNER_COLUMNS, extraFields: ['FederalTaxID', 'Currency'], @@ -473,6 +475,20 @@ export class DocumentFormPage { constructor() { if (this.copyOrigin) void this.loadCopy(this.copyOrigin); + // al cargarse los datos del socio de un documento nuevo, sus direcciones por defecto + effect(() => { + const data = this.partnerData(); + if (!data || !this.isNew()) return; + untracked(() => { + const ship = text(data['ShipToDefault']); + const bill = text(data['BilltoDefault']); + this.header.update((h) => ({ + ...h, + ...('ShipToCode' in h && text(h['ShipToCode']) === '' && ship ? { ShipToCode: ship } : {}), + ...('PayToCode' in h && text(h['PayToCode']) === '' && bill ? { PayToCode: bill } : {}), + })); + }); + }); } protected setHeader(key: string, value: unknown): void { @@ -486,6 +502,45 @@ export class DocumentFormPage { /** Tipo al que se quiere cambiar y que espera confirmación porque hay líneas escritas que se descartarían. */ protected readonly pendingType = signal(null); + // --- datos del socio: personas de contacto y direcciones --------------------------------------- + + private readonly hasPartnerData = hasPartnerData(this.layout); + /** Código del socio cuyos datos se cargan: el del documento abierto o el que se acaba de elegir. */ + protected readonly partnerKey = linkedSignal(() => text(this.doc()?.['CardCode'])); + private readonly partnerDetail = httpResource(() => { + const key = this.partnerKey(); + return key && this.hasPartnerData + ? { url: `${API_URL}/sl/BusinessPartners/${encodeURIComponent(key)}`, params: { $select: 'CardCode,BPAddresses,ContactEmployees,ShipToDefault,BilltoDefault' } } + : undefined; + }); + private readonly partnerData = computed(() => (this.partnerDetail.hasValue() ? this.partnerDetail.value() : undefined)); + private readonly addresses = computed(() => (this.partnerData()?.['BPAddresses'] as DocRecord[] | undefined) ?? []); + + protected readonly contactOptions = computed(() => + ((this.partnerData()?.['ContactEmployees'] as DocRecord[] | undefined) ?? []).map((c) => ({ value: Number(c['InternalCode']), label: text(c['Name']) })), + ); + /** Direcciones del socio de ese lado (envío o facturación), por su nombre. */ + protected readonly addressOptions = (side: 'ship' | 'bill') => { + const type = side === 'ship' ? 'bo_ShipTo' : 'bo_BillTo'; + return this.addresses().filter((a) => a['AddressType'] === type).map((a) => ({ value: text(a['AddressName']), label: text(a['AddressName']) })); + }; + protected readonly shipOptions = computed(() => this.addressOptions('ship')); + protected readonly billOptions = computed(() => this.addressOptions('bill')); + + /** El texto de la dirección elegida: el que SAP guardó en el documento o, al elegir otra, el armado con los datos de la dirección del socio. */ + protected addressText(f: { key: string; side: 'ship' | 'bill' }): string { + const code = text(this.header()[f.key]); + const found = this.addresses().find((a) => text(a['AddressName']) === code && a['AddressType'] === (f.side === 'ship' ? 'bo_ShipTo' : 'bo_BillTo')); + if (found) return [found['Street'], found['Block'], found['ZipCode'], found['City'], found['County'], found['State']].map(text).filter((p) => p !== '').join(', '); + const saved = this.doc()?.[f.side === 'ship' ? 'Address' : 'Address2']; + return code === text(this.doc()?.[f.key]) ? text(saved).replace(/\r/g, '\n') : ''; + } + + /** Un código numérico de SAP (lista de precios, empleado) que se escribe o elige como texto se guarda como número. */ + protected typedLookup(f: { lookup: { valueType?: string } }, value: string): string | number { + return f.lookup.valueType === 'number' && /^-?\d+$/.test(value.trim()) ? Number(value) : value; + } + /** La celda de un grupo de campos (el indicador) cuya lista de valores está abierta. */ protected readonly groupCell = signal(null); @@ -524,6 +579,9 @@ export class DocumentFormPage { this.setHeader('CardCode', code); this.partnerName.set(''); this.partnerTaxId.set(''); + this.partnerKey.set(''); + // la persona de contacto y las direcciones eran del socio anterior + this.header.update((h) => ({ ...h, ContactPerson: '', ShipToCode: '', PayToCode: '' })); } protected notify(textMessage: string): void { @@ -531,6 +589,7 @@ export class DocumentFormPage { } protected onPartner(row: Record): void { + this.partnerKey.set(text(row['CardCode'])); this.partnerName.set(text(row['CardName'])); this.partnerTaxId.set(text(row['FederalTaxID'])); // el documento toma la moneda del socio (si es una sola y no es la local), como en SAP diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts index 84a8b27..fdce1bd 100644 --- a/apps/web/src/app/features/documents/document-layouts.ts +++ b/apps/web/src/app/features/documents/document-layouts.ts @@ -62,6 +62,13 @@ export type HeaderField = | { type: 'rounding'; label: string } /** Moneda del documento (`DocCurrency`) y su tipo de cambio (`DocRate`): el de la fecha del documento si no es la local. */ | { type: 'currency'; label: string } + /** Persona de contacto del socio (`ContactPerson`): se elige de las del socio de negocio. */ + | { type: 'contact'; key: string; label: string } + /** + * Dirección de envío (`ShipToCode`) o de facturación (`PayToCode`) del socio: se elige de sus direcciones y debajo se + * muestra su texto. `side` dice cuáles direcciones de la lista de direcciones del socio se ofrecen. + */ + | { type: 'address'; key: string; label: string; side: 'ship' | 'bill' } | { type: 'computed'; label: string; source: ComputedSource }; /** Las listas de valores (ventanas modales) de un formulario. */ @@ -122,7 +129,40 @@ export const today = (): string => todayIso(); // --- catálogo de campos --------------------------------------------------------------------- /** Catálogo de campos: cada pantalla elige cuáles lleva. */ +/** Lista de precios de un documento (`PriceList`): las números de SAP y, en negativo, los costos (-2 último precio de compra). */ +export const PRICE_LIST_LOOKUP: VhLookup = { + title: 'Listas de precios', + resource: 'PriceLists', + orderby: 'PriceListNo', + valueType: 'number', + columns: [ + { key: 'PriceListNo', title: 'Código', width: '90px', mono: true }, + { key: 'PriceListName', title: 'Nombre' }, + ], +}; + +/** Empleado de ventas o compras del documento (`SalesPersonCode`). */ +export const SALES_PERSON_LOOKUP: VhLookup = { + title: 'Empleados de ventas', + resource: 'SalesPersons', + orderby: 'SalesEmployeeCode', + valueType: 'number', + baseFilter: `Active eq 'tYES'`, + columns: [ + { key: 'SalesEmployeeCode', title: 'Código', width: '90px', mono: true }, + { key: 'SalesEmployeeName', title: 'Nombre' }, + ], +}; + export const FIELDS = { + /** Persona de contacto del socio. */ + contact: { type: 'contact', key: 'ContactPerson', label: 'Persona de contacto' }, + /** Dirección de destino (envío) del socio. */ + shipTo: { type: 'address', key: 'ShipToCode', label: 'Destino', side: 'ship' }, + /** Dirección de facturación del socio. */ + payTo: { type: 'address', key: 'PayToCode', label: 'Dirección de facturación', side: 'bill' }, + priceList: { type: 'lookup', key: 'PriceList', label: 'Lista de precios', lookup: PRICE_LIST_LOOKUP }, + salesPerson: { type: 'lookup', key: 'SalesPersonCode', label: 'Empleado del departamento', lookup: SALES_PERSON_LOOKUP }, /** Tipo del documento: de artículos o de servicio (las líneas cambian). */ docType: { type: 'select', @@ -371,6 +411,8 @@ export const hasSeries = (layout: DocumentLayout): boolean => allFields(layout). export const hasExpenses = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'expenses'); +export const hasPartnerData = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'contact' || f.type === 'address'); + export const hasCurrency = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'currency'); export const hasFolio = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'folio' || (f.type === 'group' && f.cells.some((c) => c.key === 'FolioNumber'))); diff --git a/apps/web/src/app/features/documents/inventario/inventario.layouts.ts b/apps/web/src/app/features/documents/inventario/inventario.layouts.ts index 0832f77..03d2523 100644 --- a/apps/web/src/app/features/documents/inventario/inventario.layouts.ts +++ b/apps/web/src/app/features/documents/inventario/inventario.layouts.ts @@ -55,11 +55,21 @@ const transfer = (immutable: boolean): DocumentLayout => { lines: { key: 'StockTransferLines', fields: [...lineFieldsOf(columns), 'UoMEntry', ...(immutable ? DETAIL_KEYS : [])], base: true }, // Las transferencias de stock indican lotes y series; la solicitud de traslado no. ...(immutable ? { details: LINE_DETAILS } : {}), + // Como la ventana de SAP: socio de negocio con su persona de contacto, destino y lista de precios a la izquierda; + // número, fechas, folio y almacenes a la derecha. header: { - left: [FIELDS.fromWarehouse, FIELDS.toWarehouse, FIELDS.indicator, FIELDS.project], - right: [FIELDS.docNumber, FIELDS.docDate, FIELDS.taxDate, FIELDS.dueDate, FIELDS.reference2, FIELDS.journalMemo], + left: [ + { type: 'partner', key: 'CardCode', label: 'Socio de negocios' }, + { type: 'computed', label: 'Nombre', source: 'partnerName' }, + FIELDS.contact, + FIELDS.shipTo, + FIELDS.priceList, + FIELDS.indicator, + FIELDS.project, + ], + right: [FIELDS.docNumber, FIELDS.docDate, FIELDS.taxDate, FIELDS.dueDate, FIELDS.reference2, FIELDS.folio, FIELDS.fromWarehouse, FIELDS.toWarehouse], }, - footer: commentsOnly, + footer: { left: [FIELDS.salesPerson, FIELDS.comments], right: [FIELDS.journalMemo] }, columns, newHeader: () => ({ DocDate: today() }), newLine: (header) => ({ diff --git a/tools/finanzas.png b/tools/finanzas.png new file mode 100644 index 0000000..f561b9d Binary files /dev/null and b/tools/finanzas.png differ diff --git a/tools/logistica.png b/tools/logistica.png new file mode 100644 index 0000000..3da6ac4 Binary files /dev/null and b/tools/logistica.png differ diff --git a/tools/solicitud de compra.png b/tools/solicitud de compra.png new file mode 100644 index 0000000..c03ad77 Binary files /dev/null and b/tools/solicitud de compra.png differ diff --git a/tools/transferencia stock.png b/tools/transferencia stock.png new file mode 100644 index 0000000..0ffdaa6 Binary files /dev/null and b/tools/transferencia stock.png differ