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:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 11:22:52 -05:00
1 parent c567bc6836
commit 77270d6909
6 files changed
+145 -24

No files matched your search

@@ -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.`);