feat(web): asiento con «Cuenta / socio», «Nombre» y «Cuenta» (solo asociadas para socios, repetida para cuentas)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c567bc6836
commit
77270d6909
6 files changed
+145
-24
No files matched your search
@@ -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<ReadonlyMap<string, string>>(new Map());
|
||||
/** Códigos ya pedidos (con o sin respuesta): no se repiten. */
|
||||
private readonly asked = new Set<string>();
|
||||
|
||||
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<void> {
|
||||
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<VhRow[]> {
|
||||
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<SlListResponse<VhRow>>(`${API_URL}/sl/${resource}`, { params }))).value;
|
||||
} catch {
|
||||
for (const c of codes) this.asked.delete(c); // se reintenta la próxima vez
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>): Record<string, unknown> => ({ ShortName: partner['CardCode'] });
|
||||
const isPartnerLine = (row: Record<string, unknown>): boolean => !!row['ShortName'] && row['ShortName'] !== row['AccountCode'];
|
||||
const isAccountLine = (row: Record<string, unknown>): 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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>, row: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
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' },
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
@@ -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<string, unknown>) => VhLookup | undefined;
|
||||
/** La celda no se edita en esta fila. */
|
||||
readonlyFor?: (row: Record<string, unknown>) => boolean;
|
||||
/** Texto calculado de la fila (solo se muestra; no es un campo que se guarde). */
|
||||
computed?: (row: Record<string, unknown>) => 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<string, unknown>) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Una de las listas de una celda con varias: al elegir, `apply` devuelve los campos de la fila que cambian. */
|
||||
|
||||
@@ -38,10 +38,10 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
|
||||
<tr>
|
||||
<td class="rn">{{ i + 1 }}</td>
|
||||
@for (c of columns(); track c.key) {
|
||||
<td [class.ro]="locked(c)">
|
||||
<td [class.ro]="locked(c, row)">
|
||||
@if (c.type === 'select') {
|
||||
<select
|
||||
[disabled]="locked(c)"
|
||||
[disabled]="locked(c, row)"
|
||||
[attr.data-r]="i"
|
||||
[attr.aria-label]="c.title + ' de la fila ' + (i + 1)"
|
||||
(change)="setSelect(i, c, $any($event.target).value)"
|
||||
@@ -55,14 +55,14 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
|
||||
<div class="pick">
|
||||
<input
|
||||
autocomplete="off"
|
||||
[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)"
|
||||
(change)="set(i, c, $any($event.target))"
|
||||
(keydown.f4)="$event.preventDefault(); openLookup(i, c, $event)"
|
||||
/>
|
||||
@if (!locked(c)) {
|
||||
@if (!locked(c, row)) {
|
||||
<button type="button" tabindex="-1" [attr.aria-label]="'Abrir la lista de ' + c.title" title="Lista de valores (F4)" (click)="openLookup(i, c, $event)">…</button>
|
||||
}
|
||||
</div>
|
||||
@@ -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<DetailColumn['lookup']> | 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 */
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user