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:
1 parent
89e25ad8e6
commit
bb19e499fe
18 files changed
+292
-280
No files matched your search
@@ -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[];
|
||||
}
|
||||
|
||||
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 |
@@ -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')
|
||||
Reference in new issue
Block a user