diff --git a/apps/web/src/app/core/sl/line-names.ts b/apps/web/src/app/core/sl/line-names.ts new file mode 100644 index 0000000..e70c0b3 --- /dev/null +++ b/apps/web/src/app/core/sl/line-names.ts @@ -0,0 +1,65 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject, signal } from '@angular/core'; +import type { SlListResponse } from '@basic-erp/sap-types'; +import { firstValueFrom } from 'rxjs'; +import type { VhRow } from '../../shared/value-help/value-help.types'; +import { API_URL } from '../api'; +import { ACCOUNT_CODES } from './account-codes'; +import { escapeODataString } from './odata'; + +/** Cuántos códigos se piden por consulta. */ +const BATCH = 20; + +/** + * Nombre de lo que identifica el código de una línea de asiento: una cuenta del plan de cuentas o un socio de + * negocio (la columna «Cuenta / socio»). Los nombres se piden al servidor la primera vez que se necesitan: primero + * como cuenta y, los códigos que no lo son, como socio. + */ +@Injectable({ providedIn: 'root' }) +export class LineNamesService { + private readonly http = inject(HttpClient); + private readonly names = signal>(new Map()); + /** Códigos ya pedidos (con o sin respuesta): no se repiten. */ + private readonly asked = new Set(); + + name(code: string): string { + return this.names().get(code) ?? ''; + } + + remember(code: unknown, name: unknown): void { + const c = String(code ?? ''); + const n = String(name ?? ''); + if (c && n && this.names().get(c) !== n) this.names.update((m) => new Map(m).set(c, n)); + } + + async ensure(codes: readonly string[]): Promise { + const missing = [...new Set(codes.filter((c) => c && !this.names().has(c) && !this.asked.has(c)))]; + for (const c of missing) this.asked.add(c); + const notAccounts: string[] = []; + for (let i = 0; i < missing.length; i += BATCH) { + const batch = missing.slice(i, i + BATCH); + const found = await this.fetch('ChartOfAccounts', 'Code', 'Code,FormatCode,Name', batch); + for (const row of found) { + ACCOUNT_CODES.remember(row); + this.remember(row['Code'], row['Name']); + } + const accountCodes = new Set(found.map((r) => String(r['Code']))); + notAccounts.push(...batch.filter((c) => !accountCodes.has(c))); + } + for (let i = 0; i < notAccounts.length; i += BATCH) { + const found = await this.fetch('BusinessPartners', 'CardCode', 'CardCode,CardName', notAccounts.slice(i, i + BATCH)); + for (const row of found) this.remember(row['CardCode'], row['CardName']); + } + } + + private async fetch(resource: string, key: string, select: string, codes: readonly string[]): Promise { + try { + const filter = codes.map((c) => `${key} eq '${escapeODataString(c)}'`).join(' or '); + const params = new HttpParams().set('$select', select).set('$filter', filter); + return (await firstValueFrom(this.http.get>(`${API_URL}/sl/${resource}`, { params }))).value; + } catch { + for (const c of codes) this.asked.delete(c); // se reintenta la próxima vez + return []; + } + } +} diff --git a/apps/web/src/app/features/finance/journal-form.page.ts b/apps/web/src/app/features/finance/journal-form.page.ts index fcb9e7a..bf27a9c 100644 --- a/apps/web/src/app/features/finance/journal-form.page.ts +++ b/apps/web/src/app/features/finance/journal-form.page.ts @@ -1,5 +1,5 @@ import { HttpClient, HttpErrorResponse, HttpParams, httpResource } from '@angular/common/http'; -import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, inject, linkedSignal, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import type { SlListResponse } from '@basic-erp/sap-types'; @@ -12,6 +12,7 @@ import type { DetailColumn } from '../../shared/grid/grid.types'; import { RowsTable } from '../../shared/grid/rows-table'; import { Icon, SboWindow, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window'; import { ACCOUNT_CODES } from '../../core/sl/account-codes'; +import { LineNamesService } from '../../core/sl/line-names'; import type { VhLookup } from '../../shared/value-help/value-help.types'; import { ACCOUNT_LOOKUP, INDICATOR_LOOKUP, PROJECT_LOOKUP, dimensionLookup } from '../documents/dimension-lookups'; import type { NavTarget } from '../documents/document-model'; @@ -31,8 +32,15 @@ const partnerLookup = (type: 'cCustomer' | 'cSupplier', title: string): VhLookup ], }); -/** Una línea de socio: el código del socio (su cuenta de control se pide aparte: SAP falla al listarla junto con los socios). */ -const partnerLine = (partner: Record): Record => ({ ShortName: partner['CardCode'] }); +const isPartnerLine = (row: Record): boolean => !!row['ShortName'] && row['ShortName'] !== row['AccountCode']; +const isAccountLine = (row: Record): boolean => !!row['ShortName'] && row['ShortName'] === row['AccountCode']; + +/** Cuentas asociadas (las de control de los socios): las únicas que admite la línea de un cliente o proveedor. */ +const ASSOCIATED_ACCOUNT_LOOKUP: VhLookup = { + ...ACCOUNT_LOOKUP, + title: 'Cuentas asociadas', + baseFilter: `ActiveAccount eq 'tYES' and LockManualTransaction eq 'tYES'`, +}; /** Tipo de objeto de SAP del asiento contable (para pedir sus series de numeración). */ const OBJECT_TYPE = 30; @@ -123,29 +131,60 @@ export class JournalFormPage { // --- vista ----------------------------------------------------------------------------------- + private readonly names = inject(LineNamesService); + + constructor() { + // los nombres de las cuentas y socios de las líneas que se cargan + effect(() => void this.names.ensure(this.lines().map((l) => String(l['ShortName'] || l['AccountCode'] || '')).filter((c) => c !== ''))); + } + + /** Una línea de cuenta: la cuenta elegida queda en «Cuenta / socio» y se repite en «Cuenta». */ + private accountLine(account: Record): Record { + this.names.remember(account['Code'], account['Name']); + return { ShortName: account['Code'], AccountCode: account['Code'] }; + } + + /** Una línea de socio: el socio queda en «Cuenta / socio»; su cuenta de control se pide aparte (SAP falla al listarla junto con los socios). */ + private partnerLine(partner: Record): Record { + this.names.remember(partner['CardCode'], partner['CardName']); + return { ShortName: partner['CardCode'], AccountCode: '' }; + } + /** La cuenta de control del socio, para una línea que todavía no tiene cuenta. */ private async controlAccount(partner: Record, row: Record): Promise> { - if (row['AccountCode']) return {}; const res = await firstValueFrom( this.http.get<{ DebitorAccount?: string }>(`${API_URL}/sl/BusinessPartners/${encodeURIComponent(String(partner['CardCode']))}`, { params: new HttpParams().set('$select', 'DebitorAccount') }), ); return res.DebitorAccount ? { AccountCode: res.DebitorAccount } : {}; } + /** «Cuenta / socio»: una cuenta o un socio (cliente, proveedor). Si es socio, la «Cuenta» es su cuenta de control (asociada). */ protected readonly columns: readonly DetailColumn[] = [ - { key: 'AccountCode', title: 'Cuenta', type: 'lookup', required: true, lookup: ACCOUNT_LOOKUP, width: '170px' }, { key: 'ShortName', title: 'Cuenta / socio', type: 'lookup', + required: true, width: '170px', codes: ACCOUNT_CODES, lookups: [ - { label: 'Cuentas', lookup: ACCOUNT_LOOKUP, apply: (a) => ({ ShortName: a['Code'], AccountCode: a['Code'] }) }, - { label: 'Clientes', lookup: partnerLookup('cCustomer', 'Clientes'), apply: partnerLine, complete: (p, row) => this.controlAccount(p, row) }, - { label: 'Proveedores', lookup: partnerLookup('cSupplier', 'Proveedores'), apply: partnerLine, complete: (p, row) => this.controlAccount(p, row) }, + { label: 'Cuentas', lookup: ACCOUNT_LOOKUP, apply: (a) => this.accountLine(a) }, + { label: 'Clientes', lookup: partnerLookup('cCustomer', 'Clientes'), apply: (p) => this.partnerLine(p), complete: (p, row) => this.controlAccount(p, row) }, + { label: 'Proveedores', lookup: partnerLookup('cSupplier', 'Proveedores'), apply: (p) => this.partnerLine(p), complete: (p, row) => this.controlAccount(p, row) }, ], }, + { key: '_Name', title: 'Nombre', type: 'text', width: '260px', computed: (row) => this.names.name(String(row['ShortName'] || row['AccountCode'] || '')) }, + { + key: 'AccountCode', + title: 'Cuenta', + type: 'lookup', + lookup: ACCOUNT_LOOKUP, + // la cuenta de un socio es su cuenta de control; la de una cuenta repite la elegida y no se edita + lookupFor: (row) => (isPartnerLine(row) ? ASSOCIATED_ACCOUNT_LOOKUP : undefined), + readonlyFor: isAccountLine, + pick: (a, row) => (row['ShortName'] ? {} : this.accountLine(a)), + width: '170px', + }, { key: 'Debit', title: 'Debe', type: 'number', decimals: 2, width: '120px' }, { key: 'Credit', title: 'Haber', type: 'number', decimals: 2, width: '120px' }, { key: 'DueDate', title: 'Vencimiento', type: 'date', width: '120px' }, diff --git a/apps/web/src/app/features/finance/journal-model.spec.ts b/apps/web/src/app/features/finance/journal-model.spec.ts index 7bc5909..57c9274 100644 --- a/apps/web/src/app/features/finance/journal-model.spec.ts +++ b/apps/web/src/app/features/finance/journal-model.spec.ts @@ -40,7 +40,7 @@ describe('asiento contable', () => { it('cada línea lleva cuenta y debe o haber, no los dos', () => { const errors = validateJournal(draft({ lines: [{ AccountCode: '', Debit: 5 }, { AccountCode: 'B', Debit: 2, Credit: 2 }, { AccountCode: 'C' }] })); - expect(errors).toContain('Línea 1: indique la cuenta.'); + expect(errors).toContain('Línea 1: indique la cuenta o el socio.'); expect(errors).toContain('Línea 2: indique el debe o el haber, no los dos.'); expect(errors).toContain('Línea 3: indique el debe o el haber.'); }); diff --git a/apps/web/src/app/features/finance/journal-model.ts b/apps/web/src/app/features/finance/journal-model.ts index 09daa2b..7b70319 100644 --- a/apps/web/src/app/features/finance/journal-model.ts +++ b/apps/web/src/app/features/finance/journal-model.ts @@ -47,7 +47,7 @@ export function validateJournal(d: JournalDraft): string[] { const lines = d.lines.filter((l) => !isBlank(l)); if (lines.length < 2) errors.push('Un asiento necesita al menos dos líneas.'); lines.forEach((l, i) => { - if (!has(l['AccountCode'])) errors.push(`Línea ${i + 1}: indique la cuenta.`); + if (!has(l['AccountCode'])) errors.push(`Línea ${i + 1}: indique la cuenta${has(l['ShortName']) ? ' (la cuenta de control del socio)' : ' o el socio'}.`); const debit = num(l['Debit']); const credit = num(l['Credit']); if (debit < 0 || credit < 0) errors.push(`Línea ${i + 1}: los importes no pueden ser negativos.`); diff --git a/apps/web/src/app/shared/grid/grid.types.ts b/apps/web/src/app/shared/grid/grid.types.ts index 5b90f00..8ff6387 100644 --- a/apps/web/src/app/shared/grid/grid.types.ts +++ b/apps/web/src/app/shared/grid/grid.types.ts @@ -66,6 +66,14 @@ export interface DetailColumn { lookups?: readonly DetailLookupOption[]; /** Texto que se ve en vez del código guardado, para una columna con varias listas (en una con `lookup`, va en la lista). */ codes?: CodeDisplay; + /** La lista de una celda `lookup` que depende de la fila (la «Cuenta» de un socio solo admite cuentas asociadas); sin ella, `lookup`. */ + lookupFor?: (row: Record) => VhLookup | undefined; + /** La celda no se edita en esta fila. */ + readonlyFor?: (row: Record) => boolean; + /** Texto calculado de la fila (solo se muestra; no es un campo que se guarde). */ + computed?: (row: Record) => string; + /** Campos de la fila que cambian además del propio al elegir o escribir un valor de la lista (la cuenta elegida primero completa el «Cuenta / socio»). */ + pick?: (picked: ListRow, row: Record) => Record; } /** Una de las listas de una celda con varias: al elegir, `apply` devuelve los campos de la fila que cambian. */ diff --git a/apps/web/src/app/shared/grid/rows-table.ts b/apps/web/src/app/shared/grid/rows-table.ts index 4f194b7..9ad7166 100644 --- a/apps/web/src/app/shared/grid/rows-table.ts +++ b/apps/web/src/app/shared/grid/rows-table.ts @@ -38,10 +38,10 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) {{ i + 1 }} @for (c of columns(); track c.key) { - + @if (c.type === 'select') { - @if (!locked(c)) { + @if (!locked(c, row)) { } @@ -71,7 +71,7 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) [type]="c.type === 'date' ? 'date' : 'text'" autocomplete="off" [class.num]="c.type === 'number'" - [readOnly]="locked(c)" + [readOnly]="locked(c, row)" [attr.data-r]="i" [attr.aria-label]="c.title + ' de la fila ' + (i + 1)" [value]="text(row, c)" @@ -168,13 +168,20 @@ export class RowsTable { /** El menú de listas de una celda con varias (cuentas, clientes, proveedores), con su posición en pantalla. */ protected readonly menu = signal<{ row: number; col: DetailColumn; x: number; y: number } | null>(null); + /** La lista de una celda en una fila: la que depende de la fila o, si no, la de la columna. */ + private lookupOf(row: Row | undefined, c: DetailColumn): NonNullable | undefined { + return (row && c.lookupFor?.(row)) || c.lookup; + } + protected openLookup(row: number, col: DetailColumn, event?: Event): void { - if (this.locked(col)) return; + const line = this.rows()[row]; + if (this.locked(col, line)) return; if (col.lookups?.length) { const box = (event?.target as HTMLElement | undefined)?.getBoundingClientRect?.(); this.menu.set({ row, col, x: box?.left ?? 0, y: box?.bottom ?? 0 }); - } else if (col.lookup) { - this.target.set({ row, col, lookup: col.lookup }); + } else { + const lookup = this.lookupOf(line, col); + if (lookup) this.target.set({ row, col, lookup }); } } @@ -195,18 +202,19 @@ export class RowsTable { return; } const key = t.lookup.valueKey ?? t.lookup.columns[0]?.key ?? ''; - this.rows.update((rs) => rs.map((r, i) => (i === t.row ? { ...r, [t.col.key]: picked[key] ?? '' } : r))); + this.rows.update((rs) => rs.map((r, i) => (i === t.row ? { ...r, ...t.col.pick?.(picked, r), [t.col.key]: picked[key] ?? '' } : r))); } protected canRemove(): boolean { return !this.readonly() && this.removable(); } - protected locked(c: DetailColumn): boolean { - return this.readonly() || !!c.readonly; + protected locked(c: DetailColumn, row?: Row): boolean { + return this.readonly() || !!c.readonly || !!c.computed || (!!row && !!c.readonlyFor?.(row)); } protected text(row: Row, c: DetailColumn): string { + if (c.computed) return c.computed(row); const v = row[c.key]; if (c.type === 'date') return dateOnly(v); if (c.type === 'number') return v === null || v === undefined || v === '' ? '' : formatNumber(v, c.decimals); @@ -236,7 +244,7 @@ export class RowsTable { input.value = parsed === null ? '' : formatNumber(parsed, c.decimals); } this.rows.update((rs) => rs.map((r, i) => (i === index ? { ...r, [c.key]: value } : r))); - if (c.type === 'lookup' && (c.lookup?.codes || c.lookups?.length) && raw.trim() !== '') void this.resolveTyped(index, c, raw.trim()); + if (c.type === 'lookup' && (c.lookup?.codes || c.lookups?.length || c.lookupFor) && raw.trim() !== '') void this.resolveTyped(index, c, raw.trim()); } /** @@ -273,13 +281,14 @@ export class RowsTable { } return; } - const lookup = c.lookup!; + const lookup = this.lookupOf(this.rows()[index], c); + if (!lookup) return; const found = await this.lookupApi.find(lookup, code); if (!found) return; lookup.codes?.remember(found); const key = lookup.valueKey ?? lookup.columns[0]?.key ?? ''; const value = found[key] ?? code; - this.rows.update((rs) => rs.map((r, i) => (i === index && r[c.key] === code ? { ...r, [c.key]: value } : r))); + this.rows.update((rs) => rs.map((r, i) => (i === index && r[c.key] === code ? { ...r, ...c.pick?.(found, r), [c.key]: value } : r))); } catch { /* sin conexión: queda lo escrito */ }