From 8bbd05d07b474d0d55324382180412763acccd7b Mon Sep 17 00:00:00 2001 From: George Date: Fri, 16 Oct 2026 14:36:24 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20m=C3=B3dulo=20Finanzas=20(plan=20de=20c?= =?UTF-8?q?uentas=20y=20asientos=20contables=20con=20cancelaci=C3=B3n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 4 + apps/api/src/sl/sl-resources.spec.ts | 19 +- apps/api/src/sl/sl-resources.ts | 6 +- apps/web/src/app/app.routes.spec.ts | 7 + apps/web/src/app/app.routes.ts | 18 +- .../src/app/core/menu/menu.service.spec.ts | 2 +- apps/web/src/app/core/sl/sl-document.ts | 10 +- .../features/documents/document-layouts.ts | 6 +- .../app/features/documents/document-model.ts | 18 +- .../features/finance/journal-form.page.css | 8 + .../features/finance/journal-form.page.html | 65 +++++ .../app/features/finance/journal-form.page.ts | 228 ++++++++++++++++++ .../app/features/finance/journal-list.page.ts | 56 +++++ .../features/finance/journal-model.spec.ts | 70 ++++++ .../src/app/features/finance/journal-model.ts | 102 ++++++++ .../src/app/features/finance/journal-paths.ts | 2 + .../masters/finanzas/finanzas.extensions.ts | 8 + .../masters/finanzas/finanzas.screens.ts | 87 +++++++ .../src/app/features/masters/master-field.ts | 4 +- .../src/app/features/masters/master-model.ts | 2 +- .../src/app/features/masters/master-paths.ts | 2 +- .../app/features/masters/master-screens.ts | 5 +- libs/sap-types/src/menu.ts | 8 + 23 files changed, 708 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/app/features/finance/journal-form.page.css create mode 100644 apps/web/src/app/features/finance/journal-form.page.html create mode 100644 apps/web/src/app/features/finance/journal-form.page.ts create mode 100644 apps/web/src/app/features/finance/journal-list.page.ts create mode 100644 apps/web/src/app/features/finance/journal-model.spec.ts create mode 100644 apps/web/src/app/features/finance/journal-model.ts create mode 100644 apps/web/src/app/features/finance/journal-paths.ts create mode 100644 apps/web/src/app/features/masters/finanzas/finanzas.extensions.ts create mode 100644 apps/web/src/app/features/masters/finanzas/finanzas.screens.ts diff --git a/CLAUDE.md b/CLAUDE.md index 68eee73..424e423 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,10 @@ Menú `Bancos` → `/bancos/pagos-recibidos` (`IncomingPayments`) y `/bancos/pag Reglas de SAP verificadas: nota de crédito se aplica con `SumApplied` **negativo**; los cheques exigen `CheckAccount` en la cabecera; pagar de más trunca en silencio (se valida en el cliente); solo moneda local (`localCurrency` de la pantalla, hoy CLP). En `SBODemoCL` los pagos efectuados a facturas fallan por falta de la cuenta de "diferencias de conversión realizadas" (configuración de SAP); los de `rAccount` funcionan. Pendiente: depósitos y conciliación bancaria, moneda extranjera. +## Finanzas (plan de cuentas y asientos) + +Menú `Finanzas`: **Plan de cuentas** (`/finanzas/plan-de-cuentas`) es un maestro más (`masters/finanzas/`, recurso `ChartOfAccounts`, clave `Code`; extensiones por cliente en `finanzas.extensions.ts`) y **Asientos contables** (`/finanzas/asientos`, `features/finance/`, recurso `JournalEntries`, clave `JdtNum`) tiene lista y formulario propios (`journal-model.ts`: cuadre debe = haber, líneas con cuenta, dimensiones y proyecto). Verificado en SAP: en una cuenta ya creada solo se puede cambiar el nombre y similares; `ActiveAccount` (de movimiento / de título) y la cuenta padre no se cambian (campos `immutable`), y congelarla exige un rango de fechas (no se ofrece). Un asiento no se modifica; solo los **manuales** (`OriginalJournal = ttJournalEntry`) se cancelan con `Cancel`, que genera el asiento de reverso (el original no lleva marca de cancelado). Los asientos de documentos se cancelan con su documento. Pendiente: libro mayor y saldos, asientos recurrentes, árbol del plan de cuentas. + ## Criterios de la interfaz Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de componentes (Material/PrimeNG), sin webfonts ni animaciones, componentes propios y densos (13 px), zoneless + `OnPush`, rutas con carga diferida, listas largas paginadas en el servidor (50 por página). Presupuesto del bundle inicial en `angular.json` (aviso 300 kB; hoy ~286 kB, 81 kB comprimido; la configuración de las pantallas de documento se carga en diferido con un `resolve`, y `document-paths.ts` es la única lista que el enrutador necesita al arrancar: `httpResource` aporta ~18 kB al chunk compartido; si hace falta recuperarlos, reemplazarlo por RxJS). diff --git a/apps/api/src/sl/sl-resources.spec.ts b/apps/api/src/sl/sl-resources.spec.ts index 768f3f2..ad82dd1 100644 --- a/apps/api/src/sl/sl-resources.spec.ts +++ b/apps/api/src/sl/sl-resources.spec.ts @@ -18,7 +18,7 @@ describe('recursos del Service Layer', () => { }); it('dimensiones, proyectos y cuentas son catálogos de solo lectura', () => { - for (const name of ['FactoringIndicators', 'DistributionRules', 'Projects', 'ChartOfAccounts', 'AdditionalExpenses', 'ItemGroups', 'BusinessPartnerGroups']) { + for (const name of ['FactoringIndicators', 'DistributionRules', 'Projects', 'AdditionalExpenses', 'ItemGroups', 'BusinessPartnerGroups']) { const r = SL_RESOURCE_MAP.get(name)!; expect(r.methods, name).toEqual(['GET']); expect(r.read, name).toBeNull(); @@ -43,8 +43,8 @@ describe('recursos del Service Layer', () => { } }); - it('solo los pagos tienen acciones sobre un registro', () => { - expect(SL_RESOURCES.filter((r) => r.actions?.length).map((r) => r.name)).toEqual(['IncomingPayments', 'VendorPayments']); + it('solo los pagos y los asientos tienen acciones sobre un registro', () => { + expect(SL_RESOURCES.filter((r) => r.actions?.length).map((r) => r.name)).toEqual(['IncomingPayments', 'VendorPayments', 'JournalEntries']); }); it('las facturas y notas de crédito se pueden consultar desde la pantalla de pagos, sin poder modificarlas', () => { @@ -64,4 +64,17 @@ describe('recursos del Service Layer', () => { expect(SL_RESOURCE_MAP.get('Banks')!.methods).toEqual(['GET']); expect(SL_RESOURCE_MAP.get('CreditCards')!.methods).toEqual(['GET']); }); + + it('el plan de cuentas se lee desde cualquier sesión y solo se modifica desde Finanzas', () => { + const r = SL_RESOURCE_MAP.get('ChartOfAccounts')!; + expect(r.read).toBeNull(); + expect(r.write).toBe('/finanzas/plan-de-cuentas'); + }); + + it('los asientos se crean y se cancelan, no se modifican', () => { + const r = SL_RESOURCE_MAP.get('JournalEntries')!; + expect(r.methods).toEqual(['GET', 'POST']); + expect(r.actions).toEqual(['Cancel']); + expect(r.write).toBe('/finanzas/asientos'); + }); }); diff --git a/apps/api/src/sl/sl-resources.ts b/apps/api/src/sl/sl-resources.ts index a093812..68cbf8a 100644 --- a/apps/api/src/sl/sl-resources.ts +++ b/apps/api/src/sl/sl-resources.ts @@ -80,6 +80,9 @@ export const SL_RESOURCES: readonly SlResource[] = [ { ...document('IncomingPayments', '/bancos/pagos-recibidos'), methods: ['GET', 'POST'], actions: ['Cancel'] }, { ...document('VendorPayments', '/bancos/pagos-efectuados'), methods: ['GET', 'POST'], actions: ['Cancel'] }, + // Finanzas: los asientos manuales se crean y se cancelan (`Cancel` genera el asiento de reverso), no se modifican. + { ...document('JournalEntries', '/finanzas/asientos'), methods: ['GET', 'POST'], actions: ['Cancel'] }, + // Maestros (consulta para cualquier sesión; escritura según el módulo) catalog('BusinessPartners', 'string', ['/socios/clientes', '/socios/proveedores']), catalog('Items', 'string', '/inventario/articulos'), @@ -101,7 +104,8 @@ export const SL_RESOURCES: readonly SlResource[] = [ catalog('FactoringIndicators', 'string'), catalog('DistributionRules', 'string'), catalog('Projects', 'string'), - catalog('ChartOfAccounts', 'string'), + // Plan de cuentas: se consulta desde cualquier documento y se mantiene en Finanzas. + catalog('ChartOfAccounts', 'string', '/finanzas/plan-de-cuentas'), // Series de numeración válidas de un tipo de documento. queryAction('SeriesService_GetDocumentSeries'), // Gastos adicionales de un documento (el flete): se leen para elegir el código de gasto. diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index 3d6ac0c..d24f7f1 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -2,6 +2,7 @@ import { MENU } from '@basic-erp/sap-types'; import { describe, expect, it } from 'vitest'; import { routes } from './app.routes'; import { permissionGuard } from './core/auth/guards'; +import { JOURNAL_PATH } from './features/finance/journal-paths'; import { PAYMENT_PATHS } from './features/banking/payment-paths'; import { PAYMENT_SCREENS } from './features/banking/payment-screens'; import { DOCUMENT_PATHS } from './features/documents/document-paths'; @@ -67,6 +68,12 @@ describe('rutas', () => { expect([...PAYMENT_PATHS].sort()).toEqual(PAYMENT_SCREENS.map((s) => s.path).sort()); }); + it('el asiento contable ofrece lista, "nuevo" y "ver/cancelar" por su número', () => { + const route = children.find((r) => r.path === JOURNAL_PATH.slice(1))!; + expect(route.children?.map((c) => c.path)).toEqual(['', 'nuevo', ':jdtNum']); + expect(route.canActivate).toContain(permissionGuard); + }); + it('la página de componentes está restringida a desarrollo y no está en el menú', () => { const route = children.find((r) => r.path === 'dev/componentes'); expect(route?.loadComponent).toBeDefined(); diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 7636b04..9c8a0de 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -3,6 +3,7 @@ import type { Route, Routes } from '@angular/router'; import { MENU } from '@basic-erp/sap-types'; import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards'; import { DOCUMENT_PATHS } from './features/documents/document-paths'; +import { JOURNAL_PATH } from './features/finance/journal-paths'; import { PAYMENT_PATHS } from './features/banking/payment-paths'; import { MASTER_PATHS } from './features/masters/master-paths'; @@ -76,6 +77,20 @@ const paymentRoutes: Routes = PAYMENT_PATHS.map( }), ); +/** Asientos contables: lista, `nuevo` y `:jdtNum` (consulta y cancelación). */ +const journalRoutes: Routes = [ + { + path: JOURNAL_PATH.slice(1), + canActivate: [permissionGuard], + data: { path: JOURNAL_PATH, title: menuLabel(JOURNAL_PATH) }, + children: [ + { path: '', loadComponent: () => import('./features/finance/journal-list.page').then((m) => m.JournalListPage) }, + { path: 'nuevo', loadComponent: () => import('./features/finance/journal-form.page').then((m) => m.JournalFormPage) }, + { path: ':jdtNum', loadComponent: () => import('./features/finance/journal-form.page').then((m) => m.JournalFormPage) }, + ], + }, +]; + /** * Página de componentes: solo coincide al ejecutar `npm run dev:web` (isDevMode se evalúa al navegar); * en producción cae en la ruta comodín. No aparece en el menú. @@ -105,7 +120,7 @@ const adminRoutes: Routes = [ ]; /** Resto de pantallas del menú (maestros, etc.): aún sin construir. */ -const builtPaths = new Set([...DOCUMENT_PATHS, ...MASTER_PATHS, ...PAYMENT_PATHS, '/admin/permisos', '/admin/auditoria']); +const builtPaths = new Set([...DOCUMENT_PATHS, ...MASTER_PATHS, ...PAYMENT_PATHS, JOURNAL_PATH, '/admin/permisos', '/admin/auditoria']); const pendingRoutes: Routes = MENU.flatMap((m) => m.items) .filter((item) => !builtPaths.has(item.path)) .map( @@ -137,6 +152,7 @@ export const routes: Routes = [ ...documentRoutes, ...masterRoutes, ...paymentRoutes, + ...journalRoutes, ...adminRoutes, ...pendingRoutes, ], diff --git a/apps/web/src/app/core/menu/menu.service.spec.ts b/apps/web/src/app/core/menu/menu.service.spec.ts index e18f85b..66b0cbf 100644 --- a/apps/web/src/app/core/menu/menu.service.spec.ts +++ b/apps/web/src/app/core/menu/menu.service.spec.ts @@ -36,7 +36,7 @@ describe('MenuService', () => { const p = menu.ensureLoaded(); ctrl.expectOne('/api/user/menu').flush({ allowedPaths: ['*'] }); await p; - expect(menu.modules().map((m) => m.id)).toEqual(['ventas', 'compras', 'inventario', 'bancos', 'socios', 'admin']); + expect(menu.modules().map((m) => m.id)).toEqual(['ventas', 'compras', 'inventario', 'bancos', 'finanzas', 'socios', 'admin']); }); it('si falla marca el error y permite reintentar', async () => { diff --git a/apps/web/src/app/core/sl/sl-document.ts b/apps/web/src/app/core/sl/sl-document.ts index 9e4fe7f..83e0437 100644 --- a/apps/web/src/app/core/sl/sl-document.ts +++ b/apps/web/src/app/core/sl/sl-document.ts @@ -20,14 +20,14 @@ export class SlDocumentApi { return firstValueFrom(this.http.get(this.url(resource, docEntry))); } - /** DocEntry del documento vecino (primero / anterior / siguiente / último), o `null` si no hay. */ - async neighbour(resource: string, target: NavTarget, from: number | null, baseFilter?: string): Promise { - const q = navigationQuery(target, from); - let params = new HttpParams().set('$select', 'DocEntry').set('$top', 1).set('$orderby', q.$orderby); + /** Clave (`DocEntry`, o `keyField`, ej. `JdtNum`) del documento vecino (primero / anterior / siguiente / último), o `null` si no hay. */ + async neighbour(resource: string, target: NavTarget, from: number | null, baseFilter?: string, keyField = 'DocEntry'): Promise { + const q = navigationQuery(target, from, keyField); + let params = new HttpParams().set('$select', keyField).set('$top', 1).set('$orderby', q.$orderby); const filter = combineFilters(baseFilter, q.$filter); if (filter) params = params.set('$filter', filter); const res = await firstValueFrom(this.http.get>(this.url(resource), { params })); - const entry = res.value[0]?.['DocEntry']; + const entry = res.value[0]?.[keyField]; return typeof entry === 'number' ? entry : null; } diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts index 233cd7b..1306ed3 100644 --- a/apps/web/src/app/features/documents/document-layouts.ts +++ b/apps/web/src/app/features/documents/document-layouts.ts @@ -27,12 +27,12 @@ export type HeaderField = | { type: 'date'; key: string; label: string; required?: boolean; missing?: string } | { type: 'number'; key: string; label: string; decimals?: number; readonly?: boolean } /** Casilla: en SAP un campo `tYES` / `tNO`. */ - | { type: 'checkbox'; key: string; label: string; readonly?: boolean } + | { type: 'checkbox'; key: string; label: string; readonly?: boolean; immutable?: boolean } /** Campo de código con su ventana de selección (grupo, moneda, lista de precios…). */ - | { type: 'lookup'; key: string; label: string; lookup: VhLookup; required?: boolean; missing?: string } + | { type: 'lookup'; key: string; label: string; lookup: VhLookup; required?: boolean; missing?: string; immutable?: boolean } | { type: 'textarea'; key: string; label: string } /** Lista de valores válidos (típico de un campo de usuario con valores válidos en SAP). */ - | { type: 'select'; key: string; label: string; options: readonly { value: string | number; label: string }[]; required?: boolean; missing?: string } + | { type: 'select'; key: string; label: string; options: readonly { value: string | number; label: string }[]; required?: boolean; missing?: string; immutable?: boolean } /** Abre la lista de almacenes. */ | { type: 'warehouse'; key: string; label: string; required?: boolean; missing?: string } /** Código del socio de negocio (cliente o proveedor) con su lista; solo editable al crear. */ diff --git a/apps/web/src/app/features/documents/document-model.ts b/apps/web/src/app/features/documents/document-model.ts index 1b0d7b8..ff443b5 100644 --- a/apps/web/src/app/features/documents/document-model.ts +++ b/apps/web/src/app/features/documents/document-model.ts @@ -221,20 +221,18 @@ export function compact(record: DocRecord): DocRecord { export type NavTarget = 'first' | 'prev' | 'next' | 'last'; -/** Consulta de un solo documento para ir al primero / anterior / siguiente / último por `DocEntry`. */ -export function navigationQuery(target: NavTarget, docEntry: number | null): { $filter?: string; $orderby: string } { +/** Consulta de un solo documento para ir al primero / anterior / siguiente / último por `DocEntry` (u otra clave numérica). */ +export function navigationQuery(target: NavTarget, docEntry: number | null, key = 'DocEntry'): { $filter?: string; $orderby: string } { + const asc = `${key} asc`; + const desc = `${key} desc`; switch (target) { case 'first': - return { $orderby: 'DocEntry asc' }; + return { $orderby: asc }; case 'last': - return { $orderby: 'DocEntry desc' }; + return { $orderby: desc }; case 'prev': - return docEntry === null - ? { $orderby: 'DocEntry desc' } - : { $filter: `DocEntry lt ${docEntry}`, $orderby: 'DocEntry desc' }; + return docEntry === null ? { $orderby: desc } : { $filter: `${key} lt ${docEntry}`, $orderby: desc }; case 'next': - return docEntry === null - ? { $orderby: 'DocEntry asc' } - : { $filter: `DocEntry gt ${docEntry}`, $orderby: 'DocEntry asc' }; + return docEntry === null ? { $orderby: asc } : { $filter: `${key} gt ${docEntry}`, $orderby: asc }; } } diff --git a/apps/web/src/app/features/finance/journal-form.page.css b/apps/web/src/app/features/finance/journal-form.page.css new file mode 100644 index 0000000..22a76bc --- /dev/null +++ b/apps/web/src/app/features/finance/journal-form.page.css @@ -0,0 +1,8 @@ +:host { display: block; } +h3 { margin: 14px 0 4px; font-size: 13px; font-weight: 600; } +.row { display: flex; gap: 8px; align-items: center; margin: 4px 0; } +.totals { margin: 12px 0 0 auto; width: 320px; border-collapse: collapse; } +.totals td { padding: 2px 6px; } +.totals td:last-child { text-align: right; font-variant-numeric: tabular-nums; } +.totals tr.strong td { font-weight: 600; border-top: 1px solid var(--field-b); } +.confirm { color: var(--danger, #b00020); margin-right: 8px; } diff --git a/apps/web/src/app/features/finance/journal-form.page.html b/apps/web/src/app/features/finance/journal-form.page.html new file mode 100644 index 0000000..eaf1e70 --- /dev/null +++ b/apps/web/src/app/features/finance/journal-form.page.html @@ -0,0 +1,65 @@ + + Lista + Nuevo + + + + + + + + +
+
+ @if (isNew()) { + + } @else { + + } + +
+
+ + + +
+
+
+
+ +
+
+ +
+
+ +

Líneas

+ + @if (!saved()) { +

+ + +

+ } + + + + + + + +
Debe{{ fmt(totals().debit) }}
Haber{{ fmt(totals().credit) }}
Diferencia{{ fmt(totals().difference) }}
+ + @if (isNew()) { + + } @else if (manual() && doc()) { + @if (confirmCancel()) { + ¿Cancelar el asiento? Se generará su reverso. + + + } @else { + + } + } + {{ saved() ? 'Cerrar' : 'Descartar' }} +
diff --git a/apps/web/src/app/features/finance/journal-form.page.ts b/apps/web/src/app/features/finance/journal-form.page.ts new file mode 100644 index 0000000..ba474ca --- /dev/null +++ b/apps/web/src/app/features/finance/journal-form.page.ts @@ -0,0 +1,228 @@ +import { HttpErrorResponse, httpResource } from '@angular/common/http'; +import { ChangeDetectionStrategy, Component, computed, 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'; +import { map } from 'rxjs'; +import { API_URL, apiErrorMessage } from '../../core/api'; +import { formatNumber } from '../../core/format'; +import { SlDocumentApi } from '../../core/sl/sl-document'; +import { DateField, SelectField, TextField } from '../../shared/form'; +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_LOOKUP, PROJECT_LOOKUP, dimensionLookup } from '../documents/dimension-lookups'; +import type { NavTarget } from '../documents/document-model'; +import { isManualJournal, journalOrigin, journalPayload, journalTotals, validateJournal } from './journal-model'; + +type Row = Record; + +/** Tipo de objeto de SAP del asiento contable (para pedir sus series de numeración). */ +const OBJECT_TYPE = 30; + +const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v)); +const today = (): string => new Date().toISOString().slice(0, 10); +const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : ''); + +/** + * Formulario de un asiento contable: `nuevo` (vacío) o `:jdtNum`. Cada línea lleva una cuenta y el debe o el + * haber; el asiento solo se crea si cuadra. Un asiento contabilizado no se modifica: se consulta y, si es manual, + * se cancela (SAP genera el asiento de reverso). + */ +@Component({ + selector: 'sbo-journal-form-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DateField, Icon, RouterLink, RowsTable, SboWindow, SelectField, TextField, Toolbar], + templateUrl: './journal-form.page.html', + styleUrl: './journal-form.page.css', +}) +export class JournalFormPage { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly api = inject(SlDocumentApi); + + protected readonly title = this.route.snapshot.data['title'] as string; + protected readonly jdtNum = toSignal(this.route.paramMap.pipe(map((p) => (p.has('jdtNum') ? Number(p.get('jdtNum')) : null))), { + initialValue: this.route.snapshot.paramMap.has('jdtNum') ? Number(this.route.snapshot.paramMap.get('jdtNum')) : null, + }); + protected readonly isNew = computed(() => this.jdtNum() === null); + protected readonly saved = computed(() => !this.isNew()); + + // --- datos --------------------------------------------------------------------------------- + + private readonly resource = httpResource(() => { + const id = this.jdtNum(); + return id === null ? undefined : `${API_URL}/sl/JournalEntries/${id}`; + }); + protected readonly doc = computed(() => (this.resource.hasValue() ? this.resource.value() : undefined)); + protected readonly loading = computed(() => this.resource.isLoading()); + protected readonly loadError = computed(() => { + const err = this.resource.error(); + return err ? (err instanceof HttpErrorResponse && err.status === 404 ? 'El asiento no existe.' : apiErrorMessage(err)) : ''; + }); + protected readonly manual = computed(() => isManualJournal(this.doc())); + + // --- el borrador (o el asiento guardado, mostrado) ----------------------------------------- + + protected readonly referenceDate = linkedSignal(() => (this.doc() ? dateOnly(this.doc()!['ReferenceDate']) : today())); + protected readonly dueDate = linkedSignal(() => dateOnly(this.doc()?.['DueDate'])); + protected readonly taxDate = linkedSignal(() => dateOnly(this.doc()?.['TaxDate'])); + protected readonly series = linkedSignal(() => (this.doc() ? Number(this.doc()!['Series']) || null : null)); + protected readonly memo = linkedSignal(() => text(this.doc()?.['Memo'])); + protected readonly reference = linkedSignal(() => text(this.doc()?.['Reference'])); + protected readonly reference2 = linkedSignal(() => text(this.doc()?.['Reference2'])); + protected readonly lines = linkedSignal(() => { + const rows = ((this.doc()?.['JournalEntryLines'] as Row[] | undefined) ?? []).map((l) => ({ ...l })); + return this.doc() ? rows : [{}, {}]; + }); + + protected readonly saving = signal(false); + protected readonly confirmCancel = signal(false); + private readonly message = signal( + ((): StatusMessage | null => { + const created = (this.router.currentNavigation()?.extras.state as { created?: string } | undefined)?.created; + return created ? { kind: 'ok', text: `Asiento ${created} creado.` } : null; + })(), + ); + + protected readonly totals = computed(() => journalTotals(this.lines())); + protected readonly fmt = (n: number): string => formatNumber(n, 2); + + // --- series de numeración -------------------------------------------------------------------- + + private readonly seriesResource = httpResource>(() => + this.isNew() ? { url: `${API_URL}/sl/SeriesService_GetDocumentSeries`, method: 'POST', body: { DocumentTypeParams: { Document: String(OBJECT_TYPE) } } } : undefined, + ); + protected readonly seriesOptions = computed(() => { + const rows = this.seriesResource.hasValue() ? this.seriesResource.value().value : []; + return rows.filter((r) => r['Locked'] !== 'tYES').map((r) => ({ value: Number(r['Series']), label: text(r['Name']) || String(r['Series']) })); + }); + + // --- vista ----------------------------------------------------------------------------------- + + protected readonly columns: readonly DetailColumn[] = [ + { key: 'AccountCode', title: 'Cuenta', type: 'lookup', required: true, lookup: ACCOUNT_LOOKUP, width: '170px' }, + { key: 'Debit', title: 'Debe', type: 'number', decimals: 2, width: '120px' }, + { key: 'Credit', title: 'Haber', type: 'number', decimals: 2, width: '120px' }, + { key: 'LineMemo', title: 'Comentarios', type: 'text', width: '220px' }, + ...([1, 2, 3, 4, 5] as const).map((n): DetailColumn => ({ + key: n === 1 ? 'CostingCode' : `CostingCode${n}`, + title: `Dim. ${n}`, + type: 'lookup', + lookup: dimensionLookup(n), + width: '90px', + })), + { key: 'ProjectCode', title: 'Proyecto', type: 'lookup', lookup: PROJECT_LOOKUP, width: '100px' }, + ]; + + protected readonly status = computed(() => { + if (this.loading()) return { kind: 'info', text: 'Cargando…' }; + if (this.loadError()) return { kind: 'err', text: this.loadError() }; + const message = this.message(); + if (message) return message; + if (this.saved()) return { kind: 'info', text: this.manual() ? 'Asiento contabilizado: no se modifica; se puede cancelar.' : `Asiento generado por: ${journalOrigin(this.doc()?.['OriginalJournal'])}. Se cancela desde su documento.` }; + const t = this.totals(); + return t.difference !== 0 ? { kind: 'warn', text: `Falta ${this.fmt(Math.abs(t.difference))} para que el asiento cuadre.` } : { kind: 'info', text: 'Indique las cuentas, con el debe o el haber de cada línea.' }; + }); + protected readonly badge = computed(() => { + if (this.isNew()) return { label: 'Nuevo', tone: 'info' }; + return this.doc() ? { label: this.manual() ? 'Manual' : 'De documento', tone: this.manual() ? 'ok' : 'muted' } : undefined; + }); + protected readonly windowTitle = computed(() => { + const n = this.doc()?.['JdtNum']; + return n === undefined ? this.title : `${this.title} · ${text(n)}`; + }); + + // --- edición y acciones ---------------------------------------------------------------------- + + protected setLines(rows: Row[]): void { + this.lines.set(rows); + this.message.set(null); + } + + protected addLine(): void { + this.setLines([...this.lines(), {}]); + } + + /** Lleva a la última línea lo que falta para cuadrar: el debe o el haber, según el lado que sobra. */ + protected balance(): void { + const t = this.totals(); + if (t.difference === 0) return; + const rows = [...this.lines()]; + const last = rows.length - 1; + const row = { ...rows[last] }; + if (t.difference > 0) { + // sobra debe: la línea toma el faltante en el haber + row['Credit'] = (Number(row['Credit']) || 0) + t.difference; + row['Debit'] = null; + } else { + row['Debit'] = (Number(row['Debit']) || 0) - t.difference; + row['Credit'] = null; + } + rows[last] = row; + this.setLines(rows); + } + + protected set(key: 'memo' | 'reference' | 'reference2', value: string): void { + this[key].set(value); + this.message.set(null); + } + + protected async go(target: NavTarget): Promise { + try { + const entry = await this.api.neighbour('JournalEntries', target, this.jdtNum(), undefined, 'JdtNum'); + if (entry === null) { + this.message.set({ kind: 'warn', text: 'No hay más asientos en esa dirección.' }); + return; + } + this.message.set(null); + await this.router.navigate(['..', entry], { relativeTo: this.route }); + } catch (e) { + this.message.set({ kind: 'err', text: apiErrorMessage(e) }); + } + } + + protected async save(): Promise { + if (this.saving() || this.saved()) return; + const draft = { + referenceDate: this.referenceDate(), + dueDate: this.dueDate(), + taxDate: this.taxDate(), + series: this.series(), + memo: this.memo(), + reference: this.reference(), + reference2: this.reference2(), + lines: this.lines(), + }; + const errors = validateJournal(draft); + if (errors.length) { + this.message.set({ kind: 'err', text: errors[0] }); + return; + } + this.saving.set(true); + try { + const created = await this.api.create('JournalEntries', journalPayload(draft)); + await this.router.navigate(['..', created['JdtNum']], { relativeTo: this.route, state: { created: text(created['JdtNum']) } }); + } catch (e) { + this.message.set({ kind: 'err', text: apiErrorMessage(e) }); + } finally { + this.saving.set(false); + } + } + + protected async cancelEntry(): Promise { + const id = this.jdtNum(); + if (id === null || this.saving()) return; + this.saving.set(true); + this.confirmCancel.set(false); + try { + await this.api.action('JournalEntries', id, 'Cancel'); + this.message.set({ kind: 'ok', text: 'Asiento cancelado: SAP generó el asiento de reverso.' }); + this.resource.reload(); + } catch (e) { + this.message.set({ kind: 'err', text: apiErrorMessage(e) }); + } finally { + this.saving.set(false); + } + } +} diff --git a/apps/web/src/app/features/finance/journal-list.page.ts b/apps/web/src/app/features/finance/journal-list.page.ts new file mode 100644 index 0000000..545e475 --- /dev/null +++ b/apps/web/src/app/features/finance/journal-list.page.ts @@ -0,0 +1,56 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import type { ListFilter } from '../../core/sl/list-filters'; +import { ListView } from '../../shared/list/list-view'; +import type { ListColumn, ListRow } from '../../shared/list/list.types'; +import { journalOrigin } from './journal-model'; + +const COLUMNS: readonly ListColumn[] = [ + { key: 'JdtNum', title: 'Nº', type: 'number', align: 'left', width: '90px', format: (r) => String(r['JdtNum'] ?? ''), searchable: true }, + { key: 'ReferenceDate', title: 'Fecha', type: 'date', width: '120px' }, + { key: 'Memo', title: 'Comentarios', searchable: true }, + { key: 'Reference', title: 'Referencia', width: '130px', searchable: true }, + { key: 'OriginalJournal', title: 'Origen', width: '210px', format: (r) => journalOrigin(r['OriginalJournal']) }, +]; + +const FILTERS: readonly ListFilter[] = [ + { type: 'date', key: 'ReferenceDate', label: 'Fecha' }, + { type: 'select', key: 'OriginalJournal', label: 'Origen', options: [{ value: 'ttJournalEntry', label: 'Asiento manual' }] }, +]; + +/** Lista de los últimos asientos contables (manuales y de documentos); abre uno o crea uno nuevo. */ +@Component({ + selector: 'sbo-journal-list-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ListView], + template: ` + + `, + styles: ':host { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }', +}) +export class JournalListPage { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + protected readonly title = this.route.snapshot.data['title'] as string; + protected readonly columns = COLUMNS; + protected readonly filters = FILTERS; + + protected open(row: ListRow): void { + void this.router.navigate([String(row['JdtNum'])], { relativeTo: this.route }); + } + + protected create(): void { + void this.router.navigate(['nuevo'], { relativeTo: this.route }); + } +} diff --git a/apps/web/src/app/features/finance/journal-model.spec.ts b/apps/web/src/app/features/finance/journal-model.spec.ts new file mode 100644 index 0000000..e9cc8e7 --- /dev/null +++ b/apps/web/src/app/features/finance/journal-model.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { type JournalDraft, isManualJournal, journalOrigin, journalPayload, journalTotals, validateJournal } from './journal-model'; + +const draft = (over: Partial = {}): JournalDraft => ({ + referenceDate: '2026-10-16', + dueDate: '', + taxDate: '', + series: null, + memo: '', + reference: '', + reference2: '', + lines: [ + { AccountCode: '1-1-010-10-001', Debit: 1000 }, + { AccountCode: '6-1-010-40-000', Credit: 1000 }, + ], + ...over, +}); + +describe('asiento contable', () => { + it('totaliza el debe y el haber sin errores de coma flotante', () => { + expect(journalTotals([{ Debit: 0.1 }, { Debit: 0.2 }, { Credit: 0.3 }])).toEqual({ debit: 0.3, credit: 0.3, difference: 0 }); + expect(journalTotals([{ Debit: 100 }, { Credit: 40.5 }]).difference).toBe(59.5); + }); + + it('un asiento cuadrado es válido', () => { + expect(validateJournal(draft())).toEqual([]); + }); + + it('pide fecha, dos líneas y que cuadre', () => { + expect(validateJournal(draft({ referenceDate: '' }))).toEqual(['Indique la fecha de contabilización.']); + expect(validateJournal(draft({ lines: [{ AccountCode: 'A', Debit: 1 }] }))).toEqual(['Un asiento necesita al menos dos líneas.']); + expect(validateJournal(draft({ lines: [{ AccountCode: 'A', Debit: 1000 }, { AccountCode: 'B', Credit: 900 }] }))).toEqual([ + 'El asiento no cuadra: el debe (1000) y el haber (900) difieren en 100.', + ]); + }); + + 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 2: indique el debe o el haber, no los dos.'); + expect(errors).toContain('Línea 3: indique el debe o el haber.'); + }); + + it('ignora las filas vacías', () => { + expect(validateJournal(draft({ lines: [...draft().lines, {}, { AccountCode: '' }] }))).toEqual([]); + }); + + it('el cuerpo lleva solo lo con valor y el debe o el haber de cada línea', () => { + const body = journalPayload( + draft({ memo: 'Ajuste', series: 20, lines: [{ AccountCode: 'A', Debit: 10.005, LineMemo: 'x', CostingCode: '', ProjectCode: 'P1' }, { AccountCode: 'B', Credit: 10.01 }, {}] }), + ); + expect(body).toEqual({ + ReferenceDate: '2026-10-16', + Series: 20, + Memo: 'Ajuste', + JournalEntryLines: [ + { AccountCode: 'A', Debit: 10.01, LineMemo: 'x', ProjectCode: 'P1' }, + { AccountCode: 'B', Credit: 10.01 }, + ], + }); + }); + + it('solo los asientos manuales se cancelan; el origen se muestra en español', () => { + expect(isManualJournal({ OriginalJournal: 'ttJournalEntry' })).toBe(true); + expect(isManualJournal({ OriginalJournal: 'ttARInvoice' })).toBe(false); + expect(isManualJournal(undefined)).toBe(false); + expect(journalOrigin('ttARInvoice')).toBe('Factura de venta'); + expect(journalOrigin('ttRaro')).toBe('ttRaro'); + }); +}); diff --git a/apps/web/src/app/features/finance/journal-model.ts b/apps/web/src/app/features/finance/journal-model.ts new file mode 100644 index 0000000..4606f29 --- /dev/null +++ b/apps/web/src/app/features/finance/journal-model.ts @@ -0,0 +1,102 @@ +type Row = Record; + +const num = (v: unknown): number => (typeof v === 'number' ? v : Number(v) || 0); +const round2 = (n: number): number => Math.round((n + Number.EPSILON) * 100) / 100; +const has = (v: unknown): boolean => !(v === null || v === undefined || (typeof v === 'string' && v.trim() === '')); + +/** Campos de la línea que se envían a SAP además de la cuenta y los importes. */ +export const JOURNAL_LINE_TEXT_FIELDS = ['LineMemo', 'CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode'] as const; + +export interface JournalDraft { + referenceDate: string; + dueDate: string; + taxDate: string; + series: number | null; + memo: string; + reference: string; + reference2: string; + lines: Row[]; +} + +/** Una fila sin cuenta ni importes se ignora (la que se añadió y no se llenó). */ +const isBlank = (l: Row): boolean => !has(l['AccountCode']) && num(l['Debit']) === 0 && num(l['Credit']) === 0; + +export interface JournalTotals { + debit: number; + credit: number; + /** Debe − haber: un asiento solo se contabiliza en cero. */ + difference: number; +} + +export function journalTotals(lines: readonly Row[]): JournalTotals { + const debit = round2(lines.reduce((s, l) => s + num(l['Debit']), 0)); + const credit = round2(lines.reduce((s, l) => s + num(l['Credit']), 0)); + return { debit, credit, difference: round2(debit - credit) }; +} + +/** Errores que se pueden detectar sin preguntarle a SAP. */ +export function validateJournal(d: JournalDraft): string[] { + const errors: string[] = []; + if (!has(d.referenceDate)) errors.push('Indique la fecha de contabilización.'); + 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.`); + 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.`); + else if (debit > 0 && credit > 0) errors.push(`Línea ${i + 1}: indique el debe o el haber, no los dos.`); + else if (debit === 0 && credit === 0) errors.push(`Línea ${i + 1}: indique el debe o el haber.`); + }); + const t = journalTotals(lines); + if (lines.length >= 2 && t.difference !== 0) errors.push(`El asiento no cuadra: el debe (${t.debit}) y el haber (${t.credit}) difieren en ${Math.abs(t.difference)}.`); + return errors; +} + +/** Cuerpo para crear el asiento: solo lo que tiene valor; cada línea lleva el debe o el haber, nunca los dos. */ +export function journalPayload(d: JournalDraft): Row { + const body: Row = { ReferenceDate: d.referenceDate }; + if (has(d.dueDate)) body['DueDate'] = d.dueDate; + if (has(d.taxDate)) body['TaxDate'] = d.taxDate; + if (d.series) body['Series'] = d.series; + if (has(d.memo)) body['Memo'] = d.memo; + if (has(d.reference)) body['Reference'] = d.reference; + if (has(d.reference2)) body['Reference2'] = d.reference2; + body['JournalEntryLines'] = d.lines + .filter((l) => !isBlank(l)) + .map((l) => { + const line: Row = { AccountCode: l['AccountCode'] }; + if (num(l['Debit']) > 0) line['Debit'] = round2(num(l['Debit'])); + else line['Credit'] = round2(num(l['Credit'])); + for (const k of JOURNAL_LINE_TEXT_FIELDS) if (has(l[k])) line[k] = l[k]; + return line; + }); + return body; +} + +/** Solo un asiento manual se cancela desde aquí: los que generan los documentos se cancelan con el documento. */ +export const isManualJournal = (doc: Row | undefined): boolean => doc?.['OriginalJournal'] === 'ttJournalEntry'; + +/** Origen del asiento (`OriginalJournal`, el tipo de transacción de SAP) en texto. */ +const ORIGINS: Readonly> = { + ttJournalEntry: 'Asiento manual', + ttOpeningBalance: 'Saldo de apertura', + ttARInvoice: 'Factura de venta', + ttARCredItnote: 'Nota de crédito de venta', + ttDelivery: 'Entrega', + ttReturn: 'Devolución', + ttAPInvoice: 'Factura de proveedor', + ttAPCreditNote: 'Nota de crédito de compra', + ttPurchaseDeliveryNote: 'Recepción de compra', + ttPurchaseReturn: 'Devolución de compra', + ttReceipt: 'Pago recibido', + ttVendorPayment: 'Pago efectuado', + ttDeposit: 'Depósito', + ttGeneralReceiptToStock: 'Entrada de inventario', + ttGeneralReleaseFromStock: 'Salida de inventario', + ttTransferBetweenWarehouses: 'Transferencia de stock', + ttInventoryValuation: 'Revalorización de inventario', + ttInternalReconciliation: 'Conciliación interna', +}; + +export const journalOrigin = (code: unknown): string => ORIGINS[String(code ?? '')] ?? String(code ?? ''); diff --git a/apps/web/src/app/features/finance/journal-paths.ts b/apps/web/src/app/features/finance/journal-paths.ts new file mode 100644 index 0000000..7b35d3f --- /dev/null +++ b/apps/web/src/app/features/finance/journal-paths.ts @@ -0,0 +1,2 @@ +/** Ruta de menú del asiento contable: lo único que el enrutador necesita al arrancar. */ +export const JOURNAL_PATH = '/finanzas/asientos'; diff --git a/apps/web/src/app/features/masters/finanzas/finanzas.extensions.ts b/apps/web/src/app/features/masters/finanzas/finanzas.extensions.ts new file mode 100644 index 0000000..0e736d8 --- /dev/null +++ b/apps/web/src/app/features/masters/finanzas/finanzas.extensions.ts @@ -0,0 +1,8 @@ +import type { MasterExtensions } from '../master-extensions'; + +/** + * Campos que este cliente agrega al plan de cuentas. En `main` va vacío: el proyecto base solo lleva campos + * estándar de SAP. La rama `cliente/` lo completa, por ruta de pantalla (`/finanzas/plan-de-cuentas`), + * igual que las extensiones de artículos y de socios de negocio. + */ +export const FINANZAS_EXTENSIONS: MasterExtensions = {}; diff --git a/apps/web/src/app/features/masters/finanzas/finanzas.screens.ts b/apps/web/src/app/features/masters/finanzas/finanzas.screens.ts new file mode 100644 index 0000000..f07ac99 --- /dev/null +++ b/apps/web/src/app/features/masters/finanzas/finanzas.screens.ts @@ -0,0 +1,87 @@ +import type { SelectFilter } from '../../../core/sl/list-filters'; +import type { HeaderField } from '../../documents/document-layouts'; +import { ACCOUNT_LOOKUP } from '../../documents/dimension-lookups'; +import { MASTER_LOOKUPS } from '../master-lookups'; +import type { MasterScreen } from '../master-types'; + +const field = (f: HeaderField): HeaderField => f; + +/** Tipos de cuenta de SAP (`BoAccountTypes`). */ +const ACCOUNT_TYPES = [ + { value: 'at_Other', label: 'Otras' }, + { value: 'at_Revenues', label: 'Ingresos' }, + { value: 'at_Expenses', label: 'Gastos' }, +]; + +/** Una cuenta de título agrupa a otras y no recibe asientos; la de movimiento (`ActiveAccount`) sí. */ +const postableFilter: SelectFilter = { + type: 'select', + key: 'ActiveAccount', + label: 'Clase', + options: [ + { value: 'tYES', label: 'De movimiento' }, + { value: 'tNO', label: 'De título' }, + ], +}; + +/** Cuentas de título: las únicas que pueden ser la cuenta padre de otra. */ +const TITLE_ACCOUNTS = { ...ACCOUNT_LOOKUP, title: 'Cuentas de título', baseFilter: `ActiveAccount eq 'tNO'` }; + +/** Plan de cuentas (`ChartOfAccounts`): solo campos estándar de SAP. */ +export const FINANZAS_SCREENS: readonly MasterScreen[] = [ + { + path: '/finanzas/plan-de-cuentas', + resource: 'ChartOfAccounts', + keyField: 'Code', + singular: 'Cuenta', + createLabel: 'Nueva cuenta', + orderby: 'Code', + extraFields: ['ActiveAccount'], + columns: [ + { key: 'Code', title: 'Código', width: '170px', mono: true, searchable: true }, + { key: 'Name', title: 'Nombre', searchable: true }, + { key: 'AccountLevel', title: 'Nivel', type: 'number', align: 'left', width: '70px' }, + { + key: 'AccountType', + title: 'Tipo', + width: '100px', + format: (r) => ACCOUNT_TYPES.find((t) => t.value === r['AccountType'])?.label ?? String(r['AccountType'] ?? ''), + }, + { key: 'Balance', title: 'Saldo', type: 'number', decimals: 2, width: '140px' }, + { + key: 'ActiveAccount', + title: 'Clase', + width: '120px', + format: (r) => (r['ActiveAccount'] === 'tYES' ? 'De movimiento' : 'De título'), + tone: (r) => (r['ActiveAccount'] === 'tYES' ? 'ok' : 'muted'), + }, + ], + filters: [postableFilter, { type: 'select', key: 'AccountType', label: 'Tipo', options: ACCOUNT_TYPES }], + layout: { + header: { + left: [ + field({ type: 'text', key: 'Code', label: 'Código', immutable: true, required: true, missing: 'el código' }), + field({ type: 'text', key: 'Name', label: 'Nombre', required: true, missing: 'el nombre' }), + ], + right: [field({ type: 'checkbox', key: 'ActiveAccount', label: 'Cuenta de movimiento', immutable: true })], + }, + tabs: [ + { + id: 'general', + label: 'General', + left: [ + field({ type: 'lookup', key: 'FatherAccountKey', label: 'Cuenta padre', lookup: TITLE_ACCOUNTS, immutable: true }), + field({ type: 'select', key: 'AccountType', label: 'Tipo de cuenta', options: ACCOUNT_TYPES }), + field({ type: 'lookup', key: 'AcctCurrency', label: 'Moneda', lookup: MASTER_LOOKUPS.currency }), + ], + right: [ + field({ type: 'checkbox', key: 'CashAccount', label: 'Cuenta de caja' }), + field({ type: 'number', key: 'AccountLevel', label: 'Nivel', readonly: true }), + field({ type: 'number', key: 'Balance', label: 'Saldo', decimals: 2, readonly: true }), + ], + }, + ], + newHeader: () => ({ ActiveAccount: 'tYES', AccountType: 'at_Other', CashAccount: 'tNO' }), + }, + }, +]; diff --git a/apps/web/src/app/features/masters/master-field.ts b/apps/web/src/app/features/masters/master-field.ts index 4af6da9..139d875 100644 --- a/apps/web/src/app/features/masters/master-field.ts +++ b/apps/web/src/app/features/masters/master-field.ts @@ -108,8 +108,8 @@ export class MasterField { }); protected readonly ro = computed(() => { const f = this.field(); - if (f.type === 'text') return f.readonly || (f.immutable && this.saved()); - return 'readonly' in f ? f.readonly : false; + if ('immutable' in f && f.immutable && this.saved()) return true; + return 'readonly' in f ? !!f.readonly : false; }); protected readonly lookup = computed(() => { const f = this.field(); diff --git a/apps/web/src/app/features/masters/master-model.ts b/apps/web/src/app/features/masters/master-model.ts index 0a786f0..98c1f95 100644 --- a/apps/web/src/app/features/masters/master-model.ts +++ b/apps/web/src/app/features/masters/master-model.ts @@ -20,7 +20,7 @@ export const masterRequiredOf = (layout: MasterLayout): { key: string; label: st /** Campos que no se pueden cambiar una vez creado el registro (el código). */ export const masterImmutableOf = (layout: MasterLayout): ReadonlySet => - new Set(masterFieldsOf(layout).flatMap((f) => (f.type === 'text' && f.immutable ? [f.key] : []))); + new Set(masterFieldsOf(layout).flatMap((f) => ('immutable' in f && f.immutable ? [f.key] : []))); /** Valores editables de un registro de SAP (los vacíos quedan como texto vacío). */ export function pickMaster(doc: DocRecord | undefined, keys: readonly string[], blank: DocRecord): DocRecord { diff --git a/apps/web/src/app/features/masters/master-paths.ts b/apps/web/src/app/features/masters/master-paths.ts index ca00520..0a9252b 100644 --- a/apps/web/src/app/features/masters/master-paths.ts +++ b/apps/web/src/app/features/masters/master-paths.ts @@ -3,4 +3,4 @@ * configuración completa (`master-screens.ts`) se carga en diferido al abrir una. Un test comprueba que * coincide con `MASTER_SCREENS`. */ -export const MASTER_PATHS: readonly string[] = ['/socios/clientes', '/socios/proveedores', '/inventario/articulos']; +export const MASTER_PATHS: readonly string[] = ['/socios/clientes', '/socios/proveedores', '/inventario/articulos', '/finanzas/plan-de-cuentas']; diff --git a/apps/web/src/app/features/masters/master-screens.ts b/apps/web/src/app/features/masters/master-screens.ts index 891a557..20349c6 100644 --- a/apps/web/src/app/features/masters/master-screens.ts +++ b/apps/web/src/app/features/masters/master-screens.ts @@ -1,3 +1,5 @@ +import { FINANZAS_EXTENSIONS } from './finanzas/finanzas.extensions'; +import { FINANZAS_SCREENS } from './finanzas/finanzas.screens'; import { ARTICULOS_SCREENS } from './articulos/articulos.screens'; import { ARTICULOS_EXTENSIONS } from './articulos/articulos.extensions'; import { type MasterExtensions, applyMasterExtensions } from './master-extensions'; @@ -6,7 +8,7 @@ import { SOCIOS_SCREENS } from './socios-negocio/socios.screens'; import type { MasterScreen } from './master-types'; /** Las pantallas de maestros del proyecto base, agrupadas por módulo (una carpeta cada uno). */ -export const BASE_MASTER_SCREENS: readonly MasterScreen[] = [...SOCIOS_SCREENS, ...ARTICULOS_SCREENS]; +export const BASE_MASTER_SCREENS: readonly MasterScreen[] = [...SOCIOS_SCREENS, ...ARTICULOS_SCREENS, ...FINANZAS_SCREENS]; /** Cada módulo solo puede extender sus propias pantallas: una ruta ajena es un error, no un descuido silencioso. */ function ownedBy(screens: readonly MasterScreen[], extensions: MasterExtensions): MasterExtensions { @@ -20,6 +22,7 @@ function ownedBy(screens: readonly MasterScreen[], extensions: MasterExtensions) export const MASTER_EXTENSIONS: MasterExtensions = { ...ownedBy(SOCIOS_SCREENS, SOCIOS_EXTENSIONS), ...ownedBy(ARTICULOS_SCREENS, ARTICULOS_EXTENSIONS), + ...ownedBy(FINANZAS_SCREENS, FINANZAS_EXTENSIONS), }; /** Las pantallas con lo que cada cliente agrega (en `main`, igual al base). */ diff --git a/libs/sap-types/src/menu.ts b/libs/sap-types/src/menu.ts index 2f6cc08..8c5e8c8 100644 --- a/libs/sap-types/src/menu.ts +++ b/libs/sap-types/src/menu.ts @@ -64,6 +64,14 @@ export const MENU: readonly MenuModule[] = [ { path: '/bancos/pagos-efectuados', label: 'Pagos efectuados' }, ], }, + { + id: 'finanzas', + label: 'Finanzas', + items: [ + { path: '/finanzas/plan-de-cuentas', label: 'Plan de cuentas' }, + { path: '/finanzas/asientos', label: 'Asientos contables' }, + ], + }, { id: 'socios', label: 'Socios de negocio',