feat(web): plan de cuentas como SAP (árbol con grupos y nivel al lado del formulario; propiedades, costos e info detallada)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 09:06:12 -05:00
1 parent 89e25ad8e6
commit bb19e499fe
18 files changed
+292 -280

No files matched your search

+1 -66
View File
@@ -25,69 +25,4 @@ Reglas de SAP verificadas: nota de crédito se aplica con `SumApplied` **negativ
## 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. El plan de cuentas se abre como **árbol** (`chart-tree.page`: se cargan todas las cuentas de a 200 y el árbol se arma en el navegador; buscar, expandir/contraer, ramas de primer nivel como casillas) y su formulario muestra las casillas de grupo (Activos, Pasivos, Patrimonio…; `account-groups.ts`, solo informativas: el grupo es la cuenta de primer nivel que empieza con el mismo carácter que el código, porque el Service Layer no expone `GroupMask` y `Category` viene vacía). El asiento lleva indicador, folio y referencias 1 a 3 en cabecera y línea, más `ShortName` y `DueDate` por línea; SAP ignoró el folio en el asiento de prueba. Pendiente: libro mayor y saldos, asientos recurrentes.
## 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 ~289 kB, 82 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).
## Comandos
- `npm install` — **npm 11** (npm 10 falla al resolver las dependencias de Nest 12: `npx npm@11 install`); instala todo y compila `libs/sap-types`.
- `npm run dev:api` (`:3000`) · `npm run dev:web` (`:4200`, proxy `/api`) · `npm run build` · `npm test`.
- **Pruebas con otra persona en la red:** `npm run dev:web:lan` abre la web a la red (`http://<IP del equipo>:4200`; el proxy `/api` llega al backend local). El backend (`:3000`) escucha en todas las interfaces y la web normal (`dev:web`) solo en `localhost`; ambos son de desarrollo, sin HTTPS. La otra persona necesita su propio usuario web (`U_WEB_USER`/`U_WEB_PASS` en `EmployeesInfo`) y permisos de menú (`/admin/permisos`): hay **una sesión por usuario**, así que no debe entrar con el mismo que tú.
- Backend contra SAP real (opt-in): `SAP_LIVE=1 npm run test:e2e -w apps/api`.
- Página de componentes **solo desarrollo**: `http://localhost:4200/dev/componentes` (toolbar, ventana, campos, pestañas, grilla y listas de valores con datos reales; no guarda nada).
## Decisiones tomadas (con su porqué)
- **Login web** con `U_WEB_USER`/`U_WEB_PASS` (se mantiene). Mismo mensaje para usuario inexistente y clave errónea; comparación en tiempo constante; 429 tras 5 fallos por usuario / 20 por IP; una sesión por usuario; timeout deslizante 30 min (el servidor informa `sessionTimeoutMinutes`); token `Authorization: Bearer`.
- **Usuario SAP compartido** (hoy Manager de pruebas; luego un usuario de licencia indirecta). Todo sale auditado con ese usuario.
- **Concurrencia hacia el Service Layer = 1** (`SAPB1_SL_MAX_CONCURRENCY`): medido en SAP10 (MSSQL, 32 GB), 12 peticiones tardan 0.6 s en serie y 7.5 s con 2 en paralelo. Un pool de sesiones no ayuda; la cola sí. Repetir la medición en cada cliente.
- **TLS:** `SAPB1_SL_CA_FILE` opcional (certificado autofirmado de SAP o CA de firewall); `VERIFY_SSL=true` por defecto y no se puede apagar en producción. `fortinet.cer` no sirve para SAP10: presenta su propio certificado autofirmado (`apps/api/certs/sap10.pem`, ignorado por git).
- **Proxy `/api/sl/:recurso`**: el frontend arma `$select/$filter/...`; el backend valida y ejecuta solo recursos registrados (`apps/api/src/sl/sl-resources.ts`), con permiso de menú y cabeceras `B1S-*` de lista blanca. Nunca registrar `EmployeesInfo` (tiene `U_WEB_PASS`). `InventoryRequests` no existe: es `InventoryTransferRequests`.
- **Permisos de menú** en SQLite (`SQLITE_PATH`), patrones `*`, `/ventas/*`, `/ventas/ordenes`; `ADMIN_WEB_USERS` garantiza el acceso total; no se puede quitar el último admin.
- **Listas de documentos:** más recientes primero (`DocEntry desc`), 50 por página, **Estado = Abierto por defecto** (como el mini-erp; cambiarlo es un `defaultValue`), filtro de fecha **siempre rango [desde – hasta]**, multi-selección con **chips**, búsqueda numérica por igualdad. **Sin filtro base en las demás listas; las de `Invoices` filtran por subtipo exacto:** cuatro pantallas comparten el recurso y se separan por `DocumentSubType` (enum `BoDocumentSubType`: `bod_None` 0, `bod_InvoiceExempt` 1, `bod_DebitMemo` 2, `bod_Bill` 3, `bod_ExemptBill` 4, `bod_PurchaseDebitMemo` 5, `bod_ExportInvoice` 6…) y por `ReserveInvoice` (`tYES` = de reserva): Facturas (`tNO` y `eq bod_None`), Boletas (`tNO` y `eq bod_Bill`), Facturas de reserva (`tYES` y `eq bod_None`) y Boletas de reserva (`tYES` y `eq bod_Bill`); `baseFilter` para la lista y `fixed` (subtipo, y `ReserveInvoice` en las reservas) al crear. Nunca `ne`: los demás subtipos (factura exenta, nota de débito, boleta exenta, exportación) no se mezclan y tendrán su propia pantalla si se necesitan. `baseFilter` de la pantalla se aplica también a la navegación primero/anterior/siguiente/último. El PDF y los reportes se migran después vía API Gateway (otro servicio, con su propia cola y sesión).
- Reportes/PDF **no** se generan en la base; el botón PDF queda pendiente hasta migrar el API Gateway.
## Flujo de las pantallas de documento
Cada menú abre primero una **lista de los últimos documentos** (barra de filtros + búsqueda por columna); desde ahí se crea uno nuevo (`/…/nuevo`, formulario vacío) o se abre uno existente (`/…/:docEntry`, ver/editar; los cerrados/cancelados en solo lectura). Las pantallas se definen por módulo en `apps/web/src/app/features/documents/` (17 documentos): `ventas/ventas.screens.ts`, `compras/compras.screens.ts` (+ `compras.layouts.ts`) e `inventario/inventario.screens.ts` (+ `inventario.layouts.ts`); los maestros (socios de negocio, artículos) irán en su propia carpeta. `screen-kit.ts` tiene el tipo `DocumentScreen` y las columnas y filtros comunes; `document-screens.ts` las reúne en `DOCUMENT_SCREENS`. Las columnas de línea estándar están en `features/documents/line-columns.ts`.
## Cómo verificar cambios de interfaz
- Probar contra el SAP real con la extensión de Chrome (`list_connected_browsers` debe mostrar un navegador). Si no conecta, usar Chrome headless por CDP.
- **No escribir contraseñas en formularios**: obtener el token con `curl` a `/api/auth/login` (la clave de prueba se lee de `EmployeesInfo` solo para eso, nunca se imprime ni se guarda) e inyectar solo el token en `localStorage` (`erp_session`).
- Al terminar: cerrar la sesión de prueba (`POST /api/auth/logout`), borrar `erp_session`/`erp_theme` del navegador, cerrar la pestaña, apagar los servidores y borrar archivos temporales (token, `apps/api/data/`). No dejar páginas de demostración fuera de `/dev/componentes`.
- **Escrituras a SAP (POST/PATCH):** el usuario autorizó (2026-10-14) escribir en `SBODemoCL`, su ambiente propio de pruebas; ya se probaron crear/actualizar órdenes de venta y de compra, reemplazo de líneas y el 412 por conflicto. Documentos de prueba creados: orden de venta 315 (cerrada, copiada a la entrega 580) y orden de compra 2883 (cerrada, copiada a la recepción 299). Aun así, avisar antes de escribir en cualquier otra base.
- Los tests no detectan todo: el foco tras crear una línea (se pedía antes de que existiera la fila) solo falló en el navegador real.
## Estado y próximos pasos
Hecho (commits en `main`): monorepo · API (config, capa SAP con sesión compartida y cola, login, permisos SQLite, proxy `/api/sl`) · web (temas, login, layout con menú por permisos, `sbo-value-help`, campos y filas compuestas, listas con filtros, ventana/toolbar/pestañas/menús, grilla de líneas, `/dev/componentes`) · formularios de todos los documentos y maestros · auditoría · Bancos (pagos recibidos y efectuados) · Finanzas (plan de cuentas y asientos).
Pendiente, en orden:
1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Un código escrito a mano (socio, artículo, almacén, impuesto) se resuelve al salir del campo con `SlLookupApi` (mismo recurso y filtro base que la lista "…"); si no existe se avisa. "Copiar de/a" hecho (`document-copy.ts`: flujos estándar y `BaseType/BaseEntry/BaseLine`; probado: orden de compra 2883 → recepción 299, orden de venta 315 → entrega 580, ambas órdenes quedaron cerradas). Formularios sin socio ni totales **hechos** (`document-layouts.ts`, configuración declarativa: cabecera de 2 columnas, columnas de línea, colección `DocumentLines` o `StockTransferLines`): entradas y salidas de inventario, transferencias de stock, solicitudes de traslado y solicitudes de compra. Entradas, salidas y transferencias contabilizadas son de solo lectura (SAP no las modifica); solicitudes de traslado y de compra se crean y modifican. Probado contra `SBODemoCL`: solicitud de compra 2 (la 1 es una prueba por API), solicitud de traslado 472 (cantidad modificada) y entrada de inventario 155 (1 unidad de `3MG30080714 M 10.5` en el almacén 8803). Limitaciones conocidas: las líneas de compra/inventario llevan `UoMEntry` solo si el artículo tiene grupo de unidades (`uomEntryOf`; sin él SAP responde "specify a UoM code"); los formularios de ventas y compras también lo envían (la unidad de venta o de compra del artículo, o la de inventario; `itemColumnWithUom`), y no se envía la unidad manual (-1). Probado: orden de compra 2884 con `SU01`, que no tiene unidad de compra por defecto. **Cómo se define un formulario:** el formulario base (`document-form.page`) no sabe de qué documento se trata; recibe de la pantalla su `layout` (`DocumentLayout` en `document-layouts.ts`): `header` y `footer` (cada uno con `left`/`right`, máximo 2 columnas) son arreglos de campos tomados del catálogo `FIELDS` (texto, fecha, número, almacén, socio, folio, calculado), `columns` son las columnas de línea y `lines` la colección que se guarda. Cada pantalla (en la carpeta de su módulo) declara los suyos (la orden de venta no lleva folio; las facturas, notas de crédito, entregas y recepciones sí, con `TAX_DOCUMENT`); agregar o quitar un campo es editar ese arreglo, sin tocar el HTML. En ventas y compras la pantalla puede agregar campos (`headerLeft`/`headerRight`), reemplazar la cabecera (`header`) o elegir sus columnas de línea del catálogo `LINE_COLUMNS` (`lineColumns: [LINE_COLUMNS.item, LINE_COLUMNS.quantity, …]`; lo que se envía por línea sale de ellas); sin elegir, el documento lleva la cabecera y todas las columnas base.
**Campos por cliente (estándar adicionales o `U_*`):** no se editan las pantallas base. Cada módulo tiene su archivo de extensiones, vacío en `main`: `ventas/ventas.extensions.ts`, `compras/compras.extensions.ts` e `inventario/inventario.extensions.ts` (reunidos en `client-extensions.ts`). La rama `cliente/<nombre>` declara ahí, por ruta de pantalla, qué suma: `headerLeft`/`headerRight`/`footerLeft`/`footerRight` (campos; tipos `text`, `date`, `number`, `textarea`, `select` con `options`, `warehouse`…), `lineColumns` (se agregan al final y se envían a SAP) y `listColumns`. Además de agregar, la extensión puede **ocultar** (`hideHeaderFields`, `hideLineColumns`, `hideListColumns`; en maestros `hideFields`, `hideListColumns`) y dejar de **solo lectura** (`readOnlyHeaderFields`, `readOnlyLineColumns`; en maestros `readOnlyFields`): lo oculto ni se muestra ni se envía; lo de solo lectura se ve pero no se edita y su valor sigue enviándose (columna `locked`, a diferencia de `readonly`, que no se envía). Los nombres son los de SAP (`CostingCode2`, `Indicator`…); un nombre inexistente, ocultar un campo/columna obligatorio, o una ruta inexistente o ajena al módulo lanzan un error al arrancar. El test «el proyecto base no usa campos de usuario» revisa `BASE_SCREENS`, no las extensiones. Verificado: el proxy `/api/sl` deja pasar nombres `U_*` en `$select`/`$orderby` y en el cuerpo (el error de un campo inexistente lo da SAP). Límite: una columna de línea de usuario con lista de valores válidos aún no tiene editor desplegable (solo texto/número). La alineación de una columna se declara con `align: 'left' | 'center' | 'right'` (listas y detalle; por defecto, derecha en las numéricas).
**Columnas de las ventanas de selección (cliente, artículo, almacén, impuesto):** son configurables por documento. En una pantalla del base (ventas/compras): `lookups: { item: [...], partner: [...], warehouse: [...], tax: [...] }` en el último parámetro de `marketing(...)` *sustituye* las columnas de esa lista (la primera columna es el código que se escribe en el campo). En la rama del cliente: `lookupColumns` en su archivo de extensiones *suma* columnas al final (por ejemplo `U_Marca` en artículos). El recurso, el filtro y el orden de cada lista no cambian; para los documentos de inventario o solicitudes se usa `withLookupColumns(layout, cols, 'replace' | 'add')`. Las columnas de almacén aplican también al almacén de la cabecera y al de origen de una transferencia. Los formularios con socio muestran `NumAtCard` y el RUT (`FederalTaxID`, solo lectura: lo completa SAP desde el socio); facturas, notas de crédito, entregas y recepciones (`FOLIO_PATHS` en `document-screens.ts`) añaden el folio tributario, que en el Service Layer es `FolioPrefixString` + `FolioNumber` (se edita como texto y se envía como número); las listas de facturas también los muestran. **Campos de contabilidad analítica y numeración (estándar):** cabecera de todos los documentos: `Series` (la fila «Número» lleva la lista de series del documento, pedida con la acción `SeriesService_GetDocumentSeries` del Service Layer —registrada en el proxy como acción de solo consulta—, filtrada por el subtipo de la pantalla; no se puede cambiar una vez creado; sin elegir, SAP usa la por defecto), `Indicator` (ventana de selección sobre `FactoringIndicators` —tabla OIDC; en tu SAP: 33 Factura, 39 Boleta; el recurso `Indicators` no existe en este Service Layer) y `Project` (lista de proyectos). Detalle de ventas y compras: `PriceAfterVAT` (el precio con IVA; solo se envía a SAP el que el usuario escribió, `Price` o `PriceAfterVAT`, nunca los dos), `CostingCode` a `CostingCode5` y `ProjectCode` (cada uno con su **ventana modal** de selección: reglas de distribución por dimensión y proyectos), `AccountCode` (plan de cuentas) y `BaseType`/`BaseEntry`/`BaseLine` (solo lectura: el documento de origen de una línea copiada). Inventario y solicitudes de compra llevan en el detalle solo las cinco dimensiones y el proyecto. `OBJECT_TYPES` (`document-objects.ts`) da el tipo de objeto de SAP de cada pantalla. El subtipo de las series de boleta (`IB`) y el resto de `SERIES_SUBTYPE` no está verificado contra SBODemoCL (solo hay series `--`).
**Movimientos de inventario:** cabecera con fecha de contabilización, fecha del documento (`TaxDate`), vencimiento (`DueDate`, solo traslados), `Reference2` y comentarios del asiento (`JournalMemo`); `Reference1` lo asigna SAP. Copiar de/a: la transferencia de stock se crea desde una solicitud de traslado (`COPY_FLOWS`); en las líneas de transferencia `BaseType` es un **enumerado de texto** de SAP (`InventoryTransferRequest`, `WarehouseTransfers`…; `STOCK_BASE_TYPES`, `hasBaseRef`), no el número de objeto de ventas y compras.
**Lotes y series (entradas, salidas y transferencias de stock):** si el artículo se gestiona por lote (`ManageBatchNumbers`) o por serie (`ManageSerialNumbers`; el maestro de artículos los trae con `extraFields` al elegirlo) la línea muestra un botón «Indicar lotes / series» (columna «Lotes / Series») que abre una ventana con una tabla editable: lotes = `BatchNumber`, `Quantity`, `ExpiryDate`, `ManufacturingDate`; series = `InternalSerialNumber`, `ManufacturerSerialNumber`, fechas (cada serie con `Quantity: 1`). Viajan dentro de la línea como `BatchNumbers` / `SerialNumbers`; solo se envían los campos propios (no los que SAP agrega al leer). Validación: los lotes deben sumar la cantidad de la línea y debe haber una serie por unidad. Al cambiar de artículo se descartan. Un documento contabilizado los muestra en solo lectura. Las solicitudes de traslado, las de compra y los documentos de ventas/compras no los llevan todavía (`layout.details`, `LINE_DETAILS` en `line-details.ts`). Probado: entrada de inventario 156 (1 unidad de `SU01` en el almacén 01, lote `LT-PRUEBA-01`). Pendiente: elegir de una lista los lotes existentes en una salida o transferencia (hoy se escribe el número; SAP valida que exista y haya saldo).
Flete hecho: fila «Flete» en el pie de ventas y compras (gasto adicional + importe; `freightPayload` lo guarda como `DocumentAdditionalExpenses`, reemplazando la colección; el catálogo `AdditionalExpenses` está registrado en el proxy; con varios gastos adicionales cargados en SAP solo se muestra). `freight: false` en `marketing(...)` lo quita de un documento. El toolbar del formulario ocupa todo el ancho del contenido (`<sbo-toolbar bleed>`). Falta: botón PDF. En ventas el flete falla en SBODemoCL ("Define the freight revenue account…"): el gasto adicional «Flete» solo tiene cuenta de gasto, no de ingresos (configuración de SAP, no del código); en compras funciona (orden de compra 2885).
2. ~~Pantallas de maestros~~ — hechas (clientes, proveedores y artículos): `features/masters/`, con la misma idea que los documentos pero con clave de texto (`CardCode`, `ItemCode`). Cada pantalla declara su `MasterScreen` (`socios-negocio/socios.screens.ts`, `articulos/articulos.screens.ts`): columnas y filtros de la lista, `baseFilter` (clientes = `CardType eq 'cCustomer'`, proveedores = `cSupplier`; el tipo también se envía al crear con `fixed`), y un `MasterLayout` con código/nombre/estado arriba y los demás campos en **pestañas** (dos columnas, tipos de `HeaderField`: texto, número, fecha, área de texto, casilla `tYES/tNO`, lista de valores numérica o de texto, almacén). El código no se cambia una vez creado (`immutable`); «Inactivo» es `Frozen`. Rutas con `master-paths.ts` + `resolve` diferido; navegación primero/anterior/siguiente/último por clave de texto (`SlMasterApi`). Los campos por cliente van en `socios-negocio/socios.extensions.ts` y `articulos/articulos.extensions.ts` (vacíos en `main`; `header`, `tabs` por `id`, `newTabs`, `listColumns`, y también `hideFields`, `readOnlyFields`, `hideListColumns`; pestaña, campo o ruta inexistente = error). **Tablas en las pestañas de los maestros** (`MasterTab.collection`, `MasterCollection`; componente `sbo-rows-table`): clientes y proveedores tienen **Direcciones** (`BPAddresses`) y **Personas de contacto** (`ContactEmployees`); el artículo, **Precios** (`ItemPrices`) y **Stock por almacén** (`ItemWarehouseInfoCollection`, solo lectura). Cómo se envían (verificado contra SAP con un cliente y un artículo de prueba): `mode: 'replace'` (direcciones, contactos) manda la colección completa —las filas leídas con los cambios aplicados, para conservar sus campos de SAP (`RowNum`, `InternalCode`…), más las nuevas— con `B1S-ReplaceCollectionsOnPatch: true` (así también se puede quitar una fila, si SAP lo permite); `mode: 'merge'` (precios) manda **solo las filas cambiadas**, por `rowKey` (`PriceList`) y sin reemplazo, porque reenviar las demás listas con valores viejos impide que SAP recalcule las derivadas por factor. Las filas vacías (sin `identity`) no se guardan. Probado: cliente `TESTBP01` (dirección de envío añadida) y artículo `TESTITEM01` (precio de la lista 3), ambos de prueba en SBODemoCL. Pendiente en maestros: las extensiones de cliente aún no pueden agregar u ocultar columnas de estas tablas, ni cambiar el tipo de socio. Los catálogos `BusinessPartnerGroups` e `ItemGroups` están registrados en el proxy.
3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`).
4. **Auditoría — en marcha** (`apps/api/src/audit/`, tabla `audit_log` en la misma SQLite): registra inicios de sesión (correctos, fallidos con su motivo —credenciales, usuario inactivo, demasiados intentos— y cierres), cada **creación y modificación** que pasa por el proxy `/api/sl` (recurso, clave, resultado `ok`/`error` con el mensaje de SAP, y **solo los nombres de los campos** y cuántas filas lleva cada colección: nunca valores ni contraseñas), las operaciones **denegadas** por permisos y los cambios de permisos de menú. Las lecturas y las acciones de solo consulta (series) no se auditan. `AuditService.record` nunca lanza (un fallo del registro no cambia la operación del usuario). Consulta solo para administradores: `GET /api/audit` (filtros usuario, acción, recurso, resultado, desde/hasta; 50 por página, máx. 200) y la pantalla `/admin/auditoria` (menú Administración, solo acceso total). Pendiente de afinar: retención (`purgeOlderThan` existe, falta programarlo y su variable de entorno), la IP detrás del proxy de desarrollo (todo llega como loopback; hace falta `xfwd` en el proxy y `trust proxy` solo cuando el backend no esté expuesto), exportar a CSV, y `nestjs-cls` para poner el usuario en cada línea del log.
5. Migrar reportes/PDF vía API Gateway (módulo y cola aparte; no registrar credenciales de los `tools/` del mini-erp, traen contraseñas en claro).
6. Revisar las vulnerabilidades de `npm audit` y definir una política para `U_WEB_PASS` (texto plano, claves cortas) antes de producción.
7. Docker/despliegue (HTTPS tras Fortinet; sesiones web en memoria ⇒ un solo proceso).
## Flujo de trabajo por cliente (branches)
1. `main` es la versión base/genérica, sin personalizaciones de cliente.
2. Cada cliente tiene su branch (`cliente/<nombre-cliente>`); ahí viven sus personalizaciones y fixes.
3. Los cambios de una branch de cliente no se mezclan con `main` ni con otro cliente, salvo backport genérico decidido explícitamente.
4. Antes de crear branches, mergear a `main`, o hacer merge/backport entre clientes, confirmar con el usuario. No hacer `git push` sin confirmación. Cada paso termina en un commit propio.
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. El plan de cuentas se abre como **árbol** (`chart-tree.page` → `chart-tree.panel`: se cargan todas las cuentas de a 200 y el árbol se arma en el navegador; pestañas verticales por grupo —las cuentas de primer nivel—, selector «Nivel», azul = título, verde = asociada) y al elegir una cuenta su formulario queda a la izquierda con el árbol a la derecha (`MasterLayout.chartTree`), como SAP. Campos del formulario según SAP (verificado con el usuario): «Cuenta asociada» = `LockManualTransaction`, «Cuenta monetaria» = `CashAccount`, «Bloquear contabilización manual» = `BlockManualPosting`, «Confidencial» = `Protected`; pestañas Propiedades, Contabilidad de costos e Info detallada (sin `DefaultVatGroup` ni `VATRegNum`). No se ofrece congelar/validar por fechas ni se puede cambiar `ActiveAccount` ni la cuenta padre. anualTransaction`, «Cuenta monetaria» = `CashAccount`, «Bloquear contabilización manual» = `BlockManualPosting`, «Confidencial» = `Protected`; pestañas Propiedades, Contabilidad de costos e Info detallada (sin `DefaultVatGroup` ni `VATRegNum`). No se ofrece congelar/validar por fechas ni se puede cambiar `ActiveAccount` ni la cuenta padre.
@@ -1,41 +0,0 @@
import { httpResource } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import type { SlListResponse } from '@basic-erp/sap-types';
import { API_URL } from '../../../core/api';
interface Group {
Code: string;
Name: string;
}
/**
* Las casillas de grupo del plan de cuentas (Activos, Pasivos, Patrimonio, Ingresos…) como las muestra SAP: son
* las cuentas de primer nivel y la cuenta pertenece a la que empieza con su mismo carácter (SAP numera los
* grupos así). Solo informan: el grupo lo fija el código de la cuenta (o el de su cuenta padre, si es nueva).
*/
@Component({
selector: 'sbo-account-groups',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="groups" role="group" aria-label="Grupo de la cuenta">
@for (g of groups(); track g.Code) {
<label class="grp"><input type="checkbox" disabled [checked]="g.Code.charAt(0) === first()" />{{ g.Name }}</label>
}
</div>
`,
styles: `
:host { display: block; margin: 6px 0 10px; }
.groups { display: flex; gap: 14px; flex-wrap: wrap; }
.grp { display: inline-flex; align-items: center; gap: 4px; }
`,
})
export class AccountGroups {
/** Código de la cuenta o, si todavía no lo tiene, el de su cuenta padre. */
readonly code = input('');
private readonly resource = httpResource<SlListResponse<Group>>(
() => `${API_URL}/sl/ChartOfAccounts?$select=Code,Name&$filter=${encodeURIComponent('AccountLevel eq 1')}&$orderby=Code`,
);
protected readonly groups = computed(() => (this.resource.hasValue() ? this.resource.value().value : []));
protected readonly first = computed(() => this.code().trim().charAt(0));
}
@@ -53,6 +53,11 @@ describe('árbol del plan de cuentas', () => {
expect(visibleRows([acc('9-9', 'Suelta', 2, 'no-existe')], open).map((r) => [r.account.Code, r.depth])).toEqual([['9-9', 0]]);
});
it('el nivel máximo oculta las cuentas más profundas', () => {
expect(visibleRows(accounts, { ...open, maxLevel: 2 }).map((r) => r.account.Code)).toEqual(['1', '1-1', '1-2', '2']);
expect(visibleRows(accounts, { ...open, maxLevel: 1 }).map((r) => r.account.Code)).toEqual(['1', '2']);
});
it('lista los códigos con hijas', () => {
expect(parentsOf(accounts).sort()).toEqual(['1', '1-1']);
});
@@ -1,5 +1,5 @@
/** Campos de cada cuenta que pide el árbol. */
export const TREE_FIELDS = 'Code,Name,AccountLevel,FatherAccountKey,ActiveAccount,AccountType,Balance';
export const TREE_FIELDS = 'Code,Name,AccountLevel,FatherAccountKey,ActiveAccount,AccountType,Balance,PrimaryAccount';
export interface TreeAccount {
Code: string;
@@ -9,6 +9,8 @@ export interface TreeAccount {
ActiveAccount: string;
AccountType: string;
Balance: number;
/** Cuenta asociada (SAP la pinta en verde). */
PrimaryAccount?: string;
}
export interface TreeRow {
@@ -26,6 +28,8 @@ export interface TreeOptions {
query: string;
/** Solo la rama de esta cuenta de primer nivel (Activos, Pasivos…); `null` = todas. */
root: string | null;
/** Solo hasta este nivel de cuenta (el selector «Nivel» de SAP); sin él, todos. */
maxLevel?: number;
}
/**
@@ -53,7 +57,8 @@ export function visibleRows(accounts: readonly TreeAccount[], opts: TreeOptions)
const out: TreeRow[] = [];
const walk = (a: TreeAccount, depth: number): void => {
if (shown && !shown.has(a.Code)) return;
const kids = (children.get(a.Code) ?? []).filter((k) => !shown || shown.has(k.Code));
const within = (k: TreeAccount): boolean => k.AccountLevel <= (opts.maxLevel ?? Infinity);
const kids = (children.get(a.Code) ?? []).filter((k) => within(k) && (!shown || shown.has(k.Code)));
const expanded = kids.length > 0 && (shown !== null || !opts.collapsed.has(a.Code));
out.push({ account: a, depth, hasChildren: kids.length > 0, expanded });
if (expanded) for (const k of kids) walk(k, depth + 1);
@@ -1,18 +0,0 @@
:host { display: block; }
.bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.sp { flex: 1; }
.search { width: 280px; height: 24px; padding: 1px 6px; background: var(--field); border: 1px solid var(--field-b); border-radius: var(--radius); }
.groups { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 8px; }
.grp { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; }
.wrap { max-height: calc(100vh - 290px); overflow: auto; border: 1px solid var(--line); }
table { width: 100%; border-collapse: collapse; }
th { position: sticky; top: 0; padding: 4px 8px; background: var(--grid-head); border-bottom: 1px solid var(--line); font-size: 12.5px; font-weight: 600; text-align: left; }
td { padding: 2px 8px; border-bottom: 1px solid var(--line-soft); white-space: nowrap; }
tbody tr { cursor: pointer; }
tbody tr:hover, tbody tr:focus-visible { background: var(--btn-h); outline: none; }
tr.title td:first-child { font-weight: 600; }
.w-type { width: 110px; }
.w-num, td.num { width: 150px; text-align: right; font-variant-numeric: tabular-nums; }
.code { font-family: var(--mono, monospace); color: var(--quiet); margin-right: 6px; }
.tog { display: inline-block; width: 18px; padding: 0; background: none; border: none; color: var(--quiet); cursor: pointer; text-align: center; }
tr.none td { padding: 14px; text-align: center; color: var(--quiet); cursor: default; }
@@ -1,45 +0,0 @@
<sbo-window [title]="title" [status]="status()">
<div class="bar">
<input class="search" type="search" placeholder="Buscar por código o nombre" aria-label="Buscar cuenta" [value]="query()" (input)="query.set($any($event.target).value)" />
<button type="button" class="btn" (click)="expandAll()">Expandir todo</button>
<button type="button" class="btn" (click)="collapseAll()">Contraer todo</button>
<span class="sp"></span>
<a class="btn main" routerLink="nuevo">Nueva cuenta</a>
</div>
<div class="groups" role="group" aria-label="Grupo de cuentas">
@for (g of groups(); track g.Code) {
<label class="grp"><input type="checkbox" [checked]="root() === g.Code" (change)="pickGroup(g.Code)" />{{ g.Name }}</label>
}
</div>
<div class="wrap">
<table>
<thead>
<tr>
<th>Código / Nombre</th>
<th class="w-type">Tipo</th>
<th class="w-num">Saldo</th>
</tr>
</thead>
<tbody>
@for (r of rows(); track r.account.Code) {
<tr [class.title]="r.hasChildren || r.account.ActiveAccount !== 'tYES'" (click)="open(r.account.Code)" (keydown.enter)="open(r.account.Code)" tabindex="0">
<td [style.padding-left.px]="8 + r.depth * 18">
@if (r.hasChildren) {
<button type="button" class="tog" [attr.aria-label]="(r.expanded ? 'Contraer ' : 'Expandir ') + r.account.Name" [attr.aria-expanded]="r.expanded" (click)="$event.stopPropagation(); toggle(r.account.Code)">{{ r.expanded ? '▾' : '▸' }}</button>
} @else {
<span class="tog"></span>
}
<span class="code">{{ r.account.Code }}</span> {{ r.account.Name }}
</td>
<td>{{ typeOf(r.account) }}</td>
<td class="num">{{ r.account.ActiveAccount === 'tYES' ? fmt(r.account.Balance) : '' }}</td>
</tr>
} @empty {
<tr class="none"><td colspan="3">{{ accounts.isLoading() ? 'Cargando…' : 'Sin cuentas.' }}</td></tr>
}
</tbody>
</table>
</div>
</sbo-window>
@@ -1,89 +1,19 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, inject, resource, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import type { SlListResponse } from '@basic-erp/sap-types';
import { firstValueFrom } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../../core/api';
import { formatNumber } from '../../../core/format';
import { SboWindow, type StatusMessage } from '../../../shared/window';
import { TREE_FIELDS, type TreeAccount, parentsOf, visibleRows } from './chart-tree.model';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { SboWindow } from '../../../shared/window';
import { ChartTreePanel } from './chart-tree.panel';
const PAGE = 200;
const TYPES: Readonly<Record<string, string>> = { at_Other: 'Otras', at_Revenues: 'Ingresos', at_Expenses: 'Gastos' };
/**
* Plan de cuentas como árbol (como en SAP): cuentas de título que se despliegan y, debajo, las de movimiento.
* Se cargan todas las cuentas una sola vez (de a 200) y el árbol se arma en el navegador; las ramas de primer
* nivel (Activos, Pasivos, Patrimonio…) se eligen como casillas. Una fila abre el formulario de la cuenta.
*/
/** Entrada al plan de cuentas: el árbol a pantalla completa; elegir una cuenta abre su formulario (con el árbol al lado). */
@Component({
selector: 'sbo-chart-tree-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, SboWindow],
templateUrl: './chart-tree.page.html',
styleUrl: './chart-tree.page.css',
imports: [ChartTreePanel, SboWindow],
template: `
<sbo-window [title]="title" [status]="{ kind: 'info', text: 'Elija una cuenta o cree una nueva.' }">
<sbo-chart-tree-panel />
</sbo-window>
`,
})
export class ChartTreePage {
private readonly http = inject(HttpClient);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
protected readonly title = this.route.snapshot.data['title'] as string;
protected readonly accounts = resource({
loader: async (): Promise<TreeAccount[]> => {
const all: TreeAccount[] = [];
for (let skip = 0; ; skip += PAGE) {
const params = new HttpParams().set('$select', TREE_FIELDS).set('$orderby', 'Code').set('$top', PAGE).set('$skip', skip);
const page = await firstValueFrom(this.http.get<SlListResponse<TreeAccount>>(`${API_URL}/sl/ChartOfAccounts`, { params }));
all.push(...page.value);
if (page.value.length < PAGE) return all;
}
},
});
protected readonly list = computed(() => (this.accounts.hasValue() ? this.accounts.value() : []));
protected readonly query = signal('');
protected readonly root = signal<string | null>(null);
/** Arranca con solo las cuentas de primer nivel a la vista: `null` hasta que el usuario despliega o contrae algo. */
private readonly toggled = signal<ReadonlySet<string> | null>(null);
protected readonly collapsed = computed(() => this.toggled() ?? new Set(parentsOf(this.list()).filter((c) => this.levelOf(c) >= 2)));
private levelOf(code: string): number {
return this.list().find((a) => a.Code === code)?.AccountLevel ?? 1;
}
protected readonly groups = computed(() => this.list().filter((a) => a.AccountLevel === 1 && !a.FatherAccountKey));
protected readonly rows = computed(() => visibleRows(this.list(), { collapsed: this.collapsed(), query: this.query(), root: this.root() }));
protected readonly status = computed<StatusMessage>(() => {
if (this.accounts.isLoading()) return { kind: 'info', text: 'Cargando el plan de cuentas…' };
if (this.accounts.error()) return { kind: 'err', text: apiErrorMessage(this.accounts.error()) };
return { kind: 'info', text: `${this.rows().length} de ${this.list().length} cuentas.` };
});
protected fmt(n: number): string {
return formatNumber(n, 2);
}
protected typeOf(a: TreeAccount): string {
return TYPES[a.AccountType] ?? a.AccountType;
}
protected toggle(code: string): void {
const next = new Set(this.collapsed());
if (!next.delete(code)) next.add(code);
this.toggled.set(next);
}
protected expandAll(): void {
this.toggled.set(new Set());
}
protected collapseAll(): void {
this.toggled.set(new Set(parentsOf(this.list())));
}
protected pickGroup(code: string): void {
this.root.update((r) => (r === code ? null : code));
}
protected open(code: string): void {
void this.router.navigate([code], { relativeTo: this.route });
}
protected readonly title = inject(ActivatedRoute).snapshot.data['title'] as string;
}
@@ -0,0 +1,23 @@
:host { display: block; min-width: 0; }
.panel { display: grid; grid-template-columns: minmax(0, 1fr) 104px; gap: 8px; }
.main { min-width: 0; }
.bar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
.sp { flex: 1; }
.search { width: 220px; height: 24px; padding: 1px 6px; background: var(--field); border: 1px solid var(--field-b); border-radius: var(--radius); }
.lvl { display: inline-flex; align-items: center; gap: 6px; }
.lvl select { height: 24px; background: var(--field); border: 1px solid var(--field-b); border-radius: var(--radius); }
.tree { height: calc(100vh - 300px); min-height: 280px; overflow: auto; background: var(--field); border: 1px solid var(--line); }
.head { position: sticky; top: 0; padding: 2px 8px; background: var(--focus); color: #fff; font-weight: 600; z-index: 1; }
.row { padding: 1px 6px; white-space: nowrap; cursor: pointer; }
.row:hover { background: var(--btn-h); }
.row:focus-visible { outline: 1px solid var(--focus); outline-offset: -1px; }
.row.sel { background: var(--gold); }
.row.title { color: var(--focus); }
.row.assoc { color: var(--ok); }
.tog { display: inline-block; width: 16px; padding: 0; background: none; border: none; color: var(--gold); cursor: pointer; text-align: center; }
.none { padding: 14px; text-align: center; color: var(--quiet); }
.count { margin-top: 4px; color: var(--quiet); font-size: 12px; }
.tabs { display: flex; flex-direction: column; gap: 4px; }
.gtab { min-height: 44px; padding: 4px 6px; background: var(--btn); border: 1px solid var(--btn-b); border-radius: var(--radius-lg); font-size: 12px; text-align: center; cursor: pointer; }
.gtab:hover { background: var(--btn-h); }
.gtab.on { background: var(--win); border-color: var(--focus); font-weight: 600; }
@@ -0,0 +1,52 @@
<div class="panel">
<div class="main">
<div class="bar">
<input class="search" type="search" placeholder="Buscar por código o nombre" aria-label="Buscar cuenta" [value]="query()" (input)="query.set($any($event.target).value)" />
<button type="button" class="btn" (click)="expandAll()">Expandir</button>
<button type="button" class="btn" (click)="collapseAll()">Contraer</button>
<button type="button" class="btn" (click)="create()">Nueva cuenta</button>
<span class="sp"></span>
<label class="lvl">Nivel
<select aria-label="Nivel" [value]="level()" (change)="setLevel($any($event.target).value)">
@for (n of levels; track n) {
<option [value]="n" [selected]="n === level()">{{ n }}</option>
}
</select>
</label>
</div>
<div class="tree" role="tree" [attr.aria-label]="groupName()">
<div class="head">{{ groupName() }}</div>
@for (r of rows(); track r.account.Code) {
<div
class="row"
role="treeitem"
tabindex="0"
[class.sel]="r.account.Code === selected()"
[class.title]="isTitle(r.account)"
[class.assoc]="r.account.PrimaryAccount === 'tYES'"
[attr.aria-expanded]="r.hasChildren ? r.expanded : null"
[style.padding-left.px]="6 + r.depth * 16"
(click)="open(r.account.Code)"
(keydown.enter)="open(r.account.Code)"
>
@if (r.hasChildren) {
<button type="button" class="tog" [attr.aria-label]="(r.expanded ? 'Contraer ' : 'Expandir ') + r.account.Name" (click)="$event.stopPropagation(); toggle(r.account.Code)">{{ r.expanded ? '▾' : '▸' }}</button>
} @else {
<span class="tog"></span>
}
{{ r.account.Code }} - {{ r.account.Name }}
</div>
} @empty {
<div class="none">{{ error() || (accounts.isLoading() ? 'Cargando el plan de cuentas…' : 'Sin cuentas.') }}</div>
}
</div>
<div class="count">{{ count() }}</div>
</div>
<div class="tabs" role="tablist" aria-label="Grupo de cuentas">
@for (g of groups(); track g.Code) {
<button type="button" role="tab" class="gtab" [class.on]="g.Code === group()" [attr.aria-selected]="g.Code === group()" (click)="pickGroup(g.Code)">{{ g.Name }}</button>
}
</div>
</div>
@@ -0,0 +1,95 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, inject, input, resource, signal } from '@angular/core';
import { Router } from '@angular/router';
import type { SlListResponse } from '@basic-erp/sap-types';
import { firstValueFrom } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../../core/api';
import { TREE_FIELDS, type TreeAccount, parentsOf, visibleRows } from './chart-tree.model';
const PAGE = 200;
const LEVELS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
/**
* El árbol del plan de cuentas como lo muestra SAP: a la derecha una pestaña por grupo (Activos, Pasivos,
* Patrimonio…, las cuentas de primer nivel), arriba el selector «Nivel» y, en el centro, las cuentas de título
* (azul), las asociadas (verde) y las de movimiento. Se cargan todas las cuentas una vez (de a 200) y el árbol
* se arma en el navegador. Elegir una cuenta abre su formulario.
*/
@Component({
selector: 'sbo-chart-tree-panel',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './chart-tree.panel.html',
styleUrl: './chart-tree.panel.css',
})
export class ChartTreePanel {
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
/** Código de la cuenta abierta en el formulario (se resalta y fija el grupo que se ve). */
readonly selected = input('');
/** Ruta de la pantalla; la cuenta elegida se abre en `<ruta>/<código>`. */
readonly basePath = input('/finanzas/plan-de-cuentas');
protected readonly levels = LEVELS;
protected readonly accounts = resource({
loader: async (): Promise<TreeAccount[]> => {
const all: TreeAccount[] = [];
for (let skip = 0; ; skip += PAGE) {
const params = new HttpParams().set('$select', TREE_FIELDS).set('$orderby', 'Code').set('$top', PAGE).set('$skip', skip);
const page = await firstValueFrom(this.http.get<SlListResponse<TreeAccount>>(`${API_URL}/sl/ChartOfAccounts`, { params }));
all.push(...page.value);
if (page.value.length < PAGE) return all;
}
},
});
protected readonly list = computed(() => (this.accounts.hasValue() ? this.accounts.value() : []));
protected readonly error = computed(() => (this.accounts.error() ? apiErrorMessage(this.accounts.error()) : ''));
protected readonly query = signal('');
protected readonly level = signal(10);
private readonly picked = signal<string | null>(null);
/** `null` hasta que el usuario despliega o contrae algo: arranca con las cuentas de título más profundas contraídas. */
private readonly toggled = signal<ReadonlySet<string> | null>(null);
protected readonly groups = computed(() => this.list().filter((a) => a.AccountLevel === 1 && !a.FatherAccountKey));
/** Grupo a la vista: el elegido, si no el de la cuenta abierta (mismo primer carácter del código) y si no el primero. */
protected readonly group = computed(() => {
const groups = this.groups();
const chosen = this.picked();
if (chosen && groups.some((g) => g.Code === chosen)) return chosen;
const first = this.selected().charAt(0);
return (first && groups.find((g) => g.Code.charAt(0) === first)?.Code) || groups[0]?.Code || null;
});
protected readonly collapsed = computed(() => this.toggled() ?? new Set<string>());
protected readonly rows = computed(() => visibleRows(this.list(), { collapsed: this.collapsed(), query: this.query(), root: this.group(), maxLevel: this.level() }));
protected readonly groupName = computed(() => this.groups().find((g) => g.Code === this.group())?.Name ?? '');
protected readonly count = computed(() => `${this.rows().length} de ${this.list().length} cuentas`);
protected isTitle(a: TreeAccount): boolean {
return a.ActiveAccount !== 'tYES';
}
protected toggle(code: string): void {
const next = new Set(this.collapsed());
if (!next.delete(code)) next.add(code);
this.toggled.set(next);
}
protected expandAll(): void {
this.toggled.set(new Set());
}
protected collapseAll(): void {
this.toggled.set(new Set(parentsOf(this.list())));
}
protected pickGroup(code: string): void {
this.picked.set(code);
}
protected setLevel(value: string): void {
this.level.set(Number(value) || 10);
}
protected open(code: string): void {
void this.router.navigate([this.basePath(), code]);
}
protected create(): void {
void this.router.navigate([this.basePath(), 'nuevo']);
}
}
@@ -1,6 +1,6 @@
import type { SelectFilter } from '../../../core/sl/list-filters';
import type { HeaderField } from '../../documents/document-layouts';
import { ACCOUNT_LOOKUP } from '../../documents/dimension-lookups';
import { ACCOUNT_LOOKUP, PROJECT_LOOKUP, dimensionLookup } from '../../documents/dimension-lookups';
import { MASTER_LOOKUPS } from '../master-lookups';
import type { MasterScreen } from '../master-types';
@@ -8,9 +8,9 @@ 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' },
{ value: 'at_Other', label: 'Otros' },
{ value: 'at_Revenues', label: 'Entrada' },
{ value: 'at_Expenses', label: 'Salida' },
];
/** Una cuenta de título agrupa a otras y no recibe asientos; la de movimiento (`ActiveAccount`) sí. */
@@ -60,29 +60,76 @@ export const FINANZAS_SCREENS: readonly MasterScreen[] = [
layout: {
header: {
left: [
field({ type: 'text', key: 'Code', label: 'Código', immutable: true, required: true, missing: 'el código' }),
field({ type: 'text', key: 'Code', label: 'Cuenta de mayor', immutable: true, required: true, missing: 'el código' }),
field({ type: 'text', key: 'Name', label: 'Nombre', required: true, missing: 'el nombre' }),
field({ type: 'text', key: 'ExternalCode', label: 'Código externo' }),
field({ type: 'lookup', key: 'AcctCurrency', label: 'Moneda', lookup: MASTER_LOOKUPS.currency }),
],
right: [
field({ type: 'checkbox', key: 'ActiveAccount', label: 'Cuenta activa', immutable: true }),
field({ type: 'checkbox', key: 'Protected', label: 'Confidencial' }),
field({ type: 'number', key: 'AccountLevel', label: 'Nivel', readonly: true }),
field({ type: 'number', key: 'Balance', label: 'Saldo', decimals: 2, readonly: true }),
],
right: [field({ type: 'checkbox', key: 'ActiveAccount', label: 'Cuenta de movimiento', immutable: true })],
},
tabs: [
{
id: 'general',
label: 'General',
id: 'propiedades',
label: 'Propiedades',
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 }),
field({ type: 'select', key: 'AccountType', label: 'Clase de cuenta', options: ACCOUNT_TYPES }),
field({ type: 'checkbox', key: 'LockManualTransaction', label: 'Cuenta asociada' }),
field({ type: 'checkbox', key: 'CashAccount', label: 'Cuenta monetaria' }),
field({ type: 'checkbox', key: 'RevaluationCoordinated', label: 'Reval. según índice' }),
],
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 }),
field({ type: 'checkbox', key: 'BlockManualPosting', label: 'Bloquear contabilización manual' }),
field({ type: 'checkbox', key: 'CashFlowRelevant', label: 'Relevante para flujo de caja' }),
],
},
{
id: 'costos',
label: 'Contabilidad de costos',
left: [
field({ type: 'checkbox', key: 'ProjectRelevant', label: 'Relevante para proyecto' }),
field({ type: 'lookup', key: 'ProjectCode', label: 'Proyecto', lookup: PROJECT_LOOKUP }),
field({ type: 'checkbox', key: 'CostElementRelevant', label: 'Relevante para elemento de costo' }),
field({ type: 'text', key: 'CostElementCode', label: 'Elemento de costo' }),
],
right: ([1, 2, 3, 4, 5] as const).flatMap((n) => [
field({ type: 'checkbox', key: n === 1 ? 'DistributionRuleRelevant' : `DistributionRule${n}Relevant`, label: `Norma de reparto: dimensión ${n}` }),
field({ type: 'lookup', key: n === 1 ? 'LoadingFactorCode' : `LoadingFactorCode${n}`, label: `Dimensión ${n}`, lookup: dimensionLookup(n) }),
]),
},
{
id: 'detalle',
label: 'Info detallada',
left: [
field({ type: 'text', key: 'ForeignName', label: 'Nombre extranjero' }),
field({ type: 'text', key: 'DataExportCode', label: 'Código para exportación' }),
field({ type: 'text', key: 'PlanningLevel', label: 'Nivel de planificación' }),
field({ type: 'textarea', key: 'Details', label: 'Comentarios' }),
],
right: [
field({ type: 'checkbox', key: 'CostAccountingOnly', label: 'Solo ajuste de contabilidad de costos' }),
field({ type: 'checkbox', key: 'AllowMultipleLinking', label: 'Permitir enlace múltiple a modelos financieros' }),
field({ type: 'checkbox', key: 'AllowChangeVatGroup', label: 'Permitir otro indicador de impuestos' }),
field({ type: 'checkbox', key: 'ExchangeRateDifference', label: 'Diferencias de tipo de cambio' }),
field({ type: 'checkbox', key: 'RateConversion', label: 'Conversión de tipo de cambio' }),
],
},
],
accountGroups: true,
newHeader: () => ({ ActiveAccount: 'tYES', AccountType: 'at_Other', CashAccount: 'tNO' }),
chartTree: true,
newHeader: () => ({
ActiveAccount: 'tYES',
AccountType: 'at_Other',
CashAccount: 'tNO',
Protected: 'tNO',
AllowChangeVatGroup: 'tYES',
ExchangeRateDifference: 'tYES',
RateConversion: 'tYES',
}),
},
},
];
@@ -9,6 +9,8 @@
</sbo-toolbar>
<sbo-window [title]="windowTitle()" [badge]="badge()" [status]="status()">
<div class="split" [class.two]="layout.chartTree">
<div class="left">
<div class="sbo-hdr">
<div class="sbo-col">
@for (f of layout.header.left; track $index) {
@@ -22,10 +24,6 @@
</div>
</div>
@if (layout.accountGroups) {
<sbo-account-groups [code]="groupCode()" />
}
<sbo-tabs ariaLabel="Datos del registro">
@for (tab of layout.tabs; track tab.id) {
<sbo-tab [label]="tab.label">
@@ -57,6 +55,11 @@
</sbo-tab>
}
</sbo-tabs>
</div>
@if (layout.chartTree) {
<sbo-chart-tree-panel [selected]="groupCode()" />
}
</div>
<button sboActions class="btn main" type="button" [disabled]="saving() || loading()" (click)="save()">
{{ isNew() ? 'Crear' : 'Actualizar' }}
@@ -9,7 +9,7 @@ import { RowsTable } from '../../shared/grid/rows-table';
import { Icon, SboWindow, Tab, Tabs, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
import { updatePayload, type DocRecord } from '../documents/document-model';
import type { HeaderField } from '../documents/document-layouts';
import { AccountGroups } from './finanzas/account-groups';
import { ChartTreePanel } from './finanzas/chart-tree.panel';
import { MasterField } from './master-field';
import {
type CollectionRow,
@@ -37,9 +37,14 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
@Component({
selector: 'sbo-master-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AccountGroups, Icon, MasterField, RouterLink, RowsTable, SboWindow, Tab, Tabs, Toolbar],
imports: [ChartTreePanel, Icon, MasterField, RouterLink, RowsTable, SboWindow, Tab, Tabs, Toolbar],
templateUrl: './master-form.page.html',
styles: '.addrow { margin: 6px 0 0; }',
styles: `
.addrow { margin: 6px 0 0; }
.split.two { display: grid; grid-template-columns: minmax(380px, 460px) minmax(0, 1fr); gap: 16px; align-items: start; }
.split.two .sbo-hdr { grid-template-columns: minmax(0, 1fr); gap: 4px; }
.left { min-width: 0; }
`,
})
export class MasterFormPage {
private readonly route = inject(ActivatedRoute);
@@ -166,9 +171,9 @@ export class MasterFormPage {
return !c.readonly && c.mode === 'replace' && c.growable !== false;
}
/** Código con el que se ve el grupo del plan de cuentas: el de la cuenta o, si es nueva, el de su cuenta padre. */
/** Cuenta que se resalta en el árbol del plan de cuentas: la abierta o, si es nueva, su cuenta padre (que fija el grupo). */
protected groupCode(): string {
return text(this.header()['FatherAccountKey']) || text(this.header()['Code']);
return this.key() ?? text(this.header()['FatherAccountKey']);
}
protected value(key: string): unknown {
@@ -50,8 +50,8 @@ export interface MasterLayout {
newHeader: () => DocRecord;
/** Valores que se envían siempre al crear (ej. `CardType: 'cCustomer'`). */
fixed?: DocRecord;
/** Muestra las casillas de grupo del plan de cuentas (Activos, Pasivos…) bajo la cabecera. */
accountGroups?: boolean;
/** Muestra el árbol del plan de cuentas al lado del formulario (como SAP); elegir una cuenta abre su formulario. */
chartTree?: boolean;
/** Campos que se ven pero no se editan (una extensión de cliente los dejó de solo lectura). */
lockedKeys?: readonly string[];
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

+16
View File
@@ -0,0 +1,16 @@
@url=https://SAP10:50000/b1s/v1
###
# @noRejectUnauthorized
POST {{url}}/Login
Content-Type: application/json
{
"CompanyDB": "SBODemoCL",
"UserName": "manager",
"Password": "mngr"
}
###
# @noRejectUnauthorized
GET {{url}}/ChartOfAccounts('1-1-040-10-000')