Compare commits
62
Commits
81f62430b3
...
v1.0.2
@@ -0,0 +1,16 @@
|
|||||||
|
# Keep the build context small + deterministic. node_modules, build output, and
|
||||||
|
# the migration venv are all recreated inside the image, never copied from host.
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.next
|
||||||
|
**/.turbo
|
||||||
|
apps/web/.next
|
||||||
|
packages/database/generated
|
||||||
|
migration/.venv
|
||||||
|
migration/**/__pycache__
|
||||||
|
**/*.log
|
||||||
|
.git
|
||||||
|
.idea
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -3,3 +3,33 @@ DATABASE_URL=mysql://jorgecuadros:jorgecuadros@localhost:3306/jorgecuadros
|
|||||||
SESSION_SECRET=change-me-to-a-random-string
|
SESSION_SECRET=change-me-to-a-random-string
|
||||||
WEB_ORIGIN=http://localhost:3000
|
WEB_ORIGIN=http://localhost:3000
|
||||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:3001
|
NEXT_PUBLIC_API_ORIGIN=http://localhost:3001
|
||||||
|
|
||||||
|
# Object storage (MinIO / S3) for document blobs and scanned receipt pages.
|
||||||
|
# Without S3_ENDPOINT + credentials the API still boots, but every document
|
||||||
|
# upload/download and the whole recibo OCR intake are disabled. Credentials fall
|
||||||
|
# back to MINIO_ROOT_USER / MINIO_ROOT_PASSWORD when the S3_* pair is unset.
|
||||||
|
S3_ENDPOINT=http://localhost:9000
|
||||||
|
S3_BUCKET=jorgecuadros-documents
|
||||||
|
S3_ACCESS_KEY=
|
||||||
|
S3_SECRET_KEY=
|
||||||
|
|
||||||
|
# Login the "Operaciones" screen runs mysqldump/mysql as. Optional locally: when
|
||||||
|
# unset it falls back to the DATABASE_URL credentials, which a dev MySQL usually
|
||||||
|
# grants enough for. Required in any deployment, where the application user has
|
||||||
|
# only ALL ON jorgecuadros.* and mysqldump --single-transaction needs the global
|
||||||
|
# RELOAD privilege. Host/port/database always come from DATABASE_URL.
|
||||||
|
OPS_DB_ADMIN_USER=
|
||||||
|
OPS_DB_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# Company info — printed in the header of every report (PDF + browser
|
||||||
|
# print). Leave blank to use the placeholders. COMPANY_LOGO_PATH is
|
||||||
|
# optional; when unset the API falls back to apps/api/assets/company_logo.png.
|
||||||
|
COMPANY_NAME=Jorge Cuadros & Asociados
|
||||||
|
COMPANY_ADDRESS_LINE1=
|
||||||
|
COMPANY_ADDRESS_LINE2=
|
||||||
|
COMPANY_CITY_STATE=
|
||||||
|
COMPANY_PHONE=
|
||||||
|
COMPANY_EMAIL=
|
||||||
|
COMPANY_TAX_ID=
|
||||||
|
COMPANY_WEBSITE=
|
||||||
|
COMPANY_LOGO_PATH=
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Build + push the API and web container images to the git.mancinas.io registry.
|
||||||
|
#
|
||||||
|
# Two images from this one repo:
|
||||||
|
# git.mancinas.io/rmancinas/jorgecuadros-api
|
||||||
|
# git.mancinas.io/rmancinas/jorgecuadros-web
|
||||||
|
#
|
||||||
|
# Comprehensive versioning (docker/metadata-action). Every build pushes a set
|
||||||
|
# of tags so an image is addressable at several granularities:
|
||||||
|
# - vX.Y.Z / vX.Y when the trigger is a git tag vX.Y.Z (releases)
|
||||||
|
# - <branch> the branch that was pushed (e.g. master, feat-foo)
|
||||||
|
# - sha-<short> immutable per-commit id, always present
|
||||||
|
# - latest only on the default branch (master)
|
||||||
|
# The same version string + commit + build date are baked into the image as
|
||||||
|
# ARG/ENV (APP_VERSION / GIT_SHA / BUILD_DATE) and as OCI labels, so a running
|
||||||
|
# container can report exactly what is deployed.
|
||||||
|
#
|
||||||
|
# Release flow: git tag v1.2.0 && git push origin v1.2.0 -> versioned images.
|
||||||
|
|
||||||
|
name: Build and Push Images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
tags: ["v*"]
|
||||||
|
paths:
|
||||||
|
- "apps/**"
|
||||||
|
- "packages/**"
|
||||||
|
- "docker/**"
|
||||||
|
- "package.json"
|
||||||
|
- "pnpm-lock.yaml"
|
||||||
|
- ".gitea/workflows/build.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.mancinas.io
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build ${{ matrix.image }}
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: docker:27-dind
|
||||||
|
options: --privileged
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- image: jorgecuadros-api
|
||||||
|
dockerfile: docker/api.Dockerfile
|
||||||
|
- image: jorgecuadros-web
|
||||||
|
dockerfile: docker/web.Dockerfile
|
||||||
|
steps:
|
||||||
|
- name: Install Node.js for actions
|
||||||
|
run: apk add --no-cache nodejs npm
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=ref,event=branch
|
||||||
|
type=sha,format=short,prefix=sha-
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ${{ matrix.dockerfile }}
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
platforms: linux/amd64
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ steps.meta.outputs.version }}
|
||||||
|
GIT_SHA=${{ github.sha }}
|
||||||
|
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
# Manual PROD deploy to galactus — the office server, Portainer endpoint 3.
|
||||||
|
#
|
||||||
|
# galactus is STANDALONE Docker (`swarm: inactive`), so this workflow applies
|
||||||
|
# the compose files under deploy/galactus/, NOT the Swarm files in deploy/.
|
||||||
|
# .gitea/workflows/deploy.yml is the cubex/Swarm equivalent; the two are kept
|
||||||
|
# separate on purpose because plain compose silently ignores Swarm's `deploy:`
|
||||||
|
# keys rather than failing on them.
|
||||||
|
#
|
||||||
|
# This does NOT build. build.yml already built + pushed both images from one
|
||||||
|
# matrix run, so api and web at the same tag are always in step.
|
||||||
|
#
|
||||||
|
# Order of operations, and why:
|
||||||
|
# 1. db + minio (scope=full only) — the API depends on both.
|
||||||
|
# 2. pre-migrate backup dumped INSIDE the still-running OLD api container,
|
||||||
|
# so the file lands in the volume the Operaciones
|
||||||
|
# restore screen reads. Must precede the migration.
|
||||||
|
# 3. prisma migrate deploy forward-only. Prisma has no down-migrations; see
|
||||||
|
# docs/DEPLOY_AND_MIGRATIONS.md — expand/contract is
|
||||||
|
# the rule, the backup is the emergency lever.
|
||||||
|
# 4. app (api + web) the new images.
|
||||||
|
# 5. verify ask the running API what it actually is.
|
||||||
|
#
|
||||||
|
# Rollback = re-dispatch with an older `tag`. That rolls back CODE only; the
|
||||||
|
# schema stays forward. This is exactly why every schema change must be
|
||||||
|
# backward-compatible with the previous release.
|
||||||
|
#
|
||||||
|
# Prereqs (once):
|
||||||
|
# - Gitea repo secrets, galactus-specific (suffix _GALACTUS so the cubex
|
||||||
|
# secrets keep working side by side):
|
||||||
|
# PORTAINER_URL_GALACTUS https://100.103.77.46:9443
|
||||||
|
# PORTAINER_API_KEY_GALACTUS Portainer access token for galactus
|
||||||
|
# PORTAINER_ENDPOINT_ID_GALACTUS 3
|
||||||
|
# PORTAINER_APP_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-app
|
||||||
|
# PORTAINER_DB_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-db
|
||||||
|
# PORTAINER_MINIO_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-minio
|
||||||
|
# DATABASE_URL_GALACTUS mysql://jorgecuadros:<pass>@<galactus>:3306/jorgecuadros
|
||||||
|
# APP_API_ORIGIN_GALACTUS browser-facing API URL
|
||||||
|
# APP_WEB_ORIGIN_GALACTUS web public origin (API CORS)
|
||||||
|
# APP_S3_ENDPOINT_GALACTUS server-side minio URL
|
||||||
|
# SESSION_SECRET_GALACTUS 64-hex (openssl rand -hex 32)
|
||||||
|
# MINIO_ROOT_USER / MINIO_ROOT_PASSWORD
|
||||||
|
# MYSQL_PASSWORD / MYSQL_ROOT_PASSWORD
|
||||||
|
# - The runner (which lives on cubex) must be able to reach BOTH
|
||||||
|
# galactus:9443 (Portainer) and galactus:3306 (MySQL, for migrate deploy).
|
||||||
|
# If it cannot reach 3306, run the migration by hand from a host that can
|
||||||
|
# and dispatch with skip_migrate=true.
|
||||||
|
# - ONE-TIME, on a database that predates migration history (i.e. one built
|
||||||
|
# with `prisma db push`): baseline it before the first run, or step 3 fails
|
||||||
|
# with P3005 "database schema is not empty":
|
||||||
|
# npx prisma@5 migrate resolve --applied 0000_init \
|
||||||
|
# --schema packages/database/prisma/schema.prisma
|
||||||
|
|
||||||
|
name: Deploy to galactus
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Image tag to deploy (1.2.3 — no leading v — or sha-<short>, or latest)"
|
||||||
|
required: true
|
||||||
|
default: "latest"
|
||||||
|
scope:
|
||||||
|
description: "What to deploy"
|
||||||
|
type: choice
|
||||||
|
required: true
|
||||||
|
default: "app"
|
||||||
|
options:
|
||||||
|
- app
|
||||||
|
- full
|
||||||
|
bootstrap:
|
||||||
|
description: "First-ever deploy: allow the pre-migrate backup to be skipped when no API container exists yet"
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
skip_migrate:
|
||||||
|
description: "Skip prisma migrate deploy (use when the runner cannot reach MySQL and you migrated by hand)"
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.mancinas.io
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy ${{ github.event.inputs.tag }} (${{ github.event.inputs.scope }})
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: node:20-alpine
|
||||||
|
steps:
|
||||||
|
- name: Install tools
|
||||||
|
# openssl: prisma's migration engine picks its musl/openssl build at
|
||||||
|
# runtime and cannot resolve one without it.
|
||||||
|
run: apk add --no-cache openssl ca-certificates git
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# An unset secret arrives as an empty string, and the deploy action then
|
||||||
|
# fails with "Input required and not supplied: token" — which names the
|
||||||
|
# action's input, not the secret you forgot. Check them up front and say
|
||||||
|
# exactly which ones are missing.
|
||||||
|
- name: Preflight — required secrets
|
||||||
|
env:
|
||||||
|
PORTAINER_URL_GALACTUS: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
||||||
|
PORTAINER_API_KEY_GALACTUS: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
||||||
|
PORTAINER_ENDPOINT_ID_GALACTUS: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||||
|
PORTAINER_APP_STACK_NAME_GALACTUS: ${{ secrets.PORTAINER_APP_STACK_NAME_GALACTUS }}
|
||||||
|
PORTAINER_DB_STACK_NAME_GALACTUS: ${{ secrets.PORTAINER_DB_STACK_NAME_GALACTUS }}
|
||||||
|
PORTAINER_MINIO_STACK_NAME_GALACTUS: ${{ secrets.PORTAINER_MINIO_STACK_NAME_GALACTUS }}
|
||||||
|
DATABASE_URL_GALACTUS: ${{ secrets.DATABASE_URL_GALACTUS }}
|
||||||
|
SESSION_SECRET_GALACTUS: ${{ secrets.SESSION_SECRET_GALACTUS }}
|
||||||
|
APP_API_ORIGIN_GALACTUS: ${{ secrets.APP_API_ORIGIN_GALACTUS }}
|
||||||
|
APP_WEB_ORIGIN_GALACTUS: ${{ secrets.APP_WEB_ORIGIN_GALACTUS }}
|
||||||
|
APP_S3_ENDPOINT_GALACTUS: ${{ secrets.APP_S3_ENDPOINT_GALACTUS }}
|
||||||
|
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
|
||||||
|
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
||||||
|
MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
|
||||||
|
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
|
||||||
|
SCOPE: ${{ github.event.inputs.scope }}
|
||||||
|
run: |
|
||||||
|
REQUIRED="PORTAINER_URL_GALACTUS PORTAINER_API_KEY_GALACTUS
|
||||||
|
PORTAINER_ENDPOINT_ID_GALACTUS PORTAINER_APP_STACK_NAME_GALACTUS
|
||||||
|
DATABASE_URL_GALACTUS SESSION_SECRET_GALACTUS
|
||||||
|
APP_API_ORIGIN_GALACTUS APP_WEB_ORIGIN_GALACTUS
|
||||||
|
APP_S3_ENDPOINT_GALACTUS MINIO_ROOT_USER MINIO_ROOT_PASSWORD
|
||||||
|
MYSQL_ROOT_PASSWORD"
|
||||||
|
if [ "$SCOPE" = "full" ]; then
|
||||||
|
REQUIRED="$REQUIRED PORTAINER_DB_STACK_NAME_GALACTUS
|
||||||
|
PORTAINER_MINIO_STACK_NAME_GALACTUS MYSQL_PASSWORD"
|
||||||
|
fi
|
||||||
|
missing=""
|
||||||
|
for name in $REQUIRED; do
|
||||||
|
eval "value=\${$name}"
|
||||||
|
[ -z "$value" ] && missing="$missing $name"
|
||||||
|
done
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
|
echo "::error::missing repo secrets:$missing"
|
||||||
|
echo "::error::set them under Settings > Actions > Secrets"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "all required secrets present for scope=$SCOPE"
|
||||||
|
|
||||||
|
# --- full only: database ---------------------------------------------
|
||||||
|
- name: Deploy database stack
|
||||||
|
if: ${{ github.event.inputs.scope == 'full' }}
|
||||||
|
uses: cssnr/portainer-stack-deploy-action@v1
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
||||||
|
token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
||||||
|
name: ${{ secrets.PORTAINER_DB_STACK_NAME_GALACTUS }}
|
||||||
|
file: deploy/galactus/jorgecuadros-db.compose.yml
|
||||||
|
type: file
|
||||||
|
standalone: true
|
||||||
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||||
|
env_data: |
|
||||||
|
{
|
||||||
|
"MYSQL_SERVER_ID": "1",
|
||||||
|
"MYSQL_PORT": "3306",
|
||||||
|
"MYSQL_DATABASE": "jorgecuadros",
|
||||||
|
"MYSQL_USER": "jorgecuadros",
|
||||||
|
"MYSQL_PASSWORD": "${{ secrets.MYSQL_PASSWORD }}",
|
||||||
|
"MYSQL_ROOT_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- full only: object storage ---------------------------------------
|
||||||
|
- name: Deploy minio stack
|
||||||
|
if: ${{ github.event.inputs.scope == 'full' }}
|
||||||
|
uses: cssnr/portainer-stack-deploy-action@v1
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
||||||
|
token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
||||||
|
name: ${{ secrets.PORTAINER_MINIO_STACK_NAME_GALACTUS }}
|
||||||
|
file: deploy/galactus/jorgecuadros-minio.compose.yml
|
||||||
|
type: file
|
||||||
|
standalone: true
|
||||||
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||||
|
env_data: |
|
||||||
|
{
|
||||||
|
"MINIO_API_PORT": "9000",
|
||||||
|
"MINIO_CONSOLE_PORT": "9001",
|
||||||
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
||||||
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- restore point, taken while the OLD api container is still up ------
|
||||||
|
- name: Pre-migrate backup
|
||||||
|
env:
|
||||||
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
||||||
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
||||||
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL_GALACTUS }}
|
||||||
|
# The dump runs as root: --single-transaction issues FLUSH TABLES,
|
||||||
|
# which needs the global RELOAD privilege the application user
|
||||||
|
# deliberately does not have.
|
||||||
|
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
|
||||||
|
BACKUP_TAG: ${{ github.event.inputs.tag }}
|
||||||
|
ALLOW_MISSING_CONTAINER: ${{ github.event.inputs.bootstrap }}
|
||||||
|
# Portainer serves a self-signed certificate. Scoped to this step
|
||||||
|
# only, which does nothing but talk to Portainer.
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
||||||
|
run: node deploy/scripts/pre-migrate-backup.mjs
|
||||||
|
|
||||||
|
# --- schema, forward-only ---------------------------------------------
|
||||||
|
- name: Apply database migrations
|
||||||
|
if: ${{ github.event.inputs.skip_migrate != 'true' }}
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL_GALACTUS }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
SCHEMA=packages/database/prisma/schema.prisma
|
||||||
|
npx --yes prisma@5 migrate status --schema "$SCHEMA" || true
|
||||||
|
if ! npx --yes prisma@5 migrate deploy --schema "$SCHEMA"; then
|
||||||
|
echo "::error::migrate deploy failed. If this is P3005 (schema not empty),"
|
||||||
|
echo "::error::the database predates migration history — baseline it once with:"
|
||||||
|
echo "::error:: npx prisma@5 migrate resolve --applied 0000_init --schema $SCHEMA"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- make sure the host actually has the images ------------------------
|
||||||
|
# The deploy action's `pull: true` does not reliably refresh an already
|
||||||
|
# cached moving tag. Pull explicitly, or a "successful" deploy can leave
|
||||||
|
# the host serving an older build of the same tag.
|
||||||
|
- name: Pull images
|
||||||
|
env:
|
||||||
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
||||||
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
||||||
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||||
|
REGISTRY: ${{ env.REGISTRY }}
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
IMAGES: ${{ github.repository_owner }}/jorgecuadros-api,${{ github.repository_owner }}/jorgecuadros-web
|
||||||
|
TAG: ${{ github.event.inputs.tag }}
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
||||||
|
run: node deploy/scripts/pull-images.mjs
|
||||||
|
|
||||||
|
# --- always: the app (web + api) -------------------------------------
|
||||||
|
- name: Deploy app stack
|
||||||
|
uses: cssnr/portainer-stack-deploy-action@v1
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
||||||
|
token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
||||||
|
name: ${{ secrets.PORTAINER_APP_STACK_NAME_GALACTUS }}
|
||||||
|
file: deploy/galactus/jorgecuadros-app.compose.yml
|
||||||
|
type: file
|
||||||
|
standalone: true
|
||||||
|
pull: true
|
||||||
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||||
|
env_data: |
|
||||||
|
{
|
||||||
|
"APP_TAG": "${{ github.event.inputs.tag }}",
|
||||||
|
"API_PORT": "3001",
|
||||||
|
"WEB_PORT": "3000",
|
||||||
|
"S3_BUCKET": "jorgecuadros-documents",
|
||||||
|
"API_ORIGIN": "${{ secrets.APP_API_ORIGIN_GALACTUS }}",
|
||||||
|
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN_GALACTUS }}",
|
||||||
|
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT_GALACTUS }}",
|
||||||
|
"DATABASE_URL": "${{ secrets.DATABASE_URL_GALACTUS }}",
|
||||||
|
"SESSION_SECRET": "${{ secrets.SESSION_SECRET_GALACTUS }}",
|
||||||
|
"SESSION_COOKIE_SECURE": "false",
|
||||||
|
"OPS_DB_ADMIN_USER": "root",
|
||||||
|
"OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}",
|
||||||
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
||||||
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- prove it ----------------------------------------------------------
|
||||||
|
- name: Verify running version
|
||||||
|
env:
|
||||||
|
API_ORIGIN: ${{ secrets.APP_API_ORIGIN_GALACTUS }}
|
||||||
|
WEB_ORIGIN: ${{ secrets.APP_WEB_ORIGIN_GALACTUS }}
|
||||||
|
WANT: ${{ github.event.inputs.tag }}
|
||||||
|
# A stack naming a tag is not proof the containers run it. Ask BOTH
|
||||||
|
# tiers what they are, and require them to be the same commit: api and
|
||||||
|
# web are built from one matrix run, so a difference can only mean one
|
||||||
|
# of them did not actually get replaced.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
apk add --no-cache curl >/dev/null
|
||||||
|
fetch_version() {
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -fsS "$1/version" > "$2"; then return 0; fi
|
||||||
|
echo "waiting for $1 ($i/30)..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo "::error::$1/version never answered"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fetch_version "$API_ORIGIN" /tmp/api.json
|
||||||
|
fetch_version "$WEB_ORIGIN" /tmp/web.json
|
||||||
|
cat /tmp/api.json; echo; cat /tmp/web.json; echo
|
||||||
|
|
||||||
|
API_SHA=$(node -e 'console.log(require("/tmp/api.json").gitSha)')
|
||||||
|
WEB_SHA=$(node -e 'console.log(require("/tmp/web.json").gitSha)')
|
||||||
|
API_VER=$(node -e 'console.log(require("/tmp/api.json").version)')
|
||||||
|
|
||||||
|
# Compare the COMMIT, not the version string: on a branch build both
|
||||||
|
# tiers report "master", so version equality proves nothing.
|
||||||
|
if [ "$API_SHA" != "$WEB_SHA" ]; then
|
||||||
|
echo "::error::api and web are different builds — api $API_SHA, web $WEB_SHA"
|
||||||
|
echo "::error::one of the images was not replaced; check the Pull images step"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "api and web agree: $API_SHA"
|
||||||
|
|
||||||
|
# A semver dispatch is additionally comparable to the tag itself:
|
||||||
|
# metadata-action's {{version}} turns tag v1.2.3 into image 1.2.3,
|
||||||
|
# while `latest` and `sha-*` report the branch or short sha instead.
|
||||||
|
case "$WANT" in
|
||||||
|
[0-9]*.[0-9]*.[0-9]*)
|
||||||
|
if [ "$API_VER" != "$WANT" ]; then
|
||||||
|
echo "::error::deployed $WANT but the API reports $API_VER"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "verified: running $API_VER"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "dispatched '$WANT'; tiers report '$API_VER' (not directly comparable)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
# Manual PROD deploy to Portainer.
|
||||||
|
#
|
||||||
|
# This does NOT build — build.yml already builds + pushes the api/web images.
|
||||||
|
# This workflow (re)applies the deploy/*.stack.yml files to the Portainer Swarm.
|
||||||
|
# Trigger it by hand from the Actions tab ("Run workflow") and choose:
|
||||||
|
# - tag: which already-published image tag to ship (default: latest)
|
||||||
|
# - scope: how much to deploy
|
||||||
|
# app = web + api only (the usual app release) [default]
|
||||||
|
# full = db + minio + web + api (bring up / update the whole platform)
|
||||||
|
#
|
||||||
|
# The `tag` input carries NO leading `v`: metadata-action's {{version}} turns
|
||||||
|
# git tag v1.2.3 into image tag 1.2.3. Tag v1.2.3, dispatch 1.2.3.
|
||||||
|
#
|
||||||
|
# Order: db+minio (full only) -> pre-migrate backup -> prisma migrate deploy ->
|
||||||
|
# app -> verify the API reports the version you asked for. Rollback = dispatch
|
||||||
|
# an older tag; that rolls back CODE only, never the schema, which is why every
|
||||||
|
# schema change must be expand/contract. See docs/DEPLOY_AND_MIGRATIONS.md.
|
||||||
|
#
|
||||||
|
# galactus (the office server) is standalone Docker, not this Swarm — it has its
|
||||||
|
# own workflow, .gitea/workflows/deploy-galactus.yml.
|
||||||
|
#
|
||||||
|
# cssnr/portainer-stack-deploy-action creates each stack on first run and updates
|
||||||
|
# it on every run, so no manual stack pre-creation in the Portainer UI. On a
|
||||||
|
# `full` deploy the db + minio stacks are applied BEFORE the app (the API depends
|
||||||
|
# on them). db + minio are stateful + pinned to node label jorgecuadros_db=true
|
||||||
|
# (see their stack files) — re-applying them is idempotent and keeps their data.
|
||||||
|
#
|
||||||
|
# Prereqs (once):
|
||||||
|
# - one swarm node labelled jorgecuadros_db=true (db + minio + api pin there).
|
||||||
|
# - Gitea repo secrets set (Settings > Actions > Secrets):
|
||||||
|
# # Portainer
|
||||||
|
# PORTAINER_URL https://192.168.4.212:9443
|
||||||
|
# PORTAINER_API_KEY Portainer access token
|
||||||
|
# PORTAINER_ENDPOINT_ID 2 (the local Swarm endpoint)
|
||||||
|
# PORTAINER_APP_STACK_NAME e.g. jorgecuadros-prod-app
|
||||||
|
# PORTAINER_DB_STACK_NAME e.g. jorgecuadros-prod-db (full only)
|
||||||
|
# PORTAINER_MINIO_STACK_NAME e.g. jorgecuadros-prod-minio (full only)
|
||||||
|
# # App runtime
|
||||||
|
# DATABASE_URL mysql://jorgecuadros:<pass>@192.168.4.212:3306/jorgecuadros
|
||||||
|
# SESSION_SECRET 64-hex (openssl rand -hex 32)
|
||||||
|
# APP_API_ORIGIN http://192.168.4.212:3001 (browser-facing API URL)
|
||||||
|
# APP_WEB_ORIGIN http://192.168.4.212:3000 (web public origin, API CORS)
|
||||||
|
# APP_S3_ENDPOINT http://192.168.4.212:9000 (server-side minio URL)
|
||||||
|
# # Object storage (app + minio stack)
|
||||||
|
# MINIO_ROOT_USER minio access key
|
||||||
|
# MINIO_ROOT_PASSWORD minio secret key
|
||||||
|
# # Database stack (full only)
|
||||||
|
# MYSQL_PASSWORD app-user password (matches DATABASE_URL)
|
||||||
|
# MYSQL_ROOT_PASSWORD mysql root password
|
||||||
|
# - the runner must reach BOTH Portainer (9443) and MySQL (3306) — the
|
||||||
|
# migration step connects to the database directly. If it cannot reach 3306,
|
||||||
|
# migrate by hand and dispatch with skip_migrate=true.
|
||||||
|
# - ONE-TIME on a database built with `prisma db push` (i.e. every database
|
||||||
|
# that exists today): baseline it before the first run, or the migrate step
|
||||||
|
# fails with P3005 "database schema is not empty":
|
||||||
|
# npx prisma@5 migrate resolve --applied 0000_init \
|
||||||
|
# --schema packages/database/prisma/schema.prisma
|
||||||
|
|
||||||
|
name: Deploy to Portainer
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Image tag to deploy (latest, sha-<short>, or vX.Y.Z)"
|
||||||
|
required: true
|
||||||
|
default: "latest"
|
||||||
|
scope:
|
||||||
|
description: "What to deploy"
|
||||||
|
type: choice
|
||||||
|
required: true
|
||||||
|
default: "app"
|
||||||
|
options:
|
||||||
|
- app
|
||||||
|
- full
|
||||||
|
bootstrap:
|
||||||
|
description: "First-ever deploy: allow the pre-migrate backup to be skipped when no API container exists yet"
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
skip_migrate:
|
||||||
|
description: "Skip prisma migrate deploy (use when the runner cannot reach MySQL and you migrated by hand)"
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.mancinas.io
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy (${{ github.event.inputs.scope }})
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: node:20-alpine
|
||||||
|
steps:
|
||||||
|
- name: Install tools
|
||||||
|
# openssl: prisma's migration engine picks its musl/openssl build at
|
||||||
|
# runtime and cannot resolve one without it.
|
||||||
|
run: apk add --no-cache openssl ca-certificates git
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# An unset secret arrives as an empty string, and the deploy action then
|
||||||
|
# fails with "Input required and not supplied: token" — which names the
|
||||||
|
# action's input, not the secret you forgot.
|
||||||
|
- name: Preflight — required secrets
|
||||||
|
env:
|
||||||
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
|
||||||
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
|
||||||
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
|
PORTAINER_APP_STACK_NAME: ${{ secrets.PORTAINER_APP_STACK_NAME }}
|
||||||
|
PORTAINER_DB_STACK_NAME: ${{ secrets.PORTAINER_DB_STACK_NAME }}
|
||||||
|
PORTAINER_MINIO_STACK_NAME: ${{ secrets.PORTAINER_MINIO_STACK_NAME }}
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
|
||||||
|
APP_API_ORIGIN: ${{ secrets.APP_API_ORIGIN }}
|
||||||
|
APP_WEB_ORIGIN: ${{ secrets.APP_WEB_ORIGIN }}
|
||||||
|
APP_S3_ENDPOINT: ${{ secrets.APP_S3_ENDPOINT }}
|
||||||
|
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
|
||||||
|
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
||||||
|
MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
|
||||||
|
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
|
||||||
|
SCOPE: ${{ github.event.inputs.scope }}
|
||||||
|
run: |
|
||||||
|
REQUIRED="PORTAINER_URL PORTAINER_API_KEY PORTAINER_ENDPOINT_ID
|
||||||
|
PORTAINER_APP_STACK_NAME DATABASE_URL SESSION_SECRET
|
||||||
|
APP_API_ORIGIN APP_WEB_ORIGIN APP_S3_ENDPOINT
|
||||||
|
MINIO_ROOT_USER MINIO_ROOT_PASSWORD MYSQL_ROOT_PASSWORD"
|
||||||
|
if [ "$SCOPE" = "full" ]; then
|
||||||
|
REQUIRED="$REQUIRED PORTAINER_DB_STACK_NAME PORTAINER_MINIO_STACK_NAME
|
||||||
|
MYSQL_PASSWORD"
|
||||||
|
fi
|
||||||
|
missing=""
|
||||||
|
for name in $REQUIRED; do
|
||||||
|
eval "value=\${$name}"
|
||||||
|
[ -z "$value" ] && missing="$missing $name"
|
||||||
|
done
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
|
echo "::error::missing repo secrets:$missing"
|
||||||
|
echo "::error::set them under Settings > Actions > Secrets"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "all required secrets present for scope=$SCOPE"
|
||||||
|
|
||||||
|
# --- full only: database ---------------------------------------------
|
||||||
|
- name: Deploy database stack
|
||||||
|
if: ${{ github.event.inputs.scope == 'full' }}
|
||||||
|
uses: cssnr/portainer-stack-deploy-action@v1
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.PORTAINER_URL }}
|
||||||
|
token: ${{ secrets.PORTAINER_API_KEY }}
|
||||||
|
name: ${{ secrets.PORTAINER_DB_STACK_NAME }}
|
||||||
|
file: deploy/jorgecuadros-db.stack.yml
|
||||||
|
type: file
|
||||||
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
|
env_data: |
|
||||||
|
{
|
||||||
|
"MYSQL_SERVER_ID": "1",
|
||||||
|
"MYSQL_PORT": "3306",
|
||||||
|
"MYSQL_DATABASE": "jorgecuadros",
|
||||||
|
"MYSQL_USER": "jorgecuadros",
|
||||||
|
"MYSQL_PASSWORD": "${{ secrets.MYSQL_PASSWORD }}",
|
||||||
|
"MYSQL_ROOT_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- full only: object storage ---------------------------------------
|
||||||
|
- name: Deploy minio stack
|
||||||
|
if: ${{ github.event.inputs.scope == 'full' }}
|
||||||
|
uses: cssnr/portainer-stack-deploy-action@v1
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.PORTAINER_URL }}
|
||||||
|
token: ${{ secrets.PORTAINER_API_KEY }}
|
||||||
|
name: ${{ secrets.PORTAINER_MINIO_STACK_NAME }}
|
||||||
|
file: deploy/jorgecuadros-minio.stack.yml
|
||||||
|
type: file
|
||||||
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
|
env_data: |
|
||||||
|
{
|
||||||
|
"MINIO_API_PORT": "9000",
|
||||||
|
"MINIO_CONSOLE_PORT": "9001",
|
||||||
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
||||||
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- restore point, taken while the OLD api container is still up ------
|
||||||
|
# Dumped INSIDE the running api container so the file lands in the volume
|
||||||
|
# the "Operaciones" restore screen reads — a dump on the runner would be
|
||||||
|
# unreachable by the only restore path this platform has.
|
||||||
|
- name: Pre-migrate backup
|
||||||
|
env:
|
||||||
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
|
||||||
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
|
||||||
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
# The dump runs as root: --single-transaction issues FLUSH TABLES,
|
||||||
|
# which needs the global RELOAD privilege the application user
|
||||||
|
# deliberately does not have.
|
||||||
|
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
|
||||||
|
BACKUP_TAG: ${{ github.event.inputs.tag }}
|
||||||
|
ALLOW_MISSING_CONTAINER: ${{ github.event.inputs.bootstrap }}
|
||||||
|
# Portainer serves a self-signed certificate. Scoped to this step
|
||||||
|
# only, which does nothing but talk to Portainer.
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
||||||
|
run: node deploy/scripts/pre-migrate-backup.mjs
|
||||||
|
|
||||||
|
# --- schema, forward-only ---------------------------------------------
|
||||||
|
# Prisma has no down-migrations: a code rollback does NOT roll the schema
|
||||||
|
# back. See docs/DEPLOY_AND_MIGRATIONS.md — every change must be
|
||||||
|
# expand/contract so the previous release still runs against the new
|
||||||
|
# schema. Run as a deploy STEP, never as the container CMD: N replicas
|
||||||
|
# would race each other applying the same migration.
|
||||||
|
- name: Apply database migrations
|
||||||
|
if: ${{ github.event.inputs.skip_migrate != 'true' }}
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
SCHEMA=packages/database/prisma/schema.prisma
|
||||||
|
npx --yes prisma@5 migrate status --schema "$SCHEMA" || true
|
||||||
|
if ! npx --yes prisma@5 migrate deploy --schema "$SCHEMA"; then
|
||||||
|
echo "::error::migrate deploy failed. If this is P3005 (schema not empty),"
|
||||||
|
echo "::error::the database predates migration history — baseline it once with:"
|
||||||
|
echo "::error:: npx prisma@5 migrate resolve --applied 0000_init --schema $SCHEMA"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- make sure the host actually has the images ------------------------
|
||||||
|
# The deploy action's `pull: true` does not reliably refresh an already
|
||||||
|
# cached moving tag; without this a "successful" deploy can leave the host
|
||||||
|
# serving an older build of the same tag.
|
||||||
|
- name: Pull images
|
||||||
|
env:
|
||||||
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
|
||||||
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
|
||||||
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
|
REGISTRY: ${{ env.REGISTRY }}
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
IMAGES: ${{ github.repository_owner }}/jorgecuadros-api,${{ github.repository_owner }}/jorgecuadros-web
|
||||||
|
TAG: ${{ github.event.inputs.tag }}
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
||||||
|
run: node deploy/scripts/pull-images.mjs
|
||||||
|
|
||||||
|
# --- always: the app (web + api) -------------------------------------
|
||||||
|
- name: Deploy app stack
|
||||||
|
uses: cssnr/portainer-stack-deploy-action@v1
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.PORTAINER_URL }}
|
||||||
|
token: ${{ secrets.PORTAINER_API_KEY }}
|
||||||
|
name: ${{ secrets.PORTAINER_APP_STACK_NAME }}
|
||||||
|
file: deploy/jorgecuadros-app.stack.yml
|
||||||
|
type: file
|
||||||
|
pull: true
|
||||||
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
|
env_data: |
|
||||||
|
{
|
||||||
|
"APP_TAG": "${{ github.event.inputs.tag }}",
|
||||||
|
"API_PORT": "3001",
|
||||||
|
"WEB_PORT": "3000",
|
||||||
|
"S3_BUCKET": "jorgecuadros-documents",
|
||||||
|
"API_ORIGIN": "${{ secrets.APP_API_ORIGIN }}",
|
||||||
|
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN }}",
|
||||||
|
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT }}",
|
||||||
|
"DATABASE_URL": "${{ secrets.DATABASE_URL }}",
|
||||||
|
"SESSION_SECRET": "${{ secrets.SESSION_SECRET }}",
|
||||||
|
"OPS_DB_ADMIN_USER": "root",
|
||||||
|
"OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}",
|
||||||
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
||||||
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- prove it ----------------------------------------------------------
|
||||||
|
# A stack naming a tag is not proof the container is running it — a
|
||||||
|
# skipped pull leaves the old code up. Ask the API what it actually is.
|
||||||
|
- name: Verify running version
|
||||||
|
env:
|
||||||
|
API_ORIGIN: ${{ secrets.APP_API_ORIGIN }}
|
||||||
|
WEB_ORIGIN: ${{ secrets.APP_WEB_ORIGIN }}
|
||||||
|
WANT: ${{ github.event.inputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
apk add --no-cache curl >/dev/null
|
||||||
|
fetch_version() {
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -fsS "$1/version" > "$2"; then return 0; fi
|
||||||
|
echo "waiting for $1 ($i/30)..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo "::error::$1/version never answered"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fetch_version "$API_ORIGIN" /tmp/api.json
|
||||||
|
fetch_version "$WEB_ORIGIN" /tmp/web.json
|
||||||
|
cat /tmp/api.json; echo; cat /tmp/web.json; echo
|
||||||
|
|
||||||
|
API_SHA=$(node -e 'console.log(require("/tmp/api.json").gitSha)')
|
||||||
|
WEB_SHA=$(node -e 'console.log(require("/tmp/web.json").gitSha)')
|
||||||
|
API_VER=$(node -e 'console.log(require("/tmp/api.json").version)')
|
||||||
|
|
||||||
|
# Compare the COMMIT, not the version string: on a branch build both
|
||||||
|
# tiers report "master", so version equality proves nothing.
|
||||||
|
if [ "$API_SHA" != "$WEB_SHA" ]; then
|
||||||
|
echo "::error::api and web are different builds — api $API_SHA, web $WEB_SHA"
|
||||||
|
echo "::error::one of the images was not replaced; check the Pull images step"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "api and web agree: $API_SHA"
|
||||||
|
|
||||||
|
case "$WANT" in
|
||||||
|
[0-9]*.[0-9]*.[0-9]*)
|
||||||
|
if [ "$API_VER" != "$WANT" ]; then
|
||||||
|
echo "::error::deployed $WANT but the API reports $API_VER"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "verified: running $API_VER"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "dispatched '$WANT'; tiers report '$API_VER' (not directly comparable)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# Cut a release: stamp the version across every package.json, commit, tag, push.
|
||||||
|
#
|
||||||
|
# This does NOT build and does NOT deploy. Pushing the `vX.Y.Z` tag is what
|
||||||
|
# triggers build.yml, which publishes `X.Y.Z`, `X.Y`, `sha-<short>` and `latest`
|
||||||
|
# image tags. Deploying stays a separate, deliberate act: once the build is
|
||||||
|
# green, dispatch deploy-galactus.yml with `tag=X.Y.Z` (no leading v — the tag
|
||||||
|
# carries the `v`, the image tag does not).
|
||||||
|
#
|
||||||
|
# Why a workflow instead of three local commands: the release commit is the one
|
||||||
|
# thing that must be identical every time, and cutting it from a laptop is how
|
||||||
|
# a manifest bump gets forgotten or a tag lands on an unpushed commit. Here the
|
||||||
|
# only input is the number.
|
||||||
|
#
|
||||||
|
# Prereqs (once):
|
||||||
|
# - Repo secret RELEASE_TOKEN: a Gitea personal access token with
|
||||||
|
# write:repository on this repo. The built-in Actions token is deliberately
|
||||||
|
# NOT used — whether a push made with it re-triggers build.yml depends on the
|
||||||
|
# Gitea version, and a release that silently publishes no images is worse
|
||||||
|
# than one that fails. A PAT push is an ordinary push and always triggers.
|
||||||
|
# If build.yml somehow does not start, it has workflow_dispatch: run it
|
||||||
|
# against the new tag by hand.
|
||||||
|
|
||||||
|
name: Cut release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
bump:
|
||||||
|
description: "Which part to bump (choose 'explicit' to type the number)"
|
||||||
|
type: choice
|
||||||
|
required: true
|
||||||
|
default: "minor"
|
||||||
|
options:
|
||||||
|
- patch
|
||||||
|
- minor
|
||||||
|
- major
|
||||||
|
- explicit
|
||||||
|
version:
|
||||||
|
description: "Exact version when bump=explicit (x.y.z, no leading v)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Release
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: node:20-alpine
|
||||||
|
steps:
|
||||||
|
- name: Install tools
|
||||||
|
run: apk add --no-cache git
|
||||||
|
|
||||||
|
- name: Preflight — RELEASE_TOKEN
|
||||||
|
env:
|
||||||
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if [ -z "${RELEASE_TOKEN:-}" ]; then
|
||||||
|
echo "::error::Secret RELEASE_TOKEN is not set. Create a Gitea PAT with"
|
||||||
|
echo "::error::write:repository and add it as a repo secret named RELEASE_TOKEN."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Full history + tags: the duplicate-tag check below is meaningless
|
||||||
|
# against a shallow clone, which has none of them.
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: master
|
||||||
|
token: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
|
||||||
|
- name: Resolve the new version
|
||||||
|
id: ver
|
||||||
|
env:
|
||||||
|
BUMP: ${{ github.event.inputs.bump }}
|
||||||
|
EXPLICIT: ${{ github.event.inputs.version }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
CURRENT=$(node -p "require('./package.json').version")
|
||||||
|
echo "current: $CURRENT"
|
||||||
|
|
||||||
|
if [ "$BUMP" = "explicit" ]; then
|
||||||
|
NEXT="$EXPLICIT"
|
||||||
|
if [ -z "$NEXT" ]; then
|
||||||
|
echo "::error::bump=explicit requires the version input."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
NEXT=$(node -e '
|
||||||
|
const [cur, part] = process.argv.slice(1);
|
||||||
|
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(cur);
|
||||||
|
if (!m) { console.error(`unparseable current version: ${cur}`); process.exit(1); }
|
||||||
|
let [maj, min, pat] = m.slice(1).map(Number);
|
||||||
|
if (part === "major") { maj += 1; min = 0; pat = 0; }
|
||||||
|
else if (part === "minor") { min += 1; pat = 0; }
|
||||||
|
else { pat += 1; }
|
||||||
|
process.stdout.write(`${maj}.${min}.${pat}`);
|
||||||
|
' "$CURRENT" "$BUMP")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# set-version.mjs validates the shape too, but failing here keeps the
|
||||||
|
# working tree clean when the input is a typo.
|
||||||
|
case "$NEXT" in
|
||||||
|
v*) echo "::error::Version must not carry a leading 'v' (got $NEXT)."; exit 1 ;;
|
||||||
|
esac
|
||||||
|
if ! printf '%s' "$NEXT" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||||
|
echo "::error::Invalid version: $NEXT (expected x.y.z)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$NEXT" = "$CURRENT" ]; then
|
||||||
|
echo "::error::$NEXT is already the current version."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if git rev-parse -q --verify "refs/tags/v$NEXT" >/dev/null; then
|
||||||
|
echo "::error::Tag v$NEXT already exists. Releases are immutable — pick a new number."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "next: $NEXT"
|
||||||
|
echo "version=$NEXT" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Stamp the version across every manifest
|
||||||
|
run: node scripts/set-version.mjs "${{ steps.ver.outputs.version }}"
|
||||||
|
|
||||||
|
# A release whose only content is the version bump means the dispatch was
|
||||||
|
# a mistake — set-version.mjs already refused a no-op above, so an empty
|
||||||
|
# diff here means the manifests were somehow already at this number.
|
||||||
|
- name: Commit, tag, push
|
||||||
|
env:
|
||||||
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
|
ACTOR: ${{ github.actor }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if git diff --quiet; then
|
||||||
|
echo "::error::No manifest changed. Nothing to release."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git config user.name "gitea-actions"
|
||||||
|
git config user.email "actions@git.mancinas.io"
|
||||||
|
|
||||||
|
git commit -a \
|
||||||
|
-m "chore(release): v${VERSION}" \
|
||||||
|
-m "Cut by ${ACTOR} via the \"Cut release\" workflow. Pushing the tag triggers build.yml; deploy separately with tag=${VERSION}."
|
||||||
|
git tag -a "v${VERSION}" -m "v${VERSION}"
|
||||||
|
|
||||||
|
# Re-point at an authenticated remote. The token is a secret, so Gitea
|
||||||
|
# masks it in the log; nothing here echoes the URL regardless.
|
||||||
|
git remote set-url origin \
|
||||||
|
"$(printf '%s' "${GITHUB_SERVER_URL}" | sed "s#://#://x-access-token:${RELEASE_TOKEN}@#")/${GITHUB_REPOSITORY}.git"
|
||||||
|
|
||||||
|
# One push for both refs: a commit that lands without its tag builds
|
||||||
|
# nothing and looks like a successful release.
|
||||||
|
git push origin "HEAD:master" "refs/tags/v${VERSION}"
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
echo "Released v${VERSION}."
|
||||||
|
echo ""
|
||||||
|
echo "build.yml is now building git.mancinas.io/rmancinas/jorgecuadros-{api,web}:${VERSION}."
|
||||||
|
echo "When it is green, dispatch 'Deploy to galactus' with:"
|
||||||
|
echo " tag=${VERSION} scope=app bootstrap=false skip_migrate=false"
|
||||||
@@ -8,7 +8,13 @@ build/
|
|||||||
*.log
|
*.log
|
||||||
migration/output/
|
migration/output/
|
||||||
migration/.venv/
|
migration/.venv/
|
||||||
|
migration/ingest/
|
||||||
|
migration/backups/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
packages/database/generated/
|
packages/database/generated/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.codegraph/
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ All tables get a surrogate `id` (uuid or serial) plus, where the row came from a
|
|||||||
- `service_documents` (extracted blobs), `trust_accounts` (from `TRUSTVENCE`).
|
- `service_documents` (extracted blobs), `trust_accounts` (from `TRUSTVENCE`).
|
||||||
|
|
||||||
**Shared financial ledger** (one office, one set of books — no reason to keep insurance and utility transactions in separate schemas):
|
**Shared financial ledger** (one office, one set of books — no reason to keep insurance and utility transactions in separate schemas):
|
||||||
- `transactions` — unifies utilities' `EFECTIVO`/`EFECTIVO FM3`/`EFECTIVO_BACKUP`/`FEE ANUAL`/`datos2`/`fee15`/`billing`/`CHEQUE FM3`/`IVA 2015` and insurance's `EFECTIVO`, tagged by `domain` (utility/insurance/trust) and carrying the provenance columns so the de-duplication across those overlapping snapshot tables is traceable, not destructive.
|
- `transactions` — unifies utilities' `EFECTIVO`/`EFECTIVO FM3`/`EFECTIVO_BACKUP`/`FEE ANUAL`/`datos2`/`fee15`/`billing`/`CHEQUE FM3`/`IVA 2015` and insurance's `EFECTIVO`, tagged by `domain` (utility/insurance/trust) and carrying the provenance columns so the de-duplication across those overlapping snapshot tables is traceable, not destructive. **`amount` is signed:** negative = charge (cargo), positive = credit (abono), so `SUM(amount)` per customer per currency *is* the balance — negative means the customer owes the office. The two currencies are never summed together (see the billing module note in Build sequencing step 6).
|
||||||
- `exchange_rates` (from `TIPO HIST`), `type_transactions` (carry over ES/EN lookup as-is).
|
- `exchange_rates` (from `TIPO HIST`), `type_transactions` (carry over ES/EN lookup as-is).
|
||||||
- `bank_transactions` — the company's own operating bank register, from SCOTHIA's `DATOS E`/`DATOS I` unified into one signed-amount table (income positive, expense negative) with a `category` FK to `business_line_categories` (from `TABLA RAMODOS`) and a `cleared`/`operado` flag. This is deliberately **separate** from customer-facing `transactions` — it's the office's own bank reconciliation book, not money owed by/to a customer — but sharing the `business_line_categories` lookup lets you eventually answer "how much of our actual bank activity ties back to insurance vs. utilities vs. trust," which is a natural reporting win from unifying these three sources.
|
- `bank_transactions` — the company's own operating bank register, from SCOTHIA's `DATOS E`/`DATOS I` unified into one signed-amount table (income positive, expense negative) with a `category` FK to `business_line_categories` (from `TABLA RAMODOS`) and a `cleared`/`operado` flag. This is deliberately **separate** from customer-facing `transactions` — it's the office's own bank reconciliation book, not money owed by/to a customer — but sharing the `business_line_categories` lookup lets you eventually answer "how much of our actual bank activity ties back to insurance vs. utilities vs. trust," which is a natural reporting win from unifying these three sources.
|
||||||
- `business_line_categories` (from `TABLA RAMODOS`).
|
- `business_line_categories` (from `TABLA RAMODOS`).
|
||||||
@@ -108,8 +108,10 @@ All tables get a surrogate `id` (uuid or serial) plus, where the row came from a
|
|||||||
Given the amount of near-duplicate/overlapping data across snapshot tables (multiple `EFECTIVO*` variants, multiple year-stamped billing tables, `COBRO3` vs `DATGRAL`), doing a direct Access → normalized-MySQL transform in one pass is risky — a bug loses the ability to check itself against the source.
|
Given the amount of near-duplicate/overlapping data across snapshot tables (multiple `EFECTIVO*` variants, multiple year-stamped billing tables, `COBRO3` vs `DATGRAL`), doing a direct Access → normalized-MySQL transform in one pass is risky — a bug loses the ability to check itself against the source.
|
||||||
|
|
||||||
1. **Raw staging load**: dump every non-scratch Access table 1:1 into a MySQL `staging` (per-source schema/database, e.g. `stg_utilities`/`stg_seguros`/`stg_scothia`) — same columns, minimal type coercion — via a Python script across all four source files. Already built and run against real data as `migration/load_staging.py` in the new repo — see Status below. This is the audit trail — nothing is transformed yet. **Extraction toolchain note:** the original build used `pyodbc` + the Windows Access ODBC driver; the project has since moved to a macOS machine, so the extraction layer (`migration/extract.py`) is being reworked to use **mdbtools** (`mdb-tables`/`mdb-export`, installed via Homebrew) instead. mdbtools has been verified against the real files to read table data, accented-column tables (which broke pyodbc's UTF-16 path — e.g. `PROPANO`), and per-table exports cleanly. mdbtools does **not** extract Forms/Reports/Queries, but those were already captured on Windows via DAO/COM and are frozen in `migration/objects.json` + `docs/LEGACY_DATABASES_OBJECTS.md`, so nothing is lost. The only piece needing extra handling under mdbtools is `LONGBINARY` blob/document extraction (step 4), where mdbtools emits the OLE wrapper — addressed when step 4 runs, not a blocker for steps 1–3.
|
1. **Raw staging load**: dump every non-scratch Access table 1:1 into a MySQL `staging` (per-source schema/database, e.g. `stg_utilities`/`stg_seguros`/`stg_scothia`) — same columns, minimal type coercion — via a Python script across all four source files. Already built and run against real data as `migration/load_staging.py` in the new repo — see Status below. This is the audit trail — nothing is transformed yet. **Extraction toolchain note:** the original build used `pyodbc` + the Windows Access ODBC driver; the project has since moved to a macOS machine, so the extraction layer (`migration/extract.py`) is being reworked to use **mdbtools** (`mdb-tables`/`mdb-export`, installed via Homebrew) instead. mdbtools has been verified against the real files to read table data, accented-column tables (which broke pyodbc's UTF-16 path — e.g. `PROPANO`), and per-table exports cleanly. mdbtools does **not** extract Forms/Reports/Queries, but those were already captured on Windows via DAO/COM and are frozen in `migration/objects.json` + `docs/LEGACY_DATABASES_OBJECTS.md`, so nothing is lost. The only piece needing extra handling under mdbtools is `LONGBINARY` blob/document extraction (step 4), where mdbtools emits the OLE wrapper — addressed when step 4 runs, not a blocker for steps 1–3.
|
||||||
2. **Reconciliation pass** — **DONE** (`migration/reconcile.py` → `migration/RECONCILIATION.md`, run against the staged data). For each set of overlapping tables, it probes a deliberate *business key* (not naive full-row match, which gives a misleading ~0 overlap everywhere) and reports what's actually duplicate vs. distinct. **Outcome overturned all three of the plan's original "duplicate" assumptions — the union/de-dup rules below are now decided by the data:**
|
2. **Reconciliation pass** — **DONE** (`migration/reconcile.py` → `migration/RECONCILIATION.md`, run against the staged data). For each set of overlapping tables, it probes a deliberate *business key* (not naive full-row match, which gives a misleading ~0 overlap everywhere) and reports what's actually duplicate vs. distinct. The union/de-dup rules below are decided by the data:
|
||||||
- **`EFECTIVO` vs `EFECTIVO_BACKUP`:** *not* a live/backup duplicate pair. `folio` is a per-table sequential number that **collides** (12,363 shared folio numbers, all carrying different transactions); on the real business key `(cl,fecha,monto,conepto)` only **2 rows** overlap. They are near-disjoint ledgers (BACKUP ≈ 2017–2022, EFECTIVO recent). **Rule: migrate both**, keyed internally by `(legacy_source_table, folio)` provenance; no folio de-dup, don't drop BACKUP. `EFECTIVO FM3`/`CHEQUE FM3` are a separate `fee/tax/multa` stream, migrated distinctly. (`monedas` needs currency normalization — `PESOS`/`Pesos`/`DOLLARS` variants.)
|
- **`EFECTIVO` vs `EFECTIVO_BACKUP`: `EFECTIVO_BACKUP` is a stale backup copy — de-dup it. (Corrected 2026-07-22; see the box below.)** On the canonicalized business key `(cl,fecha,monto,conepto)`, **12,386 of BACKUP's 12,387 rows already exist verbatim in `EFECTIVO`** — same customer, same timestamp to the second, same amount, same concept text — leaving exactly **1** genuinely new row. `folio` is a per-table sequential number that **collides** (12,363 shared numbers, 12,204 of them on different payments), so it can never be the de-dup key. **Rule: load `EFECTIVO` in full; from `EFECTIVO_BACKUP` load only business-key-new rows.** `EFECTIVO FM3`/`CHEQUE FM3` are a separate `fee/tax/multa` stream, migrated distinctly. (`monedas` needs currency normalization — `PESOS`/`Pesos`/`DOLLARS` variants.)
|
||||||
|
|
||||||
|
> **Why this was wrong the first time.** The original pass reported only **2** overlapping rows and concluded the two tables were "near-disjoint ledgers, migrate both". That verdict came from a bug in `reconcile.py`, which compared business-key columns as raw strings on the premise that "every table went through the same mdb-export path, so identical source values serialize identically". They don't: `mdb-export` formats a numeric column from its *Access column type*, so the same amount is emitted as `5000` from one table and `27000.0000` from the other, and no two rows could ever match on `monto`. `reconcile.py` now canonicalizes numeric key columns before comparing. The bad rule had already been loaded: the ledger carried 45,861 rows with **12,386 duplicated payments**, roughly doubling every customer's historical receipt total — which would have made every balance and statement in step 6 wrong. Re-running `run_all.py` brings the ledger to **33,475** rows. Groups 2 and 3 below were re-checked under the fix and their verdicts are unchanged.
|
||||||
- **`datos2` vs `FEE ANUAL` vs `fee15`:** *not* near-duplicate exports. They are **disjoint billing runs from different periods** (`datos2` ≈2025–26, `FEE ANUAL` 2018-01-03, `fee15` 2017-01-10 — each period-table `refer` is a single constant); zero real-identity overlap. **Rule: migrate all three, no de-dup**; keep `datos2.due_date` (null for the others).
|
- **`datos2` vs `FEE ANUAL` vs `fee15`:** *not* near-duplicate exports. They are **disjoint billing runs from different periods** (`datos2` ≈2025–26, `FEE ANUAL` 2018-01-03, `fee15` 2017-01-10 — each period-table `refer` is a single constant); zero real-identity overlap. **Rule: migrate all three, no de-dup**; keep `datos2.due_date` (null for the others).
|
||||||
- **`DATGRAL` vs `COBRO3`:** `COBRO3` is *not* a filtered snapshot of the customer master — its `fee` is a **constant 75** for all 181 rows (a saved charge worklist / "cobro" = collection), and every `num_id` already exists in `DATGRAL`. **Rule: `DATGRAL` is the sole utilities customer master; COBRO3 contributes zero customers** — model its 181 rows as charge transactions if worth keeping, else exclude.
|
- **`DATGRAL` vs `COBRO3`:** `COBRO3` is *not* a filtered snapshot of the customer master — its `fee` is a **constant 75** for all 181 rows (a saved charge worklist / "cobro" = collection), and every `num_id` already exists in `DATGRAL`. **Rule: `DATGRAL` is the sole utilities customer master; COBRO3 contributes zero customers** — model its 181 rows as charge transactions if worth keeping, else exclude.
|
||||||
3. **Transform + load**: SQL/TypeScript scripts (versioned in the new repo under `migration/`) that read `staging`, apply the customer-matching and unpivot logic described above, and upsert into the real Prisma-managed tables, writing `legacy_*` provenance on every row.
|
3. **Transform + load**: SQL/TypeScript scripts (versioned in the new repo under `migration/`) that read `staging`, apply the customer-matching and unpivot logic described above, and upsert into the real Prisma-managed tables, writing `legacy_*` provenance on every row.
|
||||||
@@ -123,11 +125,32 @@ Given the amount of near-duplicate/overlapping data across snapshot tables (mult
|
|||||||
3. Customer module (list/search/detail — the unified view is the core deliverable) backed by finished migration steps 3–5 for customers only.
|
3. Customer module (list/search/detail — the unified view is the core deliverable) backed by finished migration steps 3–5 for customers only.
|
||||||
4. Insurance module (policies, vehicles, beneficiaries, claims) on top of the same customer records.
|
4. Insurance module (policies, vehicles, beneficiaries, claims) on top of the same customer records.
|
||||||
5. Utilities module (properties, services, trust accounts) on top of the same customer records.
|
5. Utilities module (properties, services, trust accounts) on top of the same customer records.
|
||||||
6. Shared billing/statements module (the payoff: one statement per customer spanning both utility and insurance transactions).
|
6. Shared billing/statements module (the payoff: one statement per customer spanning both utility and insurance transactions) — **DONE**. `apps/api/src/billing/` + web `/estado-cuenta` and `/estado-cuenta/[id]`. Two questions, two views: a per-customer **balances worklist** (who owes what) and a cross-customer **movement browser** (every charge and credit, filterable by line, concept, origin table and date range, with totals for the whole filtered set). The detail page is the actual statement: balance per currency, the same balance split by business line, charges broken out by concept, and the full movement list with a running balance. **Design constraint that shapes the whole module: balances are reported per currency and never collapsed into one number.** 912 of the 1,269 customers with a ledger move in both MXN and USD, the charge side is MXN-only while receipts arrive in both, and the legacy data never stored the exchange rate applied to a movement — so a single "total balance" would be a figure that never existed in the books.
|
||||||
7. Bank register module (`bank_transactions`/`business_line_categories` from SCOTHIA) — small, self-contained, and has no customer FK, so it can slot in independently once the core migration pipeline exists; low risk, low priority relative to the customer-facing modules.
|
7. Bank register module (`bank_transactions`/`business_line_categories` from SCOTHIA) — small, self-contained, and has no customer FK, so it can slot in independently once the core migration pipeline exists; low risk, low priority relative to the customer-facing modules.
|
||||||
8. VPS provisioning + Tailscale + MySQL replication setup. `utility_dbo`'s schema is now available (full dump on disk — 55 tables; see Status), so the exact replicated table/column set and inbox-table shape can be finalized against the real portal DB and the portal PHP code (`my-jorgecuadros-web`) that reads/writes it.
|
8. VPS provisioning + Tailscale + MySQL replication setup. `utility_dbo`'s schema is now available (full dump on disk — 55 tables; see Status), so the exact replicated table/column set and inbox-table shape can be finalized against the real portal DB and the portal PHP code (`my-jorgecuadros-web`) that reads/writes it.
|
||||||
9. Sync worker (push replicated tables' relevant subset, poll inbox tables for payment/propane submissions) — depends on step 8. Portal write points confirmed present in `utility_dbo`: `peticion_gas` (propane requests), PayPal payment writes, `notifications_settings`, `verification_codes` — these define the VPS→internal inbox set.
|
9. Sync worker (push replicated tables' relevant subset, poll inbox tables for payment/propane submissions) — depends on step 8. **The separate Phase B Access additive sync is implemented:** `migration/run_all.py --sync` and the admin `SYNC` job upsert legacy-owned rows without truncating the database or touching manual rows. Portal write points confirmed present in `utility_dbo`: `peticion_gas` (propane requests), PayPal payment writes, `notifications_settings`, `verification_codes` — these define the VPS→internal inbox set.
|
||||||
10. Reports/email campaigns/admin — parity with old app's `reports.php`/`emailCampaigns.php` intent, rebuilt properly.
|
10. Reports/email campaigns/admin — parity with old app's `reports.php`/`emailCampaigns.php` intent, rebuilt properly.
|
||||||
|
11. **Receipt capture ("Editor") completion + three net-new ops features — NOT STARTED, spec written.** Full design in [`docs/RECEIPT_CAPTURE_SPEC.md`](docs/RECEIPT_CAPTURE_SPEC.md), from the 2026-07-25/26 meeting with Jorge:
|
||||||
|
- **Receipt capture module — DONE** (2026-07-27). The legacy "Editor" replacement, built on the single-movement capture from step 6. Wires up the previously-unused `Transaction.outstanding` (NOPAGO): capture flag on `POST /billing`, `?outstanding=` list filter, `POST /billing/:id/resolve-outstanding` (gated `ledger:create`, not `ledger:void` — resolving *completes* a capture), and exclusion from every balance aggregate exactly as the legacy `SALDOS ULTIMO 0`'s `HAVING NOPAGO = 0` did. Adds `POST /billing/batch` (one `$transaction`, check-level fields shared, per-line customer/amount) and `GET /billing/by-check`, plus the `cheque-count` report replacing `REPORTE CHEQUE COUNT` / `REPORTE POR CHEQUE` / `EDITA CHEQUE ALF|COUNT|NUM` — print/PDF/CSV/XLSX come free from the existing `/reportes/:slug` machinery. Web: `/estado-cuenta/lote` (the actual "Editor" screen, with live reconciliation against the physical check amount), plus an "Estado de pago" filter, a "sin fondos" row tag and a Resolver dialog on `/estado-cuenta`. No new abilities. Verified end-to-end against dev, API + browser.
|
||||||
|
**Two pre-existing bugs found and fixed while building it:** (a) `statement()` filtered `legacySourceTable: { notIn: [...] }`, which compiles to SQL `NOT IN` — and `NULL NOT IN (…)` is NULL, so **every app-captured movement was invisible on the customer statement** (438 rows in the movement browser vs 392 on the statement) while still appearing everywhere else. This would have made the whole receipt-capture feature look broken to staff. Now NULL-safe. (b) The balances *count* query omitted the void filter its own page query applied, so the row count disagreed with the rows.
|
||||||
|
**OCR seam:** `BillingService.createBatch(dto, opts)` is the single multi-row write path and carries three contract guarantees for the step-11 OCR module to post through — `items[i]` maps to `lines[i]` (so `StatementDocument.postedTransactionId` can be zipped back on), `opts.refs[i]` stamps `captureRef` with a duplicate-post guard that a *voided* row deliberately does not block, and `opts.source` is service-level only so an HTTP client cannot label hand-keyed rows as machine-captured. Backed by a new `TransactionCaptureSource` enum (MANUAL/BATCH/OCR) + `captureRef`, both nullable so the 40,136 migrated rows stay NULL rather than being mislabelled.
|
||||||
|
- **PDF/OCR auto-capture — DONE** (2026-08-01). The ingest→split→OCR→match→review pipeline for the 300+/month/service-provider statements staff key in by hand, built in `apps/api/src/statements/` and posting through §1.2's `createBatch` seam with `source: "OCR"` and a per-document `captureRef`. Web: `/recibos` + `/recibos/:id`. Abilities `statement:ingest`/`statement:review` (STAFF — the review step is what makes machine capture safe at that tier). OCR is self-hosted **Tesseract** behind a swappable `OcrProvider` interface; `tesseract-ocr`, `tesseract-ocr-data-spa` and `poppler-utils` were added to the API image.
|
||||||
|
**Every decision was driven by 10 real scans (46 pages).** Shipped-parser results on them: provider 46/46, account ref 43/46, amount 42/46, due date 44/46 — and against the dev database **39/46 (85%) exact auto-match, 40/46 (87%) identified**, the rest genuine review cases. The scans are pure images (no text layer), so OCR is mandatory, and they arrive **bundled one customer per page**.
|
||||||
|
**The three gaps are closed, and two of them were mis-stated in the spec.** (a) `TELEPHONE` now exists and is backfilled from `Property.phone1` only — coverage is 534/18/1 across phone1/2/3, so phone is one billed line per property, not three. (b) **Clave catastral ≠ predial**: `DATMEX.clave` (934 rows, `KA903009`) is what CESPT and predial bills actually print, while `predial` — what `PROPERTY_TAX.accountNumber` holds — has only 663 distinct values across 1135 rows and appears on no statement; the clave now lives on `Property.cadastralKey` as the matcher's secondary key and predial is left untouched. (c) Gas was **not** a dead end: 160 of the 334 `DATMEX.gas` values are real account numbers (the rest are `ESTACIONARIO`/`CILINDRO` descriptors), all recovered into `GAS.meterNumber`.
|
||||||
|
**Matching is scoped per service kind and never reads the customer name** — a CESPT receipt prints `ARNAIZ ROSAS ELSA AURORA` for an account this office holds under `CATT, RANDY`, because the name on a utility bill is the registrant, not the current owner. Normalisation is per provider: CFE strips leading zeros off `NO. DE SERVICIO`, Telnor strips the 664 LADA down to the stored local 7 digits. Where a provider prints a payment barcode it is preferred over the printed label (one CFE label OCR'd a digit too many while its barcode was correct) and the two are cross-checked, with disagreement forcing review. Confirming a document whose service had no reference writes it back, so gas and any other cold start is a one-time cost.
|
||||||
|
- **Multi-bank chequera — DONE** (2026-07-27). `Bank`/`BankAccount` models so Seguros (US bank) and Utilities (Mexican bank, currently SCOTHIA) can each have their own register. `bank_transactions` gained a **required** `bankAccountId` (plus an `(bankAccountId, transactionDate)` index, since every read is now filtered by account and ordered by date), and all 22,669 existing rows were backfilled onto a seeded "Utilities — Scotiabank (MXN)" account by `migration/backfill_bank_accounts.py` — a standalone step because `prisma db push` cannot add a required column to a populated table. It is idempotent and now runs inside `run_all.py` (both normal and `--sync`) ahead of `transform_bank.py`, which fails fast if the account is missing. Every read path in `bank.service.ts` is account-scoped, including `facets()` (which had no filter at all) and *both* raw-SQL rollups in `summary()`. API: `?bankAccountId=` is required on `list`/`stats`/`facets`/`summary` — **not** optional-with-an-all-accounts-default, since summing an MXN and a USD register repeats exactly the currency-collapsing mistake the billing module exists to prevent — plus a new `bank/accounts` + `bank/banks` sub-resource under a MANAGER `bank:manage-accounts` ability. Web: `/banco` gained an account picker (remembered per browser) and reads every figure in the selected account's currency, `/banco/cuentas` manages banks and accounts, and `/inicio`'s chequera card names the account it is showing instead of implying one register. An account's `currency` is immutable after creation by design — its booked movements are denominated in it. Verified against dev + browser: a second USD account showed full read/write isolation from the MXN register, whose totals were unchanged.
|
||||||
|
- **Customer-number recycling** — promotes the legacy `NUM id` (currently only inside `customer_legacy_refs`) into a first-class, reusable `Customer.customerNumber`, automates *finding* candidates for reuse (cancelled / 1-year-inactive), and auto-assigns the lowest free number at creation — the search is automated, the release/reuse decision stays a human action. Backfill needs care: ~140 utilities rows and all insurance-only customers have no real legacy number (synthetic `rownum_N`/`insrow_N` placeholders in `transform_customers.py`, not real `NUM id`s).
|
||||||
|
|
||||||
|
Several open questions block parts of this (OCR provider/budget, the Seguros bank's identity, the clave-catastral-vs-predial mismatch, exact recycling triggers, and whether "recycling" should ever mean true data purge vs. archive-and-reuse-the-number) — see the spec's collected open-questions section.
|
||||||
|
12. **Insurance features — NOT STARTED, spec written.** Full design in [`docs/INSURANCE_FEATURES_SPEC.md`](docs/INSURANCE_FEATURES_SPEC.md), the insurance half of the same 2026-07-25/26 meeting with Jorge that produced step 11:
|
||||||
|
- **Renewal notification emails** — a daily `@nestjs/schedule` sweep that mails the customer 30 days before expiry, 15 days before, and 7 days after, mapping onto `RenewalNotice.generation` 1/2/3 with **no schema change**. Sending is **Amazon SES** (`@aws-sdk/client-sesv2`, mirroring `StorageService`'s optional-client/degrade-don't-crash pattern) — the office already runs SES, so provider and budget are settled, not open. The letter body is the *existing* `aviso-renovacion` report (`reports.registry.ts:623-799`); `@@unique([policyId, generation])` is already-in-place idempotency, so a re-run cannot double-send. Volume ≈260 mails/month, and **815 of the 893 policyholders (91%) have an email**. Also adds the manual mark-as-sent mutation the report's own comment anticipates, so the report's permanently-zero `enviadas` total becomes real. Smallest useful piece — do first.
|
||||||
|
- **Liquidación batch workflow** — ~70% already built (`liquidated`/`liquidationNumber`/`liquidationDate` are wired through DTOs, list filter, stats, form and detail page); only the *batch* print-and-mark step is missing, against a live pending set of 226 policies. Adds a ramo-parameterized pending report plus `POST /policies/liquidate-batch` under a new MANAGER `policy:liquidate` ability. Parameterized by ramo, not MULT-only — legacy `TABLA LIQUIDA MF` served `MULT`, `INCENDIO` and `M EMPR` alike.
|
||||||
|
- **Certificate / "Solicitud Atlas"** — renders from the same `format: "letter"` machinery `aviso-renovacion` uses, then reaches customers as an extension of the step-8/9 replication (PDF generated here, pushed to MinIO, pointer replicated), **not** as a new public surface in this repo. Half-blocked: "Solicitud" has zero referent in the legacy system and normally means an *application form*, a different artifact from a certificate.
|
||||||
|
- **Carrier API integration (ANA Seguros + GMX)** — shape only (`CarrierConnector` + an import-review queue rather than direct `Policy` writes, matching how step 11's OCR results are routed). Carrier research done 2026-07-27: **the two carriers are one company** — both belong to **Grupo Valore** (ANA writes autos, GMX writes daños, which is exactly this database's `AUTO`/`LICENCIAS` vs `MULT`/`INCENDIO`/`M_EMPR` split), so it is one commercial relationship, not two. **ANA has a real live SOAP service** (`server.anaseguros.com.mx/ananetws/service.asmx`, ASP.NET `.asmx`) with a published operation list — catalogs, `CalculaValor`/`CalculaMSI`, `ValidaSerie`, `RecuperaCotizacion`, `Transaccion`. **GMX publishes no machine interface at all**, only human agent portals. ⚠️ **Critical mismatch:** every ANA operation serves *new-business quoting/issuance*, not "list the policies where I am agent of record" — so if the ask is inbound portfolio sync, no evidence exists that either carrier sells it. Blocked on one phone call to Grupo Valore ((55) 5480-4000) for credentials + a direction answer, not on further research. ("GDMX" in the meeting notes was a typo for `GMX` — confirmed 2026-07-27.)
|
||||||
|
|
||||||
|
**Two pre-existing defects were found while verifying this spec and should be fixed as part of the liquidación work:** (a) `policy_types` is missing its `INCENDIO` and `M_EMPR` rows and, because `policies_policyTypeId_fkey` is `ON DELETE SET NULL`, 5 `m_empr` policies silently lost their ramo — 4 of them are pending liquidación and are invisible to every ramo-filtered query; (b) the legacy settlement slots don't match what the target model assumed — `MULT`/`INCENDIO` carry two and `M EMPR` carries four, while `Policy` collapses to one, so ≤41 MULT second settlements were dropped in migration. Spec recommends moving settlement onto `PolicyPaymentInstallment` rather than adding a second slot.
|
||||||
|
|
||||||
|
**One long-standing open question is closed by this spec:** `DATGRAL.[NUM UTIL]` is authoritative for Utilities↔Seguros reconciliation and **`UTILSEG` must not be used** — its numbers resolve to unrelated people under every reading tested (name match 58/1,024 vs. 298/563 for `NUM UTIL`), and where the two sources overlap they contradict each other on 170 of 218 shared ids. This matters to step 11's customer-number recycling, which touches the same identity space.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
@@ -137,6 +160,10 @@ Repo scaffolded at `jorgecuadros-platform/`: npm workspaces, NestJS API with a r
|
|||||||
|
|
||||||
**Portal live DB now in hand.** `utility_dbo.sql` (1.3 GB, 55 tables) and the portal codebase `my-jorgecuadros-web` (PHP/`mysqli`, Gitea repo, themed classic/modern, ~397 PHP files, core in `scripts/functions.php`) are both on disk — resolving the long-standing "`utility_dbo` schema unknown" blocker. Sync-relevant tables identified: statements/money (`utility_bills`, `accounting`, `email_alert_log`), customer/property (`home_owners`, `home_index`, `condominium`, `management`, `hoa_management`, `trust_assist`), portal-facing policy views (`fm2`/`fm3`/`fmt`, `full_coverage`, `mx_liability`, `usa_liability`), and portal write points (`peticion_gas`, PayPal payments, `notifications_settings`, `verification_codes`). A second dump, `jorgecuadros.sql` (38 MB, 11 tables — `pagos`/`pagosemail`/`PROPANO`/`TRUSTVENCE`/etc.), appears to be an older/partial export, not the portal live DB.
|
**Portal live DB now in hand.** `utility_dbo.sql` (1.3 GB, 55 tables) and the portal codebase `my-jorgecuadros-web` (PHP/`mysqli`, Gitea repo, themed classic/modern, ~397 PHP files, core in `scripts/functions.php`) are both on disk — resolving the long-standing "`utility_dbo` schema unknown" blocker. Sync-relevant tables identified: statements/money (`utility_bills`, `accounting`, `email_alert_log`), customer/property (`home_owners`, `home_index`, `condominium`, `management`, `hoa_management`, `trust_assist`), portal-facing policy views (`fm2`/`fm3`/`fmt`, `full_coverage`, `mx_liability`, `usa_liability`), and portal write points (`peticion_gas`, PayPal payments, `notifications_settings`, `verification_codes`). A second dump, `jorgecuadros.sql` (38 MB, 11 tables — `pagos`/`pagosemail`/`PROPANO`/`TRUSTVENCE`/etc.), appears to be an older/partial export, not the portal live DB.
|
||||||
|
|
||||||
|
**Step 11 is now three-quarters built.** Receipt capture, the multi-bank chequera and PDF/OCR auto-capture are all done and verified; only customer-number recycling remains unbuilt. `docs/RECEIPT_CAPTURE_SPEC.md` carries a BUILT note per section recording what shipped and, for §2, the four things real scanned statements proved the spec had wrong or unknown.
|
||||||
|
|
||||||
|
**Step 12 spec written, not built.** `docs/INSURANCE_FEATURES_SPEC.md` covers the insurance half of the same meeting (renewal emails, liquidación batch, certificate + portal delivery, carrier APIs) — see Build sequencing step 12 above. Verified the same way, plus a live query of the dev DB for the counts it quotes (email coverage, pending liquidación, installment fill rates) and of the staged Parquet for the legacy settlement-slot usage. Two of the four features are much smaller than they sound: the renewal-notice table, its idempotency key and the letter body already exist, and the per-policy liquidación fields are already wired end to end.
|
||||||
|
|
||||||
## Decisions (locked)
|
## Decisions (locked)
|
||||||
|
|
||||||
- **Stack:** Next.js + NestJS + Prisma + **MySQL** (locked earlier — see engine rationale above).
|
- **Stack:** Next.js + NestJS + Prisma + **MySQL** (locked earlier — see engine rationale above).
|
||||||
@@ -144,15 +171,44 @@ Repo scaffolded at `jorgecuadros-platform/`: npm workspaces, NestJS API with a r
|
|||||||
- **i18n:** **Spanish-first** UI — matches the source data and staff usage.
|
- **i18n:** **Spanish-first** UI — matches the source data and staff usage.
|
||||||
- **CI/CD:** **Gitea Actions** on `git.mancinas.io` — build image, push to the git.mancinas.io container registry, deploy to Portainer (mirrors the `portainer-gitea-deploy` pattern already used on this LAN). Jenkins/`git.freakma.com` dropped.
|
- **CI/CD:** **Gitea Actions** on `git.mancinas.io` — build image, push to the git.mancinas.io container registry, deploy to Portainer (mirrors the `portainer-gitea-deploy` pattern already used on this LAN). Jenkins/`git.freakma.com` dropped.
|
||||||
- **`utility_dbo`:** resolved — full dump + portal code available (see Status).
|
- **`utility_dbo`:** resolved — full dump + portal code available (see Status).
|
||||||
|
- **Phase B Access additive sync:** implemented in `migration/run_all.py --sync` and the admin
|
||||||
|
`SYNC` job. It preserves manual rows and stable legacy-owned primary keys; end-to-end database
|
||||||
|
validation remains before production use.
|
||||||
|
|
||||||
## Open items (ops, not design)
|
## Open items (ops, not design)
|
||||||
|
|
||||||
- **VPS provisioning:** provider (Hetzner vs DigitalOcean), size, and Tailscale + MySQL replica setup on it — an ops task, still pending. Design is settled; only the box is missing.
|
- **VPS provisioning:** provider (Hetzner vs DigitalOcean), size, and Tailscale + MySQL replica setup on it — an ops task, still pending. Design is settled; only the box is missing.
|
||||||
- **Old external-DB credential** (hardcoded plaintext MySQL password in the old repo's `dbConnection.php`, in git history) — rotate it regardless, since it's already exposed.
|
- **Old external-DB credential** (hardcoded plaintext MySQL password in the old repo's `dbConnection.php`, in git history) — rotate it regardless, since it's already exposed.
|
||||||
|
|
||||||
|
## Open design questions (steps 11 & 12 — need Jorge before/while building)
|
||||||
|
|
||||||
|
Unlike the ops items above, these block design decisions, not just infrastructure. Full detail in each section of `docs/RECEIPT_CAPTURE_SPEC.md` (step 11) and `docs/INSURANCE_FEATURES_SPEC.md` (step 12):
|
||||||
|
|
||||||
|
**Step 11 — utilities/ops side:**
|
||||||
|
|
||||||
|
- ~~OCR provider/budget~~ — **CLOSED**: self-hosted Tesseract, chosen on measured accuracy against real scans, so there is no per-page cost to approve.
|
||||||
|
- ~~Whether `PROPERTY_TAX.accountNumber` (from `DATMEX.PREDIAL`) is the same number as "Clave Catastral" (`DATMEX.CLAVE`)~~ — **CLOSED**: they are different numbers. Answered from real CESPT bills plus the staged data; the clave is now migrated separately and predial was left alone.
|
||||||
|
- Whether the CFE figure to charge is the rounded headline/barcode amount (`$268` — what is actually paid at the window) or the exact breakdown `Total` (`$268.88`). The parser takes the barcode amount; one confirmation from Jorge would settle it.
|
||||||
|
- The actual bank name/currency/details for the Seguros USD account, and whether any historical Seguros bank register exists to migrate. (Multi-bank support itself is **built** — this is now only the missing content: staff can open the account in `/banco/cuentas` the moment the answer arrives, and it starts empty unless a historical register turns up.)
|
||||||
|
- The exact "1 year inactivity" / "cancelled" triggers for customer-number recycling eligibility.
|
||||||
|
- Whether customer-number recycling should ever include true PII purge (matching the office's paper-world habit) or archive-and-reuse-the-number is sufficient — recommended default is archive-only, consistent with this project's existing never-hard-delete convention.
|
||||||
|
|
||||||
|
**Step 12 — insurance side:**
|
||||||
|
|
||||||
|
- Which SES region + verified sending identity/configuration set the renewal mail goes out under, and whether it reuses the existing IAM credentials or gets its own scoped `ses:SendEmail` user. (Provider and budget are *not* open — SES is settled.)
|
||||||
|
- What to do with the 78 policyholders who have no email on file: skip silently, or produce a print worklist? Recommended: the worklist, since `aviso-renovacion` already renders exactly those letters.
|
||||||
|
- Whether renewal notices go out in Spanish or English — `Customer` carries no language preference.
|
||||||
|
- What "garantías" refers to — it has zero referent in the legacy data, and it blocks the liquidación batch's exclusion filter.
|
||||||
|
- Whether policy settlement should move onto `PolicyPaymentInstallment` (recommended) or gain a second slot on `Policy`, and whether to backfill the ≤41 MULT second settlements lost in migration.
|
||||||
|
- Whether batch liquidación warrants a new MANAGER-level `policy:liquidate` ability (recommended) or should reuse the existing STAFF-level `policy:update`.
|
||||||
|
- **What "Solicitud Atlas" actually is** — an application form or a certificate. These are different artifacts with different data and timing; this blocks the whole certificate feature.
|
||||||
|
- **Carrier integration direction** — outbound quote/issue (which ANA's SOAP service supports today) or inbound sync of the office's existing book (which nothing found suggests either carrier offers)? This decides whether the feature is buildable at all. Bundle with the other three carrier questions into one call to Grupo Valore ((55) 5480-4000): WSDL + credentials for the ANA service, whether a cartera/portfolio download exists for an agent's own book, whether GMX daños has any machine interface, and whether one credential spans both carriers. ("GDMX" is resolved — it was a typo for `GMX`.)
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
- Migration: automated row-count/sum reconciliation between `staging` and final schema per table group (see step 5 above), run as part of the migration script, not a manual spot-check.
|
- Migration: automated row-count/sum reconciliation between `staging` and final schema per table group (see step 5 above), run as part of the migration script, not a manual spot-check.
|
||||||
- App: standard NestJS unit/integration tests per module (auth guards, Prisma queries), Playwright/Cypress e2e for the core "look up a customer, see their unified policies + services + statement" flow — the thing the whole project exists to deliver.
|
- App: standard NestJS unit/integration tests per module (auth guards, Prisma queries), Playwright/Cypress e2e for the core "look up a customer, see their unified policies + services + statement" flow — the thing the whole project exists to deliver.
|
||||||
- Sync: once the VPS replica and inbox tables exist, verify replication lag stays low (a few seconds to low minutes) and that a payment/propane submission on the portal reliably shows up in the internal app within one polling interval, before relying on it operationally.
|
- **Sync:** Phase B Access additive sync now has automated CLI/admin wiring, but must be verified
|
||||||
|
against a disposable DB with stable-PK, manual-row, update, and source-delete cases. The
|
||||||
|
separate VPS/portal sync still requires VPS provisioning and inbox-table implementation.
|
||||||
- Before cutover: run the new app against migrated data side-by-side with the live Access files for a period, comparing balances/statements for a sample of active customers to catch migration logic errors before the Access files are retired.
|
- Before cutover: run the new app against migrated data side-by-side with the live Access files for a period, comparing balances/statements for a sample of active customers to catch migration logic errors before the Access files are retired.
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# Jorge Cuadros & Asociados — Platform
|
||||||
|
|
||||||
|
Internal platform for a Baja California insurance brokerage and property-services
|
||||||
|
firm: a single expedient joining each client's **properties/services**,
|
||||||
|
**insurance policies**, **account statement**, and the firm's **checkbook**.
|
||||||
|
It replaces a legacy PHP/Access app (see `RESUME.md` and `PLAN.md` for the full
|
||||||
|
history and rebuild rationale).
|
||||||
|
|
||||||
|
The UI is Spanish-first; the codebase and this document are in English.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Tech | Port |
|
||||||
|
| --------- | -------------------------------------------------------------- | ---- |
|
||||||
|
| Web | Next.js 14 (App Router, React 18) | 3000 |
|
||||||
|
| API | NestJS 10 · Passport local + `express-session` · Argon2 | 3001 |
|
||||||
|
| Database | MySQL 8 via Prisma 5 (`@jorgecuadros/database` workspace pkg) | 3306 |
|
||||||
|
| Migration | Python 3 pipeline (legacy Access → staging → transforms) | — |
|
||||||
|
|
||||||
|
Monorepo managed with **pnpm workspaces**. Node **>= 20**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/
|
||||||
|
web/ Next.js frontend (@jorgecuadros/web)
|
||||||
|
api/ NestJS backend (@jorgecuadros/api)
|
||||||
|
scripts/seed-user.mjs idempotent admin seeder
|
||||||
|
packages/
|
||||||
|
database/ Prisma schema + generated client (@jorgecuadros/database)
|
||||||
|
prisma/schema.prisma
|
||||||
|
migration/ One-off Python ETL from the legacy Access DB (run_all.py)
|
||||||
|
docker/ Dockerfiles for api + web
|
||||||
|
docker-compose.yml mysql + api + web
|
||||||
|
.env.example copy to .env
|
||||||
|
```
|
||||||
|
|
||||||
|
API feature modules: `auth`, `users`, `customers`, `policies`, `properties`,
|
||||||
|
`billing`, `bank`. Web routes: `/clientes`, `/polizas`, `/servicios`,
|
||||||
|
`/estado-cuenta`, `/banco` (chequera), `/catalogos`, `/usuarios`, `/login`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js >= 20 and **pnpm** (`npm i -g pnpm`)
|
||||||
|
- Docker (for MySQL, or bring your own MySQL 8)
|
||||||
|
- Python 3 — only if you run the legacy data migration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Run it locally (development)
|
||||||
|
|
||||||
|
### 1. Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
pnpm blocks postinstall build scripts by default; the trusted ones
|
||||||
|
(`argon2`, `prisma`, `@prisma/client`, `@prisma/engines`, `@nestjs/core`) are
|
||||||
|
allowlisted in `pnpm-workspace.yaml`, so the native builds run automatically.
|
||||||
|
|
||||||
|
### 2. Configure environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Then edit `.env`. For the Docker MySQL below the defaults already line up;
|
||||||
|
just set a real `SESSION_SECRET`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=mysql://jorgecuadros:jorgecuadros@localhost:3306/jorgecuadros
|
||||||
|
SESSION_SECRET=<any long random string>
|
||||||
|
WEB_ORIGIN=http://localhost:3000
|
||||||
|
NEXT_PUBLIC_API_ORIGIN=http://localhost:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
The API loads `DATABASE_URL`, `SESSION_SECRET`, `WEB_ORIGIN`, and optional
|
||||||
|
`PORT` (default `3001`). The web app only needs `NEXT_PUBLIC_API_ORIGIN`.
|
||||||
|
|
||||||
|
### 3. Start MySQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d mysql
|
||||||
|
```
|
||||||
|
|
||||||
|
(Or point `DATABASE_URL` at an existing MySQL 8 instance.)
|
||||||
|
|
||||||
|
### 4. Create the schema + generate the Prisma client
|
||||||
|
|
||||||
|
The schema is managed with `prisma db push` (no migration history committed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @jorgecuadros/database exec prisma db push
|
||||||
|
pnpm --filter @jorgecuadros/database generate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Seed a sign-in user
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node apps/api/scripts/seed-user.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Idempotent (upsert by email). Defaults — override with `SEED_EMAIL`,
|
||||||
|
`SEED_PASSWORD`, `SEED_NAME`:
|
||||||
|
|
||||||
|
- email: `admin@jorgecuadros.local`
|
||||||
|
- password: `ChangeMe!2026`
|
||||||
|
- role: `ADMIN`
|
||||||
|
|
||||||
|
### 6. Run the apps (two terminals)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API → http://localhost:3001
|
||||||
|
pnpm --filter @jorgecuadros/api start:dev
|
||||||
|
|
||||||
|
# Web → http://localhost:3000
|
||||||
|
pnpm --filter @jorgecuadros/web dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Root shortcuts also exist: `pnpm dev:api`, `pnpm dev:web`.
|
||||||
|
|
||||||
|
### 7. Log in
|
||||||
|
|
||||||
|
Open http://localhost:3000, sign in with the seeded credentials.
|
||||||
|
Sessions are cookie-based and last 8 hours.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Run it with Docker (full stack)
|
||||||
|
|
||||||
|
Builds MySQL + API + web from `docker-compose.yml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SESSION_SECRET=$(openssl rand -hex 32)
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Web on http://localhost:3000, API on http://localhost:3001. `SESSION_SECRET`
|
||||||
|
is required (compose fails without it). After first boot, push the schema and
|
||||||
|
seed a user against the container DB:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec api node apps/api/scripts/seed-user.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
| Task | Command |
|
||||||
|
| ------------------------ | -------------------------------------------------------------- |
|
||||||
|
| Install | `pnpm install` |
|
||||||
|
| Dev — API | `pnpm dev:api` |
|
||||||
|
| Dev — Web | `pnpm dev:web` |
|
||||||
|
| Build all | `pnpm build` |
|
||||||
|
| Generate Prisma client | `pnpm prisma:generate` |
|
||||||
|
| Push schema (dev) | `pnpm --filter @jorgecuadros/database exec prisma db push` |
|
||||||
|
| Prisma Studio | `pnpm prisma:studio` |
|
||||||
|
| API tests | `pnpm --filter @jorgecuadros/api test` |
|
||||||
|
| Lint (web / api) | `pnpm --filter @jorgecuadros/web lint` · `... /api lint` |
|
||||||
|
| Seed admin user | `node apps/api/scripts/seed-user.mjs` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth & roles
|
||||||
|
|
||||||
|
Session auth via Passport local strategy; passwords hashed with Argon2 (no
|
||||||
|
plaintext, unlike the legacy app). Roles gate the UI and API — e.g. managing
|
||||||
|
`/catalogos` and `/usuarios` requires the appropriate ability (`ADMIN` /
|
||||||
|
`MANAGER`). New users are created by an admin in `/usuarios`; the first admin
|
||||||
|
comes from the seed script above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legacy data migration (optional)
|
||||||
|
|
||||||
|
`migration/` holds the one-off Python ETL that lifts data out of the old
|
||||||
|
Microsoft Access database into MySQL.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r migration/requirements.txt
|
||||||
|
python migration/run_all.py
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ `run_all.py` truncates and reloads **all** downstream tables. Never run an
|
||||||
|
> individual `transform_*.py` in isolation — it orphans dependent tables. See
|
||||||
|
> `migration/RECONCILIATION.md` for details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
- Use `pnpm --filter @jorgecuadros/database exec prisma migrate deploy` if/when
|
||||||
|
a committed migration history is adopted; today dev uses `db push`.
|
||||||
|
- Set a strong `SESSION_SECRET` and a locked-down `DATABASE_URL`.
|
||||||
|
- The API expects `WEB_ORIGIN` to match the browser origin for session cookies.
|
||||||
|
- Documents are stored in MinIO in the deployed environment (see `RESUME.md`).
|
||||||
@@ -4,11 +4,11 @@ Comprehensive state-of-the-world doc for picking this project back up. Read this
|
|||||||
before doing anything else in a fresh session — it front-loads everything that
|
before doing anything else in a fresh session — it front-loads everything that
|
||||||
took multiple rounds of investigation to establish.
|
took multiple rounds of investigation to establish.
|
||||||
|
|
||||||
**Companion doc:** the full architecture/migration plan is copied into this
|
**Companion doc:** the full architecture/migration plan is [`PLAN.md`](PLAN.md) in
|
||||||
repo as [`PLAN.md`](PLAN.md) (source of truth is
|
this repo — **that is the source of truth for the design.** (It began as
|
||||||
`C:\Users\ricar\.claude\plans\logical-yawning-tome.md` — copy here if that
|
`~/.claude/plans/logical-yawning-tome.md` on the old Windows machine; that copy is
|
||||||
one gets updated further). This file is the "what happened and what's next"
|
gone and no longer authoritative.) This file is the "what happened and what's next"
|
||||||
companion to that plan, not a replacement for it. Read both.
|
companion, not a replacement. Read both.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -32,28 +32,37 @@ infrastructure decisions below.
|
|||||||
|
|
||||||
## 2. Where everything lives (file paths)
|
## 2. Where everything lives (file paths)
|
||||||
|
|
||||||
**Source data (do not modify — read-only references):**
|
> Paths below are the **current macOS machine**. The project moved Windows → macOS on
|
||||||
- `C:\Users\ricar\Downloads\Jorge\UTILITIES.accdb` — utilities business, 52 tables, ~538MB
|
> 2026-07-22; anything still written as `C:\Users\ricar\...` in older notes is stale.
|
||||||
- `C:\Users\ricar\Downloads\Jorge\SEGUROS 16.mdb` — insurance frontend shell, **empty**, all data is in `_be`
|
|
||||||
- `C:\Users\ricar\Downloads\Jorge\SEGUROS 16_be.mdb` — insurance backend, 64 tables, ~882MB
|
**Source data (do not modify — read-only references), all in `~/Downloads/JorgeCuadros-Legacy/`:**
|
||||||
- `C:\Users\ricar\Downloads\Jorge\SCOTHIA.mdb` — office's own Scotiabank checking register ("chequera"), 7 tables, ~3MB
|
- `UTILITIES.accdb` — utilities business, 52 tables, ~538MB
|
||||||
|
- `SEGUROS 16.mdb` — insurance frontend shell, **no data tables**, but holds *all* of the
|
||||||
|
insurance line's Reports/Forms/Queries
|
||||||
|
- `SEGUROS 16_be.mdb` — insurance backend, 64 tables, ~882MB
|
||||||
|
- `SCOTHIA.mdb` — office's own Scotiabank checking register ("chequera"), 7 tables, ~3MB
|
||||||
|
- `utility_dbo.sql` — customer portal's live DB dump (1.3 GB, 55 tables)
|
||||||
|
- `jorgecuadros.sql` — older/partial export (38 MB, 11 tables), **not** the portal live DB
|
||||||
- **Full structural reference for all three, usable without Windows or the original files:** [`docs/LEGACY_DATABASES.md`](docs/LEGACY_DATABASES.md) — every table, every column with type/nullability, the cross-reference keys between the three databases, and every known data-quality quirk (the UTF-16 decode bug, the corrupted `MULT` row, near-duplicate snapshot tables, etc.), all generated from a live read of the real files via `migration/catalog_schema.py`. Regenerate it if the source files change; the raw JSON it's built from is checked in at `migration/catalog.json`.
|
- **Full structural reference for all three, usable without Windows or the original files:** [`docs/LEGACY_DATABASES.md`](docs/LEGACY_DATABASES.md) — every table, every column with type/nullability, the cross-reference keys between the three databases, and every known data-quality quirk (the UTF-16 decode bug, the corrupted `MULT` row, near-duplicate snapshot tables, etc.), all generated from a live read of the real files via `migration/catalog_schema.py`. Regenerate it if the source files change; the raw JSON it's built from is checked in at `migration/catalog.json`.
|
||||||
- **Queries/Forms/Reports reference:** [`docs/LEGACY_DATABASES_OBJECTS.md`](docs/LEGACY_DATABASES_OBJECTS.md) — none of this is visible via ODBC/`pyodbc`; it required DAO COM automation (`migration/catalog_objects.py`, needs `pywin32`) instead. Found 311 Reports, 271 Forms, and 1,274 Queries (751 "real," the rest Access-internal hidden subquery caches) across the three populated files — importantly, `SEGUROS 16.mdb` (which has zero data tables) turned out to hold *all* of the insurance line's Reports/Forms/Queries; `SEGUROS 16_be.mdb` is confirmed pure data storage. The real queries' full SQL text is the best available record of actual business logic (billing math, renewal batching) — worth reading before reimplementing any given feature from scratch. Raw JSON checked in at `migration/objects.json`.
|
- **Queries/Forms/Reports reference:** [`docs/LEGACY_DATABASES_OBJECTS.md`](docs/LEGACY_DATABASES_OBJECTS.md) — none of this is visible via ODBC/`pyodbc`; it required DAO COM automation (`migration/catalog_objects.py`, needs `pywin32`) instead. Found 311 Reports, 271 Forms, and 1,274 Queries (751 "real," the rest Access-internal hidden subquery caches) across the three populated files — importantly, `SEGUROS 16.mdb` (which has zero data tables) turned out to hold *all* of the insurance line's Reports/Forms/Queries; `SEGUROS 16_be.mdb` is confirmed pure data storage. The real queries' full SQL text is the best available record of actual business logic (billing math, renewal batching) — worth reading before reimplementing any given feature from scratch. Raw JSON checked in at `migration/objects.json`.
|
||||||
- `C:\Users\ricar\Downloads\jorgecuadros_app.sql` and `jorgecuadros_app (1).sql` — MySQL dumps of the customer-portal's **tracking/analytics** sidecar DB (`browse_tracking`, `devices` push-tokens, `task_tracking`) from `mysql.freakma.com`. **Not** the portal's real data DB — see open item #1 below.
|
- `jorgecuadros_app.sql` / `jorgecuadros_app (1).sql` (on the old machine) — MySQL dumps of the portal's **tracking/analytics** sidecar DB (`browse_tracking`, `devices` push-tokens, `task_tracking`). **Not** the portal's real data DB; superseded by `utility_dbo.sql` above.
|
||||||
|
|
||||||
|
**Customer-facing portal (out of scope to rebuild, but the sync target):**
|
||||||
|
- `~/PhpstormProjects/my-jorgecuadros-web` — PHP/`mysqli`, ~397 files, core in `scripts/functions.php`. Reads/writes `utility_dbo`.
|
||||||
|
|
||||||
**Old internal app (reference-only, not being built on):**
|
**Old internal app (reference-only, not being built on):**
|
||||||
- `C:\Users\ricar\Downloads\Jorge\jorgecuadros-intra-webapp` — PHP, MySQL (`webapp_jorgecuadros`). Schema at `db\webapp_jorgecuadros.sql` is a useful reference for field mappings/business logic. Code itself is not being reused — see §4.
|
- `jorgecuadros-intra-webapp` (on the old machine) — PHP, MySQL (`webapp_jorgecuadros`). Its `db/webapp_jorgecuadros.sql` is a useful reference for field mappings/business logic. Code itself is not reused — see §4.
|
||||||
|
|
||||||
**New platform (the actual deliverable, in progress):**
|
**New platform (the actual deliverable):**
|
||||||
- `C:\Users\ricar\Downloads\Jorge\jorgecuadros-platform` — the new repo. Not yet a git repository (no commits made this session — user hasn't asked for any yet).
|
- `~/WebstormProjects/jorgecuadros-platform` — the repo. **Is** a git repo, branch `master`, 21 commits, remote `git.mancinas.io/rmancinas/jorgecuadros-platform`.
|
||||||
|
|
||||||
**The plan document:**
|
**The plan document:**
|
||||||
- `C:\Users\ricar\.claude\plans\logical-yawning-tome.md` — full architecture, source-data inventory per table, target data model, migration strategy, infrastructure/sync design, open decisions, build sequencing. **This is the source of truth for the design** — this RESUME.md summarizes it plus session/environment state that isn't in the plan itself.
|
- [`PLAN.md`](PLAN.md) in this repo — full architecture, source-data inventory per table, target data model, migration strategy, infrastructure/sync design, locked decisions, build sequencing. **This is now the source of truth for the design** (the original `~/.claude/plans/logical-yawning-tome.md` lived on the old Windows machine). This RESUME.md is the "what happened / what's next" companion.
|
||||||
|
|
||||||
**Ephemeral / will NOT persist across sessions** (session-scoped temp directory):
|
**Staged data (gitignored, regenerable):**
|
||||||
- `C:\Users\ricar\AppData\Local\Temp\claude\...\scratchpad\` — contained exploratory helper scripts (`dump_schema.py`, `summarize_schema.py`, `test_decode_fix.py`) and the schema JSON/txt dumps used during initial analysis, plus a Parquet staging output from one run of the migration script. **None of this needs to be recovered** — the real, permanent versions of the useful scripts are in `jorgecuadros-platform/migration/`, and the Parquet output can be regenerated in under 2 minutes by rerunning `load_staging.py` (see §6).
|
- `migration/output/stg_utilities|stg_seguros|stg_scothia/*.parquet` — regenerate in ~2 min with `load_staging.py --output-dir ./output`. Every transform step reads from here.
|
||||||
|
|
||||||
## 3. Key decisions made this session
|
## 3. Key decisions (locked — see `PLAN.md` → "Decisions (locked)")
|
||||||
|
|
||||||
| Decision | Answer | Why |
|
| Decision | Answer | Why |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -65,9 +74,10 @@ infrastructure decisions below.
|
|||||||
| Infrastructure | Internal server (private) + new VPS (Tailscale-linked) running a MySQL replica | Internal server has no inbound internet exposure; shared hosting can't be a replication target; a VPS you control can be both a real replication node and internet-reachable for the portal |
|
| Infrastructure | Internal server (private) + new VPS (Tailscale-linked) running a MySQL replica | Internal server has no inbound internet exposure; shared hosting can't be a replication target; a VPS you control can be both a real replication node and internet-reachable for the portal |
|
||||||
| Auth mechanism | Session-based (Passport + `express-session`), Argon2 password hashing | Implemented already — see §5 |
|
| Auth mechanism | Session-based (Passport + `express-session`), Argon2 password hashing | Implemented already — see §5 |
|
||||||
|
|
||||||
## 4. What was actually built and verified this session
|
## 4. What is built and verified
|
||||||
|
|
||||||
Everything below was **run and confirmed working**, not just written:
|
Everything below was **run and confirmed working**, not just written. §8 carries the
|
||||||
|
per-module detail and the running status; this section is the structural tour.
|
||||||
|
|
||||||
### 4.1 Repo scaffold
|
### 4.1 Repo scaffold
|
||||||
- `jorgecuadros-platform/` — npm workspaces (`apps/*`, `packages/*`)
|
- `jorgecuadros-platform/` — npm workspaces (`apps/*`, `packages/*`)
|
||||||
@@ -94,7 +104,7 @@ Regenerate the client any time with:
|
|||||||
cd jorgecuadros-platform
|
cd jorgecuadros-platform
|
||||||
DATABASE_URL="mysql://user:pass@localhost:3306/placeholder" npx prisma generate --schema=packages/database/prisma/schema.prisma
|
DATABASE_URL="mysql://user:pass@localhost:3306/placeholder" npx prisma generate --schema=packages/database/prisma/schema.prisma
|
||||||
```
|
```
|
||||||
(A real `DATABASE_URL` isn't needed for `generate`/`validate`, just a syntactically valid one — no live DB was available in this session, see §7.)
|
(A real `DATABASE_URL` isn't needed for `generate`/`validate`, just a syntactically valid one. A live dev DB *is* available now — see §7 — so `prisma db push` works too.)
|
||||||
|
|
||||||
### 4.3 Docker Compose / Dockerfiles
|
### 4.3 Docker Compose / Dockerfiles
|
||||||
- `docker-compose.yml` — `mysql:8.4` + `api` + `web` services, healthchecked.
|
- `docker-compose.yml` — `mysql:8.4` + `api` + `web` services, healthchecked.
|
||||||
@@ -103,19 +113,28 @@ DATABASE_URL="mysql://user:pass@localhost:3306/placeholder" npx prisma generate
|
|||||||
- **Not run** — this environment has no Docker installed (`docker --version` fails). Untested beyond visual review; verify on a machine with Docker before relying on it.
|
- **Not run** — this environment has no Docker installed (`docker --version` fails). Untested beyond visual review; verify on a machine with Docker before relying on it.
|
||||||
|
|
||||||
### 4.4 Migration pipeline (`migration/`) — run end-to-end against real data
|
### 4.4 Migration pipeline (`migration/`) — run end-to-end against real data
|
||||||
- `config.py` — manifest of the 3 Access source files (paths + per-source exclude lists for confirmed-scratch tables, with reasoning in comments)
|
- `config.py` — manifest of the Access source files (`SOURCE_ROOT` + per-source exclude lists for confirmed-scratch tables, with reasoning in comments)
|
||||||
- `extract.py` — connects via `pyodbc` + the Windows Access ODBC driver (`Microsoft Access Driver (*.mdb, *.accdb)`, 64-bit — confirmed installed on this machine). Two real bugs found and fixed here:
|
- `extract.py` — shells out to **mdbtools** (`mdb-tables` / `mdb-export`, Homebrew). Rewritten from the original `pyodbc` + Windows Access ODBC version during the macOS move; public interface (`connect`/`list_tables`/`read_table`) unchanged. mdbtools also sidesteps both bugs the pyodbc path needed workarounds for: it reads accented-column tables (`PROPANO`, `FALTANTES AGUA`, `TIT`) cleanly instead of hitting a UTF-16 decode error, and it doesn't abort a whole table on `MULT`'s corrupted row.
|
||||||
1. `cursor.columns()` hits a UTF-16 decode bug on some tables (confirmed: `PROPANO`, `FALTANTES AGUA`, `TIT`) — fixed by reading column names from `cursor.description` after a `SELECT *` instead.
|
- What mdbtools **cannot** do is read Forms/Reports/Queries. Those were already captured on Windows via DAO COM and are frozen in `migration/objects.json` + `docs/LEGACY_DATABASES_OBJECTS.md` — nothing is lost, but they can't be re-extracted on this machine.
|
||||||
2. `cursor.fetchall()` aborts an entire table on the first corrupted row — confirmed on `MULT` (Jet/ACE-level "Record is deleted" error, HY109). Fixed by fetching row-by-row in a try/except, skipping and logging just the bad row. Recovered 763 of 764 rows in `MULT`. **Verified the cursor advances correctly and doesn't infinite-loop on the bad row** before trusting this for a full run.
|
- `load_staging.py` — dumps every non-excluded table into either Parquet (`--output-dir`, no DB needed) or MySQL (`--database-url`, one database per source: `stg_utilities`/`stg_seguros`/`stg_scothia`). 82 tables staged, zero unhandled errors.
|
||||||
- `load_staging.py` — dumps every non-excluded table into either Parquet (`--output-dir`, no DB needed) or MySQL (`--database-url`, one database per source: `stg_utilities`/`stg_seguros`/`stg_scothia`). **Actually run** in Parquet mode against all three Access files: **82 tables staged successfully, zero unhandled errors**, `MULT`'s corrupted row correctly skipped and logged.
|
- `reconcile.py` → `RECONCILIATION.md` — the duplicate/distinct pass (step 2). See §8 step 3.
|
||||||
- `requirements.txt` — `pyodbc`, `pandas`, `pyarrow`, `sqlalchemy`, `pymysql`.
|
- `transform_*.py`, `prune_empty_customers.py`, `blob_extract.py` — steps 3–4, all idempotent (truncate + rebuild).
|
||||||
|
- `run_all.py` — **the entry point.** Runs every step in dependency order. See the ⚠️ in §7 for why you should never run a single transform on its own.
|
||||||
|
- `dbenv.py` — `--env <name>` reads `deploy/.env.<name>` for the target DB.
|
||||||
|
- `requirements.txt` — `pandas`, `pyarrow`, `sqlalchemy`, `pymysql`, `boto3` (no `pyodbc` — that was the Windows path).
|
||||||
|
|
||||||
To rerun (from `jorgecuadros-platform/migration`, after `pip install -r requirements.txt`):
|
To rerun (from `migration/`, venv at `migration/.venv`):
|
||||||
```bash
|
```bash
|
||||||
python load_staging.py --output-dir ./output # Parquet, no DB needed — always works
|
./.venv/bin/python load_staging.py --output-dir ./output # re-extract from Access (needs mdbtools + the source files)
|
||||||
python load_staging.py --database-url mysql+pymysql://user:pass@host:3306/ # loads into real MySQL once available
|
./.venv/bin/python run_all.py --env dev # full transform+load; add --stage to re-extract first
|
||||||
|
./.venv/bin/python run_all.py --env dev --sync # additive sync: upsert legacy by provenance, keep manual rows, prune legacy empties
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--sync` mode (Phase B) upserts legacy-owned rows by their provenance keys and preserves
|
||||||
|
manual rows (`legacyId IS NULL`); every transform reuses each row's existing PK, rebuilds
|
||||||
|
legacy-owned children by scoped delete + reinsert, and drops legacy rows gone from source.
|
||||||
|
Verified end-to-end against dev 2026-07-24 — see §6 item 6.
|
||||||
|
|
||||||
## 5. Infrastructure & sync architecture (designed, not yet built)
|
## 5. Infrastructure & sync architecture (designed, not yet built)
|
||||||
|
|
||||||
- **Internal server** — on-prem, private IP `192.168.1.xx`, no inbound internet exposure. Runs the platform + canonical MySQL (source of truth).
|
- **Internal server** — on-prem, private IP `192.168.1.xx`, no inbound internet exposure. Runs the platform + canonical MySQL (source of truth).
|
||||||
@@ -124,22 +143,77 @@ python load_staging.py --database-url mysql+pymysql://user:pass@host:3306/ # l
|
|||||||
- **Internal → VPS:** one-way native MySQL replication (binlog/GTID) for the subset of data the portal needs to read (statements, balances, customer profile). Internal-only tables (staff notes, adjuster info, activity logs) are deliberately excluded from what replicates.
|
- **Internal → VPS:** one-way native MySQL replication (binlog/GTID) for the subset of data the portal needs to read (statements, balances, customer profile). Internal-only tables (staff notes, adjuster info, activity logs) are deliberately excluded from what replicates.
|
||||||
- **VPS → Internal:** the portal also *writes* (payment submissions, propane orders) — one-way replication can't carry that back, and multi-master MySQL replication was deliberately ruled out as too fragile for this system's size. Instead: unreplicated "inbox" tables on the VPS (`payment_submissions`, `propane_order_requests`) that the portal writes to directly, polled every 1–5 min by a worker on the internal server (over Tailscale) that turns new rows into real records.
|
- **VPS → Internal:** the portal also *writes* (payment submissions, propane orders) — one-way replication can't carry that back, and multi-master MySQL replication was deliberately ruled out as too fragile for this system's size. Instead: unreplicated "inbox" tables on the VPS (`payment_submissions`, `propane_order_requests`) that the portal writes to directly, polled every 1–5 min by a worker on the internal server (over Tailscale) that turns new rows into real records.
|
||||||
|
|
||||||
## 6. Open items — need input/access before certain next steps can proceed
|
## 6. Open items
|
||||||
|
|
||||||
1. **`utility_dbo` schema is still unknown.** This is the customer portal's actual live data database (referenced in the old app via `getExternalDBConnection()` at `mysql.freakma.com`, used there only for `email_alert_log`, but the mobile app almost certainly reads/writes statements, payments, and propane orders directly against it). The two SQL dumps provided (`jorgecuadros_app.sql`, `jorgecuadros_app (1).sql`) turned out to be a **separate** analytics/tracking database, not this one. When asked, the user pointed back to `SEGUROS 16_be.mdb` — worth revisiting; it's possible the intent was "the source data ultimately comes from the Access files" rather than "here is utility_dbo's schema." **Without the real `utility_dbo` schema, the sync worker's exact target tables/columns for the inbox pattern can't be finalized.** Ask for an export or read-only credentials, same as how the Access files and tracking-DB dumps were provided.
|
**Resolved since this section was first written** (kept as a pointer, not a to-do):
|
||||||
2. **VPS not yet provisioned** — provider (Hetzner vs DigitalOcean), size, and Tailscale/MySQL setup on it are pending. Ops task, not something done in this session.
|
`utility_dbo` schema (full dump on disk), CI/CD (Gitea Actions), i18n (Spanish-first), and
|
||||||
3. **CI/hosting** — keep Jenkins + `git.freakma.com`, or move to GitHub Actions if the new repo lives elsewhere?
|
the reconciliation pass (done, then corrected) are all closed. See §3 and §8.
|
||||||
4. **i18n** — nearly all source data and, presumably, staff usage is in Spanish; old app's code/UI was English-labeled. Confirm Spanish-first / bilingual / English before frontend work goes deep.
|
|
||||||
5. **Reconciliation pass not started** (migration plan step 2) — the near-duplicate snapshot tables (`EFECTIVO`/`EFECTIVO FM3`/`EFECTIVO_BACKUP`, `FEE ANUAL`/`datos2`/`fee15`/`billing`, `DATGRAL` vs `COBRO3`) need a diff/dedupe pass against the staged data before any transform-and-load into the real schema. This is explicitly *not* a "guess the rule up front" thing — the plan calls for writing SQL against the staged Parquet/MySQL data to see what's actually duplicate vs. distinct.
|
|
||||||
|
|
||||||
## 7. Environment notes (this machine, in case it matters for reproducing)
|
**Still open:**
|
||||||
|
1. **VPS not yet provisioned** — provider (Hetzner vs DigitalOcean), size, Tailscale + MySQL
|
||||||
|
replica setup. Pure ops task; the design is settled (§5). This is the only genuinely
|
||||||
|
blocking item left on the roadmap.
|
||||||
|
2. **Sync worker not built** — unblocked now that `utility_dbo` and the portal code are on
|
||||||
|
disk, but depends on the VPS existing. Portal write points to poll: `peticion_gas`,
|
||||||
|
PayPal payments, `notifications_settings`, `verification_codes`.
|
||||||
|
3. **Old external-DB credential** — the old repo's `dbConnection.php` has a hardcoded
|
||||||
|
plaintext MySQL password committed to git history. Not carried into the new platform,
|
||||||
|
but rotate it regardless; it is already exposed.
|
||||||
|
4. **`bank_transactions.categoryId` is null on all 22354 rows — RESOLVED as won't-build**
|
||||||
|
(plan step 7). The concept→ramo classifier was investigated and dropped: `concepto` is a
|
||||||
|
payee name (0 of 22354 match a category), and TABLA RAMODOS is a property-management
|
||||||
|
expense chart of accounts + owner names, not the insurance/servicios/fideicomiso split it
|
||||||
|
was assumed to be — so a classifier would invent data rather than produce a business-line
|
||||||
|
view. The `/banco` module intentionally has no category dimension. See §8 step 7(b).
|
||||||
|
5. **`TRASPASOS PAYPAL` is a clearing account, not a customer** — carries -7.03M MXN over
|
||||||
|
309 movements and therefore tops the adeudo worklist. Deliberately not special-cased in
|
||||||
|
code; needs a business decision on how to model it.
|
||||||
|
6. **DB Operations — Phase B (additive sync) — VERIFIED END-TO-END against dev DB 2026-07-24.**
|
||||||
|
Phase A provides the admin-only `/operaciones` page + `ops` API module (ability `db:manage`,
|
||||||
|
ADMIN), ingest folder, backup, restore, and destructive re-import. Phase B enables `SYNC`:
|
||||||
|
`OpsService` creates a safety backup and runs `run_all.py --sync`; transforms upsert
|
||||||
|
legacy-owned rows by provenance keys while preserving existing PKs and rows whose
|
||||||
|
`legacyId IS NULL` (manual). Prisma enforces provenance uniqueness for properties, policies,
|
||||||
|
transactions, and bank transactions (the vehicle unique was **removed** — one legacy policy
|
||||||
|
row carries up to 3 vehicles that share a `legacyId`, so provenance is not unique per
|
||||||
|
vehicle; vehicles are rebuilt by scoped delete + reinsert). Sync skips blob extraction, and
|
||||||
|
runs a **manual-safe prune** (`prune_empty_customers.py --sync` — only prunes empties that
|
||||||
|
carry a legacy ref, never manually-added customers) because the customer upsert otherwise
|
||||||
|
re-creates every previously-pruned empty from Parquet.
|
||||||
|
|
||||||
- Windows, PowerShell primary, Git Bash also available.
|
**The as-written sync was broken and had never been run; a batch of bugs were fixed on
|
||||||
- Node v25.2.0, npm 11.6.2 — no pnpm, yarn is present but npm workspaces were used throughout.
|
2026-07-24 before it passed** (fresh-uuid child FKs in policies/properties, unconditional
|
||||||
- Python 3.9 (`C:\Python39`), `pip install`'d this session: `pyodbc`, `pandas`, `pyarrow`, `psycopg2` (installed but no longer used after the MySQL switch — harmless to leave or remove).
|
child inserts, a `zip(customers, refs)` mispairing in transform_customers, invalid vehicle
|
||||||
- MS Access ODBC driver confirmed installed: **64-bit** `Microsoft Access Driver (*.mdb, *.accdb)` (matches 64-bit Python — this pairing matters, a 32/64-bit mismatch would break `pyodbc.connect`).
|
unique, lookup tables built with fresh uuids but never upserted, a `updatedAt=NOW()` on a
|
||||||
- **No Docker, no local MySQL, no local Postgres** on this machine — `docker-compose.yml` and the MySQL-target mode of `load_staging.py` are written but unexecuted here. Test both on whatever machine ends up running this for real.
|
table with no such column, and report crashes on NULL `legacySourceTable` for manual rows).
|
||||||
- Old repo's `dbConnection.php` has a **hardcoded plaintext MySQL password** for the external DB connection, committed to git history. Not carried forward into the new platform, but worth rotating that credential regardless since it's already exposed in the old repo's history.
|
Verified with `migration/` `verify_sync.py`-style harness: two consecutive `run_all.py --sync`
|
||||||
|
runs both exit 0 and pass 32/32 assertions (stable PKs, manual-row preservation, changed-row
|
||||||
|
updates, legacy-delete, no child duplication, zero FK orphans), idempotent (customers stable
|
||||||
|
at 1537). Schema pushed to dev, Prisma client regenerated, API build clean. Migration/web
|
||||||
|
changes uncommitted as of this update. Still open before production: run the same sync from
|
||||||
|
the `/operaciones` UI (OpsService path) and against a prod-shaped DB.
|
||||||
|
|
||||||
|
## 7. Environment notes (current macOS machine)
|
||||||
|
|
||||||
|
- macOS (Darwin 25.5.0), zsh. Node v22.23.0. Python 3.14.6 in `migration/.venv`. Homebrew, Docker, MySQL/MariaDB client all present.
|
||||||
|
- **mdbtools** installed via Homebrew — the extraction toolchain. No Access ODBC driver (and none needed).
|
||||||
|
- **`npm` is pnpm-aliased**, and pnpm ignores the `workspaces` field. Consequences:
|
||||||
|
- there is **no root `node_modules/.bin`**. Binaries live per-app: `apps/api/node_modules/.bin/nest`, `apps/web/node_modules/.bin/next`.
|
||||||
|
- Prisma CLI is run as `npx prisma@5`.
|
||||||
|
- **Dev servers** (both must be up to use the UI). ⚠️ **Ports come from the env files, not the
|
||||||
|
framework defaults** — `apps/api/.env` sets `PORT=4501` and `WEB_ORIGIN=http://localhost:4500`,
|
||||||
|
and `apps/web/.env.local` points at `NEXT_PUBLIC_API_ORIGIN=http://localhost:4501`. This doc
|
||||||
|
said `:3001`/`:3000` until 2026-07-27; that was wrong and cost a debugging detour.
|
||||||
|
- API `cd apps/api && ./node_modules/.bin/nest start --watch` → **`:4501`**
|
||||||
|
- Web `cd apps/web && ./node_modules/.bin/next dev -p 4500` → **`:4500`**
|
||||||
|
- Dev login: `admin@jorgecuadros.local`, password from `apps/api/scripts/seed-user.mjs` (`SEED_PASSWORD` env overrides the default).
|
||||||
|
- **Dev DB**: `192.168.4.212:3307` (cubex Swarm stack `jorgecuadros-dev-db`). Credentials in gitignored `deploy/.env.dev`. **MinIO** for documents: `192.168.4.212:9100`, bucket `jorgecuadros-documents`.
|
||||||
|
|
||||||
|
**Traps worth knowing before you lose an hour to one:**
|
||||||
|
- ⚠️ **Never run a single `transform_*.py` on its own — use `run_all.py`.** Each step truncates what it owns, so a lone run orphans everything downstream. `prune_empty_customers.py` must re-run after any ledger change, and `blob_extract.py` must follow properties + policies or the uploaded MinIO objects end up with no rows pointing at them.
|
||||||
|
- ⚠️ **Never run `next build` while `next dev` is running** — they share `.next` and the dev server starts serving blank white pages. Recovery: kill the dev server, `rm -rf apps/web/.next`, restart.
|
||||||
|
- ⚠️ **`mdb-export` formats numerics per Access column type** (`5000` from one table, `27000.0000` from another). Never string-compare staged Parquet numerics across two tables — canonicalize first. This exact trap produced a wrong, *locked* migration decision that shipped 12386 duplicate rows into the ledger (§8 step 4).
|
||||||
|
- Shell on this machine: `head` is aliased to an HTTP HEAD tool — use `/usr/bin/head`. `grep --include=*.md` trips zsh globbing — quote the pattern.
|
||||||
|
|
||||||
## 8. Plan locked — next actions
|
## 8. Plan locked — next actions
|
||||||
|
|
||||||
@@ -151,33 +225,304 @@ python load_staging.py --database-url mysql+pymysql://user:pass@host:3306/ # l
|
|||||||
- `utility_dbo`: **resolved** — full dump (`utility_dbo.sql`, 1.3 GB, 55 tables) and the
|
- `utility_dbo`: **resolved** — full dump (`utility_dbo.sql`, 1.3 GB, 55 tables) and the
|
||||||
portal codebase (`~/PhpstormProjects/my-jorgecuadros-web`) are both on disk.
|
portal codebase (`~/PhpstormProjects/my-jorgecuadros-web`) are both on disk.
|
||||||
|
|
||||||
**Environment: moved Windows → macOS.** Sources now at `~/Downloads/JorgeCuadros-Legacy/`
|
**Environment: moved Windows → macOS** (2026-07-22). See §7 for the current machine.
|
||||||
(all four files). This machine has Docker, MySQL/MariaDB client, Node 22, Python 3.14,
|
|
||||||
Homebrew. No Access ODBC driver, `node_modules` not installed, staging Parquet not present.
|
|
||||||
|
|
||||||
**Execution queue (in order):**
|
**Execution queue.** Steps 1–6 below are **done**; they are kept because each carries the
|
||||||
1. **Port the extraction layer to mdbtools.** Rewrite `migration/extract.py` to shell out to
|
data findings and corrections that came out of doing it. Skip to the ⏭ marker at the end
|
||||||
|
for what's actually next.
|
||||||
|
|
||||||
|
1. ~~**Port the extraction layer to mdbtools.**~~ **DONE.** Rewrote `migration/extract.py` to shell out to
|
||||||
`mdb-tables`/`mdb-export` instead of `pyodbc`. Keep the same public interface
|
`mdb-tables`/`mdb-export` instead of `pyodbc`. Keep the same public interface
|
||||||
(`connect`/`list_tables`/`read_table`) so `load_staging.py` and `config.py` are unchanged
|
(`connect`/`list_tables`/`read_table`) so `load_staging.py` and `config.py` are unchanged
|
||||||
beyond the already-fixed `SOURCE_ROOT`. Carry over the two hard-won fixes conceptually:
|
beyond the already-fixed `SOURCE_ROOT`. Carry over the two hard-won fixes conceptually:
|
||||||
accented-column tables (mdbtools reads `PROPANO` cleanly — verified) and the corrupted `MULT`
|
accented-column tables (mdbtools reads `PROPANO` cleanly — verified) and the corrupted `MULT`
|
||||||
row (mdb-export's `-b` / error handling; confirm the bad row is skipped, not fatal).
|
row (mdb-export's `-b` / error handling; confirm the bad row is skipped, not fatal).
|
||||||
2. **Re-run staging** (`python load_staging.py --output-dir ./output`) to regenerate the staged
|
2. ~~**Re-run staging**~~ **DONE** — staged Parquet regenerated on this machine
|
||||||
data on this machine, then load into a local MySQL (`docker compose up mysql`) for SQL reconciliation.
|
(`load_staging.py --output-dir ./output`), 82 tables.
|
||||||
3. **Reconciliation pass** (plan step 2) — **DONE** (`migration/reconcile.py` → `RECONCILIATION.md`).
|
3. **Reconciliation pass** (plan step 2) — **DONE** (`migration/reconcile.py` → `RECONCILIATION.md`),
|
||||||
Overturned all three "duplicate" assumptions: EFECTIVO/BACKUP are near-disjoint ledgers
|
**and corrected 2026-07-22.** Current verdicts:
|
||||||
(folio collides; migrate both), the billing tables are disjoint period runs (union all, no
|
- `EFECTIVO_BACKUP` is a **stale backup copy of `EFECTIVO`** — 12386 of its 12387 rows are
|
||||||
de-dup), and COBRO3 is a charge batch not a customer snapshot (DATGRAL is sole master). The
|
verbatim duplicates (customer + timestamp-to-the-second + amount + concept text), leaving
|
||||||
decided union/de-dup rules are in `PLAN.md` migration step 2.
|
1 new row. Load EFECTIVO in full, de-dup BACKUP on the business key. **Never de-dup on
|
||||||
4. **Transform + load** (plan step 3) — NEXT. Start with `Customer`/`CustomerLegacyRef` (every
|
`folio`** — it is per-table sequential and collides (12363 shared numbers, 12204 of them
|
||||||
other module depends on it): DATGRAL (utilities) is the master; join insurance `DATGRAL`
|
on different payments).
|
||||||
via its `NUM UTIL` cross-ref + name/address matching; COBRO3 excluded from customers. Then
|
- The billing tables (`datos2`/`FEE ANUAL`/`fee15`) are disjoint period runs — union all,
|
||||||
the ledger union per the reconciliation rules (both EFECTIVO tables, all three billing tables,
|
no de-dup.
|
||||||
provenance-keyed; normalize `monedas` currency variants).
|
- `COBRO3` is a charge batch, not a customer snapshot — `DATGRAL` is the sole master.
|
||||||
5. **Customer module** in `apps/api`/`apps/web` (list/search/detail) — first real feature,
|
|
||||||
Spanish-first UI. Run `npm install` at repo root first (node_modules absent here).
|
|
||||||
6. **Sync design finalization** — now unblocked: map the internal→VPS replicated subset and the
|
|
||||||
VPS→internal inbox tables against the real `utility_dbo` schema and the portal's read/write
|
|
||||||
points in `my-jorgecuadros-web` (`peticion_gas`, PayPal payments, `notifications_settings`).
|
|
||||||
|
|
||||||
Only genuinely-pending item is **VPS provisioning** (ops task — provider/size/Tailscale+MySQL).
|
⚠️ **This file and `PLAN.md` previously said the opposite about EFECTIVO** ("near-disjoint
|
||||||
|
ledgers, migrate both"). That was a bug, not a finding — see the Shared ledger entry in
|
||||||
|
step 4 below for the root cause and the fix. If you read a doc, comment, or commit message
|
||||||
|
from before 2026-07-22 that says "migrate both, no folio de-dup", it is stale.
|
||||||
|
The authoritative rules live in `PLAN.md` migration step 2.
|
||||||
|
4. **Transform + load** (plan step 3) — IN PROGRESS.
|
||||||
|
- **Customers — DONE** (`migration/transform_customers.py`). Loaded into the dev DB: 1682
|
||||||
|
customers (1172 utilities master + 510 insurance-only), 2242 legacy refs (all traceable),
|
||||||
|
560 insurance rows linked via `num_util` with 0 broken refs, **542 merged identities**
|
||||||
|
spanning both business lines; linked customers enriched with insurance-only ID-doc fields.
|
||||||
|
COBRO3 excluded. Re-runnable (truncate+rebuild); needs staged Parquet present
|
||||||
|
(`load_staging.py --output-dir ./output` first).
|
||||||
|
- **Properties — DONE** (`migration/transform_properties.py`): 1519 properties (0 orphans),
|
||||||
|
3486 services, 553 trust accounts from DATMEX/PROFILE; PROFILE flags matched 1519/1519.
|
||||||
|
- **Policies — DONE** (`migration/transform_policies.py`): config-driven consolidation of all
|
||||||
|
insurance lines into `policies` (2378: AUTO 1307 / MULT 760 / LICENCIAS 306 / M_EMPR 5;
|
||||||
|
10 skipped for unresolved customer, 0 orphans) + 4678 installments, 1110 vehicles, 513
|
||||||
|
insured_drivers, 126 beneficiaries, 1 claim, 5 policy_types, 15 insurance_providers, 17
|
||||||
|
adjusters. Unmodeled coverage columns preserved verbatim in `coveragesJson`. Verified a
|
||||||
|
unified customer (EARWOOD, DAVID) carrying both a utility property+services and 2 MULT
|
||||||
|
policies — the cross-line customer view works at the data layer.
|
||||||
|
- **Shared ledger — DONE** (`migration/transform_transactions.py`): **33475** transactions
|
||||||
|
(UTILITY 33180 / INSURANCE 295, 0 orphans) unioning EFECTIVO (13695) **plus only the 1
|
||||||
|
business-key-new row from EFECTIVO_BACKUP**, all three billing tables
|
||||||
|
(datos2/FEE ANUAL/fee15), the FM3 fee stream (amount=fee+tax+multa), IVA 2015 (nominal
|
||||||
|
date), and insurance EFECTIVO; plus 79 `type_transactions` and 2301 `exchange_rates`.
|
||||||
|
Skipped 22 no-customer + 272 no-date + **12417 EFECTIVO_BACKUP duplicates**.
|
||||||
|
**Corrected 2026-07-22 — this used to load 45861 rows.** `reconcile.py` had string-compared
|
||||||
|
`monto`, which mdb-export serializes at a different precision per Access column type
|
||||||
|
(`5000` vs `27000.0000`), so it saw 2 overlapping rows instead of 12386 and ruled
|
||||||
|
EFECTIVO_BACKUP an independent ledger. It is a stale backup copy: 12386 of its 12387 rows
|
||||||
|
match an EFECTIVO row on customer + timestamp-to-the-second + amount + concept text. The
|
||||||
|
ledger was double-counting those payments, roughly doubling every customer's historical
|
||||||
|
receipt total. Both `reconcile.py` (canonicalizes numeric key columns now) and
|
||||||
|
`transform_transactions.py` (de-dups on the business key, never on `folio` — folio
|
||||||
|
collides) are fixed, and `run_all.py --env dev` has been re-run end to end.
|
||||||
|
**Lesson for any future reconciliation: never compare mdb-export output as raw strings
|
||||||
|
across two tables — canonicalize numerics first.**
|
||||||
|
- **Bank register — DONE** (`migration/transform_bank.py`): 22354 `bank_transactions` from
|
||||||
|
SCOTHIA DATOS I/E as signed amounts (income +, expense -; net +899,375.77), 66
|
||||||
|
`business_line_categories`. No customer FK; categoryId left null (concept->ramo classifier
|
||||||
|
is a later enhancement).
|
||||||
|
- **Documents — DONE** (`migration/blob_extract.py`, migration step 4): carves embedded files
|
||||||
|
out of the Access OLE wrapper (magic-byte detection) and uploads to MinIO on cubex (stack
|
||||||
|
`jorgecuadros-dev-minio`, S3 at 192.168.4.212:9100, bucket jorgecuadros-documents), writing
|
||||||
|
service_documents/policy_documents pointer rows. Loaded 70 documents (3 service bills +
|
||||||
|
67 policy foto/docs, 0 orphans, ~290 MB). **Data finding:** the LONGBINARY columns are
|
||||||
|
almost entirely empty — DATMEX's real bill-scan columns are ILUZ/IAGUA/IPREDIAL/ITEL (not
|
||||||
|
doc_1/doc_2), but only 3 cells populated across 1520 rows; the *_MENS tables are mail-merge
|
||||||
|
templates (correctly excluded). The 538MB/882MB source files are mostly Access bloat, not
|
||||||
|
documents. **Migration steps 1-4 COMPLETE.**
|
||||||
|
- **Customer module (plan step 3) — DONE**: `apps/api/src/customers/` (list/search/detail/stats)
|
||||||
|
+ `apps/web` `/clientes` and `/clientes/[id]`, Spanish-first, verified against real data.
|
||||||
|
- **Insurance module (plan step 4) — DONE**: `apps/api/src/policies/` (`GET /policies` with
|
||||||
|
search over policy number / customer / agent / plate / driver name, vigencia buckets
|
||||||
|
active|expiring|expired|undated, ramo + aseguradora + liquidada filters, 5 sorts;
|
||||||
|
`/policies/stats`, `/policies/facets`, `/policies/:id`) + web `/polizas` (renewals-first
|
||||||
|
browser, clickable stat cells) and `/polizas/[id]`. Cross-links both ways with the customer
|
||||||
|
view. **Data finding:** the `policies.total` column is dead — only 2 of 2378 rows are
|
||||||
|
non-zero (1585 are literally 0, 791 null) and one of those two is *lower* than its own net
|
||||||
|
premium, so every premium headline and the premium sort use `netPremium` (populated on
|
||||||
|
2377/2378). This also fixed a live bug on the customer detail page, which was showing
|
||||||
|
"$0.00 Total" for 1585 policies.
|
||||||
|
- **Utilities module (plan step 5) — DONE**: `apps/api/src/properties/` (`GET /properties`
|
||||||
|
with search over address / customer / service account number / meter / trust number /
|
||||||
|
phones, filters for service kind, municipality, trust bank, trust bucket
|
||||||
|
(with|without|active|expiring|expired|undated) and `hasServices`, 5 sorts;
|
||||||
|
`/properties/stats`, `/properties/facets`, `/properties/:id`) + web `/servicios`
|
||||||
|
(renewals-first property browser with clickable stat cells and a clickable service-mix
|
||||||
|
strip) and `/servicios/[id]` (services, fideicomiso, linked policy, owner + sibling
|
||||||
|
properties, owner-level utility ledger, documents). Cross-links both ways with the
|
||||||
|
customer and policy views. **Data findings:** (a) the trust renewal date staff chase is
|
||||||
|
`trust_accounts.dueDate2` — DATMEX's `vence2`, one year after `vence1` on 531 of 541
|
||||||
|
dated trusts (18 due within 30 days, 119 already overdue); (b) `properties.zone` is
|
||||||
|
effectively dead (1444 of 1519 null, the rest near-unique), so it is not a facet;
|
||||||
|
(c) the municipality that bills a property lives in the *predial* service's `notes`
|
||||||
|
(ROSARITO 566 / TIJUANA 221 / ENSENADA 152, 939/939 populated) — that is the real
|
||||||
|
geographic filter. `PropertyService.notes` means something different per kind
|
||||||
|
(municipality / CFE PAR-IMPAR cycle / gas supply type), so the UI labels it per kind.
|
||||||
|
240 of 1519 properties have no service rows at all — surfaced as its own bucket.
|
||||||
|
- **Billing / statements module (plan step 6) — DONE**: `apps/api/src/billing/`
|
||||||
|
(`GET /billing` movement browser with search over customer / referencia / cheque /
|
||||||
|
concepto / periodo, filters for línea, moneda, cargo-vs-abono, concepto (typeId), origin
|
||||||
|
table and a from/to date range, 5 sorts, and **totals for the whole filtered set**;
|
||||||
|
`GET /billing/balances` per-customer balances with owing/credit/settled buckets and 4
|
||||||
|
sorts; `/billing/stats`, `/billing/facets`, `/billing/customers/:id`) + web
|
||||||
|
`/estado-cuenta` (two tabs: "Saldos por cliente" worklist and "Movimientos" ledger) and
|
||||||
|
`/estado-cuenta/[id]` (the statement: balance per currency, the same balance split by
|
||||||
|
business line, cargos por concepto with proportional bars, and the full movement table
|
||||||
|
with a running balance). Cross-links from the customer and property detail pages.
|
||||||
|
**Data findings:**
|
||||||
|
(a) `transactions.amount` is a *signed* ledger — every charge type is negative without
|
||||||
|
exception (WATER 3115/3117, ELECTRIC 2191/2191, PROPERTY TAXES 926/926, TRUST FEE
|
||||||
|
188/188) and every deposit type positive (CHECK/CASH DEPOSIT, PAYPAL, all of EFECTIVO),
|
||||||
|
so `SUM(amount)` is the balance and negative = the customer owes.
|
||||||
|
(b) **Currency is not summable.** 912 of the 1269 customers with a ledger move in both
|
||||||
|
MXN and USD; the charge side (datos2/FEE ANUAL/fee15) is MXN-only while receipts arrive
|
||||||
|
in both, and no per-movement exchange rate was ever stored. Every figure in the module is
|
||||||
|
per currency; the balance filter/sort takes a currency argument rather than collapsing.
|
||||||
|
(c) `type_transactions.nameEs` is **entirely null** — the legacy `TYPE OF TRX` table has
|
||||||
|
an `ESPAÑOL` column but all 79 rows are empty, so the API can only return English names.
|
||||||
|
`labels.ts:TX_TYPE_LABELS` supplies Spanish for the real service/payment categories; the
|
||||||
|
rest of the 79 "types" are payee names (LORETO GONZALEZ, ALBERCAS VALLARTA…) that fall
|
||||||
|
through untranslated, which is correct.
|
||||||
|
(d) The biggest debtor by far is **"TRASPASOS PAYPAL"** (-7.03M MXN over 309 movements) —
|
||||||
|
a house/clearing account, not a person. Left in rather than special-cased, but it will
|
||||||
|
head the adeudo worklist until someone decides how to model it.
|
||||||
|
- **Bank register module (plan step 7) — DONE**: `apps/api/src/bank/`
|
||||||
|
(`GET /bank` register browser with search over concepto/beneficiario, cheque
|
||||||
|
`reference`, `notes` and `amountInWords`; filters for direction
|
||||||
|
(income|expense|void), cleared status and a from/to date range, 5 sorts, and
|
||||||
|
**income/expense/net totals for the whole filtered set**; `/bank/stats`,
|
||||||
|
`/bank/facets` (year list), `/bank/summary` year/month rollup with a running
|
||||||
|
net figure) + web `/banco` (two tabs: "Movimientos" register and "Resumen por
|
||||||
|
periodo" with clickable year→month drill-down). Added to the AppShell nav as
|
||||||
|
"Chequera". Verified end-to-end in the browser: totals reconcile
|
||||||
|
(6948 income + 14615 expense + 791 void = 22354; net +899,375.77 matches
|
||||||
|
stats; 2013 months open at $0 and close at the year's net $794,295.78).
|
||||||
|
**Design decisions / data findings:**
|
||||||
|
(a) **Kept separate from `/estado-cuenta` on purpose** — this is the office's
|
||||||
|
own chequera, not customer money; the two ledgers are never summed or shown
|
||||||
|
together. Distinct nav entry, distinct page, distinct API module.
|
||||||
|
(b) **No category/ramo dimension, and the concept→ramo classifier was NOT
|
||||||
|
built** (closes open item §6.4): the data cannot support it. `DATOS E/I` have
|
||||||
|
no ramo column to migrate; `concepto` is a *payee* name (PAYPAL, CFE, ~1,900
|
||||||
|
individuals), and 0 of 22354 concepts match a `business_line_categories` name;
|
||||||
|
and the 66 TABLA RAMODOS rows are a property-management expense chart of
|
||||||
|
accounts (Payroll, Pool Labor, Gardening) + owner names, *not* the
|
||||||
|
insurance/servicios/fideicomiso split the migration comment implied. A
|
||||||
|
classifier would invent data, so `categoryId` stays null and the module does
|
||||||
|
not filter on it. Register is browsable by date/payee/amount/cheque instead.
|
||||||
|
(c) ~~**Single currency (MXN).**~~ **SUPERSEDED 2026-07-27 by the multi-bank
|
||||||
|
chequera** (step 11, `docs/RECEIPT_CAPTURE_SPEC.md` §3). The office keeps
|
||||||
|
more than one register, so `bank_transactions` now carries a **required**
|
||||||
|
`bankAccountId` and every read in the module is scoped to exactly one
|
||||||
|
`BankAccount`, whose `currency` the movements inherit — there is still no
|
||||||
|
currency column on the movement itself, because a real bank account doesn't
|
||||||
|
mix currencies. All 22,669 migrated rows are the Utilities/Scotiabank MXN
|
||||||
|
account (backfilled by `migration/backfill_bank_accounts.py`, which
|
||||||
|
`run_all.py` runs before `transform_bank.py`), which is why every
|
||||||
|
`amountInWords` is still spelled out in PESOS. There is deliberately no
|
||||||
|
"all accounts" option: summing an MXN and a USD register would repeat the
|
||||||
|
currency-collapsing mistake the billing module warns against.
|
||||||
|
(d) **The "acumulado" is net movement since the register opened, not a bank
|
||||||
|
balance** — SCOTHIA carries no opening balance (its `ban` table holds only the
|
||||||
|
bank's name), so the running total starts at 0 in 2013. Labelled as such in
|
||||||
|
the UI so it is never read as a statement balance.
|
||||||
|
(e) Sign convention (from `transform_bank.py`): positive = ingreso,
|
||||||
|
negative = egreso, exactly zero = a cancelled/void cheque (787 of 791 say
|
||||||
|
CANCELADO/VOID) — voids are excluded from both the income and expense sides.
|
||||||
|
(f) **Multi-account since 2026-07-27.** `/banco` opens on an account picker
|
||||||
|
(the last account is remembered per browser) and reads every figure in that
|
||||||
|
account's currency; `/banco/cuentas` manages banks and accounts under a new
|
||||||
|
MANAGER `bank:manage-accounts` ability. Accounts are never deleted — the
|
||||||
|
`bankAccountId` FK is required, so a used account can only be *closed*
|
||||||
|
(`active: false`), which hides it from new captures but keeps its history
|
||||||
|
readable. An account's currency is immutable after creation, since its
|
||||||
|
booked movements are denominated in it.
|
||||||
|
- Full pipeline reproducible in one command: `run_all.py --env <env>` runs customers →
|
||||||
|
properties → policies → transactions → prune → bank accounts → bank → blobs in order
|
||||||
|
(all idempotent); add `--stage` to re-extract from the Access files first. Verified
|
||||||
|
end-to-end against dev.
|
||||||
|
|
||||||
|
5. **Infra** — **DONE.** Dev MySQL deployed to the cubex Swarm via the Portainer API as stack
|
||||||
|
`jorgecuadros-dev-db` (MySQL 8.4, `192.168.4.212:3307`, node `cubex` labeled
|
||||||
|
`jorgecuadros_db=true`); Prisma schema pushed (26 tables). Stack file
|
||||||
|
`deploy/jorgecuadros-db.stack.yml` deploys prod from the same file as
|
||||||
|
`jorgecuadros-prod-db` on :3306. MinIO for documents deployed as `jorgecuadros-dev-minio`.
|
||||||
|
|
||||||
|
6. **Staff web UI** — **DONE** for all five modules (§8 step 4 + step 7: clientes, polizas,
|
||||||
|
servicios, estado-cuenta, banco). Spanish-first, session-cookie auth against the API,
|
||||||
|
verified against real migrated data. **All app-layer feature work is complete.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- **Sync implementation — DONE + VALIDATED end-to-end against dev 2026-07-24.** `run_all.py
|
||||||
|
--sync` performs the non-destructive legacy upsert path for customers, properties, policies,
|
||||||
|
transactions, and bank rows, plus a manual-safe empty-customer prune. It preserves manual rows
|
||||||
|
and stable legacy-owned primary keys; the admin SYNC job auto-creates a pre-sync backup. The
|
||||||
|
as-written code was broken and had never been run — a batch of bugs was fixed before it passed
|
||||||
|
(see §6 item 6). Two consecutive syncs both exit 0 and pass 32/32 assertions (added, changed,
|
||||||
|
removed, and manually-created rows), idempotent. Remaining: exercise the same path from the
|
||||||
|
`/operaciones` admin UI and against a prod-shaped DB.
|
||||||
|
- **Plan step 9: portal sync worker** remains separate and blocked on VPS provisioning. This
|
||||||
|
Phase B feature synchronizes Access source files into the internal platform; it does not yet
|
||||||
|
poll `utility_dbo` inbox tables or replicate portal-facing data to a VPS.
|
||||||
|
- **Small / open:** (a) `TRASPASOS PAYPAL` clearing account still tops the adeudo worklist
|
||||||
|
(§6.4d) — a business modelling call, not code. (b) Credential rotation on the old repo's
|
||||||
|
exposed MySQL password. (c) ~~The `/estado-cuenta` browser visual pass.~~ **DONE 2026-07-24** —
|
||||||
|
verified vs dev: Anular buttons admin-gated, voided rows struck + excluded from totals,
|
||||||
|
clicking Anular voids end-to-end (note: it uses a blocking `window.confirm`). Customer-detail
|
||||||
|
mini tx list now also strikes voided rows ("(anulado)" tag) — was the last void-UI gap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Statement OCR intake (`/recibos`) — DONE 2026-08-01
|
||||||
|
|
||||||
|
Plan step 11 §2 (`docs/RECEIPT_CAPTURE_SPEC.md` §2). The last big utilities
|
||||||
|
feature: staff scan the month's utility bills and the machine proposes customer
|
||||||
|
+ amount per page, instead of keying 300+ statements per company by hand. Built
|
||||||
|
in `apps/api/src/statements/` and `apps/web/src/app/recibos/`, posting through
|
||||||
|
step 11 §1.2's `BillingService.createBatch` seam (`source: "OCR"`, per-document
|
||||||
|
`captureRef`) so machine and hand capture share one write path and one audit
|
||||||
|
trail. Abilities `statement:ingest` / `statement:review`, both STAFF.
|
||||||
|
|
||||||
|
**Verified end to end against the live dev API + MinIO**, not just built: real
|
||||||
|
CFE and Telnor scans uploaded over HTTP, OCR'd, matched, confirmed against a
|
||||||
|
check, and the resulting rows checked in MySQL — negative (charge) amounts,
|
||||||
|
`captureSource = OCR`, concept auto-derived from the batch's service kind,
|
||||||
|
`captureRef` linking each transaction back to its page. Re-confirming a posted
|
||||||
|
batch is refused. All test data was removed afterwards.
|
||||||
|
|
||||||
|
**Everything here was decided from 10 real scanned statements (46 pages), not
|
||||||
|
from the sample-free spec.** Shipped-parser results on them: provider 46/46,
|
||||||
|
account reference 43/46, amount 42/46, due date 44/46; matched against the dev
|
||||||
|
database, **39/46 (85%) exact auto-match, 40/46 (87%) identified**. The rest are
|
||||||
|
real review cases (one shared account number, three phones not on file, one
|
||||||
|
clave not in the book, one page too poor to read).
|
||||||
|
|
||||||
|
Findings that corrected the spec, each of which changed the build:
|
||||||
|
|
||||||
|
- **The scans have no text layer at all** — they are camera images of paper, so
|
||||||
|
OCR is mandatory rather than a convenience, and they arrive **bundled, one
|
||||||
|
customer per page**.
|
||||||
|
- **Clave catastral is not predial.** `DATMEX.clave` (934 rows, `KA903009`) is
|
||||||
|
what CESPT and predial bills print; `DATMEX.predial` — which
|
||||||
|
`PROPERTY_TAX.accountNumber` holds — has only 663 distinct values across 1135
|
||||||
|
rows and appears on no statement. The clave now lives on
|
||||||
|
`Property.cadastralKey` as the matcher's secondary key; predial was left
|
||||||
|
untouched. This is the question that had been blocking predial matching.
|
||||||
|
- **Gas was recoverable after all.** The spec said no legacy gas number existed;
|
||||||
|
in fact 160 of 334 `DATMEX.gas` values are real account numbers (the rest are
|
||||||
|
`ESTACIONARIO`/`CILINDRO` descriptors). Recovered into `GAS.meterNumber`.
|
||||||
|
- **Phone is one billed line per property** (534 / 18 / 1 across phone1/2/3), so
|
||||||
|
`TELEPHONE` — a new `ServiceKind` — backfills from `phone1` only.
|
||||||
|
- **Never match on the printed name.** A CESPT receipt for account `5365218`
|
||||||
|
reads `ARNAIZ ROSAS ELSA AURORA`; the office's book, corroborated by the
|
||||||
|
clave, has `CATT, RANDY`. The name on a utility bill is the registrant, not
|
||||||
|
the current owner.
|
||||||
|
|
||||||
|
`migration/backfill_statement_match_fields.py` closes those three data gaps on
|
||||||
|
an existing database (idempotent, wired into `run_all.py` after
|
||||||
|
`transform_properties.py`, which now produces them directly on a full rebuild).
|
||||||
|
Applied to dev: 934 claves, 160 gas numbers, 534 TELEPHONE rows.
|
||||||
|
|
||||||
|
Implementation notes worth keeping:
|
||||||
|
|
||||||
|
- OCR is self-hosted **Tesseract** behind an `OcrProvider` interface — the
|
||||||
|
provider question is closed on measured accuracy, and a managed API stays a
|
||||||
|
one-line swap in `statements.module.ts`. `tesseract-ocr`,
|
||||||
|
`tesseract-ocr-data-spa` and `poppler-utils` were added to the API image; if
|
||||||
|
they are missing the module reports itself unavailable and only this feature
|
||||||
|
is disabled.
|
||||||
|
- **Payment barcodes beat printed labels.** One CFE label OCR'd a digit too
|
||||||
|
many while its barcode was correct, so the barcode is the source and the label
|
||||||
|
the cross-check; disagreement forces review.
|
||||||
|
- **Detect the provider by brand first, layout only as a fallback** — and never
|
||||||
|
interleave the two passes. A scanned CESPT header came back as `E BAJA ES
|
||||||
|
PAGO / EALIFORNIA`, which is why the layout fallback exists; a Telnor page
|
||||||
|
contains words a CFE layout rule would otherwise claim, which is why ordering
|
||||||
|
matters.
|
||||||
|
- **Parse amounts by separator position.** A real Telnor bill OCR'd as
|
||||||
|
`$ 649,00`; stripping commas as thousands separators turns that into $64,900.
|
||||||
|
- Two of the three layouts are line-oriented, but the CESPT "RECIBO" is a
|
||||||
|
**table** whose values sit under column headers — that one needs the word
|
||||||
|
boxes, which is why `OcrPage` carries geometry and not just text.
|
||||||
|
- Confirming a document whose matched service had no reference **writes the
|
||||||
|
reference back** (only into an empty field, and only when exactly one blank
|
||||||
|
service of that kind is a candidate), so gas and any other cold start is a
|
||||||
|
one-time cost rather than a permanent queue.
|
||||||
|
- Handwritten folder numbers on the bills (`9`, `405`) are **not** used for
|
||||||
|
matching — Tesseract read `405` as `205`.
|
||||||
|
|
||||||
|
**Open:** whether the CFE charge should be the rounded barcode/headline figure
|
||||||
|
(`$268`, what is paid at the window — what the parser uses today) or the exact
|
||||||
|
breakdown total (`$268.88`). One question for Jorge.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/api",
|
"name": "@jorgecuadros/api",
|
||||||
"version": "0.1.0",
|
"version": "1.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jorgecuadros/database": "0.1.0",
|
"@aws-sdk/client-s3": "^3.665.0",
|
||||||
|
"@jorgecuadros/database": "workspace:*",
|
||||||
"@nestjs/common": "^10.4.4",
|
"@nestjs/common": "^10.4.4",
|
||||||
"@nestjs/config": "^3.3.0",
|
"@nestjs/config": "^3.3.0",
|
||||||
"@nestjs/core": "^10.4.4",
|
"@nestjs/core": "^10.4.4",
|
||||||
@@ -20,7 +21,9 @@
|
|||||||
"argon2": "^0.41.1",
|
"argon2": "^0.41.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"express-session": "^1.18.0",
|
"express-session": "^1.18.0",
|
||||||
|
"pdfkit": "^0.15.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -31,8 +34,10 @@
|
|||||||
"@nestjs/testing": "^10.4.4",
|
"@nestjs/testing": "^10.4.4",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/express-session": "^1.18.0",
|
"@types/express-session": "^1.18.0",
|
||||||
|
"@types/pdfkit": "^0.13.5",
|
||||||
"@types/jest": "^29.5.13",
|
"@types/jest": "^29.5.13",
|
||||||
"@types/node": "^20.16.11",
|
"@types/node": "^20.16.11",
|
||||||
|
"@types/passport": "^1.0.17",
|
||||||
"@types/passport-local": "^1.0.38",
|
"@types/passport-local": "^1.0.38",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Seed an initial staff sign-in user. Idempotent (upsert by email).
|
||||||
|
// Run with the target DATABASE_URL in the environment, e.g.:
|
||||||
|
// DATABASE_URL="mysql://..." node apps/api/scripts/seed-user.mjs
|
||||||
|
// Optional: SEED_EMAIL, SEED_PASSWORD, SEED_NAME (dev defaults below).
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const argon2 = require("argon2");
|
||||||
|
const { PrismaClient } = require("@jorgecuadros/database");
|
||||||
|
|
||||||
|
const email = process.env.SEED_EMAIL || "admin@jorgecuadros.local";
|
||||||
|
const password = process.env.SEED_PASSWORD || "ChangeMe!2026";
|
||||||
|
const name = process.env.SEED_NAME || "Administrador";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const passwordHash = await argon2.hash(password);
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { email },
|
||||||
|
update: { passwordHash, active: true, role: "ADMIN", name },
|
||||||
|
create: { email, passwordHash, active: true, role: "ADMIN", name },
|
||||||
|
});
|
||||||
|
console.log(`seeded user: ${user.email} (role ${user.role})`);
|
||||||
|
console.log(` password: ${password}`);
|
||||||
|
await prisma.$disconnect();
|
||||||
@@ -6,4 +6,25 @@ export class AppController {
|
|||||||
health() {
|
health() {
|
||||||
return { status: "ok" };
|
return { status: "ok" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is actually running. The three values are baked into the image at
|
||||||
|
* build time by .gitea/workflows/build.yml (see docker/api.Dockerfile) and
|
||||||
|
* are the only way to confirm a deploy — or a rollback — landed: the tag you
|
||||||
|
* dispatched and the code inside the container can disagree if a stack was
|
||||||
|
* applied without pulling, or if the app stack still names an older tag.
|
||||||
|
*
|
||||||
|
* Deliberately unauthenticated, same as /health: the deploy workflow has to
|
||||||
|
* read it with no session, and it exposes nothing an attacker could not
|
||||||
|
* already infer from the repo.
|
||||||
|
*/
|
||||||
|
@Get("version")
|
||||||
|
version() {
|
||||||
|
return {
|
||||||
|
service: "api",
|
||||||
|
version: process.env.APP_VERSION ?? "dev",
|
||||||
|
gitSha: process.env.GIT_SHA ?? "unknown",
|
||||||
|
buildDate: process.env.BUILD_DATE ?? "unknown",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,36 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
import { PrismaModule } from "./prisma/prisma.module";
|
import { PrismaModule } from "./prisma/prisma.module";
|
||||||
|
import { StorageModule } from "./storage/storage.module";
|
||||||
|
import { CommonModule } from "./common/common.module";
|
||||||
import { UsersModule } from "./users/users.module";
|
import { UsersModule } from "./users/users.module";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
|
import { CustomersModule } from "./customers/customers.module";
|
||||||
|
import { PoliciesModule } from "./policies/policies.module";
|
||||||
|
import { PropertiesModule } from "./properties/properties.module";
|
||||||
|
import { BillingModule } from "./billing/billing.module";
|
||||||
|
import { StatementsModule } from "./statements/statements.module";
|
||||||
|
import { BankModule } from "./bank/bank.module";
|
||||||
|
import { OpsModule } from "./ops/ops.module";
|
||||||
|
import { ReportsModule } from "./reports/reports.module";
|
||||||
import { AppController } from "./app.controller";
|
import { AppController } from "./app.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
StorageModule,
|
||||||
|
CommonModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
CustomersModule,
|
||||||
|
PoliciesModule,
|
||||||
|
PropertiesModule,
|
||||||
|
BillingModule,
|
||||||
|
StatementsModule,
|
||||||
|
BankModule,
|
||||||
|
OpsModule,
|
||||||
|
ReportsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Server-authoritative permission matrix. Roles form an ordered rank
|
||||||
|
// (ADMIN > MANAGER > STAFF > VIEWER — this is the "level" concept); every
|
||||||
|
// write action carries a minimum rank. VIEWER holds rank 0 and is the
|
||||||
|
// read-only role. Reads are not listed here — they stay on AuthenticatedGuard
|
||||||
|
// alone, so any logged-in user (including VIEWER) can read.
|
||||||
|
//
|
||||||
|
// This is the single source of truth: the API enforces it via AbilityGuard and
|
||||||
|
// ships the resolved per-user map to the web through /auth/me, so the UI never
|
||||||
|
// keeps its own copy of the rules.
|
||||||
|
|
||||||
|
export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER";
|
||||||
|
|
||||||
|
export const ROLE_RANK: Record<Role, number> = {
|
||||||
|
VIEWER: 0,
|
||||||
|
STAFF: 1,
|
||||||
|
MANAGER: 2,
|
||||||
|
ADMIN: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Ability =
|
||||||
|
| "customer:create"
|
||||||
|
| "customer:update"
|
||||||
|
| "customer:delete"
|
||||||
|
| "policy:create"
|
||||||
|
| "policy:update"
|
||||||
|
| "policy:delete"
|
||||||
|
| "property:create"
|
||||||
|
| "property:update"
|
||||||
|
| "property:delete"
|
||||||
|
| "ledger:create"
|
||||||
|
| "ledger:void"
|
||||||
|
| "bank:create"
|
||||||
|
| "bank:void"
|
||||||
|
| "bank:manage-accounts"
|
||||||
|
| "statement:ingest"
|
||||||
|
| "statement:review"
|
||||||
|
| "lookup:manage"
|
||||||
|
| "user:manage"
|
||||||
|
| "db:manage";
|
||||||
|
|
||||||
|
/** Minimum role required for each ability. */
|
||||||
|
export const ABILITY_MIN: Record<Ability, Role> = {
|
||||||
|
"customer:create": "STAFF",
|
||||||
|
"customer:update": "STAFF",
|
||||||
|
"customer:delete": "ADMIN",
|
||||||
|
"policy:create": "STAFF",
|
||||||
|
"policy:update": "STAFF",
|
||||||
|
"policy:delete": "MANAGER",
|
||||||
|
"property:create": "STAFF",
|
||||||
|
"property:update": "STAFF",
|
||||||
|
"property:delete": "MANAGER",
|
||||||
|
"ledger:create": "STAFF",
|
||||||
|
"ledger:void": "MANAGER",
|
||||||
|
"bank:create": "STAFF",
|
||||||
|
"bank:void": "MANAGER",
|
||||||
|
// Opening or renaming a chequera is rarer and higher-stakes than posting a
|
||||||
|
// movement into one — a wrong account silently mixes two sets of books.
|
||||||
|
"bank:manage-accounts": "MANAGER",
|
||||||
|
// Uploading a stack of scans and reviewing what the OCR read are both
|
||||||
|
// "capturing a receipt" — the same trust tier as ledger:create, since
|
||||||
|
// confirming a statement *is* capturing it. The review step is what makes
|
||||||
|
// this safe at STAFF level: nothing reaches the ledger unconfirmed.
|
||||||
|
"statement:ingest": "STAFF",
|
||||||
|
"statement:review": "STAFF",
|
||||||
|
"lookup:manage": "MANAGER",
|
||||||
|
"user:manage": "ADMIN",
|
||||||
|
"db:manage": "ADMIN",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[];
|
||||||
|
|
||||||
|
export function can(role: Role, ability: Ability): boolean {
|
||||||
|
return ROLE_RANK[role] >= ROLE_RANK[ABILITY_MIN[ability]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolved {ability: boolean} map for a role — sent to the web via /auth/me. */
|
||||||
|
export function abilitiesFor(role: Role): Record<Ability, boolean> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
ALL_ABILITIES.map((a) => [a, can(role, a)]),
|
||||||
|
) as Record<Ability, boolean>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
import { Request } from "express";
|
||||||
|
import { ABILITY_KEY } from "./require-ability.decorator";
|
||||||
|
import { Ability, Role, can } from "./abilities";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforces the ability matrix (abilities.ts) against req.user.role. A route
|
||||||
|
* with no @RequireAbility passes through untouched — this guard only gates the
|
||||||
|
* routes that declare one. It does NOT check authentication; always list it
|
||||||
|
* after AuthenticatedGuard so an unauthenticated request is rejected first.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AbilityGuard implements CanActivate {
|
||||||
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const ability = this.reflector.getAllAndOverride<Ability | undefined>(
|
||||||
|
ABILITY_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
if (!ability) return true;
|
||||||
|
|
||||||
|
const req = context.switchToHttp().getRequest<Request>();
|
||||||
|
const user = req.user as { role?: Role } | undefined;
|
||||||
|
if (!user?.role || !can(user.role, ability)) {
|
||||||
|
throw new ForbiddenException("No tiene permisos para esta acción");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,33 @@
|
|||||||
import { Controller, Get, HttpCode, Post, Req, Res, UseGuards } from "@nestjs/common";
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { LocalAuthGuard } from "./local-auth.guard";
|
import { LocalAuthGuard } from "./local-auth.guard";
|
||||||
import { AuthenticatedGuard } from "./authenticated.guard";
|
import { AuthenticatedGuard } from "./authenticated.guard";
|
||||||
import { LoginDto } from "./login.dto";
|
import { LoginDto } from "./login.dto";
|
||||||
|
import { UpdatePreferencesDto } from "./update-preferences.dto";
|
||||||
|
import { abilitiesFor, Role } from "./abilities";
|
||||||
|
import { UsersService } from "../users/users.service";
|
||||||
|
|
||||||
|
/** Attach the resolved ability map so the web can gate its UI off one payload. */
|
||||||
|
function withAbilities(user: unknown) {
|
||||||
|
const u = user as { role?: Role } | undefined;
|
||||||
|
if (!u?.role) return u;
|
||||||
|
return { ...u, abilities: abilitiesFor(u.role) };
|
||||||
|
}
|
||||||
|
|
||||||
@Controller("auth")
|
@Controller("auth")
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
|
constructor(private readonly users: UsersService) {}
|
||||||
|
|
||||||
// LoginDto is only used for request-shape documentation/validation here —
|
// LoginDto is only used for request-shape documentation/validation here —
|
||||||
// the actual credential check happens inside LocalStrategy via Passport,
|
// the actual credential check happens inside LocalStrategy via Passport,
|
||||||
// which populates req.user before this handler runs.
|
// which populates req.user before this handler runs.
|
||||||
@@ -13,13 +35,26 @@ export class AuthController {
|
|||||||
@Post("login")
|
@Post("login")
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
login(@Req() req: Request, @Res({ passthrough: true }) _res: Response, _body?: LoginDto) {
|
login(@Req() req: Request, @Res({ passthrough: true }) _res: Response, _body?: LoginDto) {
|
||||||
return req.user;
|
return withAbilities(req.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthenticatedGuard)
|
@UseGuards(AuthenticatedGuard)
|
||||||
@Get("me")
|
@Get("me")
|
||||||
me(@Req() req: Request) {
|
me(@Req() req: Request) {
|
||||||
return req.user;
|
return withAbilities(req.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the caller's own UI preferences. Deliberately not on /users/:id —
|
||||||
|
* that controller is ADMIN-only, and this has to work for every role. The
|
||||||
|
* target is always the session's own user id, never a body parameter.
|
||||||
|
*/
|
||||||
|
@UseGuards(AuthenticatedGuard)
|
||||||
|
@Patch("preferences")
|
||||||
|
async updatePreferences(@Req() req: Request, @Body() dto: UpdatePreferencesDto) {
|
||||||
|
const id = (req.user as { id: string }).id;
|
||||||
|
const user = await this.users.updatePreferences(id, dto.uiScale);
|
||||||
|
return withAbilities(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("logout")
|
@Post("logout")
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { ExecutionContext, Injectable } from "@nestjs/common";
|
||||||
import { AuthGuard } from "@nestjs/passport";
|
import { AuthGuard } from "@nestjs/passport";
|
||||||
|
import { Request } from "express";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates credentials via LocalStrategy AND establishes the session:
|
||||||
|
* super.logIn(request) calls passport's req.login, which serializes the user
|
||||||
|
* into the session store so subsequent requests carry an authenticated
|
||||||
|
* session cookie (otherwise login succeeds but no session is persisted).
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LocalAuthGuard extends AuthGuard("local") {}
|
export class LocalAuthGuard extends AuthGuard("local") {
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const result = (await super.canActivate(context)) as boolean;
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
await super.logIn(request);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { SetMetadata } from "@nestjs/common";
|
||||||
|
import type { Ability } from "./abilities";
|
||||||
|
|
||||||
|
export const ABILITY_KEY = "required_ability";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tags a write route with the ability it requires. Pair with
|
||||||
|
* `@UseGuards(AuthenticatedGuard, AbilityGuard)` — AuthenticatedGuard proves
|
||||||
|
* the session, AbilityGuard checks this ability against the user's role.
|
||||||
|
*/
|
||||||
|
export const RequireAbility = (ability: Ability) =>
|
||||||
|
SetMetadata(ABILITY_KEY, ability);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsNumber, Max, Min } from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-service UI preferences — any authenticated user may set these on their
|
||||||
|
* own account, including VIEWER. No ability gate: it changes nothing but how
|
||||||
|
* the app looks to that one person.
|
||||||
|
*
|
||||||
|
* The bounds mirror MIN_UI_SCALE/MAX_UI_SCALE in apps/web/src/lib/ui-scale.ts;
|
||||||
|
* keep them in sync. The API clamps rather than trusting the client because
|
||||||
|
* this endpoint is reachable outside the UI.
|
||||||
|
*/
|
||||||
|
export class UpdatePreferencesDto {
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.9)
|
||||||
|
@Max(1.5)
|
||||||
|
uiScale!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
/** Mirrors the Prisma `Currency` enum; a chequera's is fixed at creation. */
|
||||||
|
export const BANK_CURRENCIES = ["MXN", "USD"] as const;
|
||||||
|
export type BankAccountCurrency = (typeof BANK_CURRENCIES)[number];
|
||||||
|
|
||||||
|
/** Mirrors `TransactionDomain`. A soft hint on the account, never enforced. */
|
||||||
|
export const BANK_BUSINESS_LINES = ["UTILITY", "INSURANCE", "TRUST"] as const;
|
||||||
|
export type BankBusinessLine = (typeof BANK_BUSINESS_LINES)[number];
|
||||||
|
|
||||||
|
export class CreateBankDto {
|
||||||
|
@IsString() @MinLength(1) name!: string;
|
||||||
|
/** "MX" | "US" — free text, informational only. */
|
||||||
|
@IsOptional() @IsString() country?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateBankDto {
|
||||||
|
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||||
|
@IsOptional() @IsString() country?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateBankAccountDto {
|
||||||
|
@IsString() @MinLength(1) bankId!: string;
|
||||||
|
@IsString() @MinLength(1) label!: string;
|
||||||
|
/**
|
||||||
|
* Immutable after creation (no field for it on the update DTO): every
|
||||||
|
* movement already booked into the account is denominated in it, so
|
||||||
|
* changing it would silently re-denominate history.
|
||||||
|
*/
|
||||||
|
@IsIn(BANK_CURRENCIES) currency!: BankAccountCurrency;
|
||||||
|
@IsOptional() @IsIn(BANK_BUSINESS_LINES) businessLine?: BankBusinessLine;
|
||||||
|
@IsOptional() @IsBoolean() active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateBankAccountDto {
|
||||||
|
@IsOptional() @IsString() @MinLength(1) bankId?: string;
|
||||||
|
@IsOptional() @IsString() @MinLength(1) label?: string;
|
||||||
|
@IsOptional() @IsIn(BANK_BUSINESS_LINES) businessLine?: BankBusinessLine;
|
||||||
|
/** Closing an account hides it from the picker; its movements stay readable. */
|
||||||
|
@IsOptional() @IsBoolean() active?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { IsBoolean, IsNumber, IsOptional, IsString, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new bank-register movement. `amount` is signed: positive = ingreso,
|
||||||
|
* negative = egreso (the module's sign convention). The currency is the
|
||||||
|
* account's, not the movement's — `bankAccountId` decides it.
|
||||||
|
* Booked rows are never edited — a mistake is corrected by voiding + re-capture.
|
||||||
|
*/
|
||||||
|
export class CreateBankMovementDto {
|
||||||
|
/** Which chequera this lands in. Required — see BankAccount in the schema. */
|
||||||
|
@IsString() @MinLength(1) bankAccountId!: string;
|
||||||
|
|
||||||
|
@IsNumber() amount!: number;
|
||||||
|
@IsString() @MinLength(1) transactionDate!: string;
|
||||||
|
|
||||||
|
@IsOptional() @IsString() concept?: string;
|
||||||
|
@IsOptional() @IsString() reference?: string;
|
||||||
|
@IsOptional() @IsString() transactionType?: string;
|
||||||
|
@IsOptional() @IsBoolean() cleared?: boolean;
|
||||||
|
@IsOptional() @IsBoolean() transferred?: boolean;
|
||||||
|
@IsOptional() @IsString() notes?: string;
|
||||||
|
@IsOptional() @IsString() amountInWords?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Request } from "express";
|
||||||
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||||
|
import { AbilityGuard } from "../auth/ability.guard";
|
||||||
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
|
import { AuditService } from "../common/audit.service";
|
||||||
|
import {
|
||||||
|
BankCleared,
|
||||||
|
BankDirection,
|
||||||
|
BankService,
|
||||||
|
BankSort,
|
||||||
|
} from "./bank.service";
|
||||||
|
import { CreateBankMovementDto } from "./bank-movement.dto";
|
||||||
|
import {
|
||||||
|
CreateBankAccountDto,
|
||||||
|
CreateBankDto,
|
||||||
|
UpdateBankAccountDto,
|
||||||
|
UpdateBankDto,
|
||||||
|
} from "./bank-account.dto";
|
||||||
|
|
||||||
|
const DIRECTIONS: BankDirection[] = ["income", "expense", "void"];
|
||||||
|
const CLEARED: BankCleared[] = ["cleared", "pending"];
|
||||||
|
const SORTS: BankSort[] = [
|
||||||
|
"date_desc",
|
||||||
|
"date_asc",
|
||||||
|
"amount_desc",
|
||||||
|
"amount_asc",
|
||||||
|
"reference",
|
||||||
|
];
|
||||||
|
|
||||||
|
function one<T>(allowed: T[], value: string | undefined): T | undefined {
|
||||||
|
return allowed.includes(value as T) ? (value as T) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A `YYYY-MM-DD` bound; anything unparseable is treated as absent. */
|
||||||
|
function parseDate(v: string | undefined, endOfDay = false): Date | undefined {
|
||||||
|
if (!v) return undefined;
|
||||||
|
const d = new Date(endOfDay ? `${v}T23:59:59.999Z` : `${v}T00:00:00.000Z`);
|
||||||
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
|
@Controller("bank")
|
||||||
|
export class BankController {
|
||||||
|
constructor(
|
||||||
|
private readonly bank: BankService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private actingId(req: Request): string {
|
||||||
|
return (req.user as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- accounts -------------------------------------------------------------
|
||||||
|
// Declared before the parameterised routes below so `/bank/accounts` can
|
||||||
|
// never be swallowed by a `:id`-shaped path.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account picker. Readable by any authenticated user, VIEWER included —
|
||||||
|
* nothing else on this page can render until an account is chosen.
|
||||||
|
*/
|
||||||
|
@Get("accounts")
|
||||||
|
accounts() {
|
||||||
|
return this.bank.listAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("banks")
|
||||||
|
banks() {
|
||||||
|
return this.bank.listBanks();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("banks")
|
||||||
|
@RequireAbility("bank:manage-accounts")
|
||||||
|
async createBank(@Body() dto: CreateBankDto, @Req() req: Request) {
|
||||||
|
const row = await this.bank.createBank(dto);
|
||||||
|
void this.audit.log(this.actingId(req), "bank.bank.create", {
|
||||||
|
bankId: row.id,
|
||||||
|
name: row.name,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("banks/:id")
|
||||||
|
@RequireAbility("bank:manage-accounts")
|
||||||
|
async updateBank(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: UpdateBankDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const row = await this.bank.updateBank(id, dto);
|
||||||
|
void this.audit.log(this.actingId(req), "bank.bank.update", { bankId: id });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("accounts")
|
||||||
|
@RequireAbility("bank:manage-accounts")
|
||||||
|
async createAccount(
|
||||||
|
@Body() dto: CreateBankAccountDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const row = await this.bank.createAccount(dto);
|
||||||
|
void this.audit.log(this.actingId(req), "bank.account.create", {
|
||||||
|
bankAccountId: row.id,
|
||||||
|
label: row.label,
|
||||||
|
currency: row.currency,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("accounts/:id")
|
||||||
|
@RequireAbility("bank:manage-accounts")
|
||||||
|
async updateAccount(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: UpdateBankAccountDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const row = await this.bank.updateAccount(id, dto);
|
||||||
|
void this.audit.log(this.actingId(req), "bank.account.update", {
|
||||||
|
bankAccountId: id,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- register reads (all scoped to one account) ---------------------------
|
||||||
|
|
||||||
|
@Get("stats")
|
||||||
|
async stats(@Query("bankAccountId") bankAccountId?: string) {
|
||||||
|
const account = await this.bank.requireAccount(bankAccountId);
|
||||||
|
return this.bank.stats(account.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("facets")
|
||||||
|
async facets(@Query("bankAccountId") bankAccountId?: string) {
|
||||||
|
const account = await this.bank.requireAccount(bankAccountId);
|
||||||
|
return this.bank.facets(account.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Year and month rollups with a running net-movement figure. */
|
||||||
|
@Get("summary")
|
||||||
|
async summary(
|
||||||
|
@Query("bankAccountId") bankAccountId?: string,
|
||||||
|
@Query("year") year?: string,
|
||||||
|
) {
|
||||||
|
const account = await this.bank.requireAccount(bankAccountId);
|
||||||
|
const y = Number(year);
|
||||||
|
return this.bank.summary(
|
||||||
|
account.id,
|
||||||
|
Number.isInteger(y) && y >= 1900 && y <= 2999 ? y : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The register browser. */
|
||||||
|
@Get()
|
||||||
|
async list(
|
||||||
|
@Query("bankAccountId") bankAccountId?: string,
|
||||||
|
@Query("query") query?: string,
|
||||||
|
@Query("page") page?: string,
|
||||||
|
@Query("pageSize") pageSize?: string,
|
||||||
|
@Query("direction") direction?: string,
|
||||||
|
@Query("cleared") cleared?: string,
|
||||||
|
@Query("from") from?: string,
|
||||||
|
@Query("to") to?: string,
|
||||||
|
@Query("sort") sort?: string,
|
||||||
|
) {
|
||||||
|
const account = await this.bank.requireAccount(bankAccountId);
|
||||||
|
return this.bank.list({
|
||||||
|
bankAccountId: account.id,
|
||||||
|
query,
|
||||||
|
page: Math.max(1, Number(page) || 1),
|
||||||
|
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||||
|
direction: one(DIRECTIONS, direction),
|
||||||
|
cleared: one(CLEARED, cleared),
|
||||||
|
from: parseDate(from),
|
||||||
|
to: parseDate(to, true),
|
||||||
|
sort: one(SORTS, sort) ?? "date_desc",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequireAbility("bank:create")
|
||||||
|
async create(@Body() dto: CreateBankMovementDto, @Req() req: Request) {
|
||||||
|
const row = await this.bank.createMovement(dto);
|
||||||
|
void this.audit.log(this.actingId(req), "bank.create", {
|
||||||
|
bankTransactionId: row.id,
|
||||||
|
bankAccountId: row.bankAccountId,
|
||||||
|
amount: dto.amount,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/void")
|
||||||
|
@RequireAbility("bank:void")
|
||||||
|
async void(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const row = await this.bank.voidMovement(id, this.actingId(req));
|
||||||
|
void this.audit.log(this.actingId(req), "bank.void", { bankTransactionId: id });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { BankController } from "./bank.controller";
|
||||||
|
import { BankService } from "./bank.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [BankController],
|
||||||
|
providers: [BankService],
|
||||||
|
})
|
||||||
|
export class BankModule {}
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { CreateBankMovementDto } from "./bank-movement.dto";
|
||||||
|
import {
|
||||||
|
CreateBankAccountDto,
|
||||||
|
CreateBankDto,
|
||||||
|
UpdateBankAccountDto,
|
||||||
|
UpdateBankDto,
|
||||||
|
} from "./bank-account.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-voided rows (voidedAt set) are reversed and must leave every
|
||||||
|
* income/expense/net total. This is distinct from the legacy zero-amount
|
||||||
|
* "void" cheques, which stay as amount-0 rows. List views still show voided
|
||||||
|
* rows struck-through.
|
||||||
|
*/
|
||||||
|
const NOT_VOIDED: Prisma.BankTransactionWhereInput = { voidedAt: null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bank register (chequera) module — plan step 7.
|
||||||
|
*
|
||||||
|
* This is the office's OWN operating checking account, migrated from SCOTHIA's
|
||||||
|
* `DATOS I` (ingresos) / `DATOS E` (egresos) into one signed-amount table. It
|
||||||
|
* carries no customer FK and is deliberately NOT part of `/estado-cuenta`: that
|
||||||
|
* ledger is what customers owe the office, this one is the office's own money.
|
||||||
|
* The two must never be added together or shown in the same total.
|
||||||
|
*
|
||||||
|
* SIGN CONVENTION (set by migration/transform_bank.py):
|
||||||
|
* - positive = ingreso (a deposit into the account)
|
||||||
|
* - negative = egreso (a payment out of it)
|
||||||
|
* - exactly zero = a cancelled/void cheque. 787 of the 791 zero rows say
|
||||||
|
* CANCELADO or VOID in the concept; they are neither an income nor an
|
||||||
|
* expense and are excluded from both sides, the way the ~193 zero rows are
|
||||||
|
* in the customer ledger.
|
||||||
|
*
|
||||||
|
* ONE ACCOUNT AT A TIME, CURRENCY FROM THE ACCOUNT. The office now keeps more
|
||||||
|
* than one chequera (Utilities banks in MXN, Seguros in USD), so every read
|
||||||
|
* path here is scoped to exactly one `bankAccountId` — never "all accounts".
|
||||||
|
* There is deliberately no currency column on `bank_transactions`: a movement
|
||||||
|
* inherits its account's, the way a real bank account doesn't mix currencies.
|
||||||
|
* Callers must therefore pass an account id; an unscoped total would sum MXN
|
||||||
|
* and USD into a figure that never existed, the same mistake the billing
|
||||||
|
* module's per-currency rule exists to prevent.
|
||||||
|
*
|
||||||
|
* The 22,669 migrated rows are all SCOTHIA = the Utilities MXN account
|
||||||
|
* (backfilled by `migration/backfill_bank_accounts.py`), and their
|
||||||
|
* `amountInWords` on the egreso side is spelled out in PESOS accordingly.
|
||||||
|
*
|
||||||
|
* NO CATEGORY DIMENSION. `bank_transactions.categoryId` is NULL on all 22,354
|
||||||
|
* rows and this module does not filter or group by it, because the data cannot
|
||||||
|
* support it:
|
||||||
|
* - `DATOS E` / `DATOS I` have no ramo column at all — the only columns are
|
||||||
|
* fecha, tipo, num, concepto, ingreso/egreso, operado, notas and (egresos)
|
||||||
|
* cantidad en letra. There is no key to migrate.
|
||||||
|
* - `concepto` is a *payee* name (PAYPAL, CFE, TELEFONOS DEL NOROESTE, and
|
||||||
|
* ~1,900 individual people), not a classification. Zero of the 22,354
|
||||||
|
* concepts match a `business_line_categories` name.
|
||||||
|
* - the 66 categories in TABLA RAMODOS are a property-management expense
|
||||||
|
* chart of accounts (Payroll, Pool (Labor), Gardening, Trash Coll) plus
|
||||||
|
* owner names with property numbers — not the insurance/servicios/
|
||||||
|
* fideicomiso split. Classifying concepts into them would not produce a
|
||||||
|
* business-line breakdown even if it worked.
|
||||||
|
* A concept->ramo classifier would therefore be invented data, so the register
|
||||||
|
* is browsable by date, payee, amount and cheque number instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Which side of the register a movement is on. */
|
||||||
|
export type BankDirection = "income" | "expense" | "void";
|
||||||
|
|
||||||
|
/** `operado` in the source: whether the bank has cleared the movement. */
|
||||||
|
export type BankCleared = "cleared" | "pending";
|
||||||
|
|
||||||
|
export type BankSort =
|
||||||
|
| "date_desc"
|
||||||
|
| "date_asc"
|
||||||
|
| "amount_desc"
|
||||||
|
| "amount_asc"
|
||||||
|
| "reference";
|
||||||
|
|
||||||
|
export interface BankListParams {
|
||||||
|
/** Which chequera to read. Required — see the module header. */
|
||||||
|
bankAccountId: string;
|
||||||
|
query?: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
direction?: BankDirection;
|
||||||
|
cleared?: BankCleared;
|
||||||
|
/** Inclusive bounds on `transactionDate`. */
|
||||||
|
from?: Date;
|
||||||
|
to?: Date;
|
||||||
|
sort: BankSort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw shape of a year/month rollup row. */
|
||||||
|
interface PeriodRow {
|
||||||
|
period: number;
|
||||||
|
count: bigint | number | string;
|
||||||
|
income: Prisma.Decimal | null;
|
||||||
|
expense: Prisma.Decimal | null;
|
||||||
|
net: Prisma.Decimal | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(v: bigint | number | string | null | undefined): number {
|
||||||
|
if (v === null || v === undefined) return 0;
|
||||||
|
return typeof v === "number" ? v : Number(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dec(v: Prisma.Decimal | null | undefined): string {
|
||||||
|
return (v ?? new Prisma.Decimal(0)).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BankService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
private where(p: BankListParams): Prisma.BankTransactionWhereInput {
|
||||||
|
const and: Prisma.BankTransactionWhereInput[] = [
|
||||||
|
{ bankAccountId: p.bankAccountId },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (p.query && p.query.trim()) {
|
||||||
|
const q = p.query.trim();
|
||||||
|
and.push({
|
||||||
|
OR: [
|
||||||
|
{ concept: { contains: q } },
|
||||||
|
{ reference: { contains: q } },
|
||||||
|
{ notes: { contains: q } },
|
||||||
|
{ amountInWords: { contains: q } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (p.direction === "income") and.push({ amount: { gt: 0 } });
|
||||||
|
if (p.direction === "expense") and.push({ amount: { lt: 0 } });
|
||||||
|
if (p.direction === "void") and.push({ amount: 0 });
|
||||||
|
if (p.cleared) and.push({ cleared: p.cleared === "cleared" });
|
||||||
|
if (p.from || p.to) {
|
||||||
|
and.push({
|
||||||
|
transactionDate: {
|
||||||
|
...(p.from ? { gte: p.from } : {}),
|
||||||
|
...(p.to ? { lte: p.to } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never empty: the account clause above is always present, so no read can
|
||||||
|
// accidentally span every chequera.
|
||||||
|
return { AND: and };
|
||||||
|
}
|
||||||
|
|
||||||
|
private orderBy(
|
||||||
|
sort: BankSort,
|
||||||
|
): Prisma.BankTransactionOrderByWithRelationInput[] {
|
||||||
|
switch (sort) {
|
||||||
|
case "date_asc":
|
||||||
|
return [{ transactionDate: "asc" }, { reference: "asc" }];
|
||||||
|
case "amount_desc":
|
||||||
|
return [{ amount: "desc" }];
|
||||||
|
case "amount_asc":
|
||||||
|
return [{ amount: "asc" }];
|
||||||
|
case "reference":
|
||||||
|
// `reference` is the cheque number on egresos and the deposit slip on
|
||||||
|
// ingresos; it is a string column, so this is a lexical sort.
|
||||||
|
return [{ reference: "asc" }];
|
||||||
|
default:
|
||||||
|
return [{ transactionDate: "desc" }, { reference: "desc" }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The register itself: every deposit and payment, filterable. */
|
||||||
|
async list(params: BankListParams) {
|
||||||
|
const where = this.where(params);
|
||||||
|
|
||||||
|
const [total, rows] = await this.prisma.$transaction([
|
||||||
|
this.prisma.bankTransaction.count({ where }),
|
||||||
|
this.prisma.bankTransaction.findMany({
|
||||||
|
where,
|
||||||
|
skip: (params.page - 1) * params.pageSize,
|
||||||
|
take: params.pageSize,
|
||||||
|
orderBy: this.orderBy(params.sort),
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
transactionDate: true,
|
||||||
|
transactionType: true,
|
||||||
|
reference: true,
|
||||||
|
concept: true,
|
||||||
|
amount: true,
|
||||||
|
cleared: true,
|
||||||
|
transferred: true,
|
||||||
|
notes: true,
|
||||||
|
amountInWords: true,
|
||||||
|
legacySourceTable: true,
|
||||||
|
voidedAt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Totals cover the whole filtered set, not just the page — the figure staff
|
||||||
|
// read off a filtered view ("what did we pay CFE in 2025") has to.
|
||||||
|
const totals = await this.totalsFor(where);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
transactionDate: r.transactionDate,
|
||||||
|
transactionType: r.transactionType,
|
||||||
|
reference: r.reference,
|
||||||
|
concept: r.concept,
|
||||||
|
amount: r.amount,
|
||||||
|
direction: directionOf(r.amount),
|
||||||
|
cleared: r.cleared,
|
||||||
|
transferred: r.transferred,
|
||||||
|
notes: r.notes,
|
||||||
|
amountInWords: r.amountInWords,
|
||||||
|
source: r.legacySourceTable,
|
||||||
|
voided: r.voidedAt != null,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page: params.page,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
pageCount: Math.ceil(total / params.pageSize),
|
||||||
|
totals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Income / expense / void split over an arbitrary filter. */
|
||||||
|
private async totalsFor(where: Prisma.BankTransactionWhereInput) {
|
||||||
|
const [income, expense, voided] = await Promise.all([
|
||||||
|
this.prisma.bankTransaction.aggregate({
|
||||||
|
where: { AND: [where, { amount: { gt: 0 } }, NOT_VOIDED] },
|
||||||
|
_sum: { amount: true },
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
this.prisma.bankTransaction.aggregate({
|
||||||
|
where: { AND: [where, { amount: { lt: 0 } }, NOT_VOIDED] },
|
||||||
|
_sum: { amount: true },
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
this.prisma.bankTransaction.count({
|
||||||
|
where: { AND: [where, { amount: 0 }, NOT_VOIDED] },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const inSum = income._sum.amount ?? new Prisma.Decimal(0);
|
||||||
|
const outSum = expense._sum.amount ?? new Prisma.Decimal(0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
income: inSum.toFixed(2),
|
||||||
|
incomeCount: income._count._all,
|
||||||
|
expense: outSum.toFixed(2),
|
||||||
|
expenseCount: expense._count._all,
|
||||||
|
net: inSum.plus(outSum).toFixed(2),
|
||||||
|
voidCount: voided,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top-line figures for the bank page header, for one chequera. */
|
||||||
|
async stats(bankAccountId: string) {
|
||||||
|
const account = { bankAccountId };
|
||||||
|
const [count, bounds, pending, transferred, totals] = await Promise.all([
|
||||||
|
this.prisma.bankTransaction.count({
|
||||||
|
where: { AND: [account, NOT_VOIDED] },
|
||||||
|
}),
|
||||||
|
this.prisma.bankTransaction.aggregate({
|
||||||
|
where: { AND: [account, NOT_VOIDED] },
|
||||||
|
_min: { transactionDate: true },
|
||||||
|
_max: { transactionDate: true },
|
||||||
|
}),
|
||||||
|
this.prisma.bankTransaction.count({
|
||||||
|
where: { AND: [account, { cleared: false }, NOT_VOIDED] },
|
||||||
|
}),
|
||||||
|
this.prisma.bankTransaction.count({
|
||||||
|
where: { AND: [account, { transferred: true }, NOT_VOIDED] },
|
||||||
|
}),
|
||||||
|
this.totalsFor(account),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
movements: count,
|
||||||
|
firstMovement: bounds._min.transactionDate,
|
||||||
|
lastMovement: bounds._max.transactionDate,
|
||||||
|
pending,
|
||||||
|
transferred,
|
||||||
|
...totals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Year list for the period filter, newest first, for one chequera. */
|
||||||
|
async facets(bankAccountId: string) {
|
||||||
|
// Tagged-template `$queryRaw`: the interpolation below is a bound
|
||||||
|
// parameter, not string concatenation.
|
||||||
|
const years = await this.prisma.$queryRaw<
|
||||||
|
{ year: number; count: bigint | number | string }[]
|
||||||
|
>`
|
||||||
|
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
||||||
|
FROM bank_transactions
|
||||||
|
WHERE voidedAt IS NULL AND bankAccountId = ${bankAccountId}
|
||||||
|
GROUP BY year
|
||||||
|
ORDER BY year DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
years: years.map((y) => ({ year: Number(y.year), count: num(y.count) })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Period rollup for the "Resumen" view: one row per year, plus one row per
|
||||||
|
* month when a year is selected.
|
||||||
|
*
|
||||||
|
* `cumulative` is the running sum of every movement from the start of the
|
||||||
|
* register — NOT the bank balance. SCOTHIA carries no opening balance (its
|
||||||
|
* `BAN` table holds only the bank's name), so the register starts at zero on
|
||||||
|
* its first row in 2013 and the running figure is the net movement since
|
||||||
|
* then. Labelled as such in the UI so it is never read as a statement balance.
|
||||||
|
*
|
||||||
|
* Both rollups take the SAME `bankAccountId`. Scoping only one of them would
|
||||||
|
* leave the year list and its month drill-down describing different books —
|
||||||
|
* wrong in a way that still looks right.
|
||||||
|
*/
|
||||||
|
async summary(bankAccountId: string, year?: number) {
|
||||||
|
const years = await this.prisma.$queryRaw<PeriodRow[]>`
|
||||||
|
SELECT
|
||||||
|
YEAR(transactionDate) AS period,
|
||||||
|
COUNT(*) AS count,
|
||||||
|
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
|
||||||
|
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
||||||
|
SUM(amount) AS net
|
||||||
|
FROM bank_transactions
|
||||||
|
WHERE voidedAt IS NULL AND bankAccountId = ${bankAccountId}
|
||||||
|
GROUP BY period
|
||||||
|
ORDER BY period ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const months = year
|
||||||
|
? await this.prisma.$queryRaw<PeriodRow[]>`
|
||||||
|
SELECT
|
||||||
|
MONTH(transactionDate) AS period,
|
||||||
|
COUNT(*) AS count,
|
||||||
|
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
|
||||||
|
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
||||||
|
SUM(amount) AS net
|
||||||
|
FROM bank_transactions
|
||||||
|
WHERE YEAR(transactionDate) = ${year}
|
||||||
|
AND voidedAt IS NULL
|
||||||
|
AND bankAccountId = ${bankAccountId}
|
||||||
|
GROUP BY period
|
||||||
|
ORDER BY period ASC
|
||||||
|
`
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Cumulative across years runs from the first row of the register; the
|
||||||
|
// monthly cumulative opens at the selected year's opening figure so the two
|
||||||
|
// tables agree.
|
||||||
|
let running = new Prisma.Decimal(0);
|
||||||
|
const yearRows = years.map((r) => {
|
||||||
|
const net = r.net ?? new Prisma.Decimal(0);
|
||||||
|
const opening = running;
|
||||||
|
running = running.plus(net);
|
||||||
|
return {
|
||||||
|
period: Number(r.period),
|
||||||
|
count: num(r.count),
|
||||||
|
income: dec(r.income),
|
||||||
|
expense: dec(r.expense),
|
||||||
|
net: net.toFixed(2),
|
||||||
|
opening: opening.toFixed(2),
|
||||||
|
cumulative: running.toFixed(2),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const opening =
|
||||||
|
year === undefined
|
||||||
|
? new Prisma.Decimal(0)
|
||||||
|
: new Prisma.Decimal(
|
||||||
|
yearRows.find((y) => y.period === year)?.opening ?? "0",
|
||||||
|
);
|
||||||
|
|
||||||
|
let monthRunning = opening;
|
||||||
|
const monthRows = months.map((r) => {
|
||||||
|
const net = r.net ?? new Prisma.Decimal(0);
|
||||||
|
monthRunning = monthRunning.plus(net);
|
||||||
|
return {
|
||||||
|
period: Number(r.period),
|
||||||
|
count: num(r.count),
|
||||||
|
income: dec(r.income),
|
||||||
|
expense: dec(r.expense),
|
||||||
|
net: net.toFixed(2),
|
||||||
|
cumulative: monthRunning.toFixed(2),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
year: year ?? null,
|
||||||
|
years: yearRows,
|
||||||
|
months: monthRows,
|
||||||
|
opening: opening.toFixed(2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- accounts -------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every chequera, closed ones included — a closed account still has to be
|
||||||
|
* selectable to read its history, it just isn't offered for new captures.
|
||||||
|
*/
|
||||||
|
async listAccounts() {
|
||||||
|
const rows = await this.prisma.bankAccount.findMany({
|
||||||
|
orderBy: [{ active: "desc" }, { label: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
label: true,
|
||||||
|
currency: true,
|
||||||
|
businessLine: true,
|
||||||
|
active: true,
|
||||||
|
bank: { select: { id: true, name: true, country: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return rows.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
label: a.label,
|
||||||
|
currency: a.currency,
|
||||||
|
businessLine: a.businessLine,
|
||||||
|
active: a.active,
|
||||||
|
bankId: a.bank.id,
|
||||||
|
bankName: a.bank.name,
|
||||||
|
bankCountry: a.bank.country,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listBanks() {
|
||||||
|
return this.prisma.bank.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true, country: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an account id from a request, or reject. Every read route funnels
|
||||||
|
* through this so a bad/missing id is a 400 rather than a silently empty
|
||||||
|
* register that reads as "this account has no movements".
|
||||||
|
*/
|
||||||
|
async requireAccount(bankAccountId: string | undefined) {
|
||||||
|
if (!bankAccountId || !bankAccountId.trim())
|
||||||
|
throw new BadRequestException("Falta la cuenta bancaria (bankAccountId)");
|
||||||
|
const account = await this.prisma.bankAccount.findUnique({
|
||||||
|
where: { id: bankAccountId },
|
||||||
|
select: { id: true, label: true, currency: true, active: true },
|
||||||
|
});
|
||||||
|
if (!account)
|
||||||
|
throw new NotFoundException(`Cuenta bancaria ${bankAccountId} no existe`);
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBank(dto: CreateBankDto) {
|
||||||
|
return this.prisma.bank.create({
|
||||||
|
data: { name: dto.name.trim(), country: dto.country?.trim() || null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBank(id: string, dto: UpdateBankDto) {
|
||||||
|
await this.getBankOr404(id);
|
||||||
|
return this.prisma.bank.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||||
|
...(dto.country !== undefined
|
||||||
|
? { country: dto.country.trim() || null }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getBankOr404(id: string) {
|
||||||
|
const bank = await this.prisma.bank.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!bank) throw new NotFoundException(`Banco ${id} no existe`);
|
||||||
|
return bank;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAccount(dto: CreateBankAccountDto) {
|
||||||
|
await this.getBankOr404(dto.bankId);
|
||||||
|
return this.prisma.bankAccount.create({
|
||||||
|
data: {
|
||||||
|
bankId: dto.bankId,
|
||||||
|
label: dto.label.trim(),
|
||||||
|
currency: dto.currency,
|
||||||
|
businessLine: dto.businessLine ?? null,
|
||||||
|
active: dto.active ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `currency` is intentionally absent from the update DTO: the movements
|
||||||
|
* already booked in this account are denominated in it, so changing it would
|
||||||
|
* silently re-denominate history rather than convert it.
|
||||||
|
*/
|
||||||
|
async updateAccount(id: string, dto: UpdateBankAccountDto) {
|
||||||
|
await this.requireAccount(id);
|
||||||
|
if (dto.bankId !== undefined) await this.getBankOr404(dto.bankId);
|
||||||
|
return this.prisma.bankAccount.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.bankId !== undefined ? { bankId: dto.bankId } : {}),
|
||||||
|
...(dto.label !== undefined ? { label: dto.label.trim() } : {}),
|
||||||
|
...(dto.businessLine !== undefined
|
||||||
|
? { businessLine: dto.businessLine }
|
||||||
|
: {}),
|
||||||
|
...(dto.active !== undefined ? { active: dto.active } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- writes (append + void) -----------------------------------------------
|
||||||
|
|
||||||
|
async createMovement(dto: CreateBankMovementDto) {
|
||||||
|
const date = new Date(dto.transactionDate);
|
||||||
|
if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
|
||||||
|
const account = await this.requireAccount(dto.bankAccountId);
|
||||||
|
if (!account.active)
|
||||||
|
throw new BadRequestException(
|
||||||
|
`La cuenta "${account.label}" está cerrada; no admite movimientos nuevos.`,
|
||||||
|
);
|
||||||
|
return this.prisma.bankTransaction.create({
|
||||||
|
data: {
|
||||||
|
bankAccountId: account.id,
|
||||||
|
amount: dto.amount,
|
||||||
|
transactionDate: date,
|
||||||
|
concept: dto.concept,
|
||||||
|
reference: dto.reference,
|
||||||
|
transactionType: dto.transactionType,
|
||||||
|
cleared: dto.cleared ?? false,
|
||||||
|
transferred: dto.transferred ?? false,
|
||||||
|
notes: dto.notes,
|
||||||
|
amountInWords: dto.amountInWords,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async voidMovement(id: string, userId: string) {
|
||||||
|
const row = await this.prisma.bankTransaction.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, voidedAt: true },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException(`Bank transaction ${id} not found`);
|
||||||
|
if (row.voidedAt) throw new BadRequestException("El movimiento ya está anulado");
|
||||||
|
return this.prisma.bankTransaction.update({
|
||||||
|
where: { id },
|
||||||
|
data: { voidedAt: new Date(), voidedById: userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function directionOf(amount: Prisma.Decimal): BankDirection {
|
||||||
|
if (amount.greaterThan(0)) return "income";
|
||||||
|
return amount.lessThan(0) ? "expense" : "void";
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { TransactionDomain } from "@jorgecuadros/database";
|
||||||
|
import { Request } from "express";
|
||||||
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||||
|
import { AbilityGuard } from "../auth/ability.guard";
|
||||||
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
|
import { AuditService } from "../common/audit.service";
|
||||||
|
import {
|
||||||
|
BalanceFilter,
|
||||||
|
BalanceSort,
|
||||||
|
BillingService,
|
||||||
|
LedgerCurrency,
|
||||||
|
LedgerDirection,
|
||||||
|
MovementSort,
|
||||||
|
} from "./billing.service";
|
||||||
|
import {
|
||||||
|
BatchCreateDto,
|
||||||
|
CreateMovementDto,
|
||||||
|
ResolveOutstandingDto,
|
||||||
|
} from "./movement.dto";
|
||||||
|
|
||||||
|
const DOMAINS: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"];
|
||||||
|
const CURRENCIES: LedgerCurrency[] = ["MXN", "USD"];
|
||||||
|
const DIRECTIONS: LedgerDirection[] = ["charge", "credit"];
|
||||||
|
const BALANCES: BalanceFilter[] = ["all", "owing", "credit", "settled"];
|
||||||
|
const MOVEMENT_SORTS: MovementSort[] = [
|
||||||
|
"date_desc",
|
||||||
|
"date_asc",
|
||||||
|
"amount_desc",
|
||||||
|
"amount_asc",
|
||||||
|
"customer",
|
||||||
|
];
|
||||||
|
const BALANCE_SORTS: BalanceSort[] = [
|
||||||
|
"owing_desc",
|
||||||
|
"credit_desc",
|
||||||
|
"recent",
|
||||||
|
"customer",
|
||||||
|
];
|
||||||
|
|
||||||
|
function one<T>(allowed: T[], value: string | undefined): T | undefined {
|
||||||
|
return allowed.includes(value as T) ? (value as T) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tri-state query flag: "true"/"false" filter, anything else means no filter. */
|
||||||
|
function flag(v: string | undefined): boolean | undefined {
|
||||||
|
return v === "true" ? true : v === "false" ? false : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A `YYYY-MM-DD` bound; anything unparseable is treated as absent. */
|
||||||
|
function parseDate(v: string | undefined, endOfDay = false): Date | undefined {
|
||||||
|
if (!v) return undefined;
|
||||||
|
const d = new Date(endOfDay ? `${v}T23:59:59.999Z` : `${v}T00:00:00.000Z`);
|
||||||
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
|
@Controller("billing")
|
||||||
|
export class BillingController {
|
||||||
|
constructor(
|
||||||
|
private readonly billing: BillingService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private actingId(req: Request): string {
|
||||||
|
return (req.user as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("stats")
|
||||||
|
stats() {
|
||||||
|
return this.billing.stats();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("facets")
|
||||||
|
facets() {
|
||||||
|
return this.billing.facets();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-customer balances — the receivables worklist. */
|
||||||
|
@Get("balances")
|
||||||
|
balances(
|
||||||
|
@Query("query") query?: string,
|
||||||
|
@Query("page") page?: string,
|
||||||
|
@Query("pageSize") pageSize?: string,
|
||||||
|
@Query("currency") currency?: string,
|
||||||
|
@Query("balance") balance?: string,
|
||||||
|
@Query("domain") domain?: string,
|
||||||
|
@Query("sort") sort?: string,
|
||||||
|
) {
|
||||||
|
return this.billing.balances({
|
||||||
|
query,
|
||||||
|
page: Math.max(1, Number(page) || 1),
|
||||||
|
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||||
|
currency: one(CURRENCIES, currency) ?? "MXN",
|
||||||
|
balance: one(BALANCES, balance) ?? "all",
|
||||||
|
domain: one(DOMAINS, domain),
|
||||||
|
sort: one(BALANCE_SORTS, sort) ?? "owing_desc",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every movement cut against one check, with its total — the reconciliation
|
||||||
|
* view replacing the legacy REPORTE CHEQUE COUNT. Declared before the
|
||||||
|
* `customers/:id` and `:id`-shaped routes so the literal path wins.
|
||||||
|
*/
|
||||||
|
@Get("by-check")
|
||||||
|
byCheck(@Query("checkNumber") checkNumber?: string) {
|
||||||
|
const n = checkNumber?.trim();
|
||||||
|
if (!n) throw new BadRequestException("checkNumber es obligatorio");
|
||||||
|
return this.billing.byCheck(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One customer's full statement across both business lines. */
|
||||||
|
@Get("customers/:id")
|
||||||
|
statement(@Param("id") id: string) {
|
||||||
|
return this.billing.statement(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cross-customer movement browser. */
|
||||||
|
@Get()
|
||||||
|
movements(
|
||||||
|
@Query("query") query?: string,
|
||||||
|
@Query("page") page?: string,
|
||||||
|
@Query("pageSize") pageSize?: string,
|
||||||
|
@Query("domain") domain?: string,
|
||||||
|
@Query("currency") currency?: string,
|
||||||
|
@Query("direction") direction?: string,
|
||||||
|
@Query("typeId") typeId?: string,
|
||||||
|
@Query("source") source?: string,
|
||||||
|
@Query("customerId") customerId?: string,
|
||||||
|
@Query("outstanding") outstanding?: string,
|
||||||
|
@Query("checkNumber") checkNumber?: string,
|
||||||
|
@Query("from") from?: string,
|
||||||
|
@Query("to") to?: string,
|
||||||
|
@Query("sort") sort?: string,
|
||||||
|
) {
|
||||||
|
return this.billing.movements({
|
||||||
|
query,
|
||||||
|
page: Math.max(1, Number(page) || 1),
|
||||||
|
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||||
|
domain: one(DOMAINS, domain),
|
||||||
|
currency: one(CURRENCIES, currency),
|
||||||
|
direction: one(DIRECTIONS, direction),
|
||||||
|
typeId: typeId || undefined,
|
||||||
|
source: source || undefined,
|
||||||
|
customerId: customerId || undefined,
|
||||||
|
outstanding: flag(outstanding),
|
||||||
|
checkNumber: checkNumber?.trim() || undefined,
|
||||||
|
from: parseDate(from),
|
||||||
|
to: parseDate(to, true),
|
||||||
|
sort: one(MOVEMENT_SORTS, sort) ?? "date_desc",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequireAbility("ledger:create")
|
||||||
|
async create(@Body() dto: CreateMovementDto, @Req() req: Request) {
|
||||||
|
const tx = await this.billing.createMovement(dto);
|
||||||
|
void this.audit.log(this.actingId(req), "ledger.create", {
|
||||||
|
transactionId: tx.id,
|
||||||
|
customerId: dto.customerId,
|
||||||
|
amount: dto.amount,
|
||||||
|
currency: tx.currency,
|
||||||
|
});
|
||||||
|
return tx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch capture: many customers' receipts against one physical check.
|
||||||
|
* Same ability as single capture — batching is still capturing.
|
||||||
|
*/
|
||||||
|
@Post("batch")
|
||||||
|
@RequireAbility("ledger:create")
|
||||||
|
async createBatch(@Body() dto: BatchCreateDto, @Req() req: Request) {
|
||||||
|
const result = await this.billing.createBatch(dto);
|
||||||
|
void this.audit.log(this.actingId(req), "ledger.batch", {
|
||||||
|
checkNumber: dto.checkNumber,
|
||||||
|
count: result.count,
|
||||||
|
total: result.total,
|
||||||
|
currency: result.currency,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an outstanding (NOPAGO) row — `ledger:create`, not `ledger:void`:
|
||||||
|
* resolving completes a capture, it doesn't reverse one.
|
||||||
|
*/
|
||||||
|
@Post(":id/resolve-outstanding")
|
||||||
|
@RequireAbility("ledger:create")
|
||||||
|
async resolveOutstanding(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: ResolveOutstandingDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const tx = await this.billing.resolveOutstanding(id, dto);
|
||||||
|
void this.audit.log(this.actingId(req), "ledger.resolve-outstanding", {
|
||||||
|
transactionId: id,
|
||||||
|
checkNumber: dto.checkNumber,
|
||||||
|
});
|
||||||
|
return tx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/void")
|
||||||
|
@RequireAbility("ledger:void")
|
||||||
|
async void(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const tx = await this.billing.voidMovement(id, this.actingId(req));
|
||||||
|
void this.audit.log(this.actingId(req), "ledger.void", { transactionId: id });
|
||||||
|
return tx;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { BillingController } from "./billing.controller";
|
||||||
|
import { BillingService } from "./billing.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [BillingController],
|
||||||
|
providers: [BillingService],
|
||||||
|
// The statements module posts confirmed OCR captures through
|
||||||
|
// BillingService.createBatch rather than writing Transaction rows itself.
|
||||||
|
exports: [BillingService],
|
||||||
|
})
|
||||||
|
export class BillingModule {}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
|||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { Currency, TransactionDomain } from "@jorgecuadros/database";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new ledger movement. `amount` is signed: negative = cargo (charge),
|
||||||
|
* positive = abono (credit) — the module's sign convention. Booked movements
|
||||||
|
* are never edited; a mistake is corrected by voiding and re-capturing.
|
||||||
|
*/
|
||||||
|
export class CreateMovementDto {
|
||||||
|
@IsString() @MinLength(1) customerId!: string;
|
||||||
|
@IsEnum(TransactionDomain) domain!: TransactionDomain;
|
||||||
|
@IsNumber() amount!: number;
|
||||||
|
@IsString() @MinLength(1) transactionDate!: string;
|
||||||
|
|
||||||
|
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||||
|
@IsOptional() @IsString() typeId?: string;
|
||||||
|
@IsOptional() @IsString() period?: string;
|
||||||
|
@IsOptional() @IsString() reference?: string;
|
||||||
|
@IsOptional() @IsString() checkNumber?: string;
|
||||||
|
@IsOptional() @IsString() message?: string;
|
||||||
|
/**
|
||||||
|
* Legacy "NOPAGO": the bill was captured but not actually paid (no funds).
|
||||||
|
* The row posts normally and stays visible, but is kept out of every balance
|
||||||
|
* aggregate until resolved — see BillingService's NOT_OUTSTANDING.
|
||||||
|
*/
|
||||||
|
@IsOptional() @IsBoolean() outstanding?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolving an outstanding row: the check finally got cut, so the movement
|
||||||
|
* takes the resolution date and check number and starts counting toward the
|
||||||
|
* balance. Legacy behavior: "se actualiza registro con fecha del día y el
|
||||||
|
* cheque a pagar y quitas outstanding".
|
||||||
|
*/
|
||||||
|
export class ResolveOutstandingDto {
|
||||||
|
@IsString() @MinLength(1) checkNumber!: string;
|
||||||
|
@IsString() @MinLength(1) resolvedDate!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One customer's line within a batch; check-level fields live on the parent. */
|
||||||
|
export class BatchLineDto {
|
||||||
|
@IsString() @MinLength(1) customerId!: string;
|
||||||
|
@IsNumber() amount!: number;
|
||||||
|
|
||||||
|
@IsOptional() @IsString() reference?: string;
|
||||||
|
@IsOptional() @IsString() period?: string;
|
||||||
|
@IsOptional() @IsString() message?: string;
|
||||||
|
@IsOptional() @IsBoolean() outstanding?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch capture by check — the legacy "Editor" flow: key many customers'
|
||||||
|
* receipts against one check, then reconcile the captured total against the
|
||||||
|
* physical check. Deliberately NOT a persisted batch entity: `checkNumber` is
|
||||||
|
* already a column, and grouping by it answers every legacy by-check query.
|
||||||
|
*/
|
||||||
|
export class BatchCreateDto {
|
||||||
|
@IsEnum(TransactionDomain) domain!: TransactionDomain;
|
||||||
|
@IsString() @MinLength(1) transactionDate!: string;
|
||||||
|
@IsString() @MinLength(1) checkNumber!: string;
|
||||||
|
|
||||||
|
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||||
|
@IsOptional() @IsString() typeId?: string;
|
||||||
|
|
||||||
|
// Capped so one request can't open a transaction over an unbounded row set;
|
||||||
|
// a physical check batch is tens of lines, not thousands.
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayMaxSize(500)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => BatchLineDto)
|
||||||
|
lines!: BatchLineDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin writer over the existing ActivityLog model. Every mutating route calls
|
||||||
|
* this so who-did-what is recorded — the structural replacement for the old
|
||||||
|
* PHP app's scattered Logger calls. Best-effort: a logging failure must never
|
||||||
|
* fail the underlying write, so callers `void audit.log(...)` without awaiting.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AuditService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async log(
|
||||||
|
userId: string | null | undefined,
|
||||||
|
event: string,
|
||||||
|
message?: Record<string, unknown>,
|
||||||
|
level = "info",
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.prisma.activityLog.create({
|
||||||
|
data: {
|
||||||
|
userId: userId ?? undefined,
|
||||||
|
event,
|
||||||
|
level,
|
||||||
|
message: (message as Prisma.InputJsonValue) ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* never let audit logging break a real write */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Small shared coercers for DTO fields that arrive as strings from JSON.
|
||||||
|
// Distinguishing "field absent" (undefined -> leave unchanged) from
|
||||||
|
// "field cleared" (null/"" -> set null) matters for PATCH semantics.
|
||||||
|
|
||||||
|
export function toDate(v?: string | null): Date | null | undefined {
|
||||||
|
if (v === undefined) return undefined;
|
||||||
|
if (v === "" || v === null) return null;
|
||||||
|
const d = new Date(v);
|
||||||
|
return isNaN(d.getTime()) ? undefined : d;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from "@nestjs/common";
|
||||||
|
import { AuditService } from "./audit.service";
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [AuditService],
|
||||||
|
exports: [AuditService],
|
||||||
|
})
|
||||||
|
export class CommonModule {}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEmail,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Currency } from "@jorgecuadros/database";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editable customer fields. Internal/derived columns (nameSource, nameMissing,
|
||||||
|
* legacy* provenance, archivedAt) are managed by the service, not the client.
|
||||||
|
* `name` is the only required field; everything else is optional.
|
||||||
|
*/
|
||||||
|
export class CreateCustomerDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional() @IsString() addressLine1?: string;
|
||||||
|
@IsOptional() @IsString() addressLine2?: string;
|
||||||
|
@IsOptional() @IsString() city?: string;
|
||||||
|
@IsOptional() @IsString() state?: string;
|
||||||
|
@IsOptional() @IsString() zipCode?: string;
|
||||||
|
@IsOptional() @IsString() country?: string;
|
||||||
|
@IsOptional() @IsString() phone?: string;
|
||||||
|
@IsOptional() @IsString() mobile?: string;
|
||||||
|
@IsOptional() @IsString() fax?: string;
|
||||||
|
@IsOptional() @IsEmail() email?: string;
|
||||||
|
@IsOptional() @IsString() notes?: string;
|
||||||
|
@IsOptional() @IsString() identificationType?: string;
|
||||||
|
@IsOptional() @IsString() identificationNumber?: string;
|
||||||
|
/** ISO date string; coerced to Date by the service. */
|
||||||
|
@IsOptional() @IsString() identificationExpiration?: string;
|
||||||
|
@IsOptional() @IsString() customerSince?: string;
|
||||||
|
@IsOptional() @IsBoolean() status?: boolean;
|
||||||
|
@IsOptional() @IsNumber() minimumBalance?: number;
|
||||||
|
@IsOptional() @IsNumber() feeAmount?: number;
|
||||||
|
@IsOptional() @IsEnum(Currency) preferredCurrency?: Currency;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Request } from "express";
|
||||||
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||||
|
import { AbilityGuard } from "../auth/ability.guard";
|
||||||
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
|
import { AuditService } from "../common/audit.service";
|
||||||
|
import { CustomersService } from "./customers.service";
|
||||||
|
import { CreateCustomerDto } from "./create-customer.dto";
|
||||||
|
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||||
|
|
||||||
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
|
@Controller("customers")
|
||||||
|
export class CustomersController {
|
||||||
|
constructor(
|
||||||
|
private readonly customers: CustomersService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private actingId(req: Request): string {
|
||||||
|
return (req.user as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("stats")
|
||||||
|
stats() {
|
||||||
|
return this.customers.stats();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@Query("query") query?: string,
|
||||||
|
@Query("page") page?: string,
|
||||||
|
@Query("pageSize") pageSize?: string,
|
||||||
|
@Query("line") line?: "utility" | "insurance" | "both",
|
||||||
|
@Query("includeArchived") includeArchived?: string,
|
||||||
|
) {
|
||||||
|
const p = Math.max(1, Number(page) || 1);
|
||||||
|
const ps = Math.min(100, Math.max(1, Number(pageSize) || 25));
|
||||||
|
return this.customers.list({
|
||||||
|
query,
|
||||||
|
page: p,
|
||||||
|
pageSize: ps,
|
||||||
|
line,
|
||||||
|
includeArchived: includeArchived === "true",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id")
|
||||||
|
detail(@Param("id") id: string) {
|
||||||
|
return this.customers.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequireAbility("customer:create")
|
||||||
|
async create(@Body() dto: CreateCustomerDto, @Req() req: Request) {
|
||||||
|
const c = await this.customers.create(dto);
|
||||||
|
void this.audit.log(this.actingId(req), "customer.create", { customerId: c.id, name: c.name });
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
@RequireAbility("customer:update")
|
||||||
|
async update(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: UpdateCustomerDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const c = await this.customers.update(id, dto);
|
||||||
|
void this.audit.log(this.actingId(req), "customer.update", { customerId: id });
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
@RequireAbility("customer:delete")
|
||||||
|
async archive(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const c = await this.customers.archive(id);
|
||||||
|
void this.audit.log(this.actingId(req), "customer.archive", { customerId: id });
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/restore")
|
||||||
|
@RequireAbility("customer:delete")
|
||||||
|
async restore(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const c = await this.customers.restore(id);
|
||||||
|
void this.audit.log(this.actingId(req), "customer.restore", { customerId: id });
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { CustomersController } from "./customers.controller";
|
||||||
|
import { CustomersService } from "./customers.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CustomersController],
|
||||||
|
providers: [CustomersService],
|
||||||
|
})
|
||||||
|
export class CustomersModule {}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { CreateCustomerDto } from "./create-customer.dto";
|
||||||
|
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||||
|
|
||||||
|
export interface ListParams {
|
||||||
|
query?: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
line?: "utility" | "insurance" | "both";
|
||||||
|
includeArchived?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an optional ISO date string to a Date (or null to clear it). */
|
||||||
|
function toDate(v?: string): Date | null | undefined {
|
||||||
|
if (v === undefined) return undefined;
|
||||||
|
if (v === "" || v === null) return null;
|
||||||
|
const d = new Date(v);
|
||||||
|
return isNaN(d.getTime()) ? undefined : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CustomersService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** Unified customer list with search + business-line filter, paginated. */
|
||||||
|
async list({ query, page, pageSize, line, includeArchived }: ListParams) {
|
||||||
|
const where: Prisma.CustomerWhereInput = {};
|
||||||
|
|
||||||
|
if (!includeArchived) where.archivedAt = null;
|
||||||
|
|
||||||
|
if (query && query.trim()) {
|
||||||
|
const q = query.trim();
|
||||||
|
where.OR = [
|
||||||
|
{ name: { contains: q } },
|
||||||
|
{ email: { contains: q } },
|
||||||
|
{ phone: { contains: q } },
|
||||||
|
{ mobile: { contains: q } },
|
||||||
|
{ city: { contains: q } },
|
||||||
|
{ legacyRefs: { some: { legacyId: { contains: q } } } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (line === "utility") where.properties = { some: {} };
|
||||||
|
if (line === "insurance") where.policies = { some: {} };
|
||||||
|
if (line === "both") {
|
||||||
|
where.properties = { some: {} };
|
||||||
|
where.policies = { some: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [total, rows] = await this.prisma.$transaction([
|
||||||
|
this.prisma.customer.count({ where }),
|
||||||
|
this.prisma.customer.findMany({
|
||||||
|
where,
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
// Nameless records last: ordering by name alone floats every
|
||||||
|
// "(SIN NOMBRE)" to the top, since "(" sorts before every letter.
|
||||||
|
orderBy: [{ nameMissing: "asc" }, { name: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
nameSource: true,
|
||||||
|
city: true,
|
||||||
|
state: true,
|
||||||
|
email: true,
|
||||||
|
phone: true,
|
||||||
|
mobile: true,
|
||||||
|
status: true,
|
||||||
|
archivedAt: true,
|
||||||
|
_count: { select: { properties: true, policies: true, transactions: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const items = rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
nameSource: r.nameSource,
|
||||||
|
city: r.city,
|
||||||
|
state: r.state,
|
||||||
|
email: r.email,
|
||||||
|
phone: r.phone,
|
||||||
|
mobile: r.mobile,
|
||||||
|
status: r.status,
|
||||||
|
archived: r.archivedAt != null,
|
||||||
|
propertyCount: r._count.properties,
|
||||||
|
policyCount: r._count.policies,
|
||||||
|
transactionCount: r._count.transactions,
|
||||||
|
hasUtilities: r._count.properties > 0,
|
||||||
|
hasInsurance: r._count.policies > 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full unified customer view: identity + both business lines + ledger. */
|
||||||
|
async detail(id: string) {
|
||||||
|
const customer = await this.prisma.customer.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
legacyRefs: true,
|
||||||
|
properties: {
|
||||||
|
include: { services: true, trustAccount: true, documents: true },
|
||||||
|
},
|
||||||
|
policies: {
|
||||||
|
orderBy: { policyFrom: "desc" },
|
||||||
|
include: {
|
||||||
|
policyType: true,
|
||||||
|
insuranceProvider: true,
|
||||||
|
installments: { orderBy: { sequence: "asc" } },
|
||||||
|
vehicles: true,
|
||||||
|
insuredDrivers: true,
|
||||||
|
beneficiaries: true,
|
||||||
|
claims: true,
|
||||||
|
documents: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transactions: {
|
||||||
|
orderBy: { transactionDate: "desc" },
|
||||||
|
take: 100,
|
||||||
|
include: { type: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
throw new NotFoundException(`Customer ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ledger totals per domain + currency (the "one statement across both
|
||||||
|
// business lines" payoff), computed in the DB rather than in JS.
|
||||||
|
const summary = await this.prisma.transaction.groupBy({
|
||||||
|
by: ["domain", "currency"],
|
||||||
|
// Exclude voided rows so the per-domain balance matches the statement.
|
||||||
|
where: { customerId: id, voidedAt: null },
|
||||||
|
_sum: { amount: true },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...customer,
|
||||||
|
transactionSummary: summary.map((s) => ({
|
||||||
|
domain: s.domain,
|
||||||
|
currency: s.currency,
|
||||||
|
total: s._sum.amount,
|
||||||
|
count: s._count._all,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
|
private toData(dto: CreateCustomerDto | UpdateCustomerDto) {
|
||||||
|
// Whitelisted by the DTO already; map the date strings to Date objects.
|
||||||
|
const { identificationExpiration, customerSince, ...rest } = dto;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
...(identificationExpiration !== undefined && {
|
||||||
|
identificationExpiration: toDate(identificationExpiration),
|
||||||
|
}),
|
||||||
|
...(customerSince !== undefined && { customerSince: toDate(customerSince) }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateCustomerDto) {
|
||||||
|
return this.prisma.customer.create({
|
||||||
|
// App-created rows: nameMissing false (name is required), no legacy
|
||||||
|
// provenance — those columns stay null, marking a native record.
|
||||||
|
data: { ...this.toData(dto), name: dto.name, nameMissing: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateCustomerDto) {
|
||||||
|
await this.ensureExists(id);
|
||||||
|
return this.prisma.customer.update({ where: { id }, data: this.toData(dto) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft-delete: hide from default lists, keep the row + provenance. */
|
||||||
|
async archive(id: string) {
|
||||||
|
await this.ensureExists(id);
|
||||||
|
return this.prisma.customer.update({
|
||||||
|
where: { id },
|
||||||
|
data: { archivedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async restore(id: string) {
|
||||||
|
await this.ensureExists(id);
|
||||||
|
return this.prisma.customer.update({
|
||||||
|
where: { id },
|
||||||
|
data: { archivedAt: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureExists(id: string) {
|
||||||
|
const found = await this.prisma.customer.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!found) throw new NotFoundException(`Customer ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top-line counts for a dashboard header. */
|
||||||
|
async stats() {
|
||||||
|
const [customers, withUtilities, withInsurance, policies, properties, transactions] =
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.customer.count(),
|
||||||
|
this.prisma.customer.count({ where: { properties: { some: {} } } }),
|
||||||
|
this.prisma.customer.count({ where: { policies: { some: {} } } }),
|
||||||
|
this.prisma.policy.count(),
|
||||||
|
this.prisma.property.count(),
|
||||||
|
this.prisma.transaction.count(),
|
||||||
|
]);
|
||||||
|
const bothLines = await this.prisma.customer.count({
|
||||||
|
where: { properties: { some: {} }, policies: { some: {} } },
|
||||||
|
});
|
||||||
|
return { customers, withUtilities, withInsurance, bothLines, policies, properties, transactions };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEmail,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Currency } from "@jorgecuadros/database";
|
||||||
|
|
||||||
|
/** Same editable fields as create, all optional. */
|
||||||
|
export class UpdateCustomerDto {
|
||||||
|
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||||
|
@IsOptional() @IsString() addressLine1?: string;
|
||||||
|
@IsOptional() @IsString() addressLine2?: string;
|
||||||
|
@IsOptional() @IsString() city?: string;
|
||||||
|
@IsOptional() @IsString() state?: string;
|
||||||
|
@IsOptional() @IsString() zipCode?: string;
|
||||||
|
@IsOptional() @IsString() country?: string;
|
||||||
|
@IsOptional() @IsString() phone?: string;
|
||||||
|
@IsOptional() @IsString() mobile?: string;
|
||||||
|
@IsOptional() @IsString() fax?: string;
|
||||||
|
@IsOptional() @IsEmail() email?: string;
|
||||||
|
@IsOptional() @IsString() notes?: string;
|
||||||
|
@IsOptional() @IsString() identificationType?: string;
|
||||||
|
@IsOptional() @IsString() identificationNumber?: string;
|
||||||
|
@IsOptional() @IsString() identificationExpiration?: string;
|
||||||
|
@IsOptional() @IsString() customerSince?: string;
|
||||||
|
@IsOptional() @IsBoolean() status?: boolean;
|
||||||
|
@IsOptional() @IsNumber() minimumBalance?: number;
|
||||||
|
@IsOptional() @IsNumber() feeAmount?: number;
|
||||||
|
@IsOptional() @IsEnum(Currency) preferredCurrency?: Currency;
|
||||||
|
}
|
||||||
+21
-1
@@ -25,6 +25,26 @@ async function bootstrap() {
|
|||||||
throw new Error("SESSION_SECRET must be set (see .env.example)");
|
throw new Error("SESSION_SECRET must be set (see .env.example)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether the session cookie carries the Secure flag. This CANNOT simply
|
||||||
|
// follow NODE_ENV: express-session silently declines to send a Secure cookie
|
||||||
|
// over a plain-HTTP connection, so a production image served over http://ial
|
||||||
|
// issues no cookie at all. Login then returns 200 with a user, no session is
|
||||||
|
// established, every later request 403s, and the UI loops back to /login —
|
||||||
|
// which is exactly what happened on the first galactus deploy.
|
||||||
|
//
|
||||||
|
// Leave it ON wherever the app is reached over TLS. Turn it OFF only for a
|
||||||
|
// deployment that is HTTP but reached over an already-encrypted transport
|
||||||
|
// (the galactus install is Tailscale-only, so WireGuard encrypts the wire).
|
||||||
|
// Behind a TLS-terminating proxy, set trust proxy instead of turning this off.
|
||||||
|
// An EMPTY value counts as unset, not as "false". Compose interpolation turns
|
||||||
|
// an absent `${SESSION_COOKIE_SECURE:-}` into the empty string, so testing
|
||||||
|
// `!== undefined` here would silently drop the Secure flag on any deployment
|
||||||
|
// that merely passes the variable through without setting it.
|
||||||
|
const cookieSecureRaw = process.env.SESSION_COOKIE_SECURE;
|
||||||
|
const cookieSecure = cookieSecureRaw
|
||||||
|
? cookieSecureRaw === "true"
|
||||||
|
: process.env.NODE_ENV === "production";
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
session({
|
session({
|
||||||
secret: sessionSecret,
|
secret: sessionSecret,
|
||||||
@@ -32,7 +52,7 @@ async function bootstrap() {
|
|||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
cookie: {
|
cookie: {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: cookieSecure,
|
||||||
maxAge: 1000 * 60 * 60 * 8, // 8-hour session, matches a staff workday
|
maxAge: 1000 * 60 * 60 * 8, // 8-hour session, matches a staff workday
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
StreamableFile,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||||
|
import { AbilityGuard } from "../auth/ability.guard";
|
||||||
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
|
import { AuditService } from "../common/audit.service";
|
||||||
|
import { OpsService } from "./ops.service";
|
||||||
|
import { StartJobDto } from "./start-job.dto";
|
||||||
|
|
||||||
|
/** Every route is ADMIN-only (ability "db:manage"). */
|
||||||
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
|
@RequireAbility("db:manage")
|
||||||
|
@Controller("ops")
|
||||||
|
export class OpsController {
|
||||||
|
constructor(
|
||||||
|
private readonly ops: OpsService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private actingId(req: Request): string {
|
||||||
|
return (req.user as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- ingest */
|
||||||
|
|
||||||
|
@Get("ingest")
|
||||||
|
listIngest() {
|
||||||
|
return this.ops.listIngest();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("ingest/:name")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", { limits: { fileSize: 2 * 1024 * 1024 * 1024 } }),
|
||||||
|
)
|
||||||
|
async uploadIngest(
|
||||||
|
@Param("name") name: string,
|
||||||
|
@UploadedFile() file: { buffer: Buffer; size: number } | undefined,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
if (!file) throw new Error("No se recibió ningún archivo.");
|
||||||
|
await this.ops.saveIngest(name, file.buffer);
|
||||||
|
void this.audit.log(this.actingId(req), "ops.ingest.upload", {
|
||||||
|
name,
|
||||||
|
size: file.size,
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("ingest/:name")
|
||||||
|
async deleteIngest(@Param("name") name: string, @Req() req: Request) {
|
||||||
|
await this.ops.deleteIngest(name);
|
||||||
|
void this.audit.log(this.actingId(req), "ops.ingest.delete", { name });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ backups */
|
||||||
|
|
||||||
|
@Get("backups")
|
||||||
|
listBackups() {
|
||||||
|
return this.ops.listBackups();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("backups/:name/download")
|
||||||
|
download(
|
||||||
|
@Param("name") name: string,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
): StreamableFile {
|
||||||
|
const { stream, name: safe } = this.ops.backupStream(name);
|
||||||
|
res.set({
|
||||||
|
"Content-Type": "application/gzip",
|
||||||
|
"Content-Disposition": `attachment; filename="${safe}"`,
|
||||||
|
});
|
||||||
|
return new StreamableFile(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("backups/:name")
|
||||||
|
async deleteBackup(@Param("name") name: string, @Req() req: Request) {
|
||||||
|
await this.ops.deleteBackup(name);
|
||||||
|
void this.audit.log(this.actingId(req), "ops.backup.delete", { name });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- jobs */
|
||||||
|
|
||||||
|
@Get("jobs")
|
||||||
|
listJobs() {
|
||||||
|
return this.ops.listJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("jobs/:id")
|
||||||
|
getJob(@Param("id") id: string) {
|
||||||
|
return this.ops.getJob(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("jobs")
|
||||||
|
async startJob(@Body() dto: StartJobDto, @Req() req: Request) {
|
||||||
|
const userId = this.actingId(req);
|
||||||
|
const job = await this.ops.startJob(dto.kind, { file: dto.file }, userId);
|
||||||
|
void this.audit.log(userId, "ops.job.start", {
|
||||||
|
jobId: job.id,
|
||||||
|
kind: dto.kind,
|
||||||
|
file: dto.file,
|
||||||
|
});
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { OpsController } from "./ops.controller";
|
||||||
|
import { OpsService } from "./ops.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [OpsController],
|
||||||
|
providers: [OpsService],
|
||||||
|
})
|
||||||
|
export class OpsModule {}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
OnModuleInit,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { createReadStream, promises as fs } from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { OpsJobKind } from "@jorgecuadros/database";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin database operations. Everything long-running (mysqldump, mysql restore,
|
||||||
|
* the Python migration) runs as a detached child process recorded as one OpsJob
|
||||||
|
* row whose `log` is appended as the process talks; the web polls that row.
|
||||||
|
*
|
||||||
|
* Only ONE mutating job runs at a time (a RUNNING row blocks a new start) — a
|
||||||
|
* restore or re-import racing a migration would corrupt the database.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The four legacy Access files. Uploads are allowlisted to exactly these
|
||||||
|
* names so an ingest write can never land at an arbitrary path. */
|
||||||
|
export const INGEST_FILES = [
|
||||||
|
"UTILITIES.accdb",
|
||||||
|
"SEGUROS 16.mdb",
|
||||||
|
"SEGUROS 16_be.mdb",
|
||||||
|
"SCOTHIA.mdb",
|
||||||
|
] as const;
|
||||||
|
export type IngestName = (typeof INGEST_FILES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefix for every command containing a pipe. Without it the exit status of
|
||||||
|
* `mysqldump | gzip` is gzip's, so a dump that failed immediately still looks
|
||||||
|
* like a successful job. Both Alpine's busybox ash (the API image) and macOS
|
||||||
|
* `sh` (dev) support it; POSIX does not require it, so `sh -c` is the contract.
|
||||||
|
*/
|
||||||
|
const PIPEFAIL = "set -o pipefail; ";
|
||||||
|
|
||||||
|
interface MysqlConn {
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
database: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OpsService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(OpsService.name);
|
||||||
|
|
||||||
|
// Resolve from this source file so it works regardless of process.cwd()
|
||||||
|
// (the API runs from apps/api/, but the Python ETL lives at repo-root migration/).
|
||||||
|
private readonly migrationDir =
|
||||||
|
process.env.MIGRATION_DIR ??
|
||||||
|
path.resolve(__dirname, "..", "..", "..", "..", "migration");
|
||||||
|
private readonly ingestDir =
|
||||||
|
process.env.INGEST_DIR ?? path.join(this.migrationDir, "ingest");
|
||||||
|
private readonly backupDir =
|
||||||
|
process.env.BACKUP_DIR ?? path.join(this.migrationDir, "backups");
|
||||||
|
private readonly migrationEnv = process.env.MIGRATION_ENV ?? "dev";
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
await fs.mkdir(this.ingestDir, { recursive: true });
|
||||||
|
await fs.mkdir(this.backupDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- ingest */
|
||||||
|
|
||||||
|
private assertIngestName(name: string): IngestName {
|
||||||
|
if (!INGEST_FILES.includes(name as IngestName)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Archivo no permitido. Debe ser uno de: ${INGEST_FILES.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return name as IngestName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listIngest(): Promise<
|
||||||
|
{ name: string; present: boolean; size: number | null; modifiedAt: string | null }[]
|
||||||
|
> {
|
||||||
|
return Promise.all(
|
||||||
|
INGEST_FILES.map(async (name) => {
|
||||||
|
try {
|
||||||
|
const st = await fs.stat(path.join(this.ingestDir, name));
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
present: true,
|
||||||
|
size: st.size,
|
||||||
|
modifiedAt: st.mtime.toISOString(),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { name, present: false, size: null, modifiedAt: null };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveIngest(name: string, data: Buffer): Promise<void> {
|
||||||
|
const safe = this.assertIngestName(name);
|
||||||
|
await fs.writeFile(path.join(this.ingestDir, safe), data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIngest(name: string): Promise<void> {
|
||||||
|
const safe = this.assertIngestName(name);
|
||||||
|
await fs.rm(path.join(this.ingestDir, safe), { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- backups */
|
||||||
|
|
||||||
|
private assertBackupName(name: string): string {
|
||||||
|
// No path separators, must be a produced backup file.
|
||||||
|
if (!/^[A-Za-z0-9._-]+\.sql\.gz$/.test(name)) {
|
||||||
|
throw new BadRequestException("Nombre de respaldo inválido.");
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listBackups(): Promise<
|
||||||
|
{ name: string; size: number; createdAt: string }[]
|
||||||
|
> {
|
||||||
|
let names: string[];
|
||||||
|
try {
|
||||||
|
names = await fs.readdir(this.backupDir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const rows = await Promise.all(
|
||||||
|
names
|
||||||
|
.filter((n) => n.endsWith(".sql.gz"))
|
||||||
|
.map(async (name) => {
|
||||||
|
const st = await fs.stat(path.join(this.backupDir, name));
|
||||||
|
return { name, size: st.size, createdAt: st.mtime.toISOString() };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
backupStream(name: string) {
|
||||||
|
const safe = this.assertBackupName(name);
|
||||||
|
const full = path.join(this.backupDir, safe);
|
||||||
|
return { stream: createReadStream(full), name: safe };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBackup(name: string): Promise<void> {
|
||||||
|
const safe = this.assertBackupName(name);
|
||||||
|
await fs.rm(path.join(this.backupDir, safe), { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- jobs */
|
||||||
|
|
||||||
|
listJobs(limit = 20) {
|
||||||
|
return this.prisma.opsJob.findMany({
|
||||||
|
orderBy: { startedAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJob(id: string) {
|
||||||
|
const job = await this.prisma.opsJob.findUnique({ where: { id } });
|
||||||
|
if (!job) throw new NotFoundException("Trabajo no encontrado.");
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a mutating op. Refuses if another job is already RUNNING. Returns the
|
||||||
|
* new job row immediately; the process runs on in the background and appends
|
||||||
|
* to `log` until it exits.
|
||||||
|
*/
|
||||||
|
async startJob(
|
||||||
|
kind: OpsJobKind,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
userId: string | undefined,
|
||||||
|
) {
|
||||||
|
const running = await this.prisma.opsJob.count({ where: { status: "RUNNING" } });
|
||||||
|
if (running > 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
"Ya hay una operación en curso. Espere a que termine.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = this.opsConn();
|
||||||
|
const { cmd, resolvedParams } = await this.buildCommand(kind, params, conn);
|
||||||
|
|
||||||
|
const job = await this.prisma.opsJob.create({
|
||||||
|
data: {
|
||||||
|
kind,
|
||||||
|
status: "RUNNING",
|
||||||
|
log: "",
|
||||||
|
params: resolvedParams as object,
|
||||||
|
createdById: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.run(job.id, cmd, conn.password);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------- internals */
|
||||||
|
|
||||||
|
private parseDbUrl(): MysqlConn {
|
||||||
|
const raw = process.env.DATABASE_URL;
|
||||||
|
if (!raw) throw new BadRequestException("DATABASE_URL no está configurada.");
|
||||||
|
const u = new URL(raw);
|
||||||
|
return {
|
||||||
|
host: u.hostname,
|
||||||
|
port: u.port || "3306",
|
||||||
|
user: decodeURIComponent(u.username),
|
||||||
|
password: decodeURIComponent(u.password),
|
||||||
|
database: u.pathname.replace(/^\//, ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The credentials mysqldump/mysql run as — deliberately NOT the application
|
||||||
|
* user. `--single-transaction` issues FLUSH TABLES, which needs the global
|
||||||
|
* RELOAD privilege, and the app user is granted only `ALL ON jorgecuadros.*`
|
||||||
|
* plus `USAGE ON *.*`; `--skip-lock-tables` does not avoid it. A restore of a
|
||||||
|
* dump taken before --set-gtid-purged=OFF likewise needs SUPER to replay its
|
||||||
|
* SET @@GLOBAL.GTID_PURGED. So an admin credential is supplied out of band
|
||||||
|
* rather than elevating the runtime user for the sake of one admin screen —
|
||||||
|
* the same choice deploy/scripts/pre-migrate-backup.mjs makes.
|
||||||
|
*
|
||||||
|
* Host, port and database always come from DATABASE_URL: the ops user is a
|
||||||
|
* different login on the SAME server, never a way to point at another one.
|
||||||
|
*
|
||||||
|
* With the vars unset this falls back to the DATABASE_URL credentials, which
|
||||||
|
* is what local development wants — a dev MySQL grants the app user far more.
|
||||||
|
*/
|
||||||
|
private opsConn(): MysqlConn {
|
||||||
|
const conn = this.parseDbUrl();
|
||||||
|
const user = process.env.OPS_DB_ADMIN_USER;
|
||||||
|
const password = process.env.OPS_DB_ADMIN_PASSWORD;
|
||||||
|
if (!user || !password) {
|
||||||
|
this.logger.warn(
|
||||||
|
"OPS_DB_ADMIN_USER/OPS_DB_ADMIN_PASSWORD no configuradas; " +
|
||||||
|
`usando el usuario de la aplicación (${conn.user}) para mysqldump. ` +
|
||||||
|
"En producción esto falla por falta del privilegio RELOAD.",
|
||||||
|
);
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
return { ...conn, user, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mysql/mysqldump connection flags. The password goes through MYSQL_PWD in
|
||||||
|
* the child env, never on the command line (which would leak via `ps`). */
|
||||||
|
private connFlags(c: MysqlConn): string {
|
||||||
|
return `--host=${c.host} --port=${c.port} --user=${shq(c.user)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private timestamp(): string {
|
||||||
|
return new Date().toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One hardened mysqldump, shared by BACKUP and by the safety backups SYNC and
|
||||||
|
* REIMPORT take first. Kept byte-for-byte in spirit with the dump in
|
||||||
|
* deploy/scripts/pre-migrate-backup.mjs — the two write into the same volume
|
||||||
|
* and both are listed as restore points by this same screen.
|
||||||
|
*
|
||||||
|
* --set-gtid-purged=OFF: the production server is the replication SOURCE with
|
||||||
|
* GTID on, so without it every dump embeds SET @@GLOBAL.GTID_PURGED and is
|
||||||
|
* unrestorable onto the very server it came from.
|
||||||
|
*
|
||||||
|
* The table-count assertion is not belt-and-braces: `gzip -t` passes on the
|
||||||
|
* ~372-byte output of a mysqldump that died on its first statement, so a
|
||||||
|
* failed dump would otherwise be recorded as a successful backup. (`set -o
|
||||||
|
* pipefail` is set by the caller for the same reason — without it the exit
|
||||||
|
* status of the pipeline is gzip's, and gzip succeeded.)
|
||||||
|
*
|
||||||
|
* A failed attempt deletes its own output, so a truncated file never appears
|
||||||
|
* in the restore list looking like an ordinary restore point.
|
||||||
|
*/
|
||||||
|
private dumpCommand(flags: string, db: string, out: string): string {
|
||||||
|
return (
|
||||||
|
`( mysqldump ${flags} --single-transaction --routines --triggers ` +
|
||||||
|
`--no-tablespaces --set-gtid-purged=OFF ${db} | gzip -c > ${out} && ` +
|
||||||
|
`gzip -t ${out} && ` +
|
||||||
|
`TABLAS=$(gunzip -c ${out} | grep -c 'CREATE TABLE') && ` +
|
||||||
|
`echo "tablas capturadas: $TABLAS" && ` +
|
||||||
|
`[ "$TABLAS" -ge 1 ] ) || ` +
|
||||||
|
`{ rm -f ${out}; echo 'respaldo incompleto eliminado'; exit 1; }`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildCommand(
|
||||||
|
kind: OpsJobKind,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
conn: MysqlConn,
|
||||||
|
): Promise<{ cmd: string; resolvedParams: Record<string, unknown> }> {
|
||||||
|
const flags = this.connFlags(conn);
|
||||||
|
const db = shq(conn.database);
|
||||||
|
|
||||||
|
if (kind === "BACKUP") {
|
||||||
|
const file = `backup-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
|
||||||
|
const out = shq(path.join(this.backupDir, file));
|
||||||
|
return {
|
||||||
|
cmd: `${PIPEFAIL}${this.dumpCommand(flags, db, out)}`,
|
||||||
|
resolvedParams: { file },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "RESTORE") {
|
||||||
|
const name = this.assertBackupName(String(params.file ?? ""));
|
||||||
|
const full = path.join(this.backupDir, name);
|
||||||
|
await fs.access(full).catch(() => {
|
||||||
|
throw new NotFoundException(`Respaldo no encontrado: ${name}`);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
// pipefail matters here too: a corrupt archive makes gunzip fail while
|
||||||
|
// mysql, fed a truncated stream, can still exit 0 — a restore that
|
||||||
|
// reported success having replayed only part of the dump.
|
||||||
|
cmd: `${PIPEFAIL}gunzip -c ${shq(full)} | mysql ${flags} ${db}`,
|
||||||
|
resolvedParams: { file: name },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "SYNC") {
|
||||||
|
const file = `pre-sync-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
|
||||||
|
const out = shq(path.join(this.backupDir, file));
|
||||||
|
const py = await this.pythonBin();
|
||||||
|
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
||||||
|
const cmd =
|
||||||
|
`${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
|
||||||
|
`${this.dumpCommand(flags, db, out)} && ` +
|
||||||
|
`echo '== Sincronización aditiva desde carpeta de ingesta ==' && ` +
|
||||||
|
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --sync`;
|
||||||
|
return { cmd, resolvedParams: { safetyBackup: file } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "REIMPORT") {
|
||||||
|
// Safety backup first, then a full truncate+rebuild from the ingest files.
|
||||||
|
const file = `pre-reimport-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
|
||||||
|
const out = shq(path.join(this.backupDir, file));
|
||||||
|
const py = await this.pythonBin();
|
||||||
|
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
||||||
|
const cmd =
|
||||||
|
`${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
|
||||||
|
`${this.dumpCommand(flags, db, out)} && ` +
|
||||||
|
`echo '== Reimportación desde carpeta de ingesta ==' && ` +
|
||||||
|
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage`;
|
||||||
|
return { cmd, resolvedParams: { safetyBackup: file } };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestException(`Operación no soportada: ${kind}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prefer the migration venv python if it exists (local dev), else system. */
|
||||||
|
private async pythonBin(): Promise<string> {
|
||||||
|
const venv = path.join(this.migrationDir, ".venv", "bin", "python");
|
||||||
|
try {
|
||||||
|
await fs.access(venv);
|
||||||
|
return venv;
|
||||||
|
} catch {
|
||||||
|
return process.env.PYTHON_BIN ?? "python3";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `password` is the ops credential from opsConn(), exported as MYSQL_PWD so it
|
||||||
|
* never reaches argv (which `ps` exposes to every process on the host).
|
||||||
|
*
|
||||||
|
* It does not leak into the Python ETL that SYNC and REIMPORT go on to run:
|
||||||
|
* migration/dbenv.py connects with pymysql using the credentials inside
|
||||||
|
* DATABASE_URL and never consults MYSQL_PWD. The ETL keeps running as the
|
||||||
|
* application user, which is what it should be doing.
|
||||||
|
*/
|
||||||
|
private run(jobId: string, cmd: string, password: string): void {
|
||||||
|
const child = spawn("sh", ["-c", cmd], {
|
||||||
|
cwd: this.migrationDir,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
MYSQL_PWD: password,
|
||||||
|
INGEST_DIR: this.ingestDir,
|
||||||
|
BACKUP_DIR: this.backupDir,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let buffer = "";
|
||||||
|
let pending = "";
|
||||||
|
let flushing = false;
|
||||||
|
let flushTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
const flush = async () => {
|
||||||
|
if (flushing || !pending) return;
|
||||||
|
flushing = true;
|
||||||
|
const chunk = pending;
|
||||||
|
pending = "";
|
||||||
|
try {
|
||||||
|
await this.prisma.opsJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { log: { set: buffer } },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`ops job ${jobId} log flush failed: ${String(e)}`);
|
||||||
|
} finally {
|
||||||
|
flushing = false;
|
||||||
|
void chunk;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onData = (d: Buffer) => {
|
||||||
|
const text = d.toString();
|
||||||
|
buffer += text;
|
||||||
|
pending += text;
|
||||||
|
if (!flushTimer) {
|
||||||
|
flushTimer = setTimeout(() => {
|
||||||
|
flushTimer = null;
|
||||||
|
void flush();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
child.stdout.on("data", onData);
|
||||||
|
child.stderr.on("data", onData);
|
||||||
|
|
||||||
|
const finalize = async (status: "SUCCESS" | "FAILED", tail: string) => {
|
||||||
|
if (flushTimer) clearTimeout(flushTimer);
|
||||||
|
buffer += tail;
|
||||||
|
await this.prisma.opsJob
|
||||||
|
.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: { status, log: { set: buffer }, finishedAt: new Date() },
|
||||||
|
})
|
||||||
|
.catch((e) => this.logger.error(`ops job ${jobId} finalize failed: ${String(e)}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
child.on("error", (err) => {
|
||||||
|
void finalize("FAILED", `\n[proceso no pudo iniciar] ${err.message}\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code === 0) void finalize("SUCCESS", `\n[completado con éxito]\n`);
|
||||||
|
else void finalize("FAILED", `\n[terminó con código ${code}]\n`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-quote a value for a POSIX shell command. */
|
||||||
|
function shq(v: string): string {
|
||||||
|
return `'${v.replace(/'/g, `'\\''`)}'`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsEnum, IsOptional, IsString } from "class-validator";
|
||||||
|
import { OpsJobKind } from "@jorgecuadros/database";
|
||||||
|
|
||||||
|
export class StartJobDto {
|
||||||
|
@IsEnum(OpsJobKind)
|
||||||
|
kind!: OpsJobKind;
|
||||||
|
|
||||||
|
/** Target backup filename — required for RESTORE, ignored otherwise. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
file?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEmail,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Currency } from "@jorgecuadros/database";
|
||||||
|
|
||||||
|
// Each child DTO covers create; updates reuse the same shape with all fields
|
||||||
|
// optional via the corresponding Update class. Route supplies the policyId.
|
||||||
|
|
||||||
|
export class InstallmentDto {
|
||||||
|
@IsInt() sequence!: number;
|
||||||
|
@IsOptional() @IsNumber() amount?: number;
|
||||||
|
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||||
|
@IsOptional() @IsString() dueDate?: string;
|
||||||
|
@IsOptional() @IsString() paidDate?: string;
|
||||||
|
@IsOptional() @IsString() checkNumber?: string;
|
||||||
|
@IsOptional() @IsBoolean() isCash?: boolean;
|
||||||
|
}
|
||||||
|
export class UpdateInstallmentDto {
|
||||||
|
@IsOptional() @IsInt() sequence?: number;
|
||||||
|
@IsOptional() @IsNumber() amount?: number;
|
||||||
|
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||||
|
@IsOptional() @IsString() dueDate?: string;
|
||||||
|
@IsOptional() @IsString() paidDate?: string;
|
||||||
|
@IsOptional() @IsString() checkNumber?: string;
|
||||||
|
@IsOptional() @IsBoolean() isCash?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VehicleDto {
|
||||||
|
@IsOptional() @IsString() make?: string;
|
||||||
|
@IsOptional() @IsString() model?: string;
|
||||||
|
@IsOptional() @IsString() modelYear?: string;
|
||||||
|
@IsOptional() @IsString() bodyType?: string;
|
||||||
|
@IsOptional() @IsString() engineNumber?: string;
|
||||||
|
@IsOptional() @IsString() licensePlate?: string;
|
||||||
|
@IsOptional() @IsString() vinNumber?: string;
|
||||||
|
@IsOptional() @IsString() stateCode?: string;
|
||||||
|
@IsOptional() @IsString() notes?: string;
|
||||||
|
}
|
||||||
|
export class UpdateVehicleDto extends VehicleDto {}
|
||||||
|
|
||||||
|
export class DriverDto {
|
||||||
|
@IsOptional() @IsString() fullName?: string;
|
||||||
|
@IsOptional() @IsString() birthDate?: string;
|
||||||
|
@IsOptional() @IsString() sex?: string;
|
||||||
|
@IsOptional() @IsString() occupation?: string;
|
||||||
|
@IsOptional() @IsString() licenseNumber?: string;
|
||||||
|
@IsOptional() @IsString() licenseState?: string;
|
||||||
|
}
|
||||||
|
export class UpdateDriverDto extends DriverDto {}
|
||||||
|
|
||||||
|
export class BeneficiaryDto {
|
||||||
|
@IsOptional() @IsString() name?: string;
|
||||||
|
@IsOptional() @IsString() address?: string;
|
||||||
|
@IsOptional() @IsString() phone?: string;
|
||||||
|
@IsOptional() @IsEmail() email?: string;
|
||||||
|
}
|
||||||
|
export class UpdateBeneficiaryDto extends BeneficiaryDto {}
|
||||||
|
|
||||||
|
export class ClaimDto {
|
||||||
|
@IsOptional() @IsString() claimType?: string;
|
||||||
|
@IsOptional() @IsString() incidentDate?: string;
|
||||||
|
@IsOptional() @IsString() reportedDate?: string;
|
||||||
|
@IsOptional() @IsString() description?: string;
|
||||||
|
@IsOptional() @IsString() adjusterId?: string;
|
||||||
|
@IsOptional() @IsNumber() claimedAmount?: number;
|
||||||
|
@IsOptional() @IsNumber() settledAmount?: number;
|
||||||
|
@IsOptional() @IsString() settlementDate?: string;
|
||||||
|
@IsOptional() @IsString() checkNumber?: string;
|
||||||
|
@IsOptional() @IsBoolean() resolved?: boolean;
|
||||||
|
@IsOptional() @IsString() resolutionNotes?: string;
|
||||||
|
}
|
||||||
|
export class UpdateClaimDto extends ClaimDto {}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { IsOptional, IsString, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class ProviderDto {
|
||||||
|
@IsString() @MinLength(1) name!: string;
|
||||||
|
}
|
||||||
|
export class UpdateProviderDto {
|
||||||
|
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PolicyTypeDto {
|
||||||
|
@IsString() @MinLength(1) name!: string;
|
||||||
|
@IsOptional() @IsString() shortDescription?: string;
|
||||||
|
}
|
||||||
|
export class UpdatePolicyTypeDto {
|
||||||
|
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||||
|
@IsOptional() @IsString() shortDescription?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdjusterDto {
|
||||||
|
@IsOptional() @IsString() company?: string;
|
||||||
|
@IsOptional() @IsString() city?: string;
|
||||||
|
@IsOptional() @IsString() name?: string;
|
||||||
|
@IsOptional() @IsString() phone?: string;
|
||||||
|
@IsOptional() @IsString() beeper?: string;
|
||||||
|
}
|
||||||
|
export class UpdateAdjusterDto extends AdjusterDto {}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Request } from "express";
|
||||||
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||||
|
import { AbilityGuard } from "../auth/ability.guard";
|
||||||
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
|
import { AuditService } from "../common/audit.service";
|
||||||
|
import { PoliciesService } from "./policies.service";
|
||||||
|
import {
|
||||||
|
AdjusterDto,
|
||||||
|
PolicyTypeDto,
|
||||||
|
ProviderDto,
|
||||||
|
UpdateAdjusterDto,
|
||||||
|
UpdatePolicyTypeDto,
|
||||||
|
UpdateProviderDto,
|
||||||
|
} from "./lookup.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insurance reference data: providers, policy types, adjusters. Reading is open
|
||||||
|
* to any authenticated user (the policy form needs the options); mutating needs
|
||||||
|
* "lookup:manage" (MANAGER+).
|
||||||
|
*/
|
||||||
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
|
@Controller("lookups")
|
||||||
|
export class LookupsController {
|
||||||
|
constructor(
|
||||||
|
private readonly policies: PoliciesService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private actingId(req: Request): string {
|
||||||
|
return (req.user as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.policies.listLookups();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("providers")
|
||||||
|
@RequireAbility("lookup:manage")
|
||||||
|
async createProvider(@Body() dto: ProviderDto, @Req() req: Request) {
|
||||||
|
const row = await this.policies.createProvider(dto);
|
||||||
|
void this.audit.log(this.actingId(req), "lookup.provider.create", {
|
||||||
|
providerId: row.id,
|
||||||
|
name: row.name,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
@Patch("providers/:id")
|
||||||
|
@RequireAbility("lookup:manage")
|
||||||
|
async updateProvider(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: UpdateProviderDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const row = await this.policies.updateProvider(id, dto);
|
||||||
|
void this.audit.log(this.actingId(req), "lookup.provider.update", {
|
||||||
|
providerId: id,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
@Delete("providers/:id")
|
||||||
|
@RequireAbility("lookup:manage")
|
||||||
|
async removeProvider(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const row = await this.policies.removeProvider(id);
|
||||||
|
void this.audit.log(this.actingId(req), "lookup.provider.delete", {
|
||||||
|
providerId: id,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("policy-types")
|
||||||
|
@RequireAbility("lookup:manage")
|
||||||
|
async createType(@Body() dto: PolicyTypeDto, @Req() req: Request) {
|
||||||
|
const row = await this.policies.createPolicyType(dto);
|
||||||
|
void this.audit.log(this.actingId(req), "lookup.policyType.create", {
|
||||||
|
policyTypeId: row.id,
|
||||||
|
name: row.name,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
@Patch("policy-types/:id")
|
||||||
|
@RequireAbility("lookup:manage")
|
||||||
|
async updateType(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: UpdatePolicyTypeDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const row = await this.policies.updatePolicyType(id, dto);
|
||||||
|
void this.audit.log(this.actingId(req), "lookup.policyType.update", {
|
||||||
|
policyTypeId: id,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
@Delete("policy-types/:id")
|
||||||
|
@RequireAbility("lookup:manage")
|
||||||
|
async removeType(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const row = await this.policies.removePolicyType(id);
|
||||||
|
void this.audit.log(this.actingId(req), "lookup.policyType.delete", {
|
||||||
|
policyTypeId: id,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("adjusters")
|
||||||
|
@RequireAbility("lookup:manage")
|
||||||
|
async createAdjuster(@Body() dto: AdjusterDto, @Req() req: Request) {
|
||||||
|
const row = await this.policies.createAdjuster(dto);
|
||||||
|
void this.audit.log(this.actingId(req), "lookup.adjuster.create", {
|
||||||
|
adjusterId: row.id,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
@Patch("adjusters/:id")
|
||||||
|
@RequireAbility("lookup:manage")
|
||||||
|
async updateAdjuster(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: UpdateAdjusterDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const row = await this.policies.updateAdjuster(id, dto);
|
||||||
|
void this.audit.log(this.actingId(req), "lookup.adjuster.update", {
|
||||||
|
adjusterId: id,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
@Delete("adjusters/:id")
|
||||||
|
@RequireAbility("lookup:manage")
|
||||||
|
async removeAdjuster(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const row = await this.policies.removeAdjuster(id);
|
||||||
|
void this.audit.log(this.actingId(req), "lookup.adjuster.delete", {
|
||||||
|
adjusterId: id,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
StreamableFile,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { downloadName, type UploadedFileLike } from "../storage/upload-file";
|
||||||
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||||
|
import { AbilityGuard } from "../auth/ability.guard";
|
||||||
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
|
import { AuditService } from "../common/audit.service";
|
||||||
|
import {
|
||||||
|
PoliciesService,
|
||||||
|
type PolicySort,
|
||||||
|
type PolicyStatus,
|
||||||
|
} from "./policies.service";
|
||||||
|
import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto";
|
||||||
|
import {
|
||||||
|
BeneficiaryDto,
|
||||||
|
ClaimDto,
|
||||||
|
DriverDto,
|
||||||
|
InstallmentDto,
|
||||||
|
UpdateBeneficiaryDto,
|
||||||
|
UpdateClaimDto,
|
||||||
|
UpdateDriverDto,
|
||||||
|
UpdateInstallmentDto,
|
||||||
|
VehicleDto,
|
||||||
|
} from "./children.dto";
|
||||||
|
|
||||||
|
const STATUSES: PolicyStatus[] = ["active", "expiring", "expired", "undated"];
|
||||||
|
const SORTS: PolicySort[] = [
|
||||||
|
"expiry_desc",
|
||||||
|
"expiry_asc",
|
||||||
|
"customer",
|
||||||
|
"number",
|
||||||
|
"premium_desc",
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseDays(days?: string): number {
|
||||||
|
return Math.min(365, Math.max(1, Number(days) || 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
|
@Controller("policies")
|
||||||
|
export class PoliciesController {
|
||||||
|
constructor(
|
||||||
|
private readonly policies: PoliciesService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private actingId(req: Request): string {
|
||||||
|
return (req.user as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("stats")
|
||||||
|
stats(@Query("days") days?: string) {
|
||||||
|
return this.policies.stats(parseDays(days));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("facets")
|
||||||
|
facets() {
|
||||||
|
return this.policies.facets();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@Query("query") query?: string,
|
||||||
|
@Query("page") page?: string,
|
||||||
|
@Query("pageSize") pageSize?: string,
|
||||||
|
@Query("status") status?: string,
|
||||||
|
@Query("days") days?: string,
|
||||||
|
@Query("typeId") typeId?: string,
|
||||||
|
@Query("providerId") providerId?: string,
|
||||||
|
@Query("liquidated") liquidated?: string,
|
||||||
|
@Query("includeArchived") includeArchived?: string,
|
||||||
|
@Query("sort") sort?: string,
|
||||||
|
) {
|
||||||
|
return this.policies.list({
|
||||||
|
query,
|
||||||
|
page: Math.max(1, Number(page) || 1),
|
||||||
|
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||||
|
status: STATUSES.includes(status as PolicyStatus)
|
||||||
|
? (status as PolicyStatus)
|
||||||
|
: undefined,
|
||||||
|
days: parseDays(days),
|
||||||
|
typeId: typeId || undefined,
|
||||||
|
providerId: providerId || undefined,
|
||||||
|
liquidated:
|
||||||
|
liquidated === "true" ? true : liquidated === "false" ? false : undefined,
|
||||||
|
includeArchived: includeArchived === "true",
|
||||||
|
sort: SORTS.includes(sort as PolicySort)
|
||||||
|
? (sort as PolicySort)
|
||||||
|
: "expiry_desc",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id")
|
||||||
|
detail(@Param("id") id: string, @Query("days") days?: string) {
|
||||||
|
return this.policies.detail(id, parseDays(days));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- header writes --------------------------------------------------------
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequireAbility("policy:create")
|
||||||
|
async create(@Body() dto: CreatePolicyDto, @Req() req: Request) {
|
||||||
|
const p = await this.policies.create(dto);
|
||||||
|
void this.audit.log(this.actingId(req), "policy.create", { policyId: p.id });
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
@RequireAbility("policy:update")
|
||||||
|
async update(@Param("id") id: string, @Body() dto: UpdatePolicyDto, @Req() req: Request) {
|
||||||
|
const p = await this.policies.update(id, dto);
|
||||||
|
void this.audit.log(this.actingId(req), "policy.update", { policyId: id });
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
@RequireAbility("policy:delete")
|
||||||
|
async archive(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const p = await this.policies.archive(id);
|
||||||
|
void this.audit.log(this.actingId(req), "policy.archive", { policyId: id });
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/restore")
|
||||||
|
@RequireAbility("policy:delete")
|
||||||
|
async restore(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const p = await this.policies.restore(id);
|
||||||
|
void this.audit.log(this.actingId(req), "policy.restore", { policyId: id });
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- children (all editing a policy => policy:update) ---------------------
|
||||||
|
|
||||||
|
@Post(":id/installments")
|
||||||
|
@RequireAbility("policy:update")
|
||||||
|
addInstallment(@Param("id") id: string, @Body() dto: InstallmentDto) {
|
||||||
|
return this.policies.addInstallment(id, dto);
|
||||||
|
}
|
||||||
|
@Patch(":id/installments/:childId")
|
||||||
|
@RequireAbility("policy:update")
|
||||||
|
updateInstallment(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Param("childId") childId: string,
|
||||||
|
@Body() dto: UpdateInstallmentDto,
|
||||||
|
) {
|
||||||
|
return this.policies.updateInstallment(id, childId, dto);
|
||||||
|
}
|
||||||
|
@Delete(":id/installments/:childId")
|
||||||
|
@RequireAbility("policy:update")
|
||||||
|
removeInstallment(@Param("id") id: string, @Param("childId") childId: string) {
|
||||||
|
return this.policies.removeInstallment(id, childId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/vehicles")
|
||||||
|
@RequireAbility("policy:update")
|
||||||
|
addVehicle(@Param("id") id: string, @Body() dto: VehicleDto) {
|
||||||
|
return this.policies.addVehicle(id, dto);
|
||||||
|
}
|
||||||
|
@Patch(":id/vehicles/:childId")
|
||||||
|
@RequireAbility("policy:update")
|
||||||
|
updateVehicle(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Param("childId") childId: string,
|
||||||
|
@Body() dto: VehicleDto,
|
||||||
|
) {
|
||||||
|
return this.policies.updateVehicle(id, childId, dto);
|
||||||
|
}
|
||||||
|
@Delete(":id/vehicles/:childId")
|
||||||
|
@RequireAbility("policy:update")
|
||||||
|
removeVehicle(@Param("id") id: string, @Param("childId") childId: string) {
|
||||||
|
return this.policies.removeVehicle(id, childId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/drivers")
|
||||||
|
@RequireAbility("policy:update")
|
||||||
|
addDriver(@Param("id") id: string, @Body() dto: DriverDto) {
|
||||||
|
return this.policies.addDriver(id, dto);
|
||||||
|
}
|
||||||
|
@Patch(":id/drivers/:childId")
|
||||||
|
@RequireAbility("policy:update")
|
||||||
|
updateDriver(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Param("childId") childId: string,
|
||||||
|
@Body() dto: UpdateDriverDto,
|
||||||
|
) {
|
||||||
|
return this.policies.updateDriver(id, childId, dto);
|
||||||
|
}
|
||||||
|
@Delete(":id/drivers/:childId")
|
||||||
|
@RequireAbility("policy:update")
|
||||||
|
removeDriver(@Param("id") id: string, @Param("childId") childId: string) {
|
||||||
|
return this.policies.removeDriver(id, childId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/beneficiaries")
|
||||||
|
@RequireAbility("policy:update")
|
||||||
|
addBeneficiary(@Param("id") id: string, @Body() dto: BeneficiaryDto) {
|
||||||
|
return this.policies.addBeneficiary(id, dto);
|
||||||
|
}
|
||||||
|
@Patch(":id/beneficiaries/:childId")
|
||||||
|
@RequireAbility("policy:update")
|
||||||
|
updateBeneficiary(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Param("childId") childId: string,
|
||||||
|
@Body() dto: UpdateBeneficiaryDto,
|
||||||
|
) {
|
||||||
|
return this.policies.updateBeneficiary(id, childId, dto);
|
||||||
|
}
|
||||||
|
@Delete(":id/beneficiaries/:childId")
|
||||||
|
@RequireAbility("policy:update")
|
||||||
|
removeBeneficiary(@Param("id") id: string, @Param("childId") childId: string) {
|
||||||
|
return this.policies.removeBeneficiary(id, childId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/claims")
|
||||||
|
@RequireAbility("policy:update")
|
||||||
|
addClaim(@Param("id") id: string, @Body() dto: ClaimDto) {
|
||||||
|
return this.policies.addClaim(id, dto);
|
||||||
|
}
|
||||||
|
@Patch(":id/claims/:childId")
|
||||||
|
@RequireAbility("policy:update")
|
||||||
|
updateClaim(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Param("childId") childId: string,
|
||||||
|
@Body() dto: UpdateClaimDto,
|
||||||
|
) {
|
||||||
|
return this.policies.updateClaim(id, childId, dto);
|
||||||
|
}
|
||||||
|
@Delete(":id/claims/:childId")
|
||||||
|
@RequireAbility("policy:update")
|
||||||
|
removeClaim(@Param("id") id: string, @Param("childId") childId: string) {
|
||||||
|
return this.policies.removeClaim(id, childId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- documents ------------------------------------------------------------
|
||||||
|
|
||||||
|
@Post(":id/documents")
|
||||||
|
@RequireAbility("policy:update")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", { limits: { fileSize: 50 * 1024 * 1024 } }),
|
||||||
|
)
|
||||||
|
addDocument(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@UploadedFile() file: UploadedFileLike | undefined,
|
||||||
|
@Query("type") type: string | undefined,
|
||||||
|
) {
|
||||||
|
if (!file) throw new Error("No se recibió ningún archivo.");
|
||||||
|
return this.policies.addDocument(id, file, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/documents/:childId/download")
|
||||||
|
async downloadDocument(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Param("childId") childId: string,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
): Promise<StreamableFile> {
|
||||||
|
const { row, stream, contentType } = await this.policies.getDocument(id, childId);
|
||||||
|
res.set({
|
||||||
|
"Content-Type": contentType ?? "application/octet-stream",
|
||||||
|
"Content-Disposition": `attachment; filename="${downloadName(row.storageKey, row.documentType)}"`,
|
||||||
|
});
|
||||||
|
return new StreamableFile(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id/documents/:childId")
|
||||||
|
@RequireAbility("policy:update")
|
||||||
|
removeDocument(@Param("id") id: string, @Param("childId") childId: string) {
|
||||||
|
return this.policies.removeDocument(id, childId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PoliciesController } from "./policies.controller";
|
||||||
|
import { LookupsController } from "./lookups.controller";
|
||||||
|
import { PoliciesService } from "./policies.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PoliciesController, LookupsController],
|
||||||
|
providers: [PoliciesService],
|
||||||
|
})
|
||||||
|
export class PoliciesModule {}
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { StorageService } from "../storage/storage.service";
|
||||||
|
import { extForUpload, type UploadedFileLike } from "../storage/upload-file";
|
||||||
|
import { toDate } from "../common/coerce";
|
||||||
|
import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto";
|
||||||
|
import {
|
||||||
|
BeneficiaryDto,
|
||||||
|
ClaimDto,
|
||||||
|
DriverDto,
|
||||||
|
InstallmentDto,
|
||||||
|
UpdateBeneficiaryDto,
|
||||||
|
UpdateClaimDto,
|
||||||
|
UpdateDriverDto,
|
||||||
|
UpdateInstallmentDto,
|
||||||
|
VehicleDto,
|
||||||
|
} from "./children.dto";
|
||||||
|
import {
|
||||||
|
AdjusterDto,
|
||||||
|
PolicyTypeDto,
|
||||||
|
ProviderDto,
|
||||||
|
UpdateAdjusterDto,
|
||||||
|
UpdatePolicyTypeDto,
|
||||||
|
UpdateProviderDto,
|
||||||
|
} from "./lookup.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vigencia buckets, derived from `policyTo` against today. `undated` is a real
|
||||||
|
* bucket rather than an error case: 528 of the migrated policies carry no end
|
||||||
|
* date at all (the legacy Access tables left it blank), so they can neither be
|
||||||
|
* called current nor expired.
|
||||||
|
*/
|
||||||
|
export type PolicyStatus = "active" | "expiring" | "expired" | "undated";
|
||||||
|
|
||||||
|
export type PolicySort =
|
||||||
|
| "expiry_desc"
|
||||||
|
| "expiry_asc"
|
||||||
|
| "customer"
|
||||||
|
| "number"
|
||||||
|
| "premium_desc";
|
||||||
|
|
||||||
|
export interface ListParams {
|
||||||
|
query?: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
status?: PolicyStatus;
|
||||||
|
/** Window in days for the `expiring` bucket. */
|
||||||
|
days: number;
|
||||||
|
typeId?: string;
|
||||||
|
providerId?: string;
|
||||||
|
liquidated?: boolean;
|
||||||
|
includeArchived?: boolean;
|
||||||
|
sort: PolicySort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Midnight today, UTC — policy dates are stored date-only at 00:00 UTC. */
|
||||||
|
function today(): Date {
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(
|
||||||
|
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(d: Date, days: number): Date {
|
||||||
|
return new Date(d.getTime() + days * 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusOf(policyTo: Date | null, from: Date, soon: Date): PolicyStatus {
|
||||||
|
if (!policyTo) return "undated";
|
||||||
|
if (policyTo < from) return "expired";
|
||||||
|
return policyTo <= soon ? "expiring" : "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysUntil(policyTo: Date | null, from: Date): number | null {
|
||||||
|
if (!policyTo) return null;
|
||||||
|
return Math.round((policyTo.getTime() - from.getTime()) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PoliciesService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly storage: StorageService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private statusWhere(
|
||||||
|
status: PolicyStatus | undefined,
|
||||||
|
days: number,
|
||||||
|
): Prisma.PolicyWhereInput {
|
||||||
|
const from = today();
|
||||||
|
switch (status) {
|
||||||
|
case "active":
|
||||||
|
return { policyTo: { gte: from } };
|
||||||
|
case "expiring":
|
||||||
|
return { policyTo: { gte: from, lte: addDays(from, days) } };
|
||||||
|
case "expired":
|
||||||
|
return { policyTo: { lt: from } };
|
||||||
|
case "undated":
|
||||||
|
return { policyTo: null };
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private orderBy(sort: PolicySort): Prisma.PolicyOrderByWithRelationInput[] {
|
||||||
|
switch (sort) {
|
||||||
|
case "expiry_asc":
|
||||||
|
return [{ policyTo: "asc" }];
|
||||||
|
case "customer":
|
||||||
|
return [{ customer: { name: "asc" } }, { policyTo: "desc" }];
|
||||||
|
case "number":
|
||||||
|
return [{ policyNumber: "asc" }];
|
||||||
|
case "premium_desc":
|
||||||
|
// Sorts on netPremium, not total: `total` is 0 or null on all but 2 of
|
||||||
|
// the 2378 migrated policies, so ordering by it is meaningless.
|
||||||
|
return [{ netPremium: "desc" }];
|
||||||
|
default:
|
||||||
|
// MySQL sorts NULLs last on DESC, which puts the 528 undated policies
|
||||||
|
// at the end instead of the top — the behaviour we want by default.
|
||||||
|
return [{ policyTo: "desc" }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Policy list with search, vigencia/type/provider filters, paginated. */
|
||||||
|
async list(params: ListParams) {
|
||||||
|
const { query, page, pageSize, status, days, typeId, providerId, liquidated,
|
||||||
|
includeArchived, sort } = params;
|
||||||
|
|
||||||
|
const where: Prisma.PolicyWhereInput = { ...this.statusWhere(status, days) };
|
||||||
|
|
||||||
|
if (!includeArchived) where.archivedAt = null;
|
||||||
|
|
||||||
|
if (query && query.trim()) {
|
||||||
|
const q = query.trim();
|
||||||
|
where.OR = [
|
||||||
|
{ policyNumber: { contains: q } },
|
||||||
|
{ customer: { name: { contains: q } } },
|
||||||
|
{ agentName: { contains: q } },
|
||||||
|
{ vehicles: { some: { licensePlate: { contains: q } } } },
|
||||||
|
{ insuredDrivers: { some: { fullName: { contains: q } } } },
|
||||||
|
{ legacyId: { contains: q } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (typeId) where.policyTypeId = typeId;
|
||||||
|
if (providerId) where.insuranceProviderId = providerId;
|
||||||
|
if (liquidated !== undefined) where.liquidated = liquidated;
|
||||||
|
|
||||||
|
const [total, rows] = await this.prisma.$transaction([
|
||||||
|
this.prisma.policy.count({ where }),
|
||||||
|
this.prisma.policy.findMany({
|
||||||
|
where,
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: this.orderBy(sort),
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
policyNumber: true,
|
||||||
|
agentName: true,
|
||||||
|
policyFrom: true,
|
||||||
|
policyTo: true,
|
||||||
|
netPremium: true,
|
||||||
|
total: true,
|
||||||
|
currency: true,
|
||||||
|
liquidated: true,
|
||||||
|
archivedAt: true,
|
||||||
|
customer: { select: { id: true, name: true, city: true } },
|
||||||
|
policyType: { select: { id: true, name: true } },
|
||||||
|
insuranceProvider: { select: { id: true, name: true } },
|
||||||
|
_count: { select: { vehicles: true, installments: true, documents: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const from = today();
|
||||||
|
const soon = addDays(from, days);
|
||||||
|
|
||||||
|
const items = rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
policyNumber: r.policyNumber,
|
||||||
|
agentName: r.agentName,
|
||||||
|
policyFrom: r.policyFrom,
|
||||||
|
policyTo: r.policyTo,
|
||||||
|
netPremium: r.netPremium,
|
||||||
|
total: r.total,
|
||||||
|
currency: r.currency,
|
||||||
|
liquidated: r.liquidated,
|
||||||
|
archived: r.archivedAt != null,
|
||||||
|
customerId: r.customer.id,
|
||||||
|
customerName: r.customer.name,
|
||||||
|
customerCity: r.customer.city,
|
||||||
|
policyType: r.policyType,
|
||||||
|
insuranceProvider: r.insuranceProvider,
|
||||||
|
status: statusOf(r.policyTo, from, soon),
|
||||||
|
daysToExpiry: daysUntil(r.policyTo, from),
|
||||||
|
vehicleCount: r._count.vehicles,
|
||||||
|
installmentCount: r._count.installments,
|
||||||
|
documentCount: r._count.documents,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top-line counts for the policies page header. */
|
||||||
|
async stats(days: number) {
|
||||||
|
const from = today();
|
||||||
|
const soon = addDays(from, days);
|
||||||
|
|
||||||
|
const [total, active, expiring, expired, undated, liquidated] =
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.policy.count(),
|
||||||
|
this.prisma.policy.count({ where: { policyTo: { gte: from } } }),
|
||||||
|
this.prisma.policy.count({
|
||||||
|
where: { policyTo: { gte: from, lte: soon } },
|
||||||
|
}),
|
||||||
|
this.prisma.policy.count({ where: { policyTo: { lt: from } } }),
|
||||||
|
this.prisma.policy.count({ where: { policyTo: null } }),
|
||||||
|
this.prisma.policy.count({ where: { liquidated: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Premium in force, per currency — the two currencies can't be summed.
|
||||||
|
const inForce = await this.prisma.policy.groupBy({
|
||||||
|
by: ["currency"],
|
||||||
|
where: { policyTo: { gte: from } },
|
||||||
|
_sum: { total: true, netPremium: true },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
active,
|
||||||
|
expiring,
|
||||||
|
expired,
|
||||||
|
undated,
|
||||||
|
liquidated,
|
||||||
|
pending: total - liquidated,
|
||||||
|
days,
|
||||||
|
premiumInForce: inForce.map((r) => ({
|
||||||
|
currency: r.currency,
|
||||||
|
total: r._sum.total,
|
||||||
|
netPremium: r._sum.netPremium,
|
||||||
|
count: r._count._all,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter dropdown options, with counts so empty choices are visible. */
|
||||||
|
async facets() {
|
||||||
|
const [types, providers] = await this.prisma.$transaction([
|
||||||
|
this.prisma.policyType.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true, _count: { select: { policies: true } } },
|
||||||
|
}),
|
||||||
|
this.prisma.insuranceProvider.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true, _count: { select: { policies: true } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
types: types.map((t) => ({ id: t.id, name: t.name, count: t._count.policies })),
|
||||||
|
providers: providers.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
count: p._count.policies,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full policy view, including the owning customer. */
|
||||||
|
async detail(id: string, days: number) {
|
||||||
|
const policy = await this.prisma.policy.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
customer: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
nameSource: true,
|
||||||
|
city: true,
|
||||||
|
state: true,
|
||||||
|
phone: true,
|
||||||
|
mobile: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
policyType: true,
|
||||||
|
insuranceProvider: true,
|
||||||
|
installments: { orderBy: { sequence: "asc" } },
|
||||||
|
vehicles: true,
|
||||||
|
insuredDrivers: true,
|
||||||
|
beneficiaries: true,
|
||||||
|
claims: { include: { adjuster: true } },
|
||||||
|
documents: true,
|
||||||
|
properties: {
|
||||||
|
select: { id: true, addressLine1: true, addressLine2: true, zone: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!policy) {
|
||||||
|
throw new NotFoundException(`Policy ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = today();
|
||||||
|
return {
|
||||||
|
...policy,
|
||||||
|
status: statusOf(policy.policyTo, from, addDays(from, days)),
|
||||||
|
daysToExpiry: daysUntil(policy.policyTo, from),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- policy header writes -------------------------------------------------
|
||||||
|
|
||||||
|
private headerData(dto: CreatePolicyDto | UpdatePolicyDto) {
|
||||||
|
const { policyDate, policyFrom, policyTo, liquidationDate, ...rest } =
|
||||||
|
dto as CreatePolicyDto;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
...(policyDate !== undefined && { policyDate: toDate(policyDate) }),
|
||||||
|
...(policyFrom !== undefined && { policyFrom: toDate(policyFrom) }),
|
||||||
|
...(policyTo !== undefined && { policyTo: toDate(policyTo) }),
|
||||||
|
...(liquidationDate !== undefined && { liquidationDate: toDate(liquidationDate) }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreatePolicyDto) {
|
||||||
|
// Validate the customer FK up front for a clean 404 instead of a raw
|
||||||
|
// Prisma constraint error.
|
||||||
|
const customer = await this.prisma.customer.findUnique({
|
||||||
|
where: { id: dto.customerId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`);
|
||||||
|
|
||||||
|
return this.prisma.policy.create({
|
||||||
|
data: {
|
||||||
|
...this.headerData(dto),
|
||||||
|
policyNumber: dto.policyNumber,
|
||||||
|
customerId: dto.customerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdatePolicyDto) {
|
||||||
|
await this.ensurePolicy(id);
|
||||||
|
return this.prisma.policy.update({ where: { id }, data: this.headerData(dto) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async archive(id: string) {
|
||||||
|
await this.ensurePolicy(id);
|
||||||
|
return this.prisma.policy.update({ where: { id }, data: { archivedAt: new Date() } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async restore(id: string) {
|
||||||
|
await this.ensurePolicy(id);
|
||||||
|
return this.prisma.policy.update({ where: { id }, data: { archivedAt: null } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensurePolicy(id: string) {
|
||||||
|
const found = await this.prisma.policy.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!found) throw new NotFoundException(`Policy ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- child rows -----------------------------------------------------------
|
||||||
|
// Each child is created under a policy and edited/removed by its own id,
|
||||||
|
// scoped to that policy so one policy's id can't touch another's rows.
|
||||||
|
|
||||||
|
private async ensureChild(
|
||||||
|
model: "policyPaymentInstallment" | "vehicle" | "insuredDriver" | "policyBeneficiary" | "claim",
|
||||||
|
policyId: string,
|
||||||
|
childId: string,
|
||||||
|
) {
|
||||||
|
await this.ensurePolicy(policyId);
|
||||||
|
// @ts-expect-error dynamic delegate access is safe for these known models
|
||||||
|
const row = await this.prisma[model].findFirst({
|
||||||
|
where: { id: childId, policyId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException(`Child ${childId} not found on policy ${policyId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addInstallment(policyId: string, dto: InstallmentDto) {
|
||||||
|
await this.ensurePolicy(policyId);
|
||||||
|
return this.prisma.policyPaymentInstallment.create({
|
||||||
|
data: {
|
||||||
|
policyId,
|
||||||
|
sequence: dto.sequence,
|
||||||
|
amount: dto.amount,
|
||||||
|
currency: dto.currency,
|
||||||
|
dueDate: toDate(dto.dueDate) ?? undefined,
|
||||||
|
paidDate: toDate(dto.paidDate) ?? undefined,
|
||||||
|
checkNumber: dto.checkNumber,
|
||||||
|
isCash: dto.isCash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async updateInstallment(policyId: string, id: string, dto: UpdateInstallmentDto) {
|
||||||
|
await this.ensureChild("policyPaymentInstallment", policyId, id);
|
||||||
|
return this.prisma.policyPaymentInstallment.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
sequence: dto.sequence,
|
||||||
|
amount: dto.amount,
|
||||||
|
currency: dto.currency,
|
||||||
|
...(dto.dueDate !== undefined && { dueDate: toDate(dto.dueDate) }),
|
||||||
|
...(dto.paidDate !== undefined && { paidDate: toDate(dto.paidDate) }),
|
||||||
|
checkNumber: dto.checkNumber,
|
||||||
|
isCash: dto.isCash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async removeInstallment(policyId: string, id: string) {
|
||||||
|
await this.ensureChild("policyPaymentInstallment", policyId, id);
|
||||||
|
return this.prisma.policyPaymentInstallment.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async addVehicle(policyId: string, dto: VehicleDto) {
|
||||||
|
await this.ensurePolicy(policyId);
|
||||||
|
return this.prisma.vehicle.create({ data: { policyId, ...dto } });
|
||||||
|
}
|
||||||
|
async updateVehicle(policyId: string, id: string, dto: VehicleDto) {
|
||||||
|
await this.ensureChild("vehicle", policyId, id);
|
||||||
|
return this.prisma.vehicle.update({ where: { id }, data: { ...dto } });
|
||||||
|
}
|
||||||
|
async removeVehicle(policyId: string, id: string) {
|
||||||
|
await this.ensureChild("vehicle", policyId, id);
|
||||||
|
return this.prisma.vehicle.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async addDriver(policyId: string, dto: DriverDto) {
|
||||||
|
await this.ensurePolicy(policyId);
|
||||||
|
return this.prisma.insuredDriver.create({
|
||||||
|
data: { policyId, ...dto, birthDate: toDate(dto.birthDate) ?? undefined },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async updateDriver(policyId: string, id: string, dto: UpdateDriverDto) {
|
||||||
|
await this.ensureChild("insuredDriver", policyId, id);
|
||||||
|
return this.prisma.insuredDriver.update({
|
||||||
|
where: { id },
|
||||||
|
data: { ...dto, ...(dto.birthDate !== undefined && { birthDate: toDate(dto.birthDate) }) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async removeDriver(policyId: string, id: string) {
|
||||||
|
await this.ensureChild("insuredDriver", policyId, id);
|
||||||
|
return this.prisma.insuredDriver.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async addBeneficiary(policyId: string, dto: BeneficiaryDto) {
|
||||||
|
await this.ensurePolicy(policyId);
|
||||||
|
return this.prisma.policyBeneficiary.create({ data: { policyId, ...dto } });
|
||||||
|
}
|
||||||
|
async updateBeneficiary(policyId: string, id: string, dto: UpdateBeneficiaryDto) {
|
||||||
|
await this.ensureChild("policyBeneficiary", policyId, id);
|
||||||
|
return this.prisma.policyBeneficiary.update({ where: { id }, data: { ...dto } });
|
||||||
|
}
|
||||||
|
async removeBeneficiary(policyId: string, id: string) {
|
||||||
|
await this.ensureChild("policyBeneficiary", policyId, id);
|
||||||
|
return this.prisma.policyBeneficiary.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async addClaim(policyId: string, dto: ClaimDto) {
|
||||||
|
await this.ensurePolicy(policyId);
|
||||||
|
return this.prisma.claim.create({ data: { policyId, ...this.claimData(dto) } });
|
||||||
|
}
|
||||||
|
async updateClaim(policyId: string, id: string, dto: UpdateClaimDto) {
|
||||||
|
await this.ensureChild("claim", policyId, id);
|
||||||
|
return this.prisma.claim.update({ where: { id }, data: this.claimData(dto) });
|
||||||
|
}
|
||||||
|
async removeClaim(policyId: string, id: string) {
|
||||||
|
await this.ensureChild("claim", policyId, id);
|
||||||
|
return this.prisma.claim.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
private claimData(dto: ClaimDto) {
|
||||||
|
const { incidentDate, reportedDate, settlementDate, ...rest } = dto;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
...(incidentDate !== undefined && { incidentDate: toDate(incidentDate) }),
|
||||||
|
...(reportedDate !== undefined && { reportedDate: toDate(reportedDate) }),
|
||||||
|
...(settlementDate !== undefined && { settlementDate: toDate(settlementDate) }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- documents ------------------------------------------------------------
|
||||||
|
// Blob in object storage under `policy/<policyId>/…`; row is the pointer.
|
||||||
|
|
||||||
|
async addDocument(
|
||||||
|
policyId: string,
|
||||||
|
file: UploadedFileLike,
|
||||||
|
documentType?: string,
|
||||||
|
) {
|
||||||
|
await this.ensurePolicy(policyId);
|
||||||
|
const key = `policy/${policyId}/${randomUUID()}${extForUpload(file)}`;
|
||||||
|
await this.storage.put(key, file.buffer, file.mimetype);
|
||||||
|
return this.prisma.policyDocument.create({
|
||||||
|
data: {
|
||||||
|
policyId,
|
||||||
|
documentType: documentType?.trim() || "DOCUMENT",
|
||||||
|
storageKey: key,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDocument(policyId: string, id: string) {
|
||||||
|
const row = await this.prisma.policyDocument.findFirst({
|
||||||
|
where: { id, policyId },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException(`Document ${id} not found on policy ${policyId}`);
|
||||||
|
const blob = await this.storage.getStream(row.storageKey);
|
||||||
|
return { row, ...blob };
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeDocument(policyId: string, id: string) {
|
||||||
|
await this.ensurePolicy(policyId);
|
||||||
|
const row = await this.prisma.policyDocument.findFirst({
|
||||||
|
where: { id, policyId },
|
||||||
|
select: { id: true, storageKey: true },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException(`Document ${id} not found on policy ${policyId}`);
|
||||||
|
const deleted = await this.prisma.policyDocument.delete({ where: { id } });
|
||||||
|
await this.storage.delete(row.storageKey);
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- lookups (providers / policy types / adjusters) -----------------------
|
||||||
|
|
||||||
|
listLookups() {
|
||||||
|
return this.prisma.$transaction([
|
||||||
|
this.prisma.insuranceProvider.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true, _count: { select: { policies: true } } },
|
||||||
|
}),
|
||||||
|
this.prisma.policyType.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
shortDescription: true,
|
||||||
|
_count: { select: { policies: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.adjuster.findMany({ orderBy: { name: "asc" } }),
|
||||||
|
]).then(([providers, types, adjusters]) => ({ providers, types, adjusters }));
|
||||||
|
}
|
||||||
|
|
||||||
|
createProvider(dto: ProviderDto) {
|
||||||
|
return this.prisma.insuranceProvider.create({ data: dto });
|
||||||
|
}
|
||||||
|
updateProvider(id: string, dto: UpdateProviderDto) {
|
||||||
|
return this.prisma.insuranceProvider.update({ where: { id }, data: dto });
|
||||||
|
}
|
||||||
|
removeProvider(id: string) {
|
||||||
|
return this.prisma.insuranceProvider.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
createPolicyType(dto: PolicyTypeDto) {
|
||||||
|
return this.prisma.policyType.create({ data: dto });
|
||||||
|
}
|
||||||
|
updatePolicyType(id: string, dto: UpdatePolicyTypeDto) {
|
||||||
|
return this.prisma.policyType.update({ where: { id }, data: dto });
|
||||||
|
}
|
||||||
|
removePolicyType(id: string) {
|
||||||
|
return this.prisma.policyType.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
createAdjuster(dto: AdjusterDto) {
|
||||||
|
return this.prisma.adjuster.create({ data: dto });
|
||||||
|
}
|
||||||
|
updateAdjuster(id: string, dto: UpdateAdjusterDto) {
|
||||||
|
return this.prisma.adjuster.update({ where: { id }, data: dto });
|
||||||
|
}
|
||||||
|
removeAdjuster(id: string) {
|
||||||
|
return this.prisma.adjuster.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Currency } from "@jorgecuadros/database";
|
||||||
|
import { IsEnum } from "class-validator";
|
||||||
|
|
||||||
|
/** Editable policy-header fields. coveragesJson (freeform legacy blob) is not
|
||||||
|
* exposed for editing. Dates arrive as ISO strings and are coerced by the
|
||||||
|
* service. `total` is legacy-dead data — the UI uses netPremium. */
|
||||||
|
export class CreatePolicyDto {
|
||||||
|
@IsString() @MinLength(1) policyNumber!: string;
|
||||||
|
@IsString() @MinLength(1) customerId!: string;
|
||||||
|
|
||||||
|
@IsOptional() @IsString() policyTypeId?: string;
|
||||||
|
@IsOptional() @IsString() insuranceProviderId?: string;
|
||||||
|
@IsOptional() @IsString() agentName?: string;
|
||||||
|
@IsOptional() @IsString() policyDate?: string;
|
||||||
|
@IsOptional() @IsString() policyFrom?: string;
|
||||||
|
@IsOptional() @IsString() policyTo?: string;
|
||||||
|
@IsOptional() @IsInt() coveragePeriodDays?: number;
|
||||||
|
@IsOptional() @IsNumber() netPremium?: number;
|
||||||
|
@IsOptional() @IsNumber() policyFee?: number;
|
||||||
|
@IsOptional() @IsNumber() brokerFee?: number;
|
||||||
|
@IsOptional() @IsNumber() commission?: number;
|
||||||
|
@IsOptional() @IsNumber() total?: number;
|
||||||
|
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||||
|
@IsOptional() @IsString() observations?: string;
|
||||||
|
@IsOptional() @IsString() notes?: string;
|
||||||
|
@IsOptional() @IsBoolean() endorsement?: boolean;
|
||||||
|
@IsOptional() @IsBoolean() liquidated?: boolean;
|
||||||
|
@IsOptional() @IsString() liquidationNumber?: string;
|
||||||
|
@IsOptional() @IsString() liquidationDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All header fields optional (customerId is not re-assignable on update). */
|
||||||
|
export class UpdatePolicyDto {
|
||||||
|
@IsOptional() @IsString() @MinLength(1) policyNumber?: string;
|
||||||
|
@IsOptional() @IsString() policyTypeId?: string;
|
||||||
|
@IsOptional() @IsString() insuranceProviderId?: string;
|
||||||
|
@IsOptional() @IsString() agentName?: string;
|
||||||
|
@IsOptional() @IsString() policyDate?: string;
|
||||||
|
@IsOptional() @IsString() policyFrom?: string;
|
||||||
|
@IsOptional() @IsString() policyTo?: string;
|
||||||
|
@IsOptional() @IsInt() coveragePeriodDays?: number;
|
||||||
|
@IsOptional() @IsNumber() netPremium?: number;
|
||||||
|
@IsOptional() @IsNumber() policyFee?: number;
|
||||||
|
@IsOptional() @IsNumber() brokerFee?: number;
|
||||||
|
@IsOptional() @IsNumber() commission?: number;
|
||||||
|
@IsOptional() @IsNumber() total?: number;
|
||||||
|
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||||
|
@IsOptional() @IsString() observations?: string;
|
||||||
|
@IsOptional() @IsString() notes?: string;
|
||||||
|
@IsOptional() @IsBoolean() endorsement?: boolean;
|
||||||
|
@IsOptional() @IsBoolean() liquidated?: boolean;
|
||||||
|
@IsOptional() @IsString() liquidationNumber?: string;
|
||||||
|
@IsOptional() @IsString() liquidationDate?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
StreamableFile,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
|
import { ServiceKind } from "@jorgecuadros/database";
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { downloadName, type UploadedFileLike } from "../storage/upload-file";
|
||||||
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||||
|
import { AbilityGuard } from "../auth/ability.guard";
|
||||||
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
|
import { AuditService } from "../common/audit.service";
|
||||||
|
import {
|
||||||
|
PropertiesService,
|
||||||
|
type PropertySort,
|
||||||
|
type TrustFilter,
|
||||||
|
} from "./properties.service";
|
||||||
|
import {
|
||||||
|
CreatePropertyDto,
|
||||||
|
ServiceDto,
|
||||||
|
TrustDto,
|
||||||
|
UpdatePropertyDto,
|
||||||
|
UpdateServiceDto,
|
||||||
|
} from "./property.dto";
|
||||||
|
|
||||||
|
const KINDS: ServiceKind[] = [
|
||||||
|
"WATER",
|
||||||
|
"ELECTRIC",
|
||||||
|
"GAS",
|
||||||
|
"CABLE",
|
||||||
|
"PROPERTY_TAX",
|
||||||
|
"FEDERAL_ZONE",
|
||||||
|
"ALARM",
|
||||||
|
"OTHER",
|
||||||
|
];
|
||||||
|
|
||||||
|
const TRUST_FILTERS: TrustFilter[] = [
|
||||||
|
"with",
|
||||||
|
"without",
|
||||||
|
"active",
|
||||||
|
"expiring",
|
||||||
|
"expired",
|
||||||
|
"undated",
|
||||||
|
];
|
||||||
|
|
||||||
|
const SORTS: PropertySort[] = [
|
||||||
|
"customer",
|
||||||
|
"address",
|
||||||
|
"services_desc",
|
||||||
|
"trust_due_asc",
|
||||||
|
"trust_due_desc",
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseDays(days?: string): number {
|
||||||
|
return Math.min(365, Math.max(1, Number(days) || 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
|
@Controller("properties")
|
||||||
|
export class PropertiesController {
|
||||||
|
constructor(
|
||||||
|
private readonly properties: PropertiesService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private actingId(req: Request): string {
|
||||||
|
return (req.user as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("stats")
|
||||||
|
stats(@Query("days") days?: string) {
|
||||||
|
return this.properties.stats(parseDays(days));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("facets")
|
||||||
|
facets() {
|
||||||
|
return this.properties.facets();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@Query("query") query?: string,
|
||||||
|
@Query("page") page?: string,
|
||||||
|
@Query("pageSize") pageSize?: string,
|
||||||
|
@Query("serviceKind") serviceKind?: string,
|
||||||
|
@Query("municipality") municipality?: string,
|
||||||
|
@Query("bank") bank?: string,
|
||||||
|
@Query("trust") trust?: string,
|
||||||
|
@Query("hasServices") hasServices?: string,
|
||||||
|
@Query("customerId") customerId?: string,
|
||||||
|
@Query("days") days?: string,
|
||||||
|
@Query("includeArchived") includeArchived?: string,
|
||||||
|
@Query("sort") sort?: string,
|
||||||
|
) {
|
||||||
|
return this.properties.list({
|
||||||
|
query,
|
||||||
|
page: Math.max(1, Number(page) || 1),
|
||||||
|
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||||
|
serviceKind: KINDS.includes(serviceKind as ServiceKind)
|
||||||
|
? (serviceKind as ServiceKind)
|
||||||
|
: undefined,
|
||||||
|
municipality: municipality || undefined,
|
||||||
|
bank: bank || undefined,
|
||||||
|
trust: TRUST_FILTERS.includes(trust as TrustFilter)
|
||||||
|
? (trust as TrustFilter)
|
||||||
|
: undefined,
|
||||||
|
hasServices:
|
||||||
|
hasServices === "true" ? true : hasServices === "false" ? false : undefined,
|
||||||
|
customerId: customerId || undefined,
|
||||||
|
days: parseDays(days),
|
||||||
|
includeArchived: includeArchived === "true",
|
||||||
|
sort: SORTS.includes(sort as PropertySort)
|
||||||
|
? (sort as PropertySort)
|
||||||
|
: "customer",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id")
|
||||||
|
detail(@Param("id") id: string, @Query("days") days?: string) {
|
||||||
|
return this.properties.detail(id, parseDays(days));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- header writes --------------------------------------------------------
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequireAbility("property:create")
|
||||||
|
async create(@Body() dto: CreatePropertyDto, @Req() req: Request) {
|
||||||
|
const p = await this.properties.create(dto);
|
||||||
|
void this.audit.log(this.actingId(req), "property.create", { propertyId: p.id });
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
@RequireAbility("property:update")
|
||||||
|
async update(@Param("id") id: string, @Body() dto: UpdatePropertyDto, @Req() req: Request) {
|
||||||
|
const p = await this.properties.update(id, dto);
|
||||||
|
void this.audit.log(this.actingId(req), "property.update", { propertyId: id });
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
@RequireAbility("property:delete")
|
||||||
|
async archive(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const p = await this.properties.archive(id);
|
||||||
|
void this.audit.log(this.actingId(req), "property.archive", { propertyId: id });
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/restore")
|
||||||
|
@RequireAbility("property:delete")
|
||||||
|
async restore(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const p = await this.properties.restore(id);
|
||||||
|
void this.audit.log(this.actingId(req), "property.restore", { propertyId: id });
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- services (property:update) -------------------------------------------
|
||||||
|
|
||||||
|
@Post(":id/services")
|
||||||
|
@RequireAbility("property:update")
|
||||||
|
addService(@Param("id") id: string, @Body() dto: ServiceDto) {
|
||||||
|
return this.properties.addService(id, dto);
|
||||||
|
}
|
||||||
|
@Patch(":id/services/:childId")
|
||||||
|
@RequireAbility("property:update")
|
||||||
|
updateService(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Param("childId") childId: string,
|
||||||
|
@Body() dto: UpdateServiceDto,
|
||||||
|
) {
|
||||||
|
return this.properties.updateService(id, childId, dto);
|
||||||
|
}
|
||||||
|
@Delete(":id/services/:childId")
|
||||||
|
@RequireAbility("property:update")
|
||||||
|
removeService(@Param("id") id: string, @Param("childId") childId: string) {
|
||||||
|
return this.properties.removeService(id, childId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- trust account (1:1) --------------------------------------------------
|
||||||
|
|
||||||
|
@Put(":id/trust")
|
||||||
|
@RequireAbility("property:update")
|
||||||
|
upsertTrust(@Param("id") id: string, @Body() dto: TrustDto) {
|
||||||
|
return this.properties.upsertTrust(id, dto);
|
||||||
|
}
|
||||||
|
@Delete(":id/trust")
|
||||||
|
@RequireAbility("property:update")
|
||||||
|
removeTrust(@Param("id") id: string) {
|
||||||
|
return this.properties.removeTrust(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- documents ------------------------------------------------------------
|
||||||
|
|
||||||
|
@Post(":id/documents")
|
||||||
|
@RequireAbility("property:update")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", { limits: { fileSize: 50 * 1024 * 1024 } }),
|
||||||
|
)
|
||||||
|
addDocument(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@UploadedFile() file: UploadedFileLike | undefined,
|
||||||
|
@Query("type") type: string | undefined,
|
||||||
|
) {
|
||||||
|
if (!file) throw new Error("No se recibió ningún archivo.");
|
||||||
|
return this.properties.addDocument(id, file, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/documents/:childId/download")
|
||||||
|
async downloadDocument(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Param("childId") childId: string,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
): Promise<StreamableFile> {
|
||||||
|
const { row, stream, contentType } = await this.properties.getDocument(id, childId);
|
||||||
|
res.set({
|
||||||
|
"Content-Type": contentType ?? "application/octet-stream",
|
||||||
|
"Content-Disposition": `attachment; filename="${downloadName(row.storageKey, row.documentType)}"`,
|
||||||
|
});
|
||||||
|
return new StreamableFile(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id/documents/:childId")
|
||||||
|
@RequireAbility("property:update")
|
||||||
|
removeDocument(@Param("id") id: string, @Param("childId") childId: string) {
|
||||||
|
return this.properties.removeDocument(id, childId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PropertiesController } from "./properties.controller";
|
||||||
|
import { PropertiesService } from "./properties.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PropertiesController],
|
||||||
|
providers: [PropertiesService],
|
||||||
|
})
|
||||||
|
export class PropertiesModule {}
|
||||||
@@ -0,0 +1,593 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { Prisma, ServiceKind } from "@jorgecuadros/database";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { StorageService } from "../storage/storage.service";
|
||||||
|
import { extForUpload } from "../storage/upload-file";
|
||||||
|
import { toDate } from "../common/coerce";
|
||||||
|
import {
|
||||||
|
CreatePropertyDto,
|
||||||
|
ServiceDto,
|
||||||
|
TrustDto,
|
||||||
|
UpdatePropertyDto,
|
||||||
|
UpdateServiceDto,
|
||||||
|
} from "./property.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trust (fideicomiso) renewal buckets, derived from `trustAccount.dueDate2`
|
||||||
|
* against today. The migration loaded DATMEX's `vence1`/`vence2` pair as
|
||||||
|
* `dueDate1`/`dueDate2`; on 531 of the 541 dated trusts `dueDate2` is exactly
|
||||||
|
* one year after `dueDate1`, so `dueDate2` is the *next* annual due date — the
|
||||||
|
* one staff chase — and `dueDate1` is the period it renewed from.
|
||||||
|
*
|
||||||
|
* `undated` is a real bucket, not an error: 12 trusts carry no dates at all.
|
||||||
|
*/
|
||||||
|
export type TrustStatus = "active" | "expiring" | "expired" | "undated";
|
||||||
|
|
||||||
|
/** `with`/`without` filter on the whole property set; the rest are trust buckets. */
|
||||||
|
export type TrustFilter = "with" | "without" | TrustStatus;
|
||||||
|
|
||||||
|
export type PropertySort =
|
||||||
|
| "customer"
|
||||||
|
| "address"
|
||||||
|
| "services_desc"
|
||||||
|
| "trust_due_asc"
|
||||||
|
| "trust_due_desc";
|
||||||
|
|
||||||
|
export interface ListParams {
|
||||||
|
query?: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
serviceKind?: ServiceKind;
|
||||||
|
/** Municipality from the predial service's notes — see `facets()`. */
|
||||||
|
municipality?: string;
|
||||||
|
bank?: string;
|
||||||
|
trust?: TrustFilter;
|
||||||
|
/** false = properties with no service rows at all (240 of 1519). */
|
||||||
|
hasServices?: boolean;
|
||||||
|
customerId?: string;
|
||||||
|
/** Window in days for the `expiring` trust bucket. */
|
||||||
|
days: number;
|
||||||
|
includeArchived?: boolean;
|
||||||
|
sort: PropertySort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Municipality lives in the predial service's `notes` (939/939 populated,
|
||||||
|
* exactly three values). FEDERAL_ZONE's notes hold the same idea but also
|
||||||
|
* carry non-municipality values like "SUSPENDIDO", so predial is the source. */
|
||||||
|
const MUNICIPALITY_KIND: ServiceKind = "PROPERTY_TAX";
|
||||||
|
|
||||||
|
/** Midnight today, UTC — trust dates are stored date-only at 00:00 UTC. */
|
||||||
|
function today(): Date {
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(
|
||||||
|
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(d: Date, days: number): Date {
|
||||||
|
return new Date(d.getTime() + days * 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trustStatusOf(
|
||||||
|
dueDate: Date | null | undefined,
|
||||||
|
from: Date,
|
||||||
|
soon: Date,
|
||||||
|
): TrustStatus {
|
||||||
|
if (!dueDate) return "undated";
|
||||||
|
if (dueDate < from) return "expired";
|
||||||
|
return dueDate <= soon ? "expiring" : "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysUntil(dueDate: Date | null | undefined, from: Date): number | null {
|
||||||
|
if (!dueDate) return null;
|
||||||
|
return Math.round((dueDate.getTime() - from.getTime()) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PropertiesService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly storage: StorageService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private trustWhere(
|
||||||
|
trust: TrustFilter | undefined,
|
||||||
|
days: number,
|
||||||
|
): Prisma.PropertyWhereInput {
|
||||||
|
const from = today();
|
||||||
|
switch (trust) {
|
||||||
|
case "with":
|
||||||
|
return { trustAccount: { isNot: null } };
|
||||||
|
case "without":
|
||||||
|
return { trustAccount: { is: null } };
|
||||||
|
case "active":
|
||||||
|
return { trustAccount: { dueDate2: { gte: from } } };
|
||||||
|
case "expiring":
|
||||||
|
return {
|
||||||
|
trustAccount: { dueDate2: { gte: from, lte: addDays(from, days) } },
|
||||||
|
};
|
||||||
|
case "expired":
|
||||||
|
return { trustAccount: { dueDate2: { lt: from } } };
|
||||||
|
case "undated":
|
||||||
|
return { trustAccount: { is: { dueDate2: null } } };
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private orderBy(sort: PropertySort): Prisma.PropertyOrderByWithRelationInput[] {
|
||||||
|
switch (sort) {
|
||||||
|
case "address":
|
||||||
|
return [{ addressLine1: "asc" }, { addressLine2: "asc" }];
|
||||||
|
case "services_desc":
|
||||||
|
return [{ services: { _count: "desc" } }, { customer: { name: "asc" } }];
|
||||||
|
case "trust_due_asc":
|
||||||
|
return [{ trustAccount: { dueDate2: "asc" } }];
|
||||||
|
case "trust_due_desc":
|
||||||
|
return [{ trustAccount: { dueDate2: "desc" } }];
|
||||||
|
default:
|
||||||
|
// Nameless customers last, same rule the customer list uses.
|
||||||
|
return [
|
||||||
|
{ customer: { nameMissing: "asc" } },
|
||||||
|
{ customer: { name: "asc" } },
|
||||||
|
{ addressLine1: "asc" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Property list with search, service/trust/municipality filters, paginated. */
|
||||||
|
async list(params: ListParams) {
|
||||||
|
const {
|
||||||
|
query,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
serviceKind,
|
||||||
|
municipality,
|
||||||
|
bank,
|
||||||
|
trust,
|
||||||
|
hasServices,
|
||||||
|
customerId,
|
||||||
|
days,
|
||||||
|
includeArchived,
|
||||||
|
sort,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const and: Prisma.PropertyWhereInput[] = [this.trustWhere(trust, days)];
|
||||||
|
|
||||||
|
if (!includeArchived) and.push({ archivedAt: null });
|
||||||
|
|
||||||
|
// Sorting by trust due date is only meaningful for properties that have a
|
||||||
|
// trust; MySQL would otherwise float the ~966 trust-less rows (NULL first
|
||||||
|
// on ASC) above every real due date. Scoping is explicit in the UI label.
|
||||||
|
if (sort === "trust_due_asc" || sort === "trust_due_desc") {
|
||||||
|
and.push({ trustAccount: { isNot: null } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query && query.trim()) {
|
||||||
|
const q = query.trim();
|
||||||
|
and.push({
|
||||||
|
OR: [
|
||||||
|
{ addressLine1: { contains: q } },
|
||||||
|
{ addressLine2: { contains: q } },
|
||||||
|
{ phone1: { contains: q } },
|
||||||
|
{ phone2: { contains: q } },
|
||||||
|
{ phone3: { contains: q } },
|
||||||
|
{ zone: { contains: q } },
|
||||||
|
{ legacyId: { contains: q } },
|
||||||
|
{ customer: { name: { contains: q } } },
|
||||||
|
{ services: { some: { accountNumber: { contains: q } } } },
|
||||||
|
{ services: { some: { meterNumber: { contains: q } } } },
|
||||||
|
{ trustAccount: { trustNumber: { contains: q } } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (serviceKind) and.push({ services: { some: { kind: serviceKind } } });
|
||||||
|
if (municipality)
|
||||||
|
and.push({
|
||||||
|
services: { some: { kind: MUNICIPALITY_KIND, notes: municipality } },
|
||||||
|
});
|
||||||
|
if (bank) and.push({ trustAccount: { bankName: bank } });
|
||||||
|
if (hasServices !== undefined)
|
||||||
|
and.push(hasServices ? { services: { some: {} } } : { services: { none: {} } });
|
||||||
|
if (customerId) and.push({ customerId });
|
||||||
|
|
||||||
|
const where: Prisma.PropertyWhereInput = { AND: and };
|
||||||
|
|
||||||
|
const [total, rows] = await this.prisma.$transaction([
|
||||||
|
this.prisma.property.count({ where }),
|
||||||
|
this.prisma.property.findMany({
|
||||||
|
where,
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: this.orderBy(sort),
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
addressLine1: true,
|
||||||
|
addressLine2: true,
|
||||||
|
phone1: true,
|
||||||
|
phone2: true,
|
||||||
|
phone3: true,
|
||||||
|
zone: true,
|
||||||
|
archivedAt: true,
|
||||||
|
customer: {
|
||||||
|
select: { id: true, name: true, city: true, state: true },
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
select: { id: true, kind: true, active: true, notes: true },
|
||||||
|
},
|
||||||
|
trustAccount: {
|
||||||
|
select: {
|
||||||
|
bankName: true,
|
||||||
|
trustNumber: true,
|
||||||
|
bankFee: true,
|
||||||
|
dueDate1: true,
|
||||||
|
dueDate2: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { select: { services: true, documents: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const from = today();
|
||||||
|
const soon = addDays(from, days);
|
||||||
|
|
||||||
|
const items = rows.map((r) => {
|
||||||
|
const predial = r.services.find((s) => s.kind === MUNICIPALITY_KIND);
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
addressLine1: r.addressLine1,
|
||||||
|
addressLine2: r.addressLine2,
|
||||||
|
zone: r.zone,
|
||||||
|
archived: r.archivedAt != null,
|
||||||
|
phones: [r.phone1, r.phone2, r.phone3].filter(Boolean) as string[],
|
||||||
|
customerId: r.customer.id,
|
||||||
|
customerName: r.customer.name,
|
||||||
|
customerCity: r.customer.city,
|
||||||
|
customerState: r.customer.state,
|
||||||
|
municipality: predial?.notes ?? null,
|
||||||
|
services: r.services.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
kind: s.kind,
|
||||||
|
active: s.active,
|
||||||
|
})),
|
||||||
|
serviceCount: r._count.services,
|
||||||
|
activeServiceCount: r.services.filter((s) => s.active).length,
|
||||||
|
documentCount: r._count.documents,
|
||||||
|
trust: r.trustAccount
|
||||||
|
? {
|
||||||
|
bankName: r.trustAccount.bankName,
|
||||||
|
trustNumber: r.trustAccount.trustNumber,
|
||||||
|
bankFee: r.trustAccount.bankFee,
|
||||||
|
dueDate1: r.trustAccount.dueDate1,
|
||||||
|
dueDate2: r.trustAccount.dueDate2,
|
||||||
|
status: trustStatusOf(r.trustAccount.dueDate2, from, soon),
|
||||||
|
daysToDue: daysUntil(r.trustAccount.dueDate2, from),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top-line counts for the utilities page header. */
|
||||||
|
async stats(days: number) {
|
||||||
|
const from = today();
|
||||||
|
const soon = addDays(from, days);
|
||||||
|
|
||||||
|
const [
|
||||||
|
properties,
|
||||||
|
owners,
|
||||||
|
services,
|
||||||
|
withoutServices,
|
||||||
|
trusts,
|
||||||
|
trustExpiring,
|
||||||
|
trustExpired,
|
||||||
|
documents,
|
||||||
|
] = await this.prisma.$transaction([
|
||||||
|
this.prisma.property.count(),
|
||||||
|
this.prisma.customer.count({ where: { properties: { some: {} } } }),
|
||||||
|
this.prisma.propertyService.count(),
|
||||||
|
this.prisma.property.count({ where: { services: { none: {} } } }),
|
||||||
|
this.prisma.property.count({ where: { trustAccount: { isNot: null } } }),
|
||||||
|
this.prisma.property.count({
|
||||||
|
where: { trustAccount: { dueDate2: { gte: from, lte: soon } } },
|
||||||
|
}),
|
||||||
|
this.prisma.property.count({
|
||||||
|
where: { trustAccount: { dueDate2: { lt: from } } },
|
||||||
|
}),
|
||||||
|
this.prisma.serviceDocument.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Service mix, per kind — the operational headline for this line of
|
||||||
|
// business (how many bills of each type the office pays every month).
|
||||||
|
const byKind = await this.prisma.propertyService.groupBy({
|
||||||
|
by: ["kind"],
|
||||||
|
_count: { _all: true },
|
||||||
|
orderBy: { _count: { kind: "desc" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeByKind = await this.prisma.propertyService.groupBy({
|
||||||
|
by: ["kind"],
|
||||||
|
where: { active: true },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
const activeMap = new Map(activeByKind.map((r) => [r.kind, r._count._all]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
properties,
|
||||||
|
owners,
|
||||||
|
services,
|
||||||
|
withoutServices,
|
||||||
|
trusts,
|
||||||
|
trustExpiring,
|
||||||
|
trustExpired,
|
||||||
|
documents,
|
||||||
|
days,
|
||||||
|
byKind: byKind.map((r) => ({
|
||||||
|
kind: r.kind,
|
||||||
|
count: r._count._all,
|
||||||
|
active: activeMap.get(r.kind) ?? 0,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter dropdown options, with counts so empty choices are visible. */
|
||||||
|
async facets() {
|
||||||
|
// Kept as separate awaits rather than one $transaction: Prisma's groupBy
|
||||||
|
// result type is lost when the calls are widened into a promise array.
|
||||||
|
const kinds = await this.prisma.propertyService.groupBy({
|
||||||
|
by: ["kind"],
|
||||||
|
_count: { _all: true },
|
||||||
|
orderBy: { _count: { kind: "desc" } },
|
||||||
|
});
|
||||||
|
const municipalities = await this.prisma.propertyService.groupBy({
|
||||||
|
by: ["notes"],
|
||||||
|
where: { kind: MUNICIPALITY_KIND, notes: { not: null } },
|
||||||
|
_count: { _all: true },
|
||||||
|
orderBy: { _count: { notes: "desc" } },
|
||||||
|
});
|
||||||
|
const banks = await this.prisma.trustAccount.groupBy({
|
||||||
|
by: ["bankName"],
|
||||||
|
where: { bankName: { not: null } },
|
||||||
|
_count: { _all: true },
|
||||||
|
orderBy: { _count: { bankName: "desc" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
kinds: kinds.map((k) => ({ kind: k.kind, count: k._count._all })),
|
||||||
|
municipalities: municipalities.map((m) => ({
|
||||||
|
name: m.notes as string,
|
||||||
|
count: m._count._all,
|
||||||
|
})),
|
||||||
|
banks: banks.map((b) => ({
|
||||||
|
name: b.bankName as string,
|
||||||
|
count: b._count._all,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full property view: services, trust, documents, owner and siblings. */
|
||||||
|
async detail(id: string, days: number) {
|
||||||
|
const property = await this.prisma.property.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
customer: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
nameSource: true,
|
||||||
|
addressLine1: true,
|
||||||
|
city: true,
|
||||||
|
state: true,
|
||||||
|
phone: true,
|
||||||
|
mobile: true,
|
||||||
|
email: true,
|
||||||
|
_count: { select: { properties: true, policies: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
services: { orderBy: { kind: "asc" } },
|
||||||
|
trustAccount: true,
|
||||||
|
documents: true,
|
||||||
|
policy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
policyNumber: true,
|
||||||
|
policyTo: true,
|
||||||
|
policyType: { select: { name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!property) {
|
||||||
|
throw new NotFoundException(`Property ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other properties of the same owner, so staff can hop between them
|
||||||
|
// without going back through the customer file.
|
||||||
|
const siblings = await this.prisma.property.findMany({
|
||||||
|
where: { customerId: property.customerId, id: { not: id } },
|
||||||
|
orderBy: [{ addressLine1: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
addressLine1: true,
|
||||||
|
addressLine2: true,
|
||||||
|
zone: true,
|
||||||
|
_count: { select: { services: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Utility-domain ledger for the OWNER, not for this property: the legacy
|
||||||
|
// data ties payments to the customer, never to a specific property, so
|
||||||
|
// these are shown as the customer's service movements.
|
||||||
|
const transactions = await this.prisma.transaction.findMany({
|
||||||
|
where: { customerId: property.customerId, domain: "UTILITY" },
|
||||||
|
orderBy: { transactionDate: "desc" },
|
||||||
|
take: 12,
|
||||||
|
include: { type: true },
|
||||||
|
});
|
||||||
|
const ledger = await this.prisma.transaction.groupBy({
|
||||||
|
by: ["currency"],
|
||||||
|
where: { customerId: property.customerId, domain: "UTILITY", voidedAt: null },
|
||||||
|
_sum: { amount: true },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const from = today();
|
||||||
|
const predial = property.services.find((s) => s.kind === MUNICIPALITY_KIND);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...property,
|
||||||
|
municipality: predial?.notes ?? null,
|
||||||
|
trustStatus: trustStatusOf(
|
||||||
|
property.trustAccount?.dueDate2,
|
||||||
|
from,
|
||||||
|
addDays(from, days),
|
||||||
|
),
|
||||||
|
daysToTrustDue: daysUntil(property.trustAccount?.dueDate2, from),
|
||||||
|
siblings: siblings.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
addressLine1: s.addressLine1,
|
||||||
|
addressLine2: s.addressLine2,
|
||||||
|
zone: s.zone,
|
||||||
|
serviceCount: s._count.services,
|
||||||
|
})),
|
||||||
|
customerTransactions: transactions,
|
||||||
|
customerLedger: ledger.map((l) => ({
|
||||||
|
currency: l.currency,
|
||||||
|
total: l._sum.amount,
|
||||||
|
count: l._count._all,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- property header writes -----------------------------------------------
|
||||||
|
|
||||||
|
async create(dto: CreatePropertyDto) {
|
||||||
|
const customer = await this.prisma.customer.findUnique({
|
||||||
|
where: { id: dto.customerId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`);
|
||||||
|
return this.prisma.property.create({ data: { ...dto } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdatePropertyDto) {
|
||||||
|
await this.ensureProperty(id);
|
||||||
|
return this.prisma.property.update({ where: { id }, data: { ...dto } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async archive(id: string) {
|
||||||
|
await this.ensureProperty(id);
|
||||||
|
return this.prisma.property.update({ where: { id }, data: { archivedAt: new Date() } });
|
||||||
|
}
|
||||||
|
async restore(id: string) {
|
||||||
|
await this.ensureProperty(id);
|
||||||
|
return this.prisma.property.update({ where: { id }, data: { archivedAt: null } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureProperty(id: string) {
|
||||||
|
const found = await this.prisma.property.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!found) throw new NotFoundException(`Property ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureService(propertyId: string, serviceId: string) {
|
||||||
|
await this.ensureProperty(propertyId);
|
||||||
|
const row = await this.prisma.propertyService.findFirst({
|
||||||
|
where: { id: serviceId, propertyId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException(`Service ${serviceId} not found on property ${propertyId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- services -------------------------------------------------------------
|
||||||
|
|
||||||
|
async addService(propertyId: string, dto: ServiceDto) {
|
||||||
|
await this.ensureProperty(propertyId);
|
||||||
|
return this.prisma.propertyService.create({ data: { propertyId, ...dto } });
|
||||||
|
}
|
||||||
|
async updateService(propertyId: string, id: string, dto: UpdateServiceDto) {
|
||||||
|
await this.ensureService(propertyId, id);
|
||||||
|
return this.prisma.propertyService.update({ where: { id }, data: { ...dto } });
|
||||||
|
}
|
||||||
|
async removeService(propertyId: string, id: string) {
|
||||||
|
await this.ensureService(propertyId, id);
|
||||||
|
return this.prisma.propertyService.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- trust account (1:1 upsert) -------------------------------------------
|
||||||
|
|
||||||
|
async upsertTrust(propertyId: string, dto: TrustDto) {
|
||||||
|
await this.ensureProperty(propertyId);
|
||||||
|
const data = {
|
||||||
|
bankName: dto.bankName,
|
||||||
|
trustNumber: dto.trustNumber,
|
||||||
|
bankFee: dto.bankFee,
|
||||||
|
...(dto.dueDate1 !== undefined && { dueDate1: toDate(dto.dueDate1) }),
|
||||||
|
...(dto.dueDate2 !== undefined && { dueDate2: toDate(dto.dueDate2) }),
|
||||||
|
};
|
||||||
|
return this.prisma.trustAccount.upsert({
|
||||||
|
where: { propertyId },
|
||||||
|
create: { propertyId, ...data },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async removeTrust(propertyId: string) {
|
||||||
|
await this.ensureProperty(propertyId);
|
||||||
|
const existing = await this.prisma.trustAccount.findUnique({
|
||||||
|
where: { propertyId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException(`No trust account on property ${propertyId}`);
|
||||||
|
return this.prisma.trustAccount.delete({ where: { propertyId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- documents ------------------------------------------------------------
|
||||||
|
// The blob lives in object storage (MinIO); the row is just the pointer. Keys
|
||||||
|
// stay under the `service/<propertyId>/…` prefix the migration established.
|
||||||
|
|
||||||
|
async addDocument(
|
||||||
|
propertyId: string,
|
||||||
|
file: { buffer: Buffer; originalname?: string; mimetype?: string },
|
||||||
|
documentType?: string,
|
||||||
|
) {
|
||||||
|
await this.ensureProperty(propertyId);
|
||||||
|
const ext = extForUpload(file);
|
||||||
|
const key = `service/${propertyId}/${randomUUID()}${ext}`;
|
||||||
|
await this.storage.put(key, file.buffer, file.mimetype);
|
||||||
|
return this.prisma.serviceDocument.create({
|
||||||
|
data: {
|
||||||
|
propertyId,
|
||||||
|
documentType: documentType?.trim() || "DOCUMENT",
|
||||||
|
storageKey: key,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDocument(propertyId: string, id: string) {
|
||||||
|
const row = await this.prisma.serviceDocument.findFirst({
|
||||||
|
where: { id, propertyId },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException(`Document ${id} not found on property ${propertyId}`);
|
||||||
|
const blob = await this.storage.getStream(row.storageKey);
|
||||||
|
return { row, ...blob };
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeDocument(propertyId: string, id: string) {
|
||||||
|
await this.ensureProperty(propertyId);
|
||||||
|
const row = await this.prisma.serviceDocument.findFirst({
|
||||||
|
where: { id, propertyId },
|
||||||
|
select: { id: true, storageKey: true },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException(`Document ${id} not found on property ${propertyId}`);
|
||||||
|
const deleted = await this.prisma.serviceDocument.delete({ where: { id } });
|
||||||
|
await this.storage.delete(row.storageKey);
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
} from "class-validator";
|
||||||
|
import { ServiceKind } from "@jorgecuadros/database";
|
||||||
|
|
||||||
|
export class CreatePropertyDto {
|
||||||
|
@IsString() @MinLength(1) customerId!: string;
|
||||||
|
@IsOptional() @IsString() policyId?: string;
|
||||||
|
@IsOptional() @IsString() addressLine1?: string;
|
||||||
|
@IsOptional() @IsString() addressLine2?: string;
|
||||||
|
@IsOptional() @IsString() phone1?: string;
|
||||||
|
@IsOptional() @IsString() phone2?: string;
|
||||||
|
@IsOptional() @IsString() phone3?: string;
|
||||||
|
@IsOptional() @IsString() zone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdatePropertyDto {
|
||||||
|
@IsOptional() @IsString() policyId?: string;
|
||||||
|
@IsOptional() @IsString() addressLine1?: string;
|
||||||
|
@IsOptional() @IsString() addressLine2?: string;
|
||||||
|
@IsOptional() @IsString() phone1?: string;
|
||||||
|
@IsOptional() @IsString() phone2?: string;
|
||||||
|
@IsOptional() @IsString() phone3?: string;
|
||||||
|
@IsOptional() @IsString() zone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServiceDto {
|
||||||
|
@IsEnum(ServiceKind) kind!: ServiceKind;
|
||||||
|
@IsOptional() @IsString() accountNumber?: string;
|
||||||
|
@IsOptional() @IsString() meterNumber?: string;
|
||||||
|
@IsOptional() @IsString() route?: string;
|
||||||
|
@IsOptional() @IsString() dueDay?: string;
|
||||||
|
@IsOptional() @IsBoolean() active?: boolean;
|
||||||
|
@IsOptional() @IsString() notes?: string;
|
||||||
|
}
|
||||||
|
export class UpdateServiceDto {
|
||||||
|
@IsOptional() @IsEnum(ServiceKind) kind?: ServiceKind;
|
||||||
|
@IsOptional() @IsString() accountNumber?: string;
|
||||||
|
@IsOptional() @IsString() meterNumber?: string;
|
||||||
|
@IsOptional() @IsString() route?: string;
|
||||||
|
@IsOptional() @IsString() dueDay?: string;
|
||||||
|
@IsOptional() @IsBoolean() active?: boolean;
|
||||||
|
@IsOptional() @IsString() notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trust is 1:1 with a property — this both creates and updates it (upsert). */
|
||||||
|
export class TrustDto {
|
||||||
|
@IsOptional() @IsString() bankName?: string;
|
||||||
|
@IsOptional() @IsString() trustNumber?: string;
|
||||||
|
@IsOptional() @IsNumber() bankFee?: number;
|
||||||
|
@IsOptional() @IsString() dueDate1?: string;
|
||||||
|
@IsOptional() @IsString() dueDate2?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Company info used on every report header (PDF + print). Read from
|
||||||
|
* the environment so the office can edit it without a code change —
|
||||||
|
* the .env.example file lists the keys; defaults below are placeholders
|
||||||
|
* the office should override for production.
|
||||||
|
*
|
||||||
|
* Single source of truth: the API renders the header. The web header
|
||||||
|
* (login + AppShell) still reads the static "Jorge Cuadros & Asociados"
|
||||||
|
* strings for now — those are visual brand, the API's COMPANY_INFO
|
||||||
|
* block is the legal/locator block on printed documents.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
export interface CompanyInfo {
|
||||||
|
name: string;
|
||||||
|
/** Street address — line 1. */
|
||||||
|
addressLine1: string;
|
||||||
|
/** Street address — line 2 (suite, floor, etc.). Optional. */
|
||||||
|
addressLine2: string;
|
||||||
|
/** "City, State, ZIP, Country" — single line. */
|
||||||
|
cityState: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
/** Mexican tax ID ("RFC"). Optional. */
|
||||||
|
taxId: string;
|
||||||
|
website: string;
|
||||||
|
/** Absolute path to the logo PNG. Null when missing — renderers fall
|
||||||
|
* back to a text mark. */
|
||||||
|
logoPath: string | null;
|
||||||
|
/** Logo buffer + intrinsic size, eagerly loaded so the PDF renderer
|
||||||
|
* doesn't do a sync read on every report. Null when no logo. */
|
||||||
|
logo: { buffer: Buffer; width: number; height: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function envOr(key: string, fallback: string): string {
|
||||||
|
const v = process.env[key];
|
||||||
|
return v && v.trim() ? v : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLogoPath(): string | null {
|
||||||
|
const explicit = process.env.COMPANY_LOGO_PATH;
|
||||||
|
if (explicit) {
|
||||||
|
return fs.existsSync(explicit) ? explicit : null;
|
||||||
|
}
|
||||||
|
// Default: look in apps/api/assets/company_logo.png (copied from
|
||||||
|
// apps/web/public/images/company_logo.png — single canonical image
|
||||||
|
// kept in lock-step; see .env.example for the override path).
|
||||||
|
const candidates = [
|
||||||
|
path.resolve(__dirname, "..", "..", "assets", "company_logo.png"),
|
||||||
|
path.resolve(__dirname, "..", "..", "..", "web", "public", "images", "company_logo.png"),
|
||||||
|
];
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (fs.existsSync(c)) return c;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached: CompanyInfo | null = null;
|
||||||
|
|
||||||
|
export function getCompanyInfo(): CompanyInfo {
|
||||||
|
if (cached) return cached;
|
||||||
|
const logoPath = resolveLogoPath();
|
||||||
|
let logo: CompanyInfo["logo"] = null;
|
||||||
|
if (logoPath) {
|
||||||
|
try {
|
||||||
|
const buf = fs.readFileSync(logoPath);
|
||||||
|
// Intrinsic PNG size: read IHDR (bytes 16-23 of the file).
|
||||||
|
// Width = BE uint32 at offset 16, height = BE uint32 at offset 20.
|
||||||
|
const w =
|
||||||
|
logoPath.endsWith(".png") && buf.length >= 24
|
||||||
|
? buf.readUInt32BE(16)
|
||||||
|
: 0;
|
||||||
|
const h =
|
||||||
|
logoPath.endsWith(".png") && buf.length >= 24
|
||||||
|
? buf.readUInt32BE(20)
|
||||||
|
: 0;
|
||||||
|
logo = { buffer: buf, width: w, height: h };
|
||||||
|
} catch {
|
||||||
|
logo = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cached = {
|
||||||
|
name: envOr("COMPANY_NAME", "Jorge Cuadros & Asociados"),
|
||||||
|
addressLine1: envOr(
|
||||||
|
"COMPANY_ADDRESS_LINE1",
|
||||||
|
"Av. Revolución 1234, Int. 5",
|
||||||
|
),
|
||||||
|
addressLine2: envOr("COMPANY_ADDRESS_LINE2", ""),
|
||||||
|
cityState: envOr(
|
||||||
|
"COMPANY_CITY_STATE",
|
||||||
|
"Tijuana, Baja California 22000, México",
|
||||||
|
),
|
||||||
|
phone: envOr("COMPANY_PHONE", "(664) 000-0000"),
|
||||||
|
email: envOr("COMPANY_EMAIL", "contacto@jorgecuadros.local"),
|
||||||
|
taxId: envOr("COMPANY_TAX_ID", ""),
|
||||||
|
website: envOr("COMPANY_WEBSITE", "jorgecuadros.local"),
|
||||||
|
logoPath,
|
||||||
|
logo,
|
||||||
|
};
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
/**
|
||||||
|
* Output renderers for the reports module.
|
||||||
|
*
|
||||||
|
* Every report's `run` returns `{ columns, rows, totals?, subtitle? }`.
|
||||||
|
* CSV/XLSX/PDF all derive from the same shape so adding a report = one
|
||||||
|
* registry entry, no per-format template.
|
||||||
|
*
|
||||||
|
* PDF uses pdfkit. The statement format (edo-cuenta-datos) uses a
|
||||||
|
* different layout than the tabular one — handled inline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// pdfkit exports its constructor via `module.exports = PDFDocument`, so a
|
||||||
|
// namespace import gets the type, and `import = require()` gets the value.
|
||||||
|
import PDFDocument = require("pdfkit");
|
||||||
|
import * as ExcelJS from "exceljs";
|
||||||
|
import type { ColumnDef, ReportResult } from "./reports.types";
|
||||||
|
import { getCompanyInfo } from "./company";
|
||||||
|
|
||||||
|
type Doc = PDFKit.PDFDocument;
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- CSV */
|
||||||
|
|
||||||
|
function csvCell(v: unknown): string {
|
||||||
|
if (v === null || v === undefined) return "";
|
||||||
|
const s = String(v);
|
||||||
|
if (s.includes(",") || s.includes('"') || s.includes("\n") || s.includes("\r")) {
|
||||||
|
return `"${s.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderCsv(columns: ColumnDef[], result: ReportResult): string {
|
||||||
|
const headers = columns.map((c) => csvCell(c.label)).join(",");
|
||||||
|
const lines = result.rows.map((r) =>
|
||||||
|
columns
|
||||||
|
.map((c) => {
|
||||||
|
const v = r[c.key];
|
||||||
|
if (typeof v === "number") return v;
|
||||||
|
return csvCell(v);
|
||||||
|
})
|
||||||
|
.join(","),
|
||||||
|
);
|
||||||
|
const totals: string[] = [];
|
||||||
|
if (result.totals) {
|
||||||
|
for (const [k, v] of Object.entries(result.totals)) {
|
||||||
|
totals.push(csvCell(k), csvCell(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [headers, ...lines, ...(totals.length ? [totals.join(",")] : [])].join(
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- XLSX */
|
||||||
|
|
||||||
|
export async function renderXlsx(
|
||||||
|
columns: ColumnDef[],
|
||||||
|
result: ReportResult,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const wb = new ExcelJS.Workbook();
|
||||||
|
wb.creator = "Jorge Cuadros & Asociados";
|
||||||
|
const ws = wb.addWorksheet("Reporte", {
|
||||||
|
views: [{ state: "frozen", ySplit: 1 }],
|
||||||
|
});
|
||||||
|
ws.columns = columns.map((c) => ({
|
||||||
|
header: c.label,
|
||||||
|
key: c.key,
|
||||||
|
width: Math.max(10, Math.min(40, (c.label.length + 2) * 1.2)),
|
||||||
|
}));
|
||||||
|
ws.getRow(1).font = { bold: true };
|
||||||
|
ws.getRow(1).fill = {
|
||||||
|
type: "pattern",
|
||||||
|
pattern: "solid",
|
||||||
|
fgColor: { argb: "FFE2EDE9" }, // brand-tint
|
||||||
|
};
|
||||||
|
for (const row of result.rows) {
|
||||||
|
ws.addRow(row);
|
||||||
|
}
|
||||||
|
// Number formatting for money columns.
|
||||||
|
for (const col of columns) {
|
||||||
|
if (col.type === "money" || col.type === "number") {
|
||||||
|
ws.getColumn(col.key).numFmt =
|
||||||
|
col.type === "money" ? "#,##0.00" : "#,##0";
|
||||||
|
ws.getColumn(col.key).alignment = { horizontal: "right" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.totals) {
|
||||||
|
const last = ws.addRow({});
|
||||||
|
let i = 1;
|
||||||
|
for (const [k, v] of Object.entries(result.totals)) {
|
||||||
|
const cell = ws.getCell(last.number, i);
|
||||||
|
cell.value = `${k}: ${v}`;
|
||||||
|
cell.font = { bold: true };
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const buf = await wb.xlsx.writeBuffer();
|
||||||
|
return Buffer.from(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- PDF */
|
||||||
|
|
||||||
|
const BRAND = "#0c322d";
|
||||||
|
const ACCENT = "#bf5a34";
|
||||||
|
const MUTED = "#756c5c";
|
||||||
|
const LINE = "#e4dccb";
|
||||||
|
|
||||||
|
function fmtMoney(v: unknown): string {
|
||||||
|
if (v === null || v === undefined || v === "") return "";
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n)) return String(v);
|
||||||
|
return n.toLocaleString("es-MX", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function pdfRow(
|
||||||
|
doc: Doc,
|
||||||
|
y: number,
|
||||||
|
cols: Array<{ label: string; width: number; align?: "left" | "right" }>,
|
||||||
|
values: Array<{ text: string; align?: "left" | "right" }>,
|
||||||
|
x: number,
|
||||||
|
): number {
|
||||||
|
let cx = x;
|
||||||
|
for (let i = 0; i < cols.length; i++) {
|
||||||
|
const c = cols[i];
|
||||||
|
const v = values[i] ?? { text: "" };
|
||||||
|
const align = v.align ?? c.align ?? "left";
|
||||||
|
const w = c.width;
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(9)
|
||||||
|
.fillColor("#211d17")
|
||||||
|
.text(v.text, cx, y, {
|
||||||
|
width: w - 4,
|
||||||
|
align,
|
||||||
|
ellipsis: true,
|
||||||
|
lineBreak: false,
|
||||||
|
height: 16,
|
||||||
|
});
|
||||||
|
cx += w;
|
||||||
|
}
|
||||||
|
return y + 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPdf(
|
||||||
|
columns: ColumnDef[],
|
||||||
|
result: ReportResult,
|
||||||
|
title: string,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const doc = new PDFDocument({
|
||||||
|
size: "LETTER",
|
||||||
|
layout: "landscape",
|
||||||
|
margins: { top: 96, bottom: 56, left: 48, right: 48 },
|
||||||
|
bufferPages: true,
|
||||||
|
info: {
|
||||||
|
Title: title,
|
||||||
|
Author: "Jorge Cuadros & Asociados",
|
||||||
|
Subject: "Reporte",
|
||||||
|
Creator: "Jorge Cuadros Platform — Reports module",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
doc.on("data", (c: Buffer) => chunks.push(c));
|
||||||
|
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||||
|
doc.on("error", reject);
|
||||||
|
|
||||||
|
const company = getCompanyInfo();
|
||||||
|
const pageW = doc.page.width - 96;
|
||||||
|
|
||||||
|
/** The header is repeated on every page (via addPage + manual draw). */
|
||||||
|
const drawHeader = () => {
|
||||||
|
// Background bar (brand pine) for the masthead.
|
||||||
|
doc.rect(0, 0, doc.page.width, 60).fill(BRAND);
|
||||||
|
|
||||||
|
// Logo, fitted to a 40px box, with 8px padding.
|
||||||
|
let textX = 48;
|
||||||
|
if (company.logo) {
|
||||||
|
const targetH = 40;
|
||||||
|
const scale = targetH / company.logo.height;
|
||||||
|
const w = company.logo.width * scale;
|
||||||
|
doc.image(company.logo.buffer, 48, 10, { height: targetH });
|
||||||
|
textX = 48 + w + 14;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Company name (large) + "Reporte" tag below it.
|
||||||
|
doc
|
||||||
|
.fillColor("#f5f1e8")
|
||||||
|
.font("Helvetica-Bold")
|
||||||
|
.fontSize(15)
|
||||||
|
.text(company.name, textX, 14, { width: pageW - (textX - 48), lineBreak: false });
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(8)
|
||||||
|
.fillColor("#cde0db")
|
||||||
|
.text("Reporte", textX, 36, { lineBreak: false });
|
||||||
|
|
||||||
|
// Right-aligned company locator (address + phone + email).
|
||||||
|
const rightLines = [
|
||||||
|
company.addressLine1,
|
||||||
|
company.addressLine2,
|
||||||
|
[company.cityState].filter(Boolean).join(" · "),
|
||||||
|
[company.phone, company.email].filter(Boolean).join(" · "),
|
||||||
|
company.taxId ? `RFC: ${company.taxId}` : "",
|
||||||
|
].filter(Boolean);
|
||||||
|
doc.font("Helvetica").fontSize(8).fillColor("#cde0db");
|
||||||
|
let ry = 12;
|
||||||
|
for (const line of rightLines) {
|
||||||
|
doc.text(line, 48, ry, {
|
||||||
|
width: pageW,
|
||||||
|
align: "right",
|
||||||
|
lineBreak: false,
|
||||||
|
ellipsis: true,
|
||||||
|
});
|
||||||
|
ry += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thin accent line under the masthead.
|
||||||
|
doc.rect(0, 60, doc.page.width, 2).fill(ACCENT);
|
||||||
|
|
||||||
|
// Title + subtitle + printed-at.
|
||||||
|
doc
|
||||||
|
.font("Helvetica-Bold")
|
||||||
|
.fontSize(15)
|
||||||
|
.fillColor(BRAND)
|
||||||
|
.text(title, 48, 72, { lineBreak: false });
|
||||||
|
let metaY = 92;
|
||||||
|
if (result.subtitle) {
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(9)
|
||||||
|
.fillColor(MUTED)
|
||||||
|
.text(result.subtitle, 48, metaY, { lineBreak: false });
|
||||||
|
metaY += 12;
|
||||||
|
}
|
||||||
|
const printedAt = new Date().toLocaleString("es-MX");
|
||||||
|
doc
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(8)
|
||||||
|
.fillColor(MUTED)
|
||||||
|
.text(`Impreso: ${printedAt}`, 48, metaY, { lineBreak: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
drawHeader();
|
||||||
|
|
||||||
|
// Column widths: distribute page width minus margins, weighted.
|
||||||
|
const totalW = columns.reduce((s, c) => s + (c.width ?? 12), 0);
|
||||||
|
const cols = columns.map((c) => ({
|
||||||
|
label: c.label,
|
||||||
|
width: ((c.width ?? 12) / totalW) * pageW,
|
||||||
|
align: c.align,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let y = 130;
|
||||||
|
const drawTableHeader = () => {
|
||||||
|
doc.rect(48, y, pageW, 18).fill("#faf6ee");
|
||||||
|
y = pdfRow(
|
||||||
|
doc,
|
||||||
|
y + 4,
|
||||||
|
cols,
|
||||||
|
cols.map((c) => ({ text: c.label, align: c.align })),
|
||||||
|
48,
|
||||||
|
);
|
||||||
|
doc
|
||||||
|
.moveTo(48, y)
|
||||||
|
.lineTo(48 + pageW, y)
|
||||||
|
.strokeColor(LINE)
|
||||||
|
.lineWidth(0.5)
|
||||||
|
.stroke();
|
||||||
|
};
|
||||||
|
drawTableHeader();
|
||||||
|
|
||||||
|
// Body rows.
|
||||||
|
for (const r of result.rows) {
|
||||||
|
if (y > doc.page.height - 64) {
|
||||||
|
doc.addPage({ layout: "landscape", margins: { top: 96, bottom: 56, left: 48, right: 48 } });
|
||||||
|
drawHeader();
|
||||||
|
y = 130;
|
||||||
|
drawTableHeader();
|
||||||
|
}
|
||||||
|
const vals = columns.map((c) => {
|
||||||
|
const v = r[c.key];
|
||||||
|
const text = c.type === "money" ? fmtMoney(v) : v == null ? "" : String(v);
|
||||||
|
return { text, align: c.align };
|
||||||
|
});
|
||||||
|
y = pdfRow(doc, y + 4, cols, vals, 48);
|
||||||
|
doc
|
||||||
|
.moveTo(48, y)
|
||||||
|
.lineTo(48 + pageW, y)
|
||||||
|
.strokeColor("#e4dccb")
|
||||||
|
.lineWidth(0.4)
|
||||||
|
.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totals.
|
||||||
|
if (result.totals) {
|
||||||
|
y += 6;
|
||||||
|
doc.rect(48, y, pageW, 18).fill(ACCENT);
|
||||||
|
doc
|
||||||
|
.font("Helvetica-Bold")
|
||||||
|
.fontSize(9)
|
||||||
|
.fillColor("#f5f1e8")
|
||||||
|
.text(
|
||||||
|
Object.entries(result.totals)
|
||||||
|
.map(([k, v]) => `${k}: ${v}`)
|
||||||
|
.join(" · "),
|
||||||
|
52,
|
||||||
|
y + 5,
|
||||||
|
{ width: pageW - 8, align: "left" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- print (HTML) */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print-stylesheet-friendly HTML. The web app's print stylesheet hides
|
||||||
|
* nav, but otherwise this is a plain table the browser paginates itself.
|
||||||
|
*
|
||||||
|
* The header carries the company info (logo + name + locator) so printed
|
||||||
|
* pages stand alone — staff can hand one to a customer and the office
|
||||||
|
* identification is on every sheet, not buried in the cover page.
|
||||||
|
*/
|
||||||
|
export function renderPrintHtml(
|
||||||
|
columns: ColumnDef[],
|
||||||
|
result: ReportResult,
|
||||||
|
title: string,
|
||||||
|
): string {
|
||||||
|
const company = getCompanyInfo();
|
||||||
|
const head = (label: string, align?: "left" | "right") =>
|
||||||
|
`<th style="text-align:${align ?? "left"};padding:6px 8px;border-bottom:2px solid #0c322d;background:#faf6ee;font-size:11px">${escapeHtml(label)}</th>`;
|
||||||
|
const cell = (v: unknown, c: ColumnDef) => {
|
||||||
|
const text = c.type === "money" ? fmtMoney(v) : v == null ? "" : String(v);
|
||||||
|
const align = c.align ?? "left";
|
||||||
|
return `<td style="text-align:${align};padding:4px 8px;border-bottom:1px solid #e4dccb;font-size:11px;${c.type === "money" ? "font-variant-numeric:tabular-nums" : ""}">${escapeHtml(text)}</td>`;
|
||||||
|
};
|
||||||
|
const rows = result.rows
|
||||||
|
.map(
|
||||||
|
(r) =>
|
||||||
|
`<tr>${columns
|
||||||
|
.map((c) => cell(r[c.key], c))
|
||||||
|
.join("")}</tr>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
const totals = result.totals
|
||||||
|
? `<tr><td colspan="${columns.length}" style="padding:8px;background:#bf5a34;color:#f5f1e8;font-weight:600;font-size:11px">${Object.entries(
|
||||||
|
result.totals,
|
||||||
|
)
|
||||||
|
.map(([k, v]) => `${escapeHtml(k)}: ${escapeHtml(String(v))}`)
|
||||||
|
.join(" · ")}</td></tr>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// Logo embedded as base64 data URL — the print page is opened as a
|
||||||
|
// new tab and printed standalone, so a relative path to the web app
|
||||||
|
// wouldn't resolve when launched outside the web's origin.
|
||||||
|
const logoDataUrl = company.logo
|
||||||
|
? `data:image/png;base64,${company.logo.buffer.toString("base64")}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const locatorLines = [
|
||||||
|
company.addressLine1,
|
||||||
|
company.addressLine2,
|
||||||
|
company.cityState,
|
||||||
|
[company.phone, company.email].filter(Boolean).join(" · "),
|
||||||
|
company.taxId ? `RFC: ${company.taxId}` : "",
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="es"><head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>${escapeHtml(title)} — ${escapeHtml(company.name)}</title>
|
||||||
|
<style>
|
||||||
|
@page { size: letter landscape; margin: 0.5in; }
|
||||||
|
body { font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif; color: #211d17; margin: 0; }
|
||||||
|
.masthead { display: flex; align-items: flex-start; gap: 16px; padding: 12px 16px; background: #0c322d; color: #f5f1e8; border-radius: 6px 6px 0 0; }
|
||||||
|
.masthead-logo { flex: 0 0 auto; }
|
||||||
|
.masthead-logo img { display: block; height: 56px; width: auto; }
|
||||||
|
.masthead-text { flex: 1; min-width: 0; }
|
||||||
|
.masthead-name { font-family: Georgia, "Times New Roman", serif; font-size: 20px; font-weight: 600; line-height: 1.1; }
|
||||||
|
.masthead-tag { font-size: 11px; color: #cde0db; margin-top: 2px; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||||
|
.masthead-locator { font-size: 10px; color: #cde0db; text-align: right; line-height: 1.35; white-space: nowrap; }
|
||||||
|
.accent { height: 3px; background: #bf5a34; }
|
||||||
|
.head { padding: 12px 4px 8px; }
|
||||||
|
.head h1 { font-family: Georgia, "Times New Roman", serif; font-size: 18px; margin: 0; color: #0c322d; }
|
||||||
|
.head p { font-size: 11px; color: #756c5c; margin: 2px 0 0; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
@media print {
|
||||||
|
.noprint { display: none; }
|
||||||
|
.masthead { border-radius: 0; }
|
||||||
|
}
|
||||||
|
.noprint { padding: 8px 0; }
|
||||||
|
.noprint button { padding: 6px 12px; background: #0c322d; color: #f5f1e8; border: 0; border-radius: 4px; cursor: pointer; font-size: 12px; }
|
||||||
|
.footer { margin-top: 16px; font-size: 9px; color: #756c5c; border-top: 1px solid #e4dccb; padding-top: 6px; display: flex; justify-content: space-between; }
|
||||||
|
</style>
|
||||||
|
</head><body>
|
||||||
|
<div class="noprint"><button onclick="window.print()">Imprimir / Guardar PDF</button></div>
|
||||||
|
<div class="masthead">
|
||||||
|
${logoDataUrl ? `<div class="masthead-logo"><img src="${logoDataUrl}" alt="" /></div>` : ""}
|
||||||
|
<div class="masthead-text">
|
||||||
|
<div class="masthead-name">${escapeHtml(company.name)}</div>
|
||||||
|
<div class="masthead-tag">Reporte</div>
|
||||||
|
</div>
|
||||||
|
<div class="masthead-locator">
|
||||||
|
${locatorLines.map((l) => escapeHtml(l)).join("<br/>")}
|
||||||
|
${company.website ? `<br/>${escapeHtml(company.website)}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="accent"></div>
|
||||||
|
<div class="head">
|
||||||
|
<h1>${escapeHtml(title)}</h1>
|
||||||
|
${result.subtitle ? `<p>${escapeHtml(result.subtitle)}</p>` : ""}
|
||||||
|
<p>Impreso: ${new Date().toLocaleString("es-MX")}</p>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr>${columns.map((c) => head(c.label, c.align)).join("")}</tr></thead>
|
||||||
|
<tbody>${rows}${totals}</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="footer">
|
||||||
|
<span>${escapeHtml(company.name)} · ${escapeHtml(company.phone)} · ${escapeHtml(company.email)}</span>
|
||||||
|
<span>${escapeHtml(title)}</span>
|
||||||
|
</div>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Header,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import type { Response } from "express";
|
||||||
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||||
|
import { ReportsService } from "./reports.service";
|
||||||
|
import {
|
||||||
|
renderCsv,
|
||||||
|
renderPdf,
|
||||||
|
renderPrintHtml,
|
||||||
|
renderXlsx,
|
||||||
|
} from "./outputs";
|
||||||
|
import { findReport } from "./reports.registry";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports routes. Every report is dispatched by slug; outputs are
|
||||||
|
* differentiated by `?format=...` (default `json`). Reads only — gated
|
||||||
|
* by AuthenticatedGuard alone, like every other read in the app.
|
||||||
|
*/
|
||||||
|
@UseGuards(AuthenticatedGuard)
|
||||||
|
@Controller("reports")
|
||||||
|
export class ReportsController {
|
||||||
|
constructor(private readonly reports: ReportsService) {}
|
||||||
|
|
||||||
|
/** Catalog of all registered reports (the /reportes index). */
|
||||||
|
@Get()
|
||||||
|
catalog() {
|
||||||
|
return { items: this.reports.catalog() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run a report and return the JSON result (rows + totals + the def's columns). */
|
||||||
|
@Get(":slug")
|
||||||
|
async runJson(
|
||||||
|
@Param("slug") slug: string,
|
||||||
|
@Query() query: Record<string, string | undefined>,
|
||||||
|
) {
|
||||||
|
const def = findReport(slug);
|
||||||
|
const result = await this.reports.run(slug, query);
|
||||||
|
return { ...result, columns: def?.columns ?? [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSV download. */
|
||||||
|
@Get(":slug/csv")
|
||||||
|
@Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
async runCsv(
|
||||||
|
@Param("slug") slug: string,
|
||||||
|
@Query() query: Record<string, string | undefined>,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const def = findReport(slug);
|
||||||
|
const result = await this.reports.run(slug, query);
|
||||||
|
const filename = `${def?.title ?? slug}-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="${filename.replace(/[^\wÀ-ſ .-]/g, "_")}"`,
|
||||||
|
);
|
||||||
|
res.send(renderCsv(def?.columns ?? [], result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** XLSX download. */
|
||||||
|
@Get(":slug/xlsx")
|
||||||
|
async runXlsx(
|
||||||
|
@Param("slug") slug: string,
|
||||||
|
@Query() query: Record<string, string | undefined>,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const def = findReport(slug);
|
||||||
|
const result = await this.reports.run(slug, query);
|
||||||
|
const filename = `${def?.title ?? slug}-${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||||
|
const buf = await renderXlsx(def?.columns ?? [], result);
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Type",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
);
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="${filename.replace(/[^\wÀ-ſ .-]/g, "_")}"`,
|
||||||
|
);
|
||||||
|
res.send(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PDF download. */
|
||||||
|
@Get(":slug/pdf")
|
||||||
|
async runPdf(
|
||||||
|
@Param("slug") slug: string,
|
||||||
|
@Query() query: Record<string, string | undefined>,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const def = findReport(slug);
|
||||||
|
const result = await this.reports.run(slug, query);
|
||||||
|
const buf = await renderPdf(def?.columns ?? [], result, def?.title ?? slug);
|
||||||
|
const filename = `${def?.title ?? slug}-${new Date().toISOString().slice(0, 10)}.pdf`;
|
||||||
|
res.setHeader("Content-Type", "application/pdf");
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="${filename.replace(/[^\wÀ-ſ .-]/g, "_")}"`,
|
||||||
|
);
|
||||||
|
res.send(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Browser-printable HTML view (the user hits Print → Save as PDF). */
|
||||||
|
@Get(":slug/print")
|
||||||
|
@Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
async runPrint(
|
||||||
|
@Param("slug") slug: string,
|
||||||
|
@Query() query: Record<string, string | undefined>,
|
||||||
|
) {
|
||||||
|
const def = findReport(slug);
|
||||||
|
const result = await this.reports.run(slug, query);
|
||||||
|
return renderPrintHtml(def?.columns ?? [], result, def?.title ?? slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST a customer-picker-driven report (statement). Mirrors GET to keep
|
||||||
|
* the param contract simple: same body shape, same response. */
|
||||||
|
@Post(":slug")
|
||||||
|
async runPost(
|
||||||
|
@Param("slug") slug: string,
|
||||||
|
@Body() body: Record<string, string | undefined>,
|
||||||
|
) {
|
||||||
|
return this.reports.run(slug, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ReportsController } from "./reports.controller";
|
||||||
|
import { ReportsService } from "./reports.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ReportsController],
|
||||||
|
providers: [ReportsService],
|
||||||
|
})
|
||||||
|
export class ReportsModule {}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { findReport, REPORTS } from "./reports.registry";
|
||||||
|
import type { ReportDef } from "./reports.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reports service. Two responsibilities:
|
||||||
|
* 1. Run a report by slug with the given params — just dispatch.
|
||||||
|
* 2. Return the catalog for the /reportes index page.
|
||||||
|
*
|
||||||
|
* Output rendering (CSV/XLSX/PDF/HTML print) lives in `outputs.ts`; this
|
||||||
|
* service is data only. The controller maps URLs to (slug, format) and
|
||||||
|
* hands the result to outputs.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ReportsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** List every registered report, in display order. */
|
||||||
|
catalog(): Array<{
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
domain: string;
|
||||||
|
legacyName: string | null;
|
||||||
|
format: string;
|
||||||
|
params: ReportDef["params"];
|
||||||
|
}> {
|
||||||
|
return REPORTS.map((r) => ({
|
||||||
|
slug: r.slug,
|
||||||
|
title: r.title,
|
||||||
|
description: r.description,
|
||||||
|
domain: r.domain,
|
||||||
|
legacyName: r.legacyName,
|
||||||
|
format: r.format,
|
||||||
|
params: r.params,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(slug: string, params: Record<string, string | undefined>) {
|
||||||
|
const def = findReport(slug);
|
||||||
|
if (!def) throw new NotFoundException(`Reporte "${slug}" no encontrado`);
|
||||||
|
return def.run(this.prisma, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* The reports module — plan step 10.
|
||||||
|
*
|
||||||
|
* Each "report" is one entry in `reports.registry.ts`. The entry declares
|
||||||
|
* its slug (URL id), title, what filters it accepts, what columns it
|
||||||
|
* returns, and a `run` function that produces the data from Prisma. The
|
||||||
|
* service dispatches on slug; the controller exposes JSON + CSV + XLSX +
|
||||||
|
* PDF + HTML print; the catalog endpoint exposes the registry itself so
|
||||||
|
* the `/reportes` page can render the same data.
|
||||||
|
*
|
||||||
|
* Output philosophy: a report returns a uniform shape — `columns` (typed
|
||||||
|
* schema) + `rows` (any[] of values matching the column types) + `totals`
|
||||||
|
* (record of column key → summary value). All three output formats
|
||||||
|
* (CSV/XLSX/PDF/print) derive from this same shape so adding a new
|
||||||
|
* report is one entry, never a per-format template.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
|
||||||
|
/** Top-level grouping for the catalog page; matches the existing nav. */
|
||||||
|
export type ReportDomain =
|
||||||
|
| "clientes"
|
||||||
|
| "polizas"
|
||||||
|
| "servicios"
|
||||||
|
| "estado-cuenta"
|
||||||
|
| "chequera";
|
||||||
|
|
||||||
|
/** How the runner should render rows: a grid, a per-customer statement, or
|
||||||
|
* one printable letter per row (e.g. renewal notices — see `format:
|
||||||
|
* "letter"` reports for the `__kind: "letter"` row shape they emit). */
|
||||||
|
export type ReportFormat = "tabular" | "statement" | "letter";
|
||||||
|
|
||||||
|
/** Filter controls the report's UI should render. */
|
||||||
|
export type ParamDef =
|
||||||
|
| {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
kind: "text" | "number";
|
||||||
|
placeholder?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
kind: "date";
|
||||||
|
/** Inclusive bound, true for `to`, false for `from`. */
|
||||||
|
endOfDay?: boolean;
|
||||||
|
defaultValue?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
kind: "select";
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
defaultValue?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
kind: "customer-picker";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One column of the output table. */
|
||||||
|
export interface ColumnDef {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
/** Render hint for the on-screen + print table. */
|
||||||
|
type: "text" | "number" | "money" | "date";
|
||||||
|
/** Right-align numbers/money; default false (left). */
|
||||||
|
align?: "left" | "right";
|
||||||
|
/** Used for column-width hints in the print/PDF layout. */
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape every report's `run` resolves to. Columns come from the def. */
|
||||||
|
export interface ReportResult {
|
||||||
|
rows: Array<Record<string, unknown>>;
|
||||||
|
totals?: Record<string, string | number>;
|
||||||
|
/** Optional free-form subtitle for print/PDF (e.g. date range, scope). */
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A report's static declaration. */
|
||||||
|
export interface ReportDef {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
domain: ReportDomain;
|
||||||
|
/** The original Access report name (per docs/LEGACY_DATABASES_OBJECTS.md)
|
||||||
|
* for traceability. Null when this is a new report with no legacy equiv. */
|
||||||
|
legacyName: string | null;
|
||||||
|
format: ReportFormat;
|
||||||
|
params: ParamDef[];
|
||||||
|
columns: ColumnDef[];
|
||||||
|
/**
|
||||||
|
* Run the report. Receives the Prisma client and the validated params
|
||||||
|
* record (keys are the `key` from ParamDef, values are the strings the
|
||||||
|
* runner collected; numeric/date params arrive as strings — the report
|
||||||
|
* parses them). Must apply the same NOT_VOIDED filter on transactions as
|
||||||
|
* the billing module so totals match.
|
||||||
|
*/
|
||||||
|
run: (
|
||||||
|
prisma: PrismaService,
|
||||||
|
params: Record<string, string | undefined>,
|
||||||
|
) => Promise<ReportResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A typed bag of helpers for the report functions. */
|
||||||
|
export interface ReportCtx {
|
||||||
|
prisma: PrismaService;
|
||||||
|
params: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper: a `YYYY-MM-DD` bound; unparseable is undefined. */
|
||||||
|
export function parseDate(
|
||||||
|
v: string | undefined,
|
||||||
|
endOfDay = false,
|
||||||
|
): Date | undefined {
|
||||||
|
if (!v) return undefined;
|
||||||
|
const d = new Date(endOfDay ? `${v}T23:59:59.999Z` : `${v}T00:00:00.000Z`);
|
||||||
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper: integer param with default. */
|
||||||
|
export function intParam(
|
||||||
|
p: Record<string, string | undefined>,
|
||||||
|
key: string,
|
||||||
|
def: number,
|
||||||
|
min = 1,
|
||||||
|
max = 1000,
|
||||||
|
): number {
|
||||||
|
const n = Number(p[key]);
|
||||||
|
if (!Number.isFinite(n)) return def;
|
||||||
|
return Math.min(max, Math.max(min, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper: not-voided filter, shared with billing.service. */
|
||||||
|
export const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null };
|
||||||
|
export const NOT_VOIDED_BANK: Prisma.BankTransactionWhereInput = {
|
||||||
|
voidedAt: null,
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* The OCR seam. Everything above this interface works in terms of page text and
|
||||||
|
* word boxes, so the concrete engine is swappable without touching the parsers,
|
||||||
|
* the matcher, or the schema.
|
||||||
|
*
|
||||||
|
* The shipped implementation is self-hosted Tesseract (see tesseract.provider).
|
||||||
|
* That choice is evidence-based rather than assumed: run against 46 pages of
|
||||||
|
* real scanned CFE, CESPT and Telnor statements, it identified the provider on
|
||||||
|
* 46/46 and extracted a usable account reference on 43/46, which is well past
|
||||||
|
* the bar for a queue whose whole point is that a human confirms every row. A
|
||||||
|
* managed document-extraction API (Textract, Document Intelligence, Document
|
||||||
|
* AI) fits behind this same interface if per-page accuracy ever proves
|
||||||
|
* insufficient, with no schema change — but at 300+ pages/month/company it
|
||||||
|
* would carry a real recurring cost for accuracy that is not currently the
|
||||||
|
* bottleneck.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One OCR'd word, with where it sits on the page. */
|
||||||
|
export interface OcrWord {
|
||||||
|
text: string;
|
||||||
|
/** Pixel box in the rendered page image. */
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
/** Engine confidence for this word, 0..1. */
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OcrPage {
|
||||||
|
/** Full page text, reading order, newline-separated. */
|
||||||
|
text: string;
|
||||||
|
/**
|
||||||
|
* Word boxes. Needed because two of the three real layouts are *tables* —
|
||||||
|
* the CESPT "RECIBO" prints `No. DE CUENTA` as a column header with the
|
||||||
|
* value in the row beneath it, which line-oriented text cannot associate.
|
||||||
|
* Parsers fall back to geometry for exactly those fields.
|
||||||
|
*/
|
||||||
|
words: OcrWord[];
|
||||||
|
/** Mean word confidence across the page, 0..1. */
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OcrProvider {
|
||||||
|
/** True when the engine is actually usable in this deployment. */
|
||||||
|
available(): Promise<boolean>;
|
||||||
|
/** Split a PDF into one rendered page image per page. */
|
||||||
|
renderPages(pdf: Buffer): Promise<Buffer[]>;
|
||||||
|
/** OCR a single rendered page image. */
|
||||||
|
recognize(pageImage: Buffer): Promise<OcrPage>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OCR_PROVIDER = Symbol("OCR_PROVIDER");
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { Injectable, Logger, ServiceUnavailableException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import type { OcrPage, OcrProvider, OcrWord } from "./ocr.provider";
|
||||||
|
|
||||||
|
const run = promisify(execFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-hosted OCR: `pdftoppm` (poppler) to rasterise, `tesseract` to read.
|
||||||
|
*
|
||||||
|
* Both are external binaries rather than a native npm addon, which keeps the
|
||||||
|
* pnpm workspace free of a compiled dependency and makes the alpine runtime
|
||||||
|
* image a two-package change (see docker/api.Dockerfile). Like StorageService,
|
||||||
|
* a missing binary degrades rather than crashes the API: the module reports
|
||||||
|
* itself unavailable and statement ingest returns 503, while every other
|
||||||
|
* feature keeps working.
|
||||||
|
*
|
||||||
|
* The settings below are not arbitrary — they were measured against the real
|
||||||
|
* scanned samples:
|
||||||
|
* - 300 DPI grayscale. The source scans are phone photos of paper at ~5MB a
|
||||||
|
* page; below 300 the small print (RMU, clave catastral) stops resolving,
|
||||||
|
* above it costs time for no additional fields.
|
||||||
|
* - `--psm 6` ("assume a single uniform block of text"). The default page
|
||||||
|
* segmentation splits these dense forms into columns and interleaves them,
|
||||||
|
* which destroys the label-then-value adjacency every parser depends on.
|
||||||
|
* - Spanish traineddata, with a graceful fall back to English if the language
|
||||||
|
* pack is absent — an accented label reads worse but the digits, which are
|
||||||
|
* what actually gets matched, are unaffected.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TesseractOcrProvider implements OcrProvider {
|
||||||
|
private readonly logger = new Logger(TesseractOcrProvider.name);
|
||||||
|
private readonly dpi: number;
|
||||||
|
private readonly lang: string;
|
||||||
|
private probe: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
this.dpi = Number(config.get("OCR_DPI") ?? 300);
|
||||||
|
this.lang = config.get<string>("OCR_LANG") ?? "spa";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cached — the binaries do not appear or vanish while the process runs. */
|
||||||
|
available(): Promise<boolean> {
|
||||||
|
if (!this.probe) {
|
||||||
|
this.probe = (async () => {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
run("tesseract", ["--version"]),
|
||||||
|
run("pdftoppm", ["-v"]),
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(
|
||||||
|
"OCR unavailable: `tesseract` and/or `pdftoppm` not found on PATH. " +
|
||||||
|
"Statement ingest is disabled; every other feature is unaffected.",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return this.probe;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async require(): Promise<void> {
|
||||||
|
if (!(await this.available())) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
"El servicio de OCR no está disponible en este servidor.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scratch<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), "stmt-ocr-"));
|
||||||
|
try {
|
||||||
|
return await fn(dir);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async renderPages(pdf: Buffer): Promise<Buffer[]> {
|
||||||
|
await this.require();
|
||||||
|
return this.scratch(async (dir) => {
|
||||||
|
const src = join(dir, "in.pdf");
|
||||||
|
await writeFile(src, pdf);
|
||||||
|
// -gray: these are grayscale scans already; colour triples the bytes
|
||||||
|
// handed to tesseract for no gain in character recognition.
|
||||||
|
await run("pdftoppm", [
|
||||||
|
"-r",
|
||||||
|
String(this.dpi),
|
||||||
|
"-gray",
|
||||||
|
"-png",
|
||||||
|
src,
|
||||||
|
join(dir, "page"),
|
||||||
|
]);
|
||||||
|
const files = (await readdir(dir))
|
||||||
|
.filter((f) => f.startsWith("page") && f.endsWith(".png"))
|
||||||
|
// pdftoppm zero-pads its page numbers, so lexical order is page order.
|
||||||
|
.sort();
|
||||||
|
return Promise.all(files.map((f) => readFile(join(dir, f))));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async recognize(pageImage: Buffer): Promise<OcrPage> {
|
||||||
|
await this.require();
|
||||||
|
return this.scratch(async (dir) => {
|
||||||
|
const img = join(dir, "page.png");
|
||||||
|
await writeFile(img, pageImage);
|
||||||
|
|
||||||
|
// One tesseract invocation produces both outputs; TSV carries the word
|
||||||
|
// boxes and per-word confidence, and its text can be reassembled into
|
||||||
|
// reading order, so there is no need to run the engine twice.
|
||||||
|
const out = join(dir, "out");
|
||||||
|
try {
|
||||||
|
await run("tesseract", [img, out, "-l", this.lang, "--psm", "6", "tsv"]);
|
||||||
|
} catch (err) {
|
||||||
|
if (this.lang !== "eng") {
|
||||||
|
this.logger.warn(
|
||||||
|
`Tesseract failed with lang "${this.lang}", retrying with "eng": ${
|
||||||
|
(err as Error).message
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
await run("tesseract", [img, out, "-l", "eng", "--psm", "6", "tsv"]);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tsv = await readFile(`${out}.tsv`, "utf8");
|
||||||
|
return parseTsv(tsv);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn tesseract's TSV into words plus reassembled text.
|
||||||
|
*
|
||||||
|
* Columns are: level, page_num, block_num, par_num, line_num, word_num, left,
|
||||||
|
* top, width, height, conf, text. Rows with level < 5 are structural (page,
|
||||||
|
* block, paragraph, line) and carry no text; only level 5 is a word. A conf of
|
||||||
|
* -1 marks a structural row, so those are dropped rather than averaged in —
|
||||||
|
* including them would drag every page's confidence toward zero.
|
||||||
|
*/
|
||||||
|
export function parseTsv(tsv: string): OcrPage {
|
||||||
|
const lines = tsv.split("\n");
|
||||||
|
const header = lines[0]?.split("\t") ?? [];
|
||||||
|
const col = (name: string) => header.indexOf(name);
|
||||||
|
const iLeft = col("left");
|
||||||
|
const iTop = col("top");
|
||||||
|
const iWidth = col("width");
|
||||||
|
const iHeight = col("height");
|
||||||
|
const iConf = col("conf");
|
||||||
|
const iText = col("text");
|
||||||
|
const iLine = col("line_num");
|
||||||
|
const iBlock = col("block_num");
|
||||||
|
|
||||||
|
const words: OcrWord[] = [];
|
||||||
|
// Keyed by block+line so the reassembled text preserves the engine's own
|
||||||
|
// reading order instead of sorting words by raw y, which interleaves columns.
|
||||||
|
const byLine = new Map<string, string[]>();
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const f = lines[i].split("\t");
|
||||||
|
if (f.length <= iText) continue;
|
||||||
|
const text = f[iText]?.trim();
|
||||||
|
if (!text) continue;
|
||||||
|
const confidence = Number(f[iConf]);
|
||||||
|
if (!Number.isFinite(confidence) || confidence < 0) continue;
|
||||||
|
|
||||||
|
words.push({
|
||||||
|
text,
|
||||||
|
left: Number(f[iLeft]) || 0,
|
||||||
|
top: Number(f[iTop]) || 0,
|
||||||
|
width: Number(f[iWidth]) || 0,
|
||||||
|
height: Number(f[iHeight]) || 0,
|
||||||
|
confidence: confidence / 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const key = `${f[iBlock]}:${f[iLine]}`;
|
||||||
|
const bucket = byLine.get(key);
|
||||||
|
if (bucket) bucket.push(text);
|
||||||
|
else byLine.set(key, [text]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = [...byLine.values()].map((w) => w.join(" ")).join("\n");
|
||||||
|
const confidence = words.length
|
||||||
|
? words.reduce((sum, w) => sum + w.confidence, 0) / words.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return { text, words, confidence };
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import type { ServiceKind } from "@jorgecuadros/database";
|
||||||
|
import type { OcrPage, OcrWord } from "../ocr/ocr.provider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one parsed statement page yields. `accountRef` is already normalised to
|
||||||
|
* the form the migrated `PropertyService` columns hold, so the matcher compares
|
||||||
|
* like with like and never has to know about provider-specific formatting.
|
||||||
|
*/
|
||||||
|
export interface ParsedStatement {
|
||||||
|
/** "CFE" | "CESPT" | "TELNOR", or null when no parser claimed the page. */
|
||||||
|
provider: string | null;
|
||||||
|
serviceKind: ServiceKind | null;
|
||||||
|
accountRef: string | null;
|
||||||
|
/** Clave catastral, when printed — a second key to match on. */
|
||||||
|
cadastralKey: string | null;
|
||||||
|
amount: number | null;
|
||||||
|
dueDate: Date | null;
|
||||||
|
period: string | null;
|
||||||
|
/**
|
||||||
|
* Independent corroboration of `accountRef`. CFE and Telnor both print a
|
||||||
|
* payment barcode that repeats the account number (and the amount), so when
|
||||||
|
* the barcode and the label agree the extraction is near-certainly right;
|
||||||
|
* when they disagree, or only one is present, the page is worth a human
|
||||||
|
* glance. Null when the layout has no second source.
|
||||||
|
*/
|
||||||
|
crossChecked: boolean | null;
|
||||||
|
/** Human-readable trail of what was read, surfaced in the review queue. */
|
||||||
|
notes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- shared helpers ---------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tesseract confuses these glyphs inside numeric runs with some regularity —
|
||||||
|
* a real clave catastral `KB078025` came back as `KBO78025`. Applied ONLY to
|
||||||
|
* fields known to be digits, never to free text, where it would corrupt words.
|
||||||
|
*/
|
||||||
|
const DIGIT_CONFUSIONS: Record<string, string> = {
|
||||||
|
O: "0",
|
||||||
|
o: "0",
|
||||||
|
D: "0",
|
||||||
|
I: "1",
|
||||||
|
l: "1",
|
||||||
|
"|": "1",
|
||||||
|
S: "5",
|
||||||
|
B: "8",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toDigits(s: string | null | undefined): string {
|
||||||
|
if (!s) return "";
|
||||||
|
return s
|
||||||
|
.split("")
|
||||||
|
.map((c) => DIGIT_CONFUSIONS[c] ?? c)
|
||||||
|
.join("")
|
||||||
|
.replace(/\D/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a printed amount, treating `,` and `.` by position rather than by
|
||||||
|
* assumption. A real Telnor bill OCR'd as "$ 649,00" — blindly stripping commas
|
||||||
|
* as thousands separators turned $649.00 into $64,900, a hundredfold error that
|
||||||
|
* would post silently. Two trailing digits after a single separator are always
|
||||||
|
* cents here; a separator followed by three digits is a thousands group.
|
||||||
|
*/
|
||||||
|
function money(s: string | null | undefined): number | null {
|
||||||
|
if (!s) return null;
|
||||||
|
const cleaned = s.replace(/[\s$]/g, "");
|
||||||
|
|
||||||
|
// 1.234,56 or 1,234.56 — grouped thousands plus optional cents.
|
||||||
|
let m = cleaned.match(/^(\d{1,3}(?:[.,]\d{3})+)([.,]\d{1,2})?$/);
|
||||||
|
if (m) {
|
||||||
|
const whole = m[1].replace(/[.,]/g, "");
|
||||||
|
const cents = m[2] ? m[2].slice(1) : "";
|
||||||
|
return Number(cents ? `${whole}.${cents.padEnd(2, "0")}` : whole);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 649,00 / 649.00 — a single separator with exactly two digits after it.
|
||||||
|
m = cleaned.match(/^(\d+)[.,](\d{2})$/);
|
||||||
|
if (m) return Number(`${m[1]}.${m[2]}`);
|
||||||
|
|
||||||
|
const n = Number(cleaned.replace(/[,.]/g, ""));
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstMatch(text: string, patterns: RegExp[]): string | null {
|
||||||
|
for (const p of patterns) {
|
||||||
|
const m = text.match(p);
|
||||||
|
if (m?.[1]) return m[1].trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTHS: Record<string, number> = {
|
||||||
|
ENE: 0, FEB: 1, MAR: 2, ABR: 3, MAY: 4, JUN: 5,
|
||||||
|
JUL: 6, AGO: 7, SEP: 8, OCT: 9, NOV: 10, DIC: 11,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Parses the three date shapes these statements actually print. */
|
||||||
|
export function parseDate(raw: string | null | undefined): Date | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
const s = raw.trim().toUpperCase();
|
||||||
|
|
||||||
|
// 16/07/2026
|
||||||
|
let m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||||
|
if (m) return utc(+m[3], +m[2] - 1, +m[1]);
|
||||||
|
|
||||||
|
// 22-JUL-2026 / 22 JUN 26
|
||||||
|
m = s.match(/^(\d{1,2})[-\s]([A-Z]{3})[A-Z]*[-\s](\d{2,4})$/);
|
||||||
|
if (m && MONTHS[m[2]] !== undefined) {
|
||||||
|
const y = m[3].length === 2 ? 2000 + +m[3] : +m[3];
|
||||||
|
return utc(y, MONTHS[m[2]], +m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2026-07-22 (already normalised, e.g. decoded from a barcode)
|
||||||
|
m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (m) return utc(+m[1], +m[2] - 1, +m[3]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function utc(y: number, mo: number, d: number): Date | null {
|
||||||
|
const dt = new Date(Date.UTC(y, mo, d));
|
||||||
|
return Number.isNaN(dt.getTime()) ? null : dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the value printed *underneath* a column header.
|
||||||
|
*
|
||||||
|
* The CESPT "RECIBO" is a table: `No. DE CUENTA` is a header cell and its value
|
||||||
|
* sits in the row below it, so no amount of label-adjacent regex on line text
|
||||||
|
* can associate the two. This walks the word boxes instead — find the header
|
||||||
|
* word, then take the nearest word below it whose horizontal centre falls
|
||||||
|
* within the column.
|
||||||
|
*/
|
||||||
|
export function valueUnder(
|
||||||
|
page: OcrPage,
|
||||||
|
header: RegExp,
|
||||||
|
opts: { maxDy?: number; tolerance?: number; match?: RegExp } = {},
|
||||||
|
): string | null {
|
||||||
|
const { maxDy = 300, tolerance = 200, match } = opts;
|
||||||
|
const centre = (w: OcrWord) => ({
|
||||||
|
x: w.left + w.width / 2,
|
||||||
|
y: w.top + w.height / 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const h of page.words.filter((w) => header.test(w.text))) {
|
||||||
|
const hc = centre(h);
|
||||||
|
const below = page.words
|
||||||
|
.filter((w) => {
|
||||||
|
const c = centre(w);
|
||||||
|
return c.y > hc.y && c.y <= hc.y + maxDy && Math.abs(c.x - hc.x) <= tolerance;
|
||||||
|
})
|
||||||
|
.sort((a, b) => centre(a).y - centre(b).y);
|
||||||
|
|
||||||
|
for (const w of below) {
|
||||||
|
if (!match || match.test(w.text)) return w.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- provider detection -----------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brand wordmarks first, page structure only as a fallback — and the two passes
|
||||||
|
* must not be interleaved. Scanned logos OCR badly (one CESPT header came back
|
||||||
|
* as "E BAJA ES PAGO / EALIFORNIA", with neither "CESPT" nor "COMISIÓN ESTATAL"
|
||||||
|
* readable), so the structural pass is what rescues those pages. But a Telnor
|
||||||
|
* bill contains the words "Pagar antes de", which a CFE structural rule
|
||||||
|
* evaluated first will happily claim — running all brand checks before any
|
||||||
|
* structural check is what keeps that from happening.
|
||||||
|
*/
|
||||||
|
const BRAND: [string, RegExp][] = [
|
||||||
|
["CFE", /comisi[oó]n federal de electricidad|CFE.?contigo|Suministrador de Servicios/i],
|
||||||
|
["CESPT", /CESPT|COMISI[OÓ]N ESTATAL DE SERVICIOS/i],
|
||||||
|
["TELNOR", /TELNOR|TELEFONOS DEL NOROESTE/i],
|
||||||
|
];
|
||||||
|
|
||||||
|
const LAYOUT: [string, RegExp][] = [
|
||||||
|
["CFE", /NO\.?\s*DE\s*SERVICIO|L[IÍ]MITE\s*DE\s*PAGO|PERIODO\s*FACTURADO/i],
|
||||||
|
["CESPT", /SALDO\s+CORRIENTE|CLAVE\s*CATASTRAL|No\.?\s*DE\s*CUENTA/i],
|
||||||
|
["TELNOR", /Mes\s*de\s*Facturaci[oó]n|Pagar\s*antes\s*de/i],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function detectProvider(text: string): string | null {
|
||||||
|
for (const group of [BRAND, LAYOUT]) {
|
||||||
|
for (const [name, pattern] of group) {
|
||||||
|
if (pattern.test(text)) return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CFE (electric) ---------------------------------------------------------
|
||||||
|
|
||||||
|
function parseCfe(page: OcrPage): ParsedStatement {
|
||||||
|
const text = page.text;
|
||||||
|
const notes: string[] = [];
|
||||||
|
|
||||||
|
// The payment barcode line repeats the service number, the due date (YYMMDD)
|
||||||
|
// and the amount in one fixed-width run, and reads far more reliably than the
|
||||||
|
// label: on one sample the label came back as "0059603001917" (a digit too
|
||||||
|
// many) while its barcode gave the correct "005960300191". So the barcode
|
||||||
|
// wins, and the label becomes the cross-check rather than the source.
|
||||||
|
const barcode = text.match(/\b01\s+([0-9OIlSBD]{12})\s+([0-9OIlSBD]{6})\s+([0-9OIlSBD]{9})\b/);
|
||||||
|
const label = firstMatch(text, [/NO\.?\s*DE\s*SERVICIO\s*[:;.]?\s*([0-9OIlSBD]{10,14})/i]);
|
||||||
|
|
||||||
|
let accountRef: string | null = null;
|
||||||
|
let amount: number | null = null;
|
||||||
|
let dueDate: Date | null = null;
|
||||||
|
let crossChecked: boolean | null = null;
|
||||||
|
|
||||||
|
if (barcode) {
|
||||||
|
// Leading zeros are print padding: DATMEX.rpu holds the bare 10 digits.
|
||||||
|
accountRef = toDigits(barcode[1]).replace(/^0+/, "");
|
||||||
|
amount = Number(toDigits(barcode[3]));
|
||||||
|
const d = toDigits(barcode[2]);
|
||||||
|
dueDate = parseDate(`20${d.slice(0, 2)}-${d.slice(2, 4)}-${d.slice(4, 6)}`);
|
||||||
|
notes.push("importe y vencimiento leídos del código de barras");
|
||||||
|
if (label) {
|
||||||
|
crossChecked = toDigits(label).replace(/^0+/, "") === accountRef;
|
||||||
|
if (!crossChecked) {
|
||||||
|
notes.push(
|
||||||
|
`el número impreso (${toDigits(label).replace(/^0+/, "")}) no coincide con el código de barras`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (label) {
|
||||||
|
accountRef = toDigits(label).replace(/^0+/, "");
|
||||||
|
notes.push("sin código de barras legible; número tomado de la etiqueta");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount == null) {
|
||||||
|
amount = money(firstMatch(text, [/TOTAL\s*A\s*PAGAR\s*[:;.]?\s*\$?\s*([\d,]+\.?\d*)/i]));
|
||||||
|
}
|
||||||
|
if (!dueDate) {
|
||||||
|
dueDate = parseDate(
|
||||||
|
firstMatch(text, [/L[IÍ]MITE\s*DE\s*PAGO\s*[:;.]?\s*(\d{1,2}\s+\w{3}\s+\d{2,4})/i]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: "CFE",
|
||||||
|
serviceKind: "ELECTRIC",
|
||||||
|
accountRef: accountRef || null,
|
||||||
|
cadastralKey: null,
|
||||||
|
amount,
|
||||||
|
dueDate,
|
||||||
|
period: firstMatch(text, [
|
||||||
|
/PERIODO\s*FACTURADO\s*[:;.]?\s*(\d{1,2}\s+\w{3}\s+\d{2}\s*-\s*\d{1,2}\s+\w{3}\s+\d{2})/i,
|
||||||
|
]),
|
||||||
|
crossChecked,
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CESPT (water) ----------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two different layouts arrive under the same brand:
|
||||||
|
* - the line-oriented "COMPROBANTE DE PAGO" (`Cuenta : 7604192`), and
|
||||||
|
* - the tabular "RECIBO", where `No. DE CUENTA` is a column header.
|
||||||
|
* Line patterns are tried first; anything they miss falls through to the
|
||||||
|
* geometric read, which is what the tabular layout needs.
|
||||||
|
*/
|
||||||
|
function parseCespt(page: OcrPage): ParsedStatement {
|
||||||
|
const text = page.text;
|
||||||
|
const notes: string[] = [];
|
||||||
|
|
||||||
|
let account = firstMatch(text, [/Cuenta\s*[:;.]?\s*([0-9OIlSBD]{5,9})/i]);
|
||||||
|
if (!account) {
|
||||||
|
account = valueUnder(page, /^CUENTA$/i, { match: /^[0-9OIlSBD]{5,9}$/ });
|
||||||
|
if (account) notes.push("número de cuenta leído de la columna del recibo");
|
||||||
|
}
|
||||||
|
|
||||||
|
let clave = firstMatch(text, [/Cve\.?\s*Cat\.?\s*[:;.]?\s*([A-Z]{2}\s?[0-9OIlSBD]{6})/i]);
|
||||||
|
if (!clave) {
|
||||||
|
clave = valueUnder(page, /^CATASTRAL$/i, { match: /^[A-Z]{2}[0-9OIlSBD]{6}$/i });
|
||||||
|
if (clave) notes.push("clave catastral leída de la columna del recibo");
|
||||||
|
}
|
||||||
|
|
||||||
|
let due = firstMatch(text, [/Fecha\s*Venc\s*[:;.]?\s*(\d{2}\/\d{2}\/\d{4})/i]);
|
||||||
|
if (!due) due = valueUnder(page, /^VENCIMIENTO$/i, { match: /^\d{2}\/\d{2}\/\d{4}$/ });
|
||||||
|
|
||||||
|
const amount = money(
|
||||||
|
firstMatch(text, [
|
||||||
|
/TOTAL\s*[:;.]?\s*\$?\s*([\d,]+\.\d{2})/i,
|
||||||
|
/SALDO\s+CORRIENTE[^\n]*?([\d,]+\.\d{2})/i,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Leading zeros are print padding here too: the RECIBO prints `0457341` for
|
||||||
|
// what DATMEX.agua holds as `457341`.
|
||||||
|
const accountRef = account ? toDigits(account).replace(/^0+/, "") : null;
|
||||||
|
const cadastralKey = clave
|
||||||
|
? clave.replace(/\s/g, "").slice(0, 2).toUpperCase() +
|
||||||
|
toDigits(clave.replace(/\s/g, "").slice(2))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: "CESPT",
|
||||||
|
serviceKind: "WATER",
|
||||||
|
accountRef: accountRef || null,
|
||||||
|
cadastralKey: cadastralKey || null,
|
||||||
|
amount,
|
||||||
|
dueDate: parseDate(due),
|
||||||
|
period: null,
|
||||||
|
crossChecked: null,
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TELNOR (telephone) -----------------------------------------------------
|
||||||
|
|
||||||
|
function parseTelnor(page: OcrPage): ParsedStatement {
|
||||||
|
const text = page.text;
|
||||||
|
const notes: string[] = [];
|
||||||
|
|
||||||
|
const label = firstMatch(text, [
|
||||||
|
/Tel[eé]fono\s*[:;.]?\s*([0-9OIlSBD]{3}\s?[0-9OIlSBD]{3}\s?[0-9OIlSBD]{4})/i,
|
||||||
|
]);
|
||||||
|
// The payment stub prints phone (10 digits) + amount in cents (9) + a check
|
||||||
|
// digit: `6646093444 000099900 7` for a $999.00 bill. Reading the amount as
|
||||||
|
// 10 digits swallows the check digit and inflates the figure 100-fold.
|
||||||
|
const barcode = text.match(/\b(\d{10})(\d{9})\d\b/);
|
||||||
|
|
||||||
|
let accountRef: string | null = null;
|
||||||
|
let crossChecked: boolean | null = null;
|
||||||
|
|
||||||
|
// The bill prints the number with its 664 Tijuana LADA; DATMEX stores the
|
||||||
|
// bare local 7 digits, so the LADA is dropped rather than the stored value
|
||||||
|
// being padded — padding would guess at an area code for the 500+ existing
|
||||||
|
// rows that never recorded one.
|
||||||
|
if (label) accountRef = toDigits(label).slice(-7);
|
||||||
|
if (barcode) {
|
||||||
|
const fromBarcode = barcode[1].slice(-7);
|
||||||
|
if (accountRef) {
|
||||||
|
crossChecked = fromBarcode === accountRef;
|
||||||
|
if (!crossChecked) notes.push("el teléfono impreso no coincide con el código de barras");
|
||||||
|
} else {
|
||||||
|
accountRef = fromBarcode;
|
||||||
|
notes.push("teléfono leído del código de barras");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let amount = money(firstMatch(text, [/Total\s*a\s*Pagar\s*[:;.]?\s*\$?\s*([\d,]+\.?\d{0,2})/i]));
|
||||||
|
if (amount == null && barcode) {
|
||||||
|
amount = Number(barcode[2]) / 100;
|
||||||
|
notes.push("importe leído del código de barras");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: "TELNOR",
|
||||||
|
serviceKind: "TELEPHONE",
|
||||||
|
accountRef: accountRef || null,
|
||||||
|
cadastralKey: null,
|
||||||
|
amount,
|
||||||
|
dueDate: parseDate(
|
||||||
|
firstMatch(text, [/Pagar\s*antes\s*de\s*[:;.]?\s*(\d{2}-\w{3}-\d{4})/i]),
|
||||||
|
),
|
||||||
|
period: firstMatch(text, [/Mes\s*de\s*Facturaci[oó]n\s*[:;.]?\s*(\w+)/i]),
|
||||||
|
crossChecked,
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARSERS: Record<string, (page: OcrPage) => ParsedStatement> = {
|
||||||
|
CFE: parseCfe,
|
||||||
|
CESPT: parseCespt,
|
||||||
|
TELNOR: parseTelnor,
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY: ParsedStatement = {
|
||||||
|
provider: null,
|
||||||
|
serviceKind: null,
|
||||||
|
accountRef: null,
|
||||||
|
cadastralKey: null,
|
||||||
|
amount: null,
|
||||||
|
dueDate: null,
|
||||||
|
period: null,
|
||||||
|
crossChecked: null,
|
||||||
|
notes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Detect the provider and run its parser. */
|
||||||
|
export function parseStatement(page: OcrPage): ParsedStatement {
|
||||||
|
const provider = detectProvider(page.text);
|
||||||
|
if (!provider) return { ...EMPTY, notes: ["no se reconoció el proveedor"] };
|
||||||
|
return PARSERS[provider](page);
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { ServiceKind } from "@jorgecuadros/database";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import type { ParsedStatement } from "./parsers/statement-parser";
|
||||||
|
|
||||||
|
export interface MatchResult {
|
||||||
|
propertyServiceId: string | null;
|
||||||
|
customerId: string | null;
|
||||||
|
/** Why it landed here — shown in the review queue verbatim. */
|
||||||
|
note: string;
|
||||||
|
/** True only for an unambiguous hit on the scoped field. */
|
||||||
|
confident: boolean;
|
||||||
|
/** Populated when more than one service claims the same number. */
|
||||||
|
candidates: { propertyServiceId: string; customerId: string; customerName: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a parsed statement to the customer who should be billed for it.
|
||||||
|
*
|
||||||
|
* Two rules govern everything here.
|
||||||
|
*
|
||||||
|
* **Match on one scoped field, never fuzzily across all identifiers.** Each
|
||||||
|
* service kind has exactly one column its statements print, and only that
|
||||||
|
* column is consulted. A blanket search over accountNumber/meterNumber/route
|
||||||
|
* would let a water account number collide with an unrelated phone number, and
|
||||||
|
* the resulting mis-post would look perfectly ordinary in the ledger.
|
||||||
|
*
|
||||||
|
* **Never match on the customer name.** The name on a utility bill is the
|
||||||
|
* account's registrant, which drifts from the current owner and is often years
|
||||||
|
* stale — one sample CESPT receipt is printed to "ARNAIZ ROSAS ELSA AURORA"
|
||||||
|
* for an account this office holds under "CATT, RANDY", who is not the same
|
||||||
|
* person. Names are displayed for the reviewer to sanity-check, and are never
|
||||||
|
* an input to matching.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class StatementMatcherService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** Which PropertyService column a given kind's statements actually print. */
|
||||||
|
private fieldFor(kind: ServiceKind): "accountNumber" | "meterNumber" | null {
|
||||||
|
switch (kind) {
|
||||||
|
case "ELECTRIC": // CFE "NO. DE SERVICIO" -> DATMEX.rpu
|
||||||
|
case "WATER": // CESPT "Cuenta" / "No. DE CUENTA" -> DATMEX.agua
|
||||||
|
case "TELEPHONE": // Telnor "Teléfono" (LADA stripped) -> DATMEX.telefono
|
||||||
|
case "FEDERAL_ZONE":
|
||||||
|
case "CABLE":
|
||||||
|
return "accountNumber";
|
||||||
|
case "GAS": // no account column in DATMEX; the number lived in notes
|
||||||
|
return "meterNumber";
|
||||||
|
// PROPERTY_TAX deliberately has no scoped column: what its
|
||||||
|
// accountNumber holds is DATMEX.predial, which is neither unique nor
|
||||||
|
// printed on any statement. Predial bills match on the clave catastral
|
||||||
|
// alone — see matchByCadastralKey.
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async match(parsed: ParsedStatement, expectedKind: ServiceKind): Promise<MatchResult> {
|
||||||
|
const kind = parsed.serviceKind ?? expectedKind;
|
||||||
|
|
||||||
|
// The uploader labels a batch with one service kind. If the parser reads a
|
||||||
|
// page as a different provider, that is a mis-sorted page, not a match —
|
||||||
|
// posting it would book a phone bill as a water charge.
|
||||||
|
if (parsed.serviceKind && parsed.serviceKind !== expectedKind) {
|
||||||
|
return this.unmatched(
|
||||||
|
`la página parece de ${parsed.provider} (${parsed.serviceKind}) pero el lote es de ${expectedKind}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = this.fieldFor(kind);
|
||||||
|
|
||||||
|
if (field && parsed.accountRef) {
|
||||||
|
const hit = await this.byServiceField(kind, field, parsed.accountRef);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary key. The clave catastral is printed on CESPT bills as well as
|
||||||
|
// predial ones, so it rescues a page whose account number did not OCR —
|
||||||
|
// which happened on real samples, where the clave read cleanly and the
|
||||||
|
// account number did not.
|
||||||
|
if (parsed.cadastralKey) {
|
||||||
|
const hit = await this.byCadastralKey(kind, parsed.cadastralKey);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!field && !parsed.cadastralKey) {
|
||||||
|
return this.unmatched(
|
||||||
|
kind === "PROPERTY_TAX"
|
||||||
|
? "el predial sólo se puede identificar por clave catastral y no se leyó ninguna"
|
||||||
|
: `no hay campo de búsqueda definido para ${kind}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.unmatched(
|
||||||
|
parsed.accountRef
|
||||||
|
? `no se encontró ningún servicio de ${kind} con la referencia ${parsed.accountRef}`
|
||||||
|
: "no se pudo leer la referencia de la cuenta",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async byServiceField(
|
||||||
|
kind: ServiceKind,
|
||||||
|
field: "accountNumber" | "meterNumber",
|
||||||
|
ref: string,
|
||||||
|
): Promise<MatchResult | null> {
|
||||||
|
const rows = await this.prisma.propertyService.findMany({
|
||||||
|
where: { kind, [field]: ref },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
property: {
|
||||||
|
select: { customerId: true, customer: { select: { name: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const candidates = rows.map((r) => ({
|
||||||
|
propertyServiceId: r.id,
|
||||||
|
customerId: r.property.customerId,
|
||||||
|
customerName: r.property.customer.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Duplicate account numbers do occur in the legacy data (the office's own
|
||||||
|
// DUPLICADOS report existed for a reason), so every candidate is surfaced
|
||||||
|
// for the reviewer to choose rather than one being picked arbitrarily.
|
||||||
|
if (rows.length > 1) {
|
||||||
|
return {
|
||||||
|
propertyServiceId: null,
|
||||||
|
customerId: null,
|
||||||
|
note: `${rows.length} servicios comparten la referencia ${ref}`,
|
||||||
|
confident: false,
|
||||||
|
candidates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
propertyServiceId: candidates[0].propertyServiceId,
|
||||||
|
customerId: candidates[0].customerId,
|
||||||
|
note: `coincidencia exacta por ${field === "accountNumber" ? "número de cuenta" : "medidor"} ${ref}`,
|
||||||
|
confident: true,
|
||||||
|
candidates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async byCadastralKey(
|
||||||
|
kind: ServiceKind,
|
||||||
|
key: string,
|
||||||
|
): Promise<MatchResult | null> {
|
||||||
|
const props = await this.prisma.property.findMany({
|
||||||
|
where: { cadastralKey: key },
|
||||||
|
select: {
|
||||||
|
customerId: true,
|
||||||
|
customer: { select: { name: true } },
|
||||||
|
services: { where: { kind }, select: { id: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (props.length === 0) return null;
|
||||||
|
|
||||||
|
const candidates = props.flatMap((p) =>
|
||||||
|
(p.services.length ? p.services.map((s) => s.id) : [null]).map((sid) => ({
|
||||||
|
propertyServiceId: sid as string,
|
||||||
|
customerId: p.customerId,
|
||||||
|
customerName: p.customer.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (candidates.length > 1) {
|
||||||
|
return {
|
||||||
|
propertyServiceId: null,
|
||||||
|
customerId: null,
|
||||||
|
note: `${candidates.length} propiedades comparten la clave catastral ${key}`,
|
||||||
|
confident: false,
|
||||||
|
candidates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clave identifies the property with certainty, but it is a *secondary*
|
||||||
|
// key: it was not the number the statement was issued against. Left for
|
||||||
|
// review so the confirm also teaches the matcher the account number, rather
|
||||||
|
// than the same page needing the fallback again next month.
|
||||||
|
return {
|
||||||
|
propertyServiceId: candidates[0].propertyServiceId ?? null,
|
||||||
|
customerId: candidates[0].customerId,
|
||||||
|
note: `identificado por clave catastral ${key}; confirme para registrar también el número de cuenta`,
|
||||||
|
confident: false,
|
||||||
|
candidates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private unmatched(note: string): MatchResult {
|
||||||
|
return {
|
||||||
|
propertyServiceId: null,
|
||||||
|
customerId: null,
|
||||||
|
note,
|
||||||
|
confident: false,
|
||||||
|
candidates: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Currency, ServiceKind, StatementDocumentStatus } from "@jorgecuadros/database";
|
||||||
|
|
||||||
|
export class CreateStatementBatchDto {
|
||||||
|
@IsEnum(ServiceKind) serviceKind!: ServiceKind;
|
||||||
|
@IsOptional() @IsString() label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Staff correction of one document's extracted fields or its match. */
|
||||||
|
export class ReviewDocumentDto {
|
||||||
|
@IsOptional() @IsString() accountRef?: string;
|
||||||
|
@IsOptional() @IsNumber() amount?: number;
|
||||||
|
@IsOptional() @IsString() period?: string;
|
||||||
|
@IsOptional() @IsString() dueDate?: string;
|
||||||
|
@IsOptional() @IsString() matchedPropertyServiceId?: string;
|
||||||
|
@IsOptional() @IsString() matchedCustomerId?: string;
|
||||||
|
// Restricted to the review-reachable states: a client cannot declare a
|
||||||
|
// document POSTED, because only a successful ledger write may do that.
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(StatementDocumentStatus)
|
||||||
|
status?: Extract<StatementDocumentStatus, "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED">;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post a batch's confirmed documents. The check-level fields are shared by
|
||||||
|
* every line, exactly as on the manual batch-capture screen — an OCR batch is
|
||||||
|
* still "these receipts, paid by this check".
|
||||||
|
*/
|
||||||
|
export class ConfirmBatchDto {
|
||||||
|
@IsString() @MinLength(1) checkNumber!: string;
|
||||||
|
@IsString() @MinLength(1) transactionDate!: string;
|
||||||
|
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||||
|
/** Overrides the concept derived from the batch's service kind. */
|
||||||
|
@IsOptional() @IsString() typeId?: string;
|
||||||
|
/** Post as outstanding (sin fondos) — captured but not yet funded. */
|
||||||
|
@IsOptional() @IsBoolean() outstanding?: boolean;
|
||||||
|
/** Also post documents a reviewer explicitly marked CONFIRMED. */
|
||||||
|
@IsOptional() @IsBoolean() includeReviewed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListBatchesQuery {
|
||||||
|
@IsOptional() @IsInt() page?: number;
|
||||||
|
@IsOptional() @IsInt() pageSize?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
StreamableFile,
|
||||||
|
UploadedFiles,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { FilesInterceptor } from "@nestjs/platform-express";
|
||||||
|
import type { ServiceKind, StatementDocumentStatus } from "@jorgecuadros/database";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||||
|
import { AbilityGuard } from "../auth/ability.guard";
|
||||||
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
|
import { AuditService } from "../common/audit.service";
|
||||||
|
import type { UploadedFileLike } from "../storage/upload-file";
|
||||||
|
import { StatementsService } from "./statements.service";
|
||||||
|
import { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statement OCR intake (RECEIPT_CAPTURE_SPEC §2).
|
||||||
|
*
|
||||||
|
* Nothing here writes to the ledger directly — confirming a batch delegates to
|
||||||
|
* BillingService, so an OCR-captured charge is indistinguishable from a
|
||||||
|
* hand-keyed one except for its `captureSource`.
|
||||||
|
*/
|
||||||
|
@Controller("statements")
|
||||||
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
|
export class StatementsController {
|
||||||
|
constructor(
|
||||||
|
private readonly statements: StatementsService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private actingId(req: Request): string {
|
||||||
|
return (req.user as { id: string } | undefined)?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this deployment can ingest scans at all — the UI hides automatic
|
||||||
|
* capture without it. Both halves are needed: OCR to read the page, object
|
||||||
|
* storage to keep it.
|
||||||
|
*/
|
||||||
|
@Get("status")
|
||||||
|
async status() {
|
||||||
|
return {
|
||||||
|
ocrAvailable: await this.statements.ocrAvailable(),
|
||||||
|
storageAvailable: this.statements.storageAvailable(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("batches")
|
||||||
|
listBatches(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
|
||||||
|
return this.statements.listBatches(
|
||||||
|
Math.max(1, Number(page) || 1),
|
||||||
|
Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("batches/:id")
|
||||||
|
getBatch(@Param("id") id: string) {
|
||||||
|
return this.statements.getBatch(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("batches/:id/documents")
|
||||||
|
listDocuments(@Param("id") id: string, @Query("status") status?: string) {
|
||||||
|
return this.statements.listDocuments(
|
||||||
|
id,
|
||||||
|
(status || undefined) as StatementDocumentStatus | undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rendered page, so a reviewer can compare it against what was read. */
|
||||||
|
@Get("documents/:id/page")
|
||||||
|
async pageImage(@Param("id") id: string, @Res({ passthrough: true }) res: Response) {
|
||||||
|
const { stream, contentType, contentLength } = await this.statements.pageImage(id);
|
||||||
|
res.set({
|
||||||
|
"Content-Type": contentType ?? "image/png",
|
||||||
|
...(contentLength ? { "Content-Length": String(contentLength) } : {}),
|
||||||
|
});
|
||||||
|
return new StreamableFile(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@Post("batches")
|
||||||
|
@RequireAbility("statement:ingest")
|
||||||
|
@UseInterceptors(
|
||||||
|
// A month of one company's statements is a handful of multi-page scans;
|
||||||
|
// 25 files at 50MB covers that with room to spare.
|
||||||
|
FilesInterceptor("files", 25, { limits: { fileSize: 50 * 1024 * 1024 } }),
|
||||||
|
)
|
||||||
|
async createBatch(
|
||||||
|
@UploadedFiles() files: UploadedFileLike[] | undefined,
|
||||||
|
@Query("serviceKind") serviceKind: ServiceKind,
|
||||||
|
@Query("label") label: string | undefined,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const batch = await this.statements.createBatch(
|
||||||
|
files ?? [],
|
||||||
|
serviceKind,
|
||||||
|
this.actingId(req),
|
||||||
|
label,
|
||||||
|
);
|
||||||
|
void this.audit.log(this.actingId(req), "statement.batch.create", {
|
||||||
|
batchId: batch.id,
|
||||||
|
serviceKind,
|
||||||
|
fileCount: batch.fileCount,
|
||||||
|
});
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("documents/:id")
|
||||||
|
@RequireAbility("statement:review")
|
||||||
|
async review(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: ReviewDocumentDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const doc = await this.statements.review(id, dto, this.actingId(req));
|
||||||
|
void this.audit.log(this.actingId(req), "statement.document.review", {
|
||||||
|
documentId: id,
|
||||||
|
status: doc.status,
|
||||||
|
});
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("documents/:id/reject")
|
||||||
|
@RequireAbility("statement:review")
|
||||||
|
async reject(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const doc = await this.statements.reject(id, this.actingId(req));
|
||||||
|
void this.audit.log(this.actingId(req), "statement.document.reject", {
|
||||||
|
documentId: id,
|
||||||
|
});
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Post every matched document in the batch, against one check. */
|
||||||
|
@Post("batches/:id/confirm")
|
||||||
|
@RequireAbility("statement:review")
|
||||||
|
async confirm(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: ConfirmBatchDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const result = await this.statements.confirmBatch(id, dto, this.actingId(req));
|
||||||
|
void this.audit.log(this.actingId(req), "statement.batch.confirm", {
|
||||||
|
batchId: id,
|
||||||
|
posted: result.posted,
|
||||||
|
total: result.total,
|
||||||
|
checkNumber: dto.checkNumber,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { BillingModule } from "../billing/billing.module";
|
||||||
|
import { StatementsController } from "./statements.controller";
|
||||||
|
import { StatementsService } from "./statements.service";
|
||||||
|
import { StatementMatcherService } from "./statement-matcher.service";
|
||||||
|
import { OCR_PROVIDER } from "./ocr/ocr.provider";
|
||||||
|
import { TesseractOcrProvider } from "./ocr/tesseract.provider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The concrete OCR engine is bound here and nowhere else — everything
|
||||||
|
* downstream depends on the OcrProvider interface, so swapping Tesseract for a
|
||||||
|
* managed extraction API is a one-line change in this file.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [BillingModule],
|
||||||
|
controllers: [StatementsController],
|
||||||
|
providers: [
|
||||||
|
StatementsService,
|
||||||
|
StatementMatcherService,
|
||||||
|
{ provide: OCR_PROVIDER, useClass: TesseractOcrProvider },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class StatementsModule {}
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
Prisma,
|
||||||
|
type ServiceKind,
|
||||||
|
type StatementDocumentStatus,
|
||||||
|
} from "@jorgecuadros/database";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { StorageService } from "../storage/storage.service";
|
||||||
|
import { BillingService } from "../billing/billing.service";
|
||||||
|
import type { UploadedFileLike } from "../storage/upload-file";
|
||||||
|
import { OCR_PROVIDER, type OcrProvider } from "./ocr/ocr.provider";
|
||||||
|
import { parseStatement } from "./parsers/statement-parser";
|
||||||
|
import { StatementMatcherService } from "./statement-matcher.service";
|
||||||
|
import type { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default ledger concept per service kind. The names are the legacy
|
||||||
|
* `TYPE OF TRX` values already in `type_transactions`, resolved by name once
|
||||||
|
* per confirm rather than hard-coded as ids, which differ per environment.
|
||||||
|
*/
|
||||||
|
const CONCEPT_BY_KIND: Partial<Record<ServiceKind, string>> = {
|
||||||
|
ELECTRIC: "ELECTRIC",
|
||||||
|
WATER: "WATER",
|
||||||
|
TELEPHONE: "TELEPHONE",
|
||||||
|
GAS: "GAS BUTANO",
|
||||||
|
PROPERTY_TAX: "PROPERTY TAXES",
|
||||||
|
FEDERAL_ZONE: "FEDERAL ZONE",
|
||||||
|
CABLE: "CABLE",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Statuses a document can still be worked on from. */
|
||||||
|
const OPEN: StatementDocumentStatus[] = ["NEEDS_REVIEW", "MATCHED", "CONFIRMED"];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StatementsService {
|
||||||
|
private readonly logger = new Logger(StatementsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly storage: StorageService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
|
private readonly matcher: StatementMatcherService,
|
||||||
|
@Inject(OCR_PROVIDER) private readonly ocr: OcrProvider,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ocrAvailable(): Promise<boolean> {
|
||||||
|
return this.ocr.available();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scans are stored as blobs, so no object storage means no intake. */
|
||||||
|
storageAvailable(): boolean {
|
||||||
|
return this.storage.available;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ingest ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept a batch of scanned PDFs and start processing.
|
||||||
|
*
|
||||||
|
* Processing is kicked off but deliberately not awaited: 300 pages of OCR is
|
||||||
|
* minutes of CPU, far past any sane HTTP timeout. The caller gets the batch
|
||||||
|
* id immediately and polls its status, which is also what lets the review
|
||||||
|
* queue show partial progress.
|
||||||
|
*/
|
||||||
|
async createBatch(
|
||||||
|
files: UploadedFileLike[],
|
||||||
|
serviceKind: ServiceKind,
|
||||||
|
uploadedById: string,
|
||||||
|
label?: string,
|
||||||
|
) {
|
||||||
|
if (!files?.length) throw new BadRequestException("No se recibió ningún archivo.");
|
||||||
|
if (!(await this.ocr.available())) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"El servidor no tiene OCR instalado; no se pueden procesar recibos.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Checked here rather than at the first `put`, which would only surface as
|
||||||
|
// a FAILED batch minutes later.
|
||||||
|
if (!this.storage.available) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"El almacenamiento de documentos no está configurado; no se pueden " +
|
||||||
|
"guardar los recibos escaneados.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const batch = await this.prisma.statementBatch.create({
|
||||||
|
data: { serviceKind, uploadedById, label, fileCount: files.length },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Buffers are held for the background pass; the request's own copies would
|
||||||
|
// otherwise be garbage once the response is sent.
|
||||||
|
const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname }));
|
||||||
|
void this.process(batch.id, copies, serviceKind).catch(async (err) => {
|
||||||
|
this.logger.error(`Batch ${batch.id} failed: ${(err as Error).message}`);
|
||||||
|
await this.prisma.statementBatch.update({
|
||||||
|
where: { id: batch.id },
|
||||||
|
data: { status: "FAILED", error: (err as Error).message },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render → OCR → parse → match, one document row per page. */
|
||||||
|
private async process(
|
||||||
|
batchId: string,
|
||||||
|
files: { buffer: Buffer; name?: string }[],
|
||||||
|
serviceKind: ServiceKind,
|
||||||
|
) {
|
||||||
|
await this.prisma.statementBatch.update({
|
||||||
|
where: { id: batchId },
|
||||||
|
data: { status: "PROCESSING" },
|
||||||
|
});
|
||||||
|
|
||||||
|
let pageNumber = 0;
|
||||||
|
for (const file of files) {
|
||||||
|
// The source PDF is kept as well as the page images: it is the artifact
|
||||||
|
// the office actually received, and the only way to re-run a corrected
|
||||||
|
// parser over the original later.
|
||||||
|
const sourceKey = `statement/${batchId}/source-${pageNumber + 1}.pdf`;
|
||||||
|
await this.storage.put(sourceKey, file.buffer, "application/pdf");
|
||||||
|
|
||||||
|
const pages = await this.ocr.renderPages(file.buffer);
|
||||||
|
for (const image of pages) {
|
||||||
|
pageNumber += 1;
|
||||||
|
const storageKey = `statement/${batchId}/page-${pageNumber}.png`;
|
||||||
|
await this.storage.put(storageKey, image, "image/png");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ocr = await this.ocr.recognize(image);
|
||||||
|
const parsed = parseStatement(ocr);
|
||||||
|
const match = await this.matcher.match(parsed, serviceKind);
|
||||||
|
|
||||||
|
const notes = [...parsed.notes, match.note].filter(Boolean);
|
||||||
|
// A confident field match is only trusted when nothing contradicts
|
||||||
|
// it: a barcode that disagrees with the printed number means one of
|
||||||
|
// the two was misread, and which one is a judgement call.
|
||||||
|
const trusted = match.confident && parsed.crossChecked !== false;
|
||||||
|
|
||||||
|
await this.prisma.statementDocument.create({
|
||||||
|
data: {
|
||||||
|
batchId,
|
||||||
|
pageNumber,
|
||||||
|
storageKey,
|
||||||
|
status: trusted ? "MATCHED" : "NEEDS_REVIEW",
|
||||||
|
ocrRawText: ocr.text,
|
||||||
|
ocrConfidence: new Prisma.Decimal(ocr.confidence.toFixed(3)),
|
||||||
|
provider: parsed.provider,
|
||||||
|
extractedAccountRef: parsed.accountRef,
|
||||||
|
extractedAmount:
|
||||||
|
parsed.amount != null ? new Prisma.Decimal(parsed.amount) : null,
|
||||||
|
extractedPeriod: parsed.period,
|
||||||
|
extractedDueDate: parsed.dueDate,
|
||||||
|
extractedCadastralKey: parsed.cadastralKey,
|
||||||
|
matchedPropertyServiceId: match.propertyServiceId,
|
||||||
|
matchedCustomerId: match.customerId,
|
||||||
|
matchNote: notes.join("; ").slice(0, 190),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// One unreadable page must not abandon the other 299.
|
||||||
|
await this.prisma.statementDocument.create({
|
||||||
|
data: {
|
||||||
|
batchId,
|
||||||
|
pageNumber,
|
||||||
|
storageKey,
|
||||||
|
status: "OCR_FAILED",
|
||||||
|
matchNote: (err as Error).message.slice(0, 190),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.statementBatch.update({
|
||||||
|
where: { id: batchId },
|
||||||
|
data: { status: "READY_FOR_REVIEW" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- reads ----------------------------------------------------------------
|
||||||
|
|
||||||
|
async listBatches(page: number, pageSize: number) {
|
||||||
|
const [total, items] = await this.prisma.$transaction([
|
||||||
|
this.prisma.statementBatch.count(),
|
||||||
|
this.prisma.statementBatch.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
include: {
|
||||||
|
uploadedBy: { select: { name: true } },
|
||||||
|
_count: { select: { documents: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBatch(id: string) {
|
||||||
|
const batch = await this.prisma.statementBatch.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { uploadedBy: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
if (!batch) throw new NotFoundException("Lote no encontrado.");
|
||||||
|
|
||||||
|
const counts = await this.prisma.statementDocument.groupBy({
|
||||||
|
by: ["status"],
|
||||||
|
where: { batchId: id },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
const totals = await this.prisma.statementDocument.aggregate({
|
||||||
|
where: { batchId: id, status: { in: OPEN } },
|
||||||
|
_sum: { extractedAmount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...batch,
|
||||||
|
byStatus: Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
|
||||||
|
pendingTotal: totals._sum.extractedAmount?.toFixed(2) ?? "0.00",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDocuments(batchId: string, status?: StatementDocumentStatus) {
|
||||||
|
return this.prisma.statementDocument.findMany({
|
||||||
|
where: { batchId, ...(status ? { status } : {}) },
|
||||||
|
orderBy: { pageNumber: "asc" },
|
||||||
|
include: {
|
||||||
|
matchedCustomer: { select: { id: true, name: true } },
|
||||||
|
matchedPropertyService: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
kind: true,
|
||||||
|
accountNumber: true,
|
||||||
|
meterNumber: true,
|
||||||
|
property: { select: { id: true, addressLine1: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rendered page image, so a reviewer can read what the parser read. */
|
||||||
|
async pageImage(documentId: string) {
|
||||||
|
const doc = await this.prisma.statementDocument.findUnique({
|
||||||
|
where: { id: documentId },
|
||||||
|
select: { storageKey: true },
|
||||||
|
});
|
||||||
|
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
||||||
|
return this.storage.getStream(doc.storageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- review ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Staff correction of an extracted field or of the match itself. */
|
||||||
|
async review(id: string, dto: ReviewDocumentDto, reviewedById: string) {
|
||||||
|
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
|
||||||
|
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
||||||
|
if (doc.status === "POSTED") {
|
||||||
|
throw new BadRequestException("Este documento ya fue registrado.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changing the service implies its owner; deriving the customer here rather
|
||||||
|
// than trusting a client-supplied pair is what stops a page being posted to
|
||||||
|
// one customer's ledger against another customer's service.
|
||||||
|
let matchedCustomerId = doc.matchedCustomerId;
|
||||||
|
let matchedPropertyServiceId = dto.matchedPropertyServiceId ?? undefined;
|
||||||
|
|
||||||
|
if (dto.matchedPropertyServiceId) {
|
||||||
|
const svc = await this.prisma.propertyService.findUnique({
|
||||||
|
where: { id: dto.matchedPropertyServiceId },
|
||||||
|
select: { property: { select: { customerId: true } } },
|
||||||
|
});
|
||||||
|
if (!svc) throw new BadRequestException("Servicio no encontrado.");
|
||||||
|
matchedCustomerId = svc.property.customerId;
|
||||||
|
} else if (dto.matchedCustomerId) {
|
||||||
|
matchedCustomerId = dto.matchedCustomerId;
|
||||||
|
|
||||||
|
// A reviewer picks a *customer*, not one of their service rows. Without
|
||||||
|
// a service the posting still works, but the confirmed reference has
|
||||||
|
// nowhere to be written back, so the same account would land in review
|
||||||
|
// again next month — which is exactly the behaviour that is supposed to
|
||||||
|
// make gas (whose numbers the migration never populated) a one-time cost.
|
||||||
|
// So: if the batch's service kind resolves to exactly one of that
|
||||||
|
// customer's services that has no reference yet, attach it. Exactly one
|
||||||
|
// — with two candidates there is no way to tell which meter or line the
|
||||||
|
// bill belongs to, and guessing would write a real number onto the wrong
|
||||||
|
// service.
|
||||||
|
const batch = await this.prisma.statementBatch.findUnique({
|
||||||
|
where: { id: doc.batchId },
|
||||||
|
select: { serviceKind: true },
|
||||||
|
});
|
||||||
|
if (batch) {
|
||||||
|
const field = batch.serviceKind === "GAS" ? "meterNumber" : "accountNumber";
|
||||||
|
const blank = await this.prisma.propertyService.findMany({
|
||||||
|
where: {
|
||||||
|
kind: batch.serviceKind,
|
||||||
|
[field]: null,
|
||||||
|
property: { customerId: matchedCustomerId },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
take: 2,
|
||||||
|
});
|
||||||
|
if (blank.length === 1) matchedPropertyServiceId = blank[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.statementDocument.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
extractedAccountRef: dto.accountRef ?? undefined,
|
||||||
|
extractedAmount:
|
||||||
|
dto.amount != null ? new Prisma.Decimal(dto.amount) : undefined,
|
||||||
|
extractedPeriod: dto.period ?? undefined,
|
||||||
|
extractedDueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
|
||||||
|
matchedPropertyServiceId,
|
||||||
|
matchedCustomerId,
|
||||||
|
status: dto.status ?? "MATCHED",
|
||||||
|
reviewedById,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async reject(id: string, reviewedById: string) {
|
||||||
|
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
|
||||||
|
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
||||||
|
if (doc.status === "POSTED") {
|
||||||
|
throw new BadRequestException("Este documento ya fue registrado.");
|
||||||
|
}
|
||||||
|
return this.prisma.statementDocument.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- posting --------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post every confirmable document in a batch to the ledger.
|
||||||
|
*
|
||||||
|
* This goes through `BillingService.createBatch` — the same method the manual
|
||||||
|
* "Editor" screen uses — rather than writing `Transaction` rows directly, so
|
||||||
|
* OCR-sourced and hand-keyed receipts share one write path, one validation
|
||||||
|
* path and one audit trail. `source: "OCR"` and a per-line `captureRef` of
|
||||||
|
* the document id give the duplicate-post guard something to key on, so a
|
||||||
|
* batch confirmed twice cannot double-charge anyone.
|
||||||
|
*/
|
||||||
|
async confirmBatch(batchId: string, dto: ConfirmBatchDto, reviewedById: string) {
|
||||||
|
const batch = await this.prisma.statementBatch.findUnique({
|
||||||
|
where: { id: batchId },
|
||||||
|
});
|
||||||
|
if (!batch) throw new NotFoundException("Lote no encontrado.");
|
||||||
|
|
||||||
|
const docs = await this.prisma.statementDocument.findMany({
|
||||||
|
where: {
|
||||||
|
batchId,
|
||||||
|
status: { in: dto.includeReviewed ? ["MATCHED", "CONFIRMED"] : ["MATCHED"] },
|
||||||
|
matchedCustomerId: { not: null },
|
||||||
|
},
|
||||||
|
orderBy: { pageNumber: "asc" },
|
||||||
|
});
|
||||||
|
if (!docs.length) {
|
||||||
|
throw new BadRequestException("No hay documentos listos para registrar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = docs.filter((d) => d.extractedAmount == null);
|
||||||
|
if (missing.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Falta el importe en ${missing.length} documento(s): página(s) ` +
|
||||||
|
missing.map((d) => d.pageNumber).join(", "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeId = dto.typeId ?? (await this.conceptFor(batch.serviceKind));
|
||||||
|
|
||||||
|
const result = await this.billing.createBatch(
|
||||||
|
{
|
||||||
|
domain: "UTILITY",
|
||||||
|
transactionDate: dto.transactionDate,
|
||||||
|
checkNumber: dto.checkNumber,
|
||||||
|
currency: dto.currency ?? "MXN",
|
||||||
|
typeId,
|
||||||
|
lines: docs.map((d) => ({
|
||||||
|
customerId: d.matchedCustomerId!,
|
||||||
|
// Charges are negative in this ledger: a negative amount is what the
|
||||||
|
// customer owes. The parser reads the printed (positive) figure, so
|
||||||
|
// the sign is applied here, at the single point where a statement
|
||||||
|
// becomes a ledger row.
|
||||||
|
amount: -Math.abs(Number(d.extractedAmount)),
|
||||||
|
reference: d.extractedAccountRef ?? undefined,
|
||||||
|
period: d.extractedPeriod ?? undefined,
|
||||||
|
outstanding: dto.outstanding ?? false,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ source: "OCR", refs: docs.map((d) => d.id) },
|
||||||
|
);
|
||||||
|
|
||||||
|
// `items[i]` is positionally parallel to `lines[i]` (seam guarantee 1), so
|
||||||
|
// the created rows zip straight back onto the documents that produced them.
|
||||||
|
await this.prisma.$transaction(
|
||||||
|
docs.map((d, i) =>
|
||||||
|
this.prisma.statementDocument.update({
|
||||||
|
where: { id: d.id },
|
||||||
|
data: {
|
||||||
|
status: "POSTED",
|
||||||
|
postedTransactionId: result.items[i].id,
|
||||||
|
reviewedById,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Teach the matcher. When a document was matched by clave catastral or by
|
||||||
|
// hand because the scoped field was blank, writing the reference back means
|
||||||
|
// next month's statement for the same account matches on its own — this is
|
||||||
|
// what turns gas (whose numbers the migration never populated) from a
|
||||||
|
// permanent review queue into a one-time cost.
|
||||||
|
await this.learnAccountRefs(docs, batch.serviceKind);
|
||||||
|
|
||||||
|
await this.closeIfDone(batchId);
|
||||||
|
|
||||||
|
return { posted: result.count, total: result.total, checkNumber: dto.checkNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write a confirmed reference onto a service that had none. */
|
||||||
|
private async learnAccountRefs(
|
||||||
|
docs: { matchedPropertyServiceId: string | null; extractedAccountRef: string | null }[],
|
||||||
|
kind: ServiceKind,
|
||||||
|
) {
|
||||||
|
const field = kind === "GAS" ? "meterNumber" : "accountNumber";
|
||||||
|
for (const d of docs) {
|
||||||
|
if (!d.matchedPropertyServiceId || !d.extractedAccountRef) continue;
|
||||||
|
await this.prisma.propertyService.updateMany({
|
||||||
|
// Only fills a hole — never overwrites a number already on file, which
|
||||||
|
// would let one misread page rewrite good reference data.
|
||||||
|
where: { id: d.matchedPropertyServiceId, [field]: null },
|
||||||
|
data: { [field]: d.extractedAccountRef },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async closeIfDone(batchId: string) {
|
||||||
|
const open = await this.prisma.statementDocument.count({
|
||||||
|
where: { batchId, status: { in: OPEN } },
|
||||||
|
});
|
||||||
|
if (open === 0) {
|
||||||
|
await this.prisma.statementBatch.update({
|
||||||
|
where: { id: batchId },
|
||||||
|
data: { status: "COMPLETED", completedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async conceptFor(kind: ServiceKind): Promise<string | undefined> {
|
||||||
|
const name = CONCEPT_BY_KIND[kind];
|
||||||
|
if (!name) return undefined;
|
||||||
|
const row = await this.prisma.typeTransaction.findFirst({
|
||||||
|
where: { nameEn: name },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return row?.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Global, Module } from "@nestjs/common";
|
||||||
|
import { StorageService } from "./storage.service";
|
||||||
|
|
||||||
|
/** Global so any feature module can inject StorageService without re-importing. */
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [StorageService],
|
||||||
|
exports: [StorageService],
|
||||||
|
})
|
||||||
|
export class StorageModule {}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import {
|
||||||
|
CreateBucketCommand,
|
||||||
|
DeleteObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
HeadBucketCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
S3Client,
|
||||||
|
} from "@aws-sdk/client-s3";
|
||||||
|
import type { Readable } from "node:stream";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3 / MinIO object storage for document blobs. MySQL keeps only the pointer
|
||||||
|
* (`storageKey`) + metadata; the bytes live here. Same bucket the migration's
|
||||||
|
* `blob_extract.py` writes to, so keys stay under the `service/…` and
|
||||||
|
* `policy/…` prefixes it established.
|
||||||
|
*
|
||||||
|
* Env (see deploy/.env.dev): S3_ENDPOINT, S3_BUCKET, and creds — S3_ACCESS_KEY
|
||||||
|
* / S3_SECRET_KEY, falling back to MINIO_ROOT_USER / MINIO_ROOT_PASSWORD so a
|
||||||
|
* single MinIO credential set drives both the migration and the API.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class StorageService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(StorageService.name);
|
||||||
|
private readonly client: S3Client | null;
|
||||||
|
readonly bucket: string;
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
const endpoint = config.get<string>("S3_ENDPOINT");
|
||||||
|
this.bucket = config.get<string>("S3_BUCKET") ?? "jorgecuadros-documents";
|
||||||
|
const accessKeyId =
|
||||||
|
config.get<string>("S3_ACCESS_KEY") ?? config.get<string>("MINIO_ROOT_USER");
|
||||||
|
const secretAccessKey =
|
||||||
|
config.get<string>("S3_SECRET_KEY") ?? config.get<string>("MINIO_ROOT_PASSWORD");
|
||||||
|
|
||||||
|
if (!endpoint || !accessKeyId || !secretAccessKey) {
|
||||||
|
this.logger.warn(
|
||||||
|
"Object storage not configured (missing S3_ENDPOINT / credentials); " +
|
||||||
|
"document upload & download are disabled.",
|
||||||
|
);
|
||||||
|
this.client = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client = new S3Client({
|
||||||
|
endpoint,
|
||||||
|
region: config.get<string>("S3_REGION") ?? "us-east-1",
|
||||||
|
credentials: { accessKeyId, secretAccessKey },
|
||||||
|
forcePathStyle: true, // MinIO needs path-style addressing
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort bucket check on boot; never blocks API startup. */
|
||||||
|
async onModuleInit() {
|
||||||
|
if (!this.client) return;
|
||||||
|
try {
|
||||||
|
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await this.client.send(new CreateBucketCommand({ Bucket: this.bucket }));
|
||||||
|
this.logger.log(`Created bucket "${this.bucket}".`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Could not verify/create bucket "${this.bucket}": ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the deployment has object storage at all. Callers use this to
|
||||||
|
* refuse work up front instead of failing halfway through — a recibo batch
|
||||||
|
* that dies on its first `put` leaves a FAILED batch and no explanation the
|
||||||
|
* office can act on.
|
||||||
|
*/
|
||||||
|
get available(): boolean {
|
||||||
|
return this.client !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private require(): S3Client {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
"El almacenamiento de documentos no está configurado.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(key: string, body: Buffer, contentType?: string): Promise<void> {
|
||||||
|
await this.require().send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key,
|
||||||
|
Body: body,
|
||||||
|
ContentType: contentType,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStream(key: string): Promise<{
|
||||||
|
stream: Readable;
|
||||||
|
contentType?: string;
|
||||||
|
contentLength?: number;
|
||||||
|
}> {
|
||||||
|
const out = await this.require().send(
|
||||||
|
new GetObjectCommand({ Bucket: this.bucket, Key: key }),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
stream: out.Body as Readable,
|
||||||
|
contentType: out.ContentType,
|
||||||
|
contentLength: out.ContentLength,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort blob delete; a missing object is not an error. */
|
||||||
|
async delete(key: string): Promise<void> {
|
||||||
|
if (!this.client) return;
|
||||||
|
try {
|
||||||
|
await this.client.send(
|
||||||
|
new DeleteObjectCommand({ Bucket: this.bucket, Key: key }),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to delete blob "${key}": ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { extname } from "node:path";
|
||||||
|
|
||||||
|
/** Multer file shape we rely on (subset of Express.Multer.File). */
|
||||||
|
export interface UploadedFileLike {
|
||||||
|
buffer: Buffer;
|
||||||
|
originalname?: string;
|
||||||
|
mimetype?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME_EXT: Record<string, string> = {
|
||||||
|
"application/pdf": ".pdf",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/gif": ".gif",
|
||||||
|
"image/tiff": ".tif",
|
||||||
|
"image/bmp": ".bmp",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** File extension for a stored blob, from the original name, else the mimetype. */
|
||||||
|
export function extForUpload(file: UploadedFileLike): string {
|
||||||
|
const fromName = file.originalname ? extname(file.originalname).toLowerCase() : "";
|
||||||
|
if (fromName) return fromName;
|
||||||
|
return (file.mimetype && MIME_EXT[file.mimetype]) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download filename for a stored document, from its key + document type. */
|
||||||
|
export function downloadName(storageKey: string, documentType: string): string {
|
||||||
|
const ext = extname(storageKey) || "";
|
||||||
|
const base = documentType.replace(/[^\w.-]+/g, "_") || "document";
|
||||||
|
return base.toLowerCase().endsWith(ext.toLowerCase()) ? base : `${base}${ext}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { IsEmail, IsEnum, IsOptional, IsString, MinLength } from "class-validator";
|
||||||
|
import { UserRole } from "@jorgecuadros/database";
|
||||||
|
|
||||||
|
export class CreateUserDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@IsEnum(UserRole)
|
||||||
|
role!: UserRole;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class ResetPasswordDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { IsBoolean, IsEmail, IsEnum, IsOptional, IsString, MinLength } from "class-validator";
|
||||||
|
import { UserRole } from "@jorgecuadros/database";
|
||||||
|
|
||||||
|
/** Password changes go through the dedicated reset-password route, not here. */
|
||||||
|
export class UpdateUserDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(UserRole)
|
||||||
|
role?: UserRole;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Request } from "express";
|
||||||
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||||
|
import { AbilityGuard } from "../auth/ability.guard";
|
||||||
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
|
import { AuditService } from "../common/audit.service";
|
||||||
|
import { UsersService } from "./users.service";
|
||||||
|
import { CreateUserDto } from "./create-user.dto";
|
||||||
|
import { UpdateUserDto } from "./update-user.dto";
|
||||||
|
import { ResetPasswordDto } from "./reset-password.dto";
|
||||||
|
|
||||||
|
/** Every route here is ADMIN-only (ability "user:manage"). */
|
||||||
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
|
@RequireAbility("user:manage")
|
||||||
|
@Controller("users")
|
||||||
|
export class UsersController {
|
||||||
|
constructor(
|
||||||
|
private readonly users: UsersService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private actingId(req: Request): string {
|
||||||
|
return (req.user as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.users.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(@Body() dto: CreateUserDto, @Req() req: Request) {
|
||||||
|
const user = await this.users.create(dto);
|
||||||
|
void this.audit.log(this.actingId(req), "user.create", {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
});
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
async update(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: UpdateUserDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const user = await this.users.update(id, dto, this.actingId(req));
|
||||||
|
void this.audit.log(this.actingId(req), "user.update", { userId: id, changes: dto });
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/reset-password")
|
||||||
|
async resetPassword(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: ResetPasswordDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const user = await this.users.resetPassword(id, dto.password);
|
||||||
|
void this.audit.log(this.actingId(req), "user.reset_password", { userId: id });
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
@HttpCode(204)
|
||||||
|
async remove(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const actingId = this.actingId(req);
|
||||||
|
await this.users.remove(id, actingId);
|
||||||
|
void this.audit.log(actingId, "user.delete", { userId: id });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { UsersService } from "./users.service";
|
import { UsersService } from "./users.service";
|
||||||
|
import { UsersController } from "./users.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [UsersService],
|
providers: [UsersService],
|
||||||
|
controllers: [UsersController],
|
||||||
exports: [UsersService],
|
exports: [UsersService],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
|||||||
@@ -1,11 +1,36 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import {
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import * as argon2 from "argon2";
|
||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
import type { User } from "@jorgecuadros/database";
|
import type { User } from "@jorgecuadros/database";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { CreateUserDto } from "./create-user.dto";
|
||||||
|
import { UpdateUserDto } from "./update-user.dto";
|
||||||
|
|
||||||
|
/** Shape returned to the UI — never carries passwordHash. */
|
||||||
|
const safeSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
active: true,
|
||||||
|
uiScale: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
} satisfies Prisma.UserSelect;
|
||||||
|
|
||||||
|
export type SafeUserRow = Prisma.UserGetPayload<{ select: typeof safeSelect }>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// --- used by auth (need the hash / full row) -----------------------------
|
||||||
|
|
||||||
findByEmail(email: string): Promise<User | null> {
|
findByEmail(email: string): Promise<User | null> {
|
||||||
return this.prisma.user.findUnique({ where: { email } });
|
return this.prisma.user.findUnique({ where: { email } });
|
||||||
}
|
}
|
||||||
@@ -13,4 +38,125 @@ export class UsersService {
|
|||||||
findById(id: string): Promise<User | null> {
|
findById(id: string): Promise<User | null> {
|
||||||
return this.prisma.user.findUnique({ where: { id } });
|
return this.prisma.user.findUnique({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- admin CRUD (safe rows only) -----------------------------------------
|
||||||
|
|
||||||
|
list(): Promise<SafeUserRow[]> {
|
||||||
|
return this.prisma.user.findMany({
|
||||||
|
orderBy: [{ active: "desc" }, { name: "asc" }],
|
||||||
|
select: safeSelect,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateUserDto): Promise<SafeUserRow> {
|
||||||
|
const passwordHash = await argon2.hash(dto.password);
|
||||||
|
try {
|
||||||
|
return await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
email: dto.email,
|
||||||
|
passwordHash,
|
||||||
|
role: dto.role,
|
||||||
|
active: dto.active ?? true,
|
||||||
|
},
|
||||||
|
select: safeSelect,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw this.mapError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `actingUserId` is the admin making the change — used to stop an admin from
|
||||||
|
* locking themselves out (deactivating or demoting their own account).
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateUserDto,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<SafeUserRow> {
|
||||||
|
await this.ensureExists(id);
|
||||||
|
|
||||||
|
if (id === actingUserId) {
|
||||||
|
if (dto.active === false) {
|
||||||
|
throw new BadRequestException("No puede desactivar su propia cuenta");
|
||||||
|
}
|
||||||
|
if (dto.role && dto.role !== "ADMIN") {
|
||||||
|
throw new BadRequestException("No puede quitarse su propio rol de administrador");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
email: dto.email,
|
||||||
|
role: dto.role,
|
||||||
|
active: dto.active,
|
||||||
|
},
|
||||||
|
select: safeSelect,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw this.mapError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-service preference write — no ability check, because the only account
|
||||||
|
* it can touch is the caller's own (the controller passes the session id).
|
||||||
|
*/
|
||||||
|
updatePreferences(id: string, uiScale: number): Promise<SafeUserRow> {
|
||||||
|
return this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { uiScale },
|
||||||
|
select: safeSelect,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPassword(id: string, password: string): Promise<SafeUserRow> {
|
||||||
|
await this.ensureExists(id);
|
||||||
|
const passwordHash = await argon2.hash(password);
|
||||||
|
return this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { passwordHash },
|
||||||
|
select: safeSelect,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard-delete a user. The schema's ActivityLog.userId FK would otherwise
|
||||||
|
* block the row (default `Restrict`), so null it out in the same
|
||||||
|
* transaction. Rows + the actor id captured in the `message` JSON stay
|
||||||
|
* intact for the audit trail.
|
||||||
|
*/
|
||||||
|
async remove(id: string, actingUserId: string): Promise<void> {
|
||||||
|
if (id === actingUserId) {
|
||||||
|
throw new BadRequestException("No puede eliminar su propia cuenta");
|
||||||
|
}
|
||||||
|
await this.ensureExists(id);
|
||||||
|
try {
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.activityLog.updateMany({
|
||||||
|
where: { userId: id },
|
||||||
|
data: { userId: null },
|
||||||
|
}),
|
||||||
|
this.prisma.user.delete({ where: { id } }),
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
throw this.mapError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureExists(id: string): Promise<void> {
|
||||||
|
const found = await this.prisma.user.findUnique({ where: { id }, select: { id: true } });
|
||||||
|
if (!found) throw new NotFoundException(`Usuario ${id} no encontrado`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapError(e: unknown): Error {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||||
|
return new ConflictException("Ya existe un usuario con ese correo");
|
||||||
|
}
|
||||||
|
return e as Error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/web",
|
"name": "@jorgecuadros/web",
|
||||||
"version": "0.1.0",
|
"version": "1.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev -p 4500",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
@@ -0,0 +1,525 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import {
|
||||||
|
createBankAccount,
|
||||||
|
createBankInstitution,
|
||||||
|
listBankAccounts,
|
||||||
|
listBankInstitutions,
|
||||||
|
updateBankAccount,
|
||||||
|
updateBankInstitution,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { domainLabel } from "@/lib/labels";
|
||||||
|
import type {
|
||||||
|
BankAccount,
|
||||||
|
BankInstitution,
|
||||||
|
Currency,
|
||||||
|
TransactionDomain,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chequera accounts admin — docs/RECEIPT_CAPTURE_SPEC.md §3.
|
||||||
|
*
|
||||||
|
* Two levels: the bank (institution) and the accounts held at it. Opening an
|
||||||
|
* account is rare and consequential — its currency is what every movement
|
||||||
|
* booked into it is denominated in, and it can't be changed afterwards without
|
||||||
|
* silently re-denominating history, so the edit form deliberately has no
|
||||||
|
* currency field.
|
||||||
|
*
|
||||||
|
* Accounts are never deleted: `bank_transactions.bankAccountId` is a required
|
||||||
|
* FK, so a used account can't be removed without destroying its register.
|
||||||
|
* Closing one (`active: false`) hides it from new captures while leaving the
|
||||||
|
* history readable, matching this app's never-hard-delete convention.
|
||||||
|
*/
|
||||||
|
const CURRENCIES: Currency[] = ["MXN", "USD"];
|
||||||
|
const BUSINESS_LINES: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"];
|
||||||
|
|
||||||
|
export default function CuentasChequeraPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<Cuentas />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Cuentas() {
|
||||||
|
const canEdit = useCan("bank:manage-accounts");
|
||||||
|
const [banks, setBanks] = useState<BankInstitution[] | null>(null);
|
||||||
|
const [accounts, setAccounts] = useState<BankAccount[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
Promise.all([listBankInstitutions(), listBankAccounts()])
|
||||||
|
.then(([b, a]) => {
|
||||||
|
setBanks(b);
|
||||||
|
setAccounts(a);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e?.message ?? "No se pudieron cargar las cuentas."));
|
||||||
|
}
|
||||||
|
useEffect(reload, []);
|
||||||
|
|
||||||
|
if (!canEdit) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<h1 className="page-title">Cuentas de chequera</h1>
|
||||||
|
</div>
|
||||||
|
<div className="state-box state-error">
|
||||||
|
No tiene permisos para administrar cuentas bancarias.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<p className="eyebrow">
|
||||||
|
<Link href="/banco">Chequera</Link>
|
||||||
|
</p>
|
||||||
|
<h1 className="page-title">Cuentas de chequera</h1>
|
||||||
|
<p className="section-note">
|
||||||
|
Cada cuenta es una chequera física y se lleva por separado. La moneda
|
||||||
|
se fija al darla de alta porque todos sus movimientos quedan
|
||||||
|
registrados en ella; para cambiarla hay que abrir otra cuenta. Las
|
||||||
|
cuentas no se eliminan: se cierran, y su historial sigue consultable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
|
|
||||||
|
{!banks || !accounts ? (
|
||||||
|
<div className="empty-inline">
|
||||||
|
<span className="spinner" aria-label="Cargando" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<BanksSection banks={banks} onChanged={reload} />
|
||||||
|
<AccountsSection
|
||||||
|
banks={banks}
|
||||||
|
accounts={accounts}
|
||||||
|
onChanged={reload}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ banks */
|
||||||
|
|
||||||
|
function BanksSection({
|
||||||
|
banks,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
banks: BankInstitution[];
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [country, setCountry] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
function startAdd() {
|
||||||
|
setEditingId(null);
|
||||||
|
setAdding(true);
|
||||||
|
setName("");
|
||||||
|
setCountry("");
|
||||||
|
}
|
||||||
|
function startEdit(b: BankInstitution) {
|
||||||
|
setAdding(false);
|
||||||
|
setEditingId(b.id);
|
||||||
|
setName(b.name);
|
||||||
|
setCountry(b.country ?? "");
|
||||||
|
}
|
||||||
|
function cancel() {
|
||||||
|
setAdding(false);
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!name.trim()) {
|
||||||
|
window.alert("El nombre del banco es obligatorio.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const payload = { name: name.trim(), country: country.trim() };
|
||||||
|
if (editingId) await updateBankInstitution(editingId, payload);
|
||||||
|
else await createBankInstitution(payload);
|
||||||
|
cancel();
|
||||||
|
onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
window.alert((e as Error)?.message ?? "No se pudo guardar el banco.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = (
|
||||||
|
<div className="child-editor">
|
||||||
|
<div className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">
|
||||||
|
Banco <span aria-hidden>*</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Ej. Scotiabank"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">País</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={country}
|
||||||
|
onChange={(e) => setCountry(e.target.value)}
|
||||||
|
placeholder="MX / US"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? "Guardando…" : editingId ? "Guardar" : "Agregar"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={cancel}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||||
|
<div className="child-head">
|
||||||
|
<h3 className="section-title" style={{ margin: 0 }}>
|
||||||
|
Bancos
|
||||||
|
<span className="section-count"> {banks.length}</span>
|
||||||
|
</h3>
|
||||||
|
{!adding && editingId === null && (
|
||||||
|
<button type="button" className="btn btn-outline" onClick={startAdd}>
|
||||||
|
+ Agregar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{banks.length === 0 && !adding ? (
|
||||||
|
<div className="empty-inline">Sin bancos registrados.</div>
|
||||||
|
) : (
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Banco</th>
|
||||||
|
<th>País</th>
|
||||||
|
<th className="num">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{adding && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3}>{editor}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{banks.map((b) =>
|
||||||
|
editingId === b.id ? (
|
||||||
|
<tr key={b.id}>
|
||||||
|
<td colSpan={3}>{editor}</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
<tr key={b.id}>
|
||||||
|
<td>{b.name}</td>
|
||||||
|
<td>{b.country || "—"}</td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => startEdit(b)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- accounts */
|
||||||
|
|
||||||
|
function AccountsSection({
|
||||||
|
banks,
|
||||||
|
accounts,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
banks: BankInstitution[];
|
||||||
|
accounts: BankAccount[];
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [bankId, setBankId] = useState("");
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [currency, setCurrency] = useState<Currency>("MXN");
|
||||||
|
const [businessLine, setBusinessLine] = useState<string>("");
|
||||||
|
const [active, setActive] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
function startAdd() {
|
||||||
|
setEditingId(null);
|
||||||
|
setAdding(true);
|
||||||
|
setBankId(banks[0]?.id ?? "");
|
||||||
|
setLabel("");
|
||||||
|
setCurrency("MXN");
|
||||||
|
setBusinessLine("");
|
||||||
|
setActive(true);
|
||||||
|
}
|
||||||
|
function startEdit(a: BankAccount) {
|
||||||
|
setAdding(false);
|
||||||
|
setEditingId(a.id);
|
||||||
|
setBankId(a.bankId);
|
||||||
|
setLabel(a.label);
|
||||||
|
setCurrency(a.currency);
|
||||||
|
setBusinessLine(a.businessLine ?? "");
|
||||||
|
setActive(a.active);
|
||||||
|
}
|
||||||
|
function cancel() {
|
||||||
|
setAdding(false);
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!bankId) {
|
||||||
|
window.alert("Selecciona el banco de la cuenta.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!label.trim()) {
|
||||||
|
window.alert("El nombre de la cuenta es obligatorio.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const line = businessLine
|
||||||
|
? (businessLine as TransactionDomain)
|
||||||
|
: undefined;
|
||||||
|
if (editingId) {
|
||||||
|
// No `currency`: see the file header.
|
||||||
|
await updateBankAccount(editingId, {
|
||||||
|
bankId,
|
||||||
|
label: label.trim(),
|
||||||
|
businessLine: line,
|
||||||
|
active,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await createBankAccount({
|
||||||
|
bankId,
|
||||||
|
label: label.trim(),
|
||||||
|
currency,
|
||||||
|
businessLine: line,
|
||||||
|
active,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cancel();
|
||||||
|
onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
window.alert((e as Error)?.message ?? "No se pudo guardar la cuenta.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = (
|
||||||
|
<div className="child-editor">
|
||||||
|
<div className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">
|
||||||
|
Banco <span aria-hidden>*</span>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={bankId}
|
||||||
|
onChange={(e) => setBankId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{banks.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">
|
||||||
|
Nombre de la cuenta <span aria-hidden>*</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
placeholder="Ej. Seguros — Bank of America (USD)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">
|
||||||
|
Moneda <span aria-hidden>*</span>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={currency}
|
||||||
|
disabled={editingId !== null}
|
||||||
|
onChange={(e) => setCurrency(e.target.value as Currency)}
|
||||||
|
>
|
||||||
|
{CURRENCIES.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{editingId !== null && (
|
||||||
|
<span className="section-note">
|
||||||
|
No se puede cambiar: los movimientos ya registrados están en esta
|
||||||
|
moneda.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Línea de negocio</span>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={businessLine}
|
||||||
|
onChange={(e) => setBusinessLine(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Sin asignar</option>
|
||||||
|
{BUSINESS_LINES.map((d) => (
|
||||||
|
<option key={d} value={d}>
|
||||||
|
{domainLabel(d)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="section-note">
|
||||||
|
Referencia nada más: una chequera puede pagar de varias líneas.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className="field"
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={active}
|
||||||
|
onChange={(e) => setActive(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="field-label" style={{ margin: 0 }}>
|
||||||
|
Cuenta abierta
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? "Guardando…" : editingId ? "Guardar" : "Agregar"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={cancel}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||||
|
<div className="child-head">
|
||||||
|
<h3 className="section-title" style={{ margin: 0 }}>
|
||||||
|
Cuentas
|
||||||
|
<span className="section-count"> {accounts.length}</span>
|
||||||
|
</h3>
|
||||||
|
{!adding && editingId === null && banks.length > 0 && (
|
||||||
|
<button type="button" className="btn btn-outline" onClick={startAdd}>
|
||||||
|
+ Agregar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{banks.length === 0 ? (
|
||||||
|
<div className="empty-inline">
|
||||||
|
Registra primero el banco donde está la cuenta.
|
||||||
|
</div>
|
||||||
|
) : accounts.length === 0 && !adding ? (
|
||||||
|
<div className="empty-inline">Sin cuentas registradas.</div>
|
||||||
|
) : (
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Cuenta</th>
|
||||||
|
<th>Banco</th>
|
||||||
|
<th>Moneda</th>
|
||||||
|
<th>Línea</th>
|
||||||
|
<th>Estatus</th>
|
||||||
|
<th className="num">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{adding && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6}>{editor}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{accounts.map((a) =>
|
||||||
|
editingId === a.id ? (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td colSpan={6}>{editor}</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td>{a.label}</td>
|
||||||
|
<td>{a.bankName}</td>
|
||||||
|
<td className="mono">{a.currency}</td>
|
||||||
|
<td>{a.businessLine ? domainLabel(a.businessLine) : "—"}</td>
|
||||||
|
<td>{a.active ? "Abierta" : "Cerrada"}</td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => startEdit(a)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<Link href="/banco" className="btn btn-ghost">
|
||||||
|
Ver movimientos
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { ChildCollection, type ChildConfig } from "@/components/ChildCollection";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import { createLookup, getLookups, removeLookup, updateLookup } from "@/lib/api";
|
||||||
|
import type { LookupsResponse } from "@/lib/types";
|
||||||
|
|
||||||
|
const PROVIDER: ChildConfig = {
|
||||||
|
apiKind: "providers",
|
||||||
|
title: "Aseguradoras",
|
||||||
|
fields: [{ key: "name", label: "Nombre" }],
|
||||||
|
};
|
||||||
|
const TYPE: ChildConfig = {
|
||||||
|
apiKind: "policy-types",
|
||||||
|
title: "Tipos de póliza",
|
||||||
|
fields: [
|
||||||
|
{ key: "name", label: "Nombre" },
|
||||||
|
{ key: "shortDescription", label: "Descripción" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const ADJUSTER: ChildConfig = {
|
||||||
|
apiKind: "adjusters",
|
||||||
|
title: "Ajustadores",
|
||||||
|
fields: [
|
||||||
|
{ key: "company", label: "Empresa" },
|
||||||
|
{ key: "name", label: "Nombre" },
|
||||||
|
{ key: "city", label: "Ciudad" },
|
||||||
|
{ key: "phone", label: "Teléfono" },
|
||||||
|
{ key: "beeper", label: "Beeper" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CatalogosPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<Catalogos />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Catalogos() {
|
||||||
|
const canEdit = useCan("lookup:manage");
|
||||||
|
const [data, setData] = useState<LookupsResponse | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
getLookups().then(setData).catch((e) => setError(e?.message ?? "Error al cargar."));
|
||||||
|
}
|
||||||
|
useEffect(reload, []);
|
||||||
|
|
||||||
|
if (!canEdit) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head"><h1 className="page-title">Catálogos</h1></div>
|
||||||
|
<div className="state-box state-error">
|
||||||
|
No tiene permisos para administrar catálogos.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const section = (config: ChildConfig, rows: Record<string, unknown>[]) => (
|
||||||
|
<ChildCollection
|
||||||
|
config={config}
|
||||||
|
rows={rows}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onAdd={async (p) => {
|
||||||
|
await createLookup(config.apiKind, p);
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
onSave={async (id, p) => {
|
||||||
|
await updateLookup(config.apiKind, id, p);
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
onRemove={async (id) => {
|
||||||
|
await removeLookup(config.apiKind, id);
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<p className="eyebrow">Datos de referencia de seguros</p>
|
||||||
|
<h1 className="page-title">Catálogos</h1>
|
||||||
|
</div>
|
||||||
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
|
{!data ? (
|
||||||
|
<div className="empty-inline"><span className="spinner" aria-label="Cargando" /></div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{section(PROVIDER, data.providers as unknown as Record<string, unknown>[])}
|
||||||
|
{section(TYPE, data.types as unknown as Record<string, unknown>[])}
|
||||||
|
{section(ADJUSTER, data.adjusters as unknown as Record<string, unknown>[])}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { CustomerForm } from "@/components/CustomerForm";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import { getCustomer } from "@/lib/api";
|
||||||
|
import type { CustomerDetail } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function EditarClientePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<EditarCliente id={params.id} />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditarCliente({ id }: { id: string }) {
|
||||||
|
const allowed = useCan("customer:update");
|
||||||
|
const [customer, setCustomer] = useState<CustomerDetail | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allowed) return;
|
||||||
|
getCustomer(id)
|
||||||
|
.then(setCustomer)
|
||||||
|
.catch((e) => setError(e?.message ?? "No se pudo cargar el cliente."));
|
||||||
|
}, [id, allowed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<Link href={`/clientes/${id}`} className="back-link">
|
||||||
|
← Cliente
|
||||||
|
</Link>
|
||||||
|
<h1 className="page-title">Editar cliente</h1>
|
||||||
|
</div>
|
||||||
|
{!allowed ? (
|
||||||
|
<div className="state-box state-error">
|
||||||
|
No tiene permisos para editar clientes.
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="state-box state-error">{error}</div>
|
||||||
|
) : !customer ? (
|
||||||
|
<div className="empty-inline">
|
||||||
|
<span className="spinner" aria-label="Cargando" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CustomerForm customer={customer} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,915 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { ContextReports } from "@/components/ContextReports";
|
||||||
|
import {
|
||||||
|
archiveCustomer,
|
||||||
|
getCustomer,
|
||||||
|
policyDocumentDownloadUrl,
|
||||||
|
propertyDocumentDownloadUrl,
|
||||||
|
restoreCustomer,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import {
|
||||||
|
domainLabel,
|
||||||
|
formatDate,
|
||||||
|
formatMoney,
|
||||||
|
premiumHeadline,
|
||||||
|
serviceKindGlyph,
|
||||||
|
serviceKindLabel,
|
||||||
|
SIN_NOMBRE,
|
||||||
|
sourceSystemLabel,
|
||||||
|
} from "@/lib/labels";
|
||||||
|
import type {
|
||||||
|
CustomerDetail,
|
||||||
|
Installment,
|
||||||
|
Policy,
|
||||||
|
Property,
|
||||||
|
Transaction,
|
||||||
|
TransactionSummaryRow,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
export default function ClienteDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
}) {
|
||||||
|
const { id } = params;
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<Detail id={id} />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Detail({ id }: { id: string }) {
|
||||||
|
const [data, setData] = useState<CustomerDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
getCustomer(id)
|
||||||
|
.then((d) => {
|
||||||
|
if (alive) {
|
||||||
|
setData(d);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) {
|
||||||
|
setError(
|
||||||
|
e?.status === 404
|
||||||
|
? "No encontramos este cliente."
|
||||||
|
: e?.message ?? "No se pudo cargar el cliente.",
|
||||||
|
);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) return <DetailSkeleton />;
|
||||||
|
|
||||||
|
if (error)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BackLink />
|
||||||
|
<div className="state-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const hasUtilities = data.properties.length > 0;
|
||||||
|
const hasInsurance = data.policies.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rise">
|
||||||
|
<div className="detail-actionbar">
|
||||||
|
<BackLink />
|
||||||
|
<ContextReports
|
||||||
|
entries={[
|
||||||
|
{
|
||||||
|
slug: "edo-cuenta-datos",
|
||||||
|
label: "Estado de cuenta (reporte)",
|
||||||
|
params: { customerId: data.id },
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<CustomerActions
|
||||||
|
customer={data}
|
||||||
|
onChange={() => getCustomer(id).then(setData).catch(() => {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Hero data={data} hasUtilities={hasUtilities} hasInsurance={hasInsurance} />
|
||||||
|
|
||||||
|
<DatosSection data={data} />
|
||||||
|
<PropiedadesSection
|
||||||
|
properties={data.properties}
|
||||||
|
customerId={data.id}
|
||||||
|
customerName={data.name}
|
||||||
|
/>
|
||||||
|
<PolizasSection
|
||||||
|
policies={data.policies}
|
||||||
|
customerId={data.id}
|
||||||
|
customerName={data.name}
|
||||||
|
/>
|
||||||
|
<EstadoCuentaSection
|
||||||
|
customerId={data.id}
|
||||||
|
summary={data.transactionSummary}
|
||||||
|
transactions={data.transactions}
|
||||||
|
/>
|
||||||
|
<DocumentosSection data={data} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackLink() {
|
||||||
|
return (
|
||||||
|
<Link href="/clientes" className="back-link">
|
||||||
|
← Volver a Clientes
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Edit / archive controls, each gated by the matching ability. */
|
||||||
|
function CustomerActions({
|
||||||
|
customer,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
customer: CustomerDetail;
|
||||||
|
onChange: () => void;
|
||||||
|
}) {
|
||||||
|
const canEdit = useCan("customer:update");
|
||||||
|
const canDelete = useCan("customer:delete");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const archived = customer.archivedAt != null;
|
||||||
|
|
||||||
|
async function toggleArchive() {
|
||||||
|
const verb = archived ? "restaurar" : "archivar";
|
||||||
|
if (!window.confirm(`¿Seguro que desea ${verb} este cliente?`)) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (archived) await restoreCustomer(customer.id);
|
||||||
|
else await archiveCustomer(customer.id);
|
||||||
|
onChange();
|
||||||
|
} catch (e) {
|
||||||
|
window.alert((e as Error)?.message ?? "No se pudo completar la acción.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canEdit && !canDelete) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row-actions">
|
||||||
|
{archived && <span className="badge badge-negative">Archivado</span>}
|
||||||
|
{canEdit && (
|
||||||
|
<Link href={`/clientes/${customer.id}/editar`} className="btn btn-outline">
|
||||||
|
Editar
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={toggleArchive}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{archived ? "Restaurar" : "Archivar"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ Hero */
|
||||||
|
function Hero({
|
||||||
|
data,
|
||||||
|
hasUtilities,
|
||||||
|
hasInsurance,
|
||||||
|
}: {
|
||||||
|
data: CustomerDetail;
|
||||||
|
hasUtilities: boolean;
|
||||||
|
hasInsurance: boolean;
|
||||||
|
}) {
|
||||||
|
const provenance = data.legacyRefs
|
||||||
|
.map((r) => `${sourceSystemLabel(r.sourceSystem)} #${r.legacyId}`)
|
||||||
|
.join(" · ");
|
||||||
|
|
||||||
|
const facts: { label: string; value: string }[] = [
|
||||||
|
{ label: "Cliente desde", value: formatDate(data.customerSince) },
|
||||||
|
{
|
||||||
|
label: "Cuota",
|
||||||
|
value:
|
||||||
|
data.feeAmount != null && data.feeAmount !== ""
|
||||||
|
? formatMoney(data.feeAmount, data.preferredCurrency)
|
||||||
|
: "—",
|
||||||
|
},
|
||||||
|
{ label: "Propiedades", value: String(data.properties.length) },
|
||||||
|
{ label: "Pólizas", value: String(data.policies.length) },
|
||||||
|
{ label: "Moneda", value: data.preferredCurrency ?? "—" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="detail-hero">
|
||||||
|
<div className="hero-top">
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
className={`hero-name${
|
||||||
|
data.name === SIN_NOMBRE ? " hero-name-missing" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{data.name}
|
||||||
|
</h1>
|
||||||
|
{data.nameSource && (
|
||||||
|
<div className="hero-provenance">
|
||||||
|
Nombre recuperado de {data.nameSource} — el registro original no
|
||||||
|
tenía nombre.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{provenance && (
|
||||||
|
<div className="hero-provenance">Origen: {provenance}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="hero-badges">
|
||||||
|
{hasUtilities && (
|
||||||
|
<span className="badge badge-servicios">
|
||||||
|
<span className="dot" /> Servicios
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasInsurance && (
|
||||||
|
<span className="badge badge-seguros">
|
||||||
|
<span className="dot" /> Seguros
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`badge ${
|
||||||
|
data.status ? "badge-on-dark" : "badge-negative"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{data.status ? "Activo" : "Inactivo"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hero-facts">
|
||||||
|
{facts.map((f) => (
|
||||||
|
<div key={f.label}>
|
||||||
|
<div className="hero-fact-label">{f.label}</div>
|
||||||
|
<div className="hero-fact-value">{f.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------- Datos del cliente */
|
||||||
|
function DatosSection({ data }: { data: CustomerDetail }) {
|
||||||
|
const mxAddress = [data.addressLine1, data.addressLine2]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
const cityLine = [
|
||||||
|
data.city?.replace(/,\s*$/, ""),
|
||||||
|
data.state,
|
||||||
|
data.zipCode,
|
||||||
|
data.country,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
const idLine =
|
||||||
|
data.identificationNumber || data.identificationType
|
||||||
|
? [
|
||||||
|
data.identificationType,
|
||||||
|
data.identificationNumber,
|
||||||
|
data.identificationExpiration
|
||||||
|
? `vence ${formatDate(data.identificationExpiration)}`
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="datos" title="Datos del cliente" />
|
||||||
|
<div className="card">
|
||||||
|
<div className="kv-grid">
|
||||||
|
<KV label="Teléfono" value={data.phone} mono />
|
||||||
|
<KV label="Móvil" value={data.mobile} mono />
|
||||||
|
<KV label="Fax" value={data.fax} mono />
|
||||||
|
<KV label="Correo electrónico" value={data.email} />
|
||||||
|
<KV label="Documento de identidad" value={idLine} />
|
||||||
|
<KV
|
||||||
|
label="Estado"
|
||||||
|
value={data.status ? "Activo" : "Inactivo"}
|
||||||
|
/>
|
||||||
|
{(mxAddress || cityLine) && (
|
||||||
|
<div className="kv-block">
|
||||||
|
<div className="kv-label">Domicilio</div>
|
||||||
|
<div className="kv-value">
|
||||||
|
{mxAddress && <div>{mxAddress}</div>}
|
||||||
|
{cityLine && <div>{cityLine}</div>}
|
||||||
|
{!mxAddress && !cityLine && "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.notes && (
|
||||||
|
<div className="kv-block">
|
||||||
|
<div className="kv-label">Notas</div>
|
||||||
|
<div className="kv-value">{data.notes}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KV({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | null | undefined;
|
||||||
|
mono?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="kv-label">{label}</div>
|
||||||
|
<div className={`kv-value${mono && value ? " mono" : ""}`}>
|
||||||
|
{value || "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------- Propiedades y servicios */
|
||||||
|
function PropiedadesSection({
|
||||||
|
properties,
|
||||||
|
customerId,
|
||||||
|
customerName,
|
||||||
|
}: {
|
||||||
|
properties: Property[];
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
}) {
|
||||||
|
const canCreate = useCan("property:create");
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<div className="detail-actionbar">
|
||||||
|
<SectionHead
|
||||||
|
rule="servicios"
|
||||||
|
title="Propiedades y servicios"
|
||||||
|
count={properties.length}
|
||||||
|
/>
|
||||||
|
{canCreate && (
|
||||||
|
<Link
|
||||||
|
href={`/servicios/nuevo?customerId=${customerId}&customerName=${encodeURIComponent(customerName)}`}
|
||||||
|
className="btn btn-outline"
|
||||||
|
>
|
||||||
|
+ Nueva propiedad
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
{properties.length === 0 ? (
|
||||||
|
<div className="empty-inline">
|
||||||
|
Este cliente no tiene propiedades registradas.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
properties.map((p) => <PropertyCard key={p.id} p={p} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PropertyCard({ p }: { p: Property }) {
|
||||||
|
const addr = [p.addressLine1, p.addressLine2].filter(Boolean).join(", ");
|
||||||
|
const phones = [p.phone1, p.phone2, p.phone3].filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="prop-card">
|
||||||
|
<div className="prop-addr">
|
||||||
|
<Link href={`/servicios/${p.id}`} className="policy-num-link">
|
||||||
|
{addr || "Propiedad"}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="prop-meta">
|
||||||
|
{p.zone && <span>Zona: {p.zone}</span>}
|
||||||
|
{phones.length > 0 && (
|
||||||
|
<span className="mono">{phones.join(" · ")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{p.services.length > 0 && (
|
||||||
|
<div className="svc-grid">
|
||||||
|
{p.services.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className={`svc-item${s.active ? "" : " inactive"}`}
|
||||||
|
>
|
||||||
|
<div className="svc-head">
|
||||||
|
<span className="svc-kind">
|
||||||
|
<span className="svc-glyph" aria-hidden>
|
||||||
|
{serviceKindGlyph(s.kind)}
|
||||||
|
</span>
|
||||||
|
{serviceKindLabel(s.kind)}
|
||||||
|
</span>
|
||||||
|
{!s.active && (
|
||||||
|
<span className="badge badge-neutral">Inactivo</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="svc-detail">
|
||||||
|
{s.accountNumber && (
|
||||||
|
<span>
|
||||||
|
Cuenta: <span className="mono">{s.accountNumber}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.meterNumber && (
|
||||||
|
<span>
|
||||||
|
Medidor: <span className="mono">{s.meterNumber}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.route && (
|
||||||
|
<span>
|
||||||
|
Ruta: <span className="mono">{s.route}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.dueDay && <span>Día de pago: {s.dueDay}</span>}
|
||||||
|
{s.notes && <span>{s.notes}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{p.trustAccount && (
|
||||||
|
<div className="trust-box">
|
||||||
|
<div className="trust-title">Fideicomiso</div>
|
||||||
|
<div className="trust-facts">
|
||||||
|
{p.trustAccount.bankName && (
|
||||||
|
<span>
|
||||||
|
<strong>Banco:</strong> {p.trustAccount.bankName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{p.trustAccount.trustNumber && (
|
||||||
|
<span>
|
||||||
|
<strong>No.:</strong>{" "}
|
||||||
|
<span className="mono">{p.trustAccount.trustNumber}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{p.trustAccount.bankFee && (
|
||||||
|
<span>
|
||||||
|
<strong>Comisión:</strong>{" "}
|
||||||
|
{formatMoney(p.trustAccount.bankFee, "MXN")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{p.trustAccount.dueDate1 && (
|
||||||
|
<span>
|
||||||
|
<strong>Vigencia:</strong>{" "}
|
||||||
|
{formatDate(p.trustAccount.dueDate1)}
|
||||||
|
{p.trustAccount.dueDate2
|
||||||
|
? ` – ${formatDate(p.trustAccount.dueDate2)}`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------ Pólizas de seguro */
|
||||||
|
function PolizasSection({
|
||||||
|
policies,
|
||||||
|
customerId,
|
||||||
|
customerName,
|
||||||
|
}: {
|
||||||
|
policies: Policy[];
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
}) {
|
||||||
|
const canCreate = useCan("policy:create");
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<div className="detail-actionbar">
|
||||||
|
<SectionHead
|
||||||
|
rule="seguros"
|
||||||
|
title="Pólizas de seguro"
|
||||||
|
count={policies.length}
|
||||||
|
/>
|
||||||
|
{canCreate && (
|
||||||
|
<Link
|
||||||
|
href={`/polizas/nuevo?customerId=${customerId}&customerName=${encodeURIComponent(customerName)}`}
|
||||||
|
className="btn btn-outline"
|
||||||
|
>
|
||||||
|
+ Nueva póliza
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
{policies.length === 0 ? (
|
||||||
|
<div className="empty-inline">
|
||||||
|
Este cliente no tiene pólizas registradas.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
policies.map((p) => <PolicyCard key={p.id} p={p} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyCard({ p }: { p: Policy }) {
|
||||||
|
const { value: headline, label: headlineLabel } = premiumHeadline(p);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="policy-card">
|
||||||
|
<div className="policy-head">
|
||||||
|
<div>
|
||||||
|
<Link href={`/polizas/${p.id}`} className="policy-num policy-num-link">
|
||||||
|
{p.policyNumber || "—"} →
|
||||||
|
</Link>
|
||||||
|
<div className="policy-type-row">
|
||||||
|
{p.policyType?.name && (
|
||||||
|
<span className="badge badge-seguros">
|
||||||
|
<span className="dot" /> {p.policyType.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{p.insuranceProvider?.name && (
|
||||||
|
<span>{p.insuranceProvider.name}</span>
|
||||||
|
)}
|
||||||
|
{p.agentName && (
|
||||||
|
<>
|
||||||
|
<span className="sep">·</span>
|
||||||
|
<span>Agente: {p.agentName}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`badge ${
|
||||||
|
p.liquidated ? "badge-positive" : "badge-neutral"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.liquidated ? "Liquidada" : "Pendiente"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="policy-type-row">
|
||||||
|
<span className="kv-label" style={{ margin: 0 }}>
|
||||||
|
Vigencia:
|
||||||
|
</span>
|
||||||
|
<span className="mono">
|
||||||
|
{formatDate(p.policyFrom)} – {formatDate(p.policyTo)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="policy-figures">
|
||||||
|
<div className="policy-total">
|
||||||
|
{formatMoney(headline, p.currency)}
|
||||||
|
</div>
|
||||||
|
<div className="policy-total-label">{headlineLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="policy-body">
|
||||||
|
{p.installments.length > 0 && (
|
||||||
|
<div className="subpanel">
|
||||||
|
<div className="subpanel-title">
|
||||||
|
<span>Pagos</span>
|
||||||
|
<span>{p.installments.length}</span>
|
||||||
|
</div>
|
||||||
|
{p.installments.map((inst) => (
|
||||||
|
<InstallmentRow key={inst.id} inst={inst} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{p.vehicles.length > 0 && (
|
||||||
|
<div className="subpanel">
|
||||||
|
<div className="subpanel-title">
|
||||||
|
<span>Vehículos</span>
|
||||||
|
<span>{p.vehicles.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mini-list">
|
||||||
|
{p.vehicles.map((v) => (
|
||||||
|
<div key={v.id}>
|
||||||
|
{[v.make, v.model, v.modelYear].filter(Boolean).join(" ") ||
|
||||||
|
"Vehículo"}
|
||||||
|
<div className="mini-sub">
|
||||||
|
{[
|
||||||
|
v.bodyType,
|
||||||
|
v.licensePlate ? `Placa ${v.licensePlate}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{p.insuredDrivers.length > 0 && (
|
||||||
|
<div className="subpanel">
|
||||||
|
<div className="subpanel-title">
|
||||||
|
<span>Asegurados</span>
|
||||||
|
<span>{p.insuredDrivers.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mini-list">
|
||||||
|
{p.insuredDrivers.map((d) => (
|
||||||
|
<div key={d.id}>
|
||||||
|
{d.fullName || "—"}
|
||||||
|
{d.licenseNumber && (
|
||||||
|
<div className="mini-sub mono">Lic. {d.licenseNumber}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{p.beneficiaries.length > 0 && (
|
||||||
|
<div className="subpanel">
|
||||||
|
<div className="subpanel-title">
|
||||||
|
<span>Beneficiarios</span>
|
||||||
|
<span>{p.beneficiaries.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mini-list">
|
||||||
|
{p.beneficiaries.map((b) => (
|
||||||
|
<div key={b.id}>
|
||||||
|
{b.name || "—"}
|
||||||
|
{(b.phone || b.email) && (
|
||||||
|
<div className="mini-sub">
|
||||||
|
{[b.phone, b.email].filter(Boolean).join(" · ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstallmentRow({ inst }: { inst: Installment }) {
|
||||||
|
const method = inst.isCash
|
||||||
|
? "Efectivo"
|
||||||
|
: inst.checkNumber
|
||||||
|
? `Ref. ${inst.checkNumber}`
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div className="pay-row">
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||||
|
<span className="pay-seq">{inst.sequence}</span>
|
||||||
|
<span>
|
||||||
|
{inst.paidDate ? formatDate(inst.paidDate) : "Sin pagar"}
|
||||||
|
{method && (
|
||||||
|
<span className="muted" style={{ fontSize: 11 }}>
|
||||||
|
{" "}
|
||||||
|
· {method}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="mono" style={{ fontWeight: 600 }}>
|
||||||
|
{formatMoney(inst.amount, inst.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------- Estado de cuenta */
|
||||||
|
function EstadoCuentaSection({
|
||||||
|
customerId,
|
||||||
|
summary,
|
||||||
|
transactions,
|
||||||
|
}: {
|
||||||
|
customerId: string;
|
||||||
|
summary: TransactionSummaryRow[];
|
||||||
|
transactions: Transaction[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead
|
||||||
|
rule="cuenta"
|
||||||
|
title="Estado de cuenta"
|
||||||
|
count={transactions.length}
|
||||||
|
countSuffix="movimientos"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{summary.length > 0 && (
|
||||||
|
<div className="summary-grid">
|
||||||
|
{summary.map((row, i) => (
|
||||||
|
<div className={`summary-card ${row.domain}`} key={i}>
|
||||||
|
<div className="summary-domain">
|
||||||
|
<span className={`tx-dot ${row.domain}`} />
|
||||||
|
{domainLabel(row.domain)} · {row.currency}
|
||||||
|
</div>
|
||||||
|
<div className="summary-total">
|
||||||
|
{formatMoney(row.total, row.currency)}
|
||||||
|
</div>
|
||||||
|
<div className="summary-count">
|
||||||
|
{row.count}{" "}
|
||||||
|
{row.count === 1 ? "movimiento" : "movimientos"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
{transactions.length === 0 ? (
|
||||||
|
<div className="empty-inline">Sin movimientos registrados.</div>
|
||||||
|
) : (
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th>Línea</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Referencia</th>
|
||||||
|
<th>Concepto</th>
|
||||||
|
<th className="num">Monto</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{transactions.map((t) => (
|
||||||
|
<TxRow key={t.id} t={t} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{transactions.length >= 100 && (
|
||||||
|
<div className="section-note" style={{ padding: "0 16px 14px" }}>
|
||||||
|
Mostrando los 100 movimientos más recientes.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{transactions.length > 0 && (
|
||||||
|
<p className="section-note">
|
||||||
|
<Link href={`/estado-cuenta/${customerId}`} className="inline-link">
|
||||||
|
Ver estado de cuenta completo →
|
||||||
|
</Link>{" "}
|
||||||
|
con saldo, saldo corrido y desglose por línea de negocio.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TxRow({ t }: { t: Transaction }) {
|
||||||
|
const num = t.amount != null ? Number(t.amount) : NaN;
|
||||||
|
const sign = !Number.isNaN(num) && num < 0 ? "neg" : "pos";
|
||||||
|
const tipo =
|
||||||
|
t.type?.nameEs || t.type?.nameEn || "—";
|
||||||
|
const concept = t.message || t.period || "—";
|
||||||
|
const voided = !!t.voidedAt;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr style={voided ? { textDecoration: "line-through", opacity: 0.55 } : undefined}>
|
||||||
|
<td className="mono" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{formatDate(t.transactionDate)}
|
||||||
|
</td>
|
||||||
|
<td className="tx-domain-cell">
|
||||||
|
<span className={`tx-dot ${t.domain}`} />
|
||||||
|
{domainLabel(t.domain)}
|
||||||
|
</td>
|
||||||
|
<td>{tipo}</td>
|
||||||
|
<td className="tx-ref">{t.reference || "—"}</td>
|
||||||
|
<td className="tx-concept">
|
||||||
|
{concept}
|
||||||
|
{voided && (
|
||||||
|
<span className="tx-cur" style={{ marginLeft: 6, textDecoration: "none" }}>
|
||||||
|
(anulado)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
<span className={`tx-amount ${sign}`}>
|
||||||
|
{formatMoney(t.amount, t.currency)}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="tx-cur">{t.currency}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------- Documentos */
|
||||||
|
function DocumentosSection({ data }: { data: CustomerDetail }) {
|
||||||
|
type Doc = { type: string; scope: string; href: string | null };
|
||||||
|
const docs: Doc[] = [];
|
||||||
|
data.properties.forEach((p) => {
|
||||||
|
const label = [p.addressLine1].filter(Boolean).join("") || "Propiedad";
|
||||||
|
p.documents.forEach((d) =>
|
||||||
|
docs.push({
|
||||||
|
type: d.documentType || "Documento",
|
||||||
|
scope: label,
|
||||||
|
href: d.id ? propertyDocumentDownloadUrl(p.id, d.id) : null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
data.policies.forEach((p) => {
|
||||||
|
p.documents.forEach((d) =>
|
||||||
|
docs.push({
|
||||||
|
type: d.documentType || "Documento",
|
||||||
|
scope: `Póliza ${p.policyNumber ?? ""}`.trim(),
|
||||||
|
href: d.id ? policyDocumentDownloadUrl(p.id, d.id) : null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="docs" title="Documentos" count={docs.length} />
|
||||||
|
<div className="card">
|
||||||
|
{docs.length === 0 ? (
|
||||||
|
<div className="empty-inline">
|
||||||
|
No hay documentos registrados para este cliente.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="doc-list">
|
||||||
|
{docs.map((d, i) => (
|
||||||
|
<div className="doc-item" key={i}>
|
||||||
|
<span className="doc-icon" aria-hidden>
|
||||||
|
▤
|
||||||
|
</span>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div className="doc-type">{d.type}</div>
|
||||||
|
<div className="doc-key">{d.scope}</div>
|
||||||
|
</div>
|
||||||
|
{d.href && (
|
||||||
|
<a className="btn btn-ghost" href={d.href}>
|
||||||
|
Descargar
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- helpers */
|
||||||
|
function SectionHead({
|
||||||
|
rule,
|
||||||
|
title,
|
||||||
|
count,
|
||||||
|
countSuffix,
|
||||||
|
}: {
|
||||||
|
rule: string;
|
||||||
|
title: string;
|
||||||
|
count?: number;
|
||||||
|
countSuffix?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="section-head">
|
||||||
|
<span className={`section-rule ${rule}`} aria-hidden />
|
||||||
|
<h2 className="section-title">{title}</h2>
|
||||||
|
{count != null && (
|
||||||
|
<span className="section-count">
|
||||||
|
{count} {countSuffix ?? ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{ height: 16, width: 140, marginBottom: 18 }}
|
||||||
|
/>
|
||||||
|
<div className="skeleton" style={{ height: 180, borderRadius: 16 }} />
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{ height: 200, borderRadius: 16, marginTop: 34 }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{ height: 260, borderRadius: 16, marginTop: 34 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { CustomerForm } from "@/components/CustomerForm";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
|
||||||
|
export default function NuevoClientePage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<NuevoCliente />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NuevoCliente() {
|
||||||
|
const allowed = useCan("customer:create");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<Link href="/clientes" className="back-link">
|
||||||
|
← Clientes
|
||||||
|
</Link>
|
||||||
|
<h1 className="page-title">Nuevo cliente</h1>
|
||||||
|
</div>
|
||||||
|
{allowed ? (
|
||||||
|
<CustomerForm />
|
||||||
|
) : (
|
||||||
|
<div className="state-box state-error">
|
||||||
|
No tiene permisos para crear clientes.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { ContextReports } from "@/components/ContextReports";
|
||||||
|
import { getStats, listCustomers } from "@/lib/api";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import { formatNumber, SIN_NOMBRE } from "@/lib/labels";
|
||||||
|
import type {
|
||||||
|
BusinessLine,
|
||||||
|
CustomerListItem,
|
||||||
|
CustomerListResponse,
|
||||||
|
CustomerStats,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
type Filter = "all" | BusinessLine;
|
||||||
|
|
||||||
|
const FILTERS: { key: Filter; label: string }[] = [
|
||||||
|
{ key: "all", label: "Todos" },
|
||||||
|
{ key: "utility", label: "Servicios" },
|
||||||
|
{ key: "insurance", label: "Seguros" },
|
||||||
|
{ key: "both", label: "Ambos" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ClientesPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<ClientesBrowser />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientesBrowser() {
|
||||||
|
const [stats, setStats] = useState<CustomerStats | null>(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [filter, setFilter] = useState<Filter>("all");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const [data, setData] = useState<CustomerListResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canCreate = useCan("customer:create");
|
||||||
|
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getStats().then(setStats).catch(() => setStats(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runSearch = useCallback(
|
||||||
|
(q: string, f: Filter, p: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
listCustomers({
|
||||||
|
query: q || undefined,
|
||||||
|
line: f === "all" ? undefined : f,
|
||||||
|
page: p,
|
||||||
|
pageSize: 25,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
setData(res);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setError(
|
||||||
|
e?.message ?? "No se pudieron cargar los clientes.",
|
||||||
|
);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Debounced search on query/filter change; resets to page 1.
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
setPage(1);
|
||||||
|
runSearch(query, filter, 1);
|
||||||
|
}, 280);
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [query, filter]);
|
||||||
|
|
||||||
|
function goToPage(p: number) {
|
||||||
|
setPage(p);
|
||||||
|
runSearch(query, filter, p);
|
||||||
|
if (typeof window !== "undefined")
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head rise">
|
||||||
|
<p className="eyebrow">Directorio unificado</p>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||||
|
<h1 className="page-title" style={{ margin: 0 }}>Clientes</h1>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<ContextReports
|
||||||
|
entries={[
|
||||||
|
{ slug: "listado-en-rojo", label: "En rojo" },
|
||||||
|
{ slug: "pagos-no-efectuados", label: "Sin pagos (agua)" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{canCreate && (
|
||||||
|
<Link href="/clientes/nuevo" className="btn btn-primary">
|
||||||
|
+ Nuevo cliente
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<StatStrip stats={stats} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
<div className="search-box">
|
||||||
|
<span className="search-icon" aria-hidden>
|
||||||
|
⌕
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input search-input"
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Buscar por nombre, ciudad, teléfono…"
|
||||||
|
aria-label="Buscar clientes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="seg"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Filtrar por línea de negocio"
|
||||||
|
>
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={filter === f.key}
|
||||||
|
className={`seg-btn ${filter === f.key ? "active" : ""}`}
|
||||||
|
onClick={() => setFilter(f.key)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data && !loading && !error && (
|
||||||
|
<div className="result-meta" aria-live="polite">
|
||||||
|
{data.total === 0
|
||||||
|
? "Sin resultados"
|
||||||
|
: `${formatNumber(data.total)} ${
|
||||||
|
data.total === 1 ? "cliente" : "clientes"
|
||||||
|
}`}
|
||||||
|
{query ? ` para “${query}”` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="state-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<ListSkeleton />
|
||||||
|
) : data && data.items.length === 0 ? (
|
||||||
|
<EmptyState query={query} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="cust-list">
|
||||||
|
{data?.items.map((c) => (
|
||||||
|
<CustomerRow key={c.id} c={c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{data && data.pageCount > 1 && (
|
||||||
|
<Pager
|
||||||
|
page={data.page}
|
||||||
|
pageCount={data.pageCount}
|
||||||
|
onChange={goToPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatStrip({ stats }: { stats: CustomerStats | null }) {
|
||||||
|
const cells: {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
accent?: boolean;
|
||||||
|
}[] = stats
|
||||||
|
? [
|
||||||
|
{ value: formatNumber(stats.customers), label: "Clientes", accent: true },
|
||||||
|
{ value: formatNumber(stats.withUtilities), label: "Con servicios" },
|
||||||
|
{ value: formatNumber(stats.withInsurance), label: "Con seguros" },
|
||||||
|
{ value: formatNumber(stats.bothLines), label: "Ambas líneas", accent: true },
|
||||||
|
{ value: formatNumber(stats.policies), label: "Pólizas" },
|
||||||
|
{ value: formatNumber(stats.properties), label: "Propiedades" },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
return (
|
||||||
|
<div className="stat-strip" aria-hidden>
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div className="stat-cell" key={i}>
|
||||||
|
<div className="skeleton" style={{ height: 25, width: "60%" }} />
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{ height: 11, width: "80%", marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stat-strip">
|
||||||
|
{cells.map((c) => (
|
||||||
|
<div
|
||||||
|
className={`stat-cell${c.accent ? " accent" : ""}`}
|
||||||
|
key={c.label}
|
||||||
|
>
|
||||||
|
<div className="stat-value">{c.value}</div>
|
||||||
|
<div className="stat-label">{c.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomerRow({ c }: { c: CustomerListItem }) {
|
||||||
|
const location = [c.city?.replace(/,\s*$/, ""), c.state]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
const contact = c.phone || c.mobile || c.email;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/clientes/${c.id}`} className="cust-row">
|
||||||
|
<div className="cust-main">
|
||||||
|
<div className="cust-name">
|
||||||
|
{!c.status && (
|
||||||
|
<span className="inactive-dot" title="Inactivo" aria-hidden />
|
||||||
|
)}
|
||||||
|
<span className={c.name === SIN_NOMBRE ? "cust-name-missing" : undefined}>
|
||||||
|
{c.name}
|
||||||
|
</span>
|
||||||
|
{c.nameSource && (
|
||||||
|
<span
|
||||||
|
className="name-source"
|
||||||
|
title={`El registro original no tenía nombre. Recuperado de ${c.nameSource}.`}
|
||||||
|
>
|
||||||
|
nombre recuperado
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="cust-sub">
|
||||||
|
{location && <span>{location}</span>}
|
||||||
|
{location && contact && <span className="sep">·</span>}
|
||||||
|
{contact && <span>{contact}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="cust-side">
|
||||||
|
{c.hasUtilities && (
|
||||||
|
<span className="badge badge-servicios">
|
||||||
|
<span className="dot" /> Servicios
|
||||||
|
{c.propertyCount > 0 && (
|
||||||
|
<span className="badge-count">· {c.propertyCount}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{c.hasInsurance && (
|
||||||
|
<span className="badge badge-seguros">
|
||||||
|
<span className="dot" /> Seguros
|
||||||
|
{c.policyCount > 0 && (
|
||||||
|
<span className="badge-count">· {c.policyCount}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pager({
|
||||||
|
page,
|
||||||
|
pageCount,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageCount: number;
|
||||||
|
onChange: (p: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<nav className="pager" aria-label="Paginación">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
onClick={() => onChange(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
← Anterior
|
||||||
|
</button>
|
||||||
|
<span className="pager-info">
|
||||||
|
Página <strong>{page}</strong> de {pageCount}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
onClick={() => onChange(page + 1)}
|
||||||
|
disabled={page >= pageCount}
|
||||||
|
>
|
||||||
|
Siguiente →
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="cust-list" aria-hidden>
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div className="skeleton skel-row" key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ query }: { query: string }) {
|
||||||
|
return (
|
||||||
|
<div className="state-box">
|
||||||
|
<div className="state-glyph" aria-hidden>
|
||||||
|
⌕
|
||||||
|
</div>
|
||||||
|
<h3>Sin resultados</h3>
|
||||||
|
<p>
|
||||||
|
{query
|
||||||
|
? `No encontramos clientes para “${query}”.`
|
||||||
|
: "No hay clientes que coincidan con el filtro."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { MovementForm } from "@/components/MovementForm";
|
||||||
|
import {
|
||||||
|
getBillingFacets,
|
||||||
|
getStatement,
|
||||||
|
voidMovement,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import {
|
||||||
|
balancePhrase,
|
||||||
|
balanceTone,
|
||||||
|
directionLabel,
|
||||||
|
domainLabel,
|
||||||
|
formatDate,
|
||||||
|
formatMoney,
|
||||||
|
formatNumber,
|
||||||
|
ledgerSourceLabel,
|
||||||
|
SIN_NOMBRE,
|
||||||
|
txTypeLabel,
|
||||||
|
} from "@/lib/labels";
|
||||||
|
import type {
|
||||||
|
BillingFacets,
|
||||||
|
LedgerCurrency,
|
||||||
|
Statement,
|
||||||
|
StatementMovement,
|
||||||
|
TransactionDomain,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One customer's statement across both business lines — the payoff of plan
|
||||||
|
* step 6 and, ultimately, of the whole unified-customer project: a utility
|
||||||
|
* charge and an insurance payment finally sit on the same page, under the same
|
||||||
|
* person, with a running balance.
|
||||||
|
*
|
||||||
|
* The running balance is per currency (the API accumulates it chronologically
|
||||||
|
* before handing the list back newest-first), so the movement table is scoped
|
||||||
|
* to one currency at a time — a column that alternated between pesos and
|
||||||
|
* dollars would be a meaningless number.
|
||||||
|
*/
|
||||||
|
export default function EstadoCuentaDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
}) {
|
||||||
|
const { id } = params;
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<StatementView id={id} />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatementView({ id }: { id: string }) {
|
||||||
|
const canCapture = useCan("ledger:create");
|
||||||
|
const canVoid = useCan("ledger:void");
|
||||||
|
const [data, setData] = useState<Statement | null>(null);
|
||||||
|
const [facets, setFacets] = useState<BillingFacets | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [captureOpen, setCaptureOpen] = useState(false);
|
||||||
|
|
||||||
|
const [currency, setCurrency] = useState<LedgerCurrency | null>(null);
|
||||||
|
const [domain, setDomain] = useState<TransactionDomain | "">("");
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
let alive = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
getStatement(id)
|
||||||
|
.then((d) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setData(d);
|
||||||
|
// Default to the currency the customer actually moves the most in;
|
||||||
|
// preserve a previously-chosen currency across reloads.
|
||||||
|
const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0];
|
||||||
|
setCurrency((prev) => prev ?? busiest?.currency ?? "MXN");
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setError(
|
||||||
|
e?.status === 404
|
||||||
|
? "No encontramos este cliente."
|
||||||
|
: e?.message ?? "No se pudo cargar el estado de cuenta.",
|
||||||
|
);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanup = reload();
|
||||||
|
getBillingFacets().then(setFacets).catch(() => setFacets(null));
|
||||||
|
return cleanup;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const movements = useMemo(() => {
|
||||||
|
if (!data || !currency) return [];
|
||||||
|
return data.movements.filter(
|
||||||
|
(m) => m.currency === currency && (!domain || m.domain === domain),
|
||||||
|
);
|
||||||
|
}, [data, currency, domain]);
|
||||||
|
|
||||||
|
if (loading) return <StatementSkeleton />;
|
||||||
|
|
||||||
|
if (error)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BackLink />
|
||||||
|
<div className="state-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data || !currency) return null;
|
||||||
|
|
||||||
|
const active = data.summary.find((s) => s.currency === currency);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rise">
|
||||||
|
<BackLink />
|
||||||
|
<Hero data={data} />
|
||||||
|
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="cuenta" title="Saldo por moneda" />
|
||||||
|
{data.summary.length === 0 ? (
|
||||||
|
<div className="card">
|
||||||
|
<div className="empty-inline">
|
||||||
|
Este cliente no tiene movimientos registrados.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="summary-grid">
|
||||||
|
{data.summary.map((s) => {
|
||||||
|
const tone = balanceTone(s.balance);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={s.currency}
|
||||||
|
className={`summary-card bal-card ${tone}${
|
||||||
|
currency === s.currency ? " selected" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setCurrency(s.currency)}
|
||||||
|
aria-pressed={currency === s.currency}
|
||||||
|
>
|
||||||
|
<div className="summary-domain">
|
||||||
|
Saldo en {s.currency} · {balancePhrase(s.balance)}
|
||||||
|
</div>
|
||||||
|
<div className={`summary-total bal-amount ${tone}`}>
|
||||||
|
{formatMoney(s.balance, s.currency)}
|
||||||
|
</div>
|
||||||
|
<div className="bal-breakdown">
|
||||||
|
<span className="tx-amount neg">
|
||||||
|
{formatMoney(s.charges, s.currency)}
|
||||||
|
</span>
|
||||||
|
<span className="bal-breakdown-label">
|
||||||
|
{formatNumber(s.chargeCount)} cargos
|
||||||
|
</span>
|
||||||
|
<span className="tx-amount pos">
|
||||||
|
{formatMoney(s.credits, s.currency)}
|
||||||
|
</span>
|
||||||
|
<span className="bal-breakdown-label">
|
||||||
|
{formatNumber(s.creditCount)} abonos
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="summary-count">
|
||||||
|
{formatDate(s.firstMovement)} a {formatDate(s.lastMovement)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="section-note">
|
||||||
|
Los saldos se muestran por separado en cada moneda. La contabilidad
|
||||||
|
heredada registró los cargos únicamente en pesos y los recibos en
|
||||||
|
ambas monedas, sin guardar el tipo de cambio aplicado a cada
|
||||||
|
movimiento, por lo que sumarlas produciría una cifra que nunca existió
|
||||||
|
en los libros.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<PorLineaSection data={data} currency={currency} />
|
||||||
|
<ConceptosSection data={data} currency={currency} />
|
||||||
|
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead
|
||||||
|
rule="cuenta"
|
||||||
|
title="Movimientos"
|
||||||
|
count={movements.length}
|
||||||
|
countSuffix={movements.length === 1 ? "movimiento" : "movimientos"}
|
||||||
|
right={
|
||||||
|
canCapture ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setCaptureOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
|
||||||
|
</button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{captureOpen && (
|
||||||
|
<MovementForm
|
||||||
|
concepts={facets?.types ?? []}
|
||||||
|
defaultCurrency={currency ?? "MXN"}
|
||||||
|
defaultCustomer={{
|
||||||
|
id: data.customer.id,
|
||||||
|
name: data.customer.name,
|
||||||
|
}}
|
||||||
|
onSaved={() => {
|
||||||
|
setCaptureOpen(false);
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
onCancel={() => setCaptureOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="filter-row">
|
||||||
|
<label className="filter-field">
|
||||||
|
<span className="filter-label">Moneda</span>
|
||||||
|
<select
|
||||||
|
className="input select"
|
||||||
|
value={currency}
|
||||||
|
onChange={(e) => setCurrency(e.target.value as LedgerCurrency)}
|
||||||
|
>
|
||||||
|
{data.summary.map((s) => (
|
||||||
|
<option key={s.currency} value={s.currency}>
|
||||||
|
{s.currency} ({formatNumber(s.count)})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="filter-field">
|
||||||
|
<span className="filter-label">Línea de negocio</span>
|
||||||
|
<select
|
||||||
|
className="input select"
|
||||||
|
value={domain}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDomain(e.target.value as TransactionDomain | "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Ambas líneas</option>
|
||||||
|
<option value="UTILITY">Servicios</option>
|
||||||
|
<option value="INSURANCE">Seguros</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
{movements.length === 0 ? (
|
||||||
|
<div className="empty-inline">
|
||||||
|
Sin movimientos en {currency}
|
||||||
|
{domain ? ` para ${domainLabel(domain)}` : ""}.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th>Línea</th>
|
||||||
|
<th>Concepto</th>
|
||||||
|
<th>Referencia</th>
|
||||||
|
<th className="num">Cargo / Abono</th>
|
||||||
|
<th className="num">Saldo</th>
|
||||||
|
{canVoid && (
|
||||||
|
<th style={{ width: 1, whiteSpace: "nowrap" }}>
|
||||||
|
Acciones
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{movements.map((m) => (
|
||||||
|
<StatementRow
|
||||||
|
key={m.id}
|
||||||
|
m={m}
|
||||||
|
canVoid={canVoid}
|
||||||
|
onVoided={reload}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{domain && movements.length > 0 && (
|
||||||
|
<div className="section-note" style={{ padding: "0 16px 14px" }}>
|
||||||
|
La columna de saldo es el saldo acumulado del cliente en{" "}
|
||||||
|
{currency} sobre <strong>todas</strong> sus líneas — filtrar por
|
||||||
|
línea oculta filas, no las descuenta.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{active && (
|
||||||
|
<p className="section-note">
|
||||||
|
Saldo final en {currency}:{" "}
|
||||||
|
<strong>{formatMoney(active.balance, currency)}</strong> (
|
||||||
|
{balancePhrase(active.balance).toLowerCase()}).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackLink() {
|
||||||
|
return (
|
||||||
|
<Link href="/estado-cuenta" className="back-link">
|
||||||
|
← Volver a Estado de cuenta
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Hero({ data }: { data: Statement }) {
|
||||||
|
const c = data.customer;
|
||||||
|
const location = [c.city?.replace(/,\s*$/, ""), c.state]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
const facts: { label: string; value: string }[] = [
|
||||||
|
{ label: "Cliente desde", value: formatDate(c.customerSince) },
|
||||||
|
{ label: "Propiedades", value: String(c.propertyCount) },
|
||||||
|
{ label: "Pólizas", value: String(c.policyCount) },
|
||||||
|
{ label: "Teléfono", value: c.phone || c.mobile || "—" },
|
||||||
|
{ label: "Correo", value: c.email || "—" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="detail-hero">
|
||||||
|
<div className="hero-top">
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
className={`hero-name${
|
||||||
|
c.name === SIN_NOMBRE ? " hero-name-missing" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</h1>
|
||||||
|
{location && <div className="hero-provenance">{location}</div>}
|
||||||
|
{c.nameSource && (
|
||||||
|
<div className="hero-provenance">
|
||||||
|
Nombre recuperado de {c.nameSource} — el registro original no
|
||||||
|
tenía nombre.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="hero-badges">
|
||||||
|
{c.propertyCount > 0 && (
|
||||||
|
<span className="badge badge-servicios">
|
||||||
|
<span className="dot" /> Servicios
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{c.policyCount > 0 && (
|
||||||
|
<span className="badge badge-seguros">
|
||||||
|
<span className="dot" /> Seguros
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={`badge ${c.status ? "badge-on-dark" : "badge-negative"}`}>
|
||||||
|
{c.status ? "Activo" : "Inactivo"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hero-facts">
|
||||||
|
{facts.map((f) => (
|
||||||
|
<div key={f.label}>
|
||||||
|
<div className="hero-fact-label">{f.label}</div>
|
||||||
|
<div className="hero-fact-value">{f.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="hero-links">
|
||||||
|
<Link href={`/clientes/${c.id}`} className="btn btn-outline">
|
||||||
|
Ver ficha del cliente
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cross-line split — the same balance, broken out by business line. */
|
||||||
|
function PorLineaSection({
|
||||||
|
data,
|
||||||
|
currency,
|
||||||
|
}: {
|
||||||
|
data: Statement;
|
||||||
|
currency: LedgerCurrency;
|
||||||
|
}) {
|
||||||
|
const rows = data.byDomain.filter((d) => d.currency === currency);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="cuenta" title={`Por línea de negocio · ${currency}`} />
|
||||||
|
<div className="summary-grid">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div className={`summary-card ${r.domain}`} key={r.domain}>
|
||||||
|
<div className="summary-domain">
|
||||||
|
<span className={`tx-dot ${r.domain}`} />
|
||||||
|
{domainLabel(r.domain)}
|
||||||
|
</div>
|
||||||
|
<div className={`summary-total bal-amount ${balanceTone(r.balance)}`}>
|
||||||
|
{formatMoney(r.balance, currency)}
|
||||||
|
</div>
|
||||||
|
<div className="bal-breakdown">
|
||||||
|
<span className="tx-amount neg">
|
||||||
|
{formatMoney(r.charges, currency)}
|
||||||
|
</span>
|
||||||
|
<span className="bal-breakdown-label">en cargos</span>
|
||||||
|
<span className="tx-amount pos">
|
||||||
|
{formatMoney(r.credits, currency)}
|
||||||
|
</span>
|
||||||
|
<span className="bal-breakdown-label">en abonos</span>
|
||||||
|
</div>
|
||||||
|
<div className="summary-count">
|
||||||
|
{formatNumber(r.count)}{" "}
|
||||||
|
{r.count === 1 ? "movimiento" : "movimientos"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the charges went — the question a customer asks about their balance. */
|
||||||
|
function ConceptosSection({
|
||||||
|
data,
|
||||||
|
currency,
|
||||||
|
}: {
|
||||||
|
data: Statement;
|
||||||
|
currency: LedgerCurrency;
|
||||||
|
}) {
|
||||||
|
const rows = data.byType.filter((t) => t.currency === currency).slice(0, 12);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const largest = Math.abs(Number(rows[0]?.total ?? 0)) || 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="servicios" title={`Cargos por concepto · ${currency}`} />
|
||||||
|
<div className="card">
|
||||||
|
<div className="concept-list">
|
||||||
|
{rows.map((t) => (
|
||||||
|
<div className="concept-row" key={`${t.name}-${t.currency}`}>
|
||||||
|
<div className="concept-name">
|
||||||
|
{txTypeLabel({ nameEn: t.name })}
|
||||||
|
<span className="concept-count">
|
||||||
|
{formatNumber(t.count)}{" "}
|
||||||
|
{t.count === 1 ? "cargo" : "cargos"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="concept-bar" aria-hidden>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: `${Math.max(
|
||||||
|
2,
|
||||||
|
(Math.abs(Number(t.total)) / largest) * 100,
|
||||||
|
)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="concept-total tx-amount neg">
|
||||||
|
{formatMoney(t.total, currency)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatementRow({
|
||||||
|
m,
|
||||||
|
canVoid,
|
||||||
|
onVoided,
|
||||||
|
}: {
|
||||||
|
m: StatementMovement;
|
||||||
|
canVoid: boolean;
|
||||||
|
onVoided: () => void;
|
||||||
|
}) {
|
||||||
|
const concept = m.message || m.period || null;
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function doVoid() {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"¿Anular este movimiento? Quedará tachado y no contará en los totales.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await voidMovement(m.id);
|
||||||
|
onVoided();
|
||||||
|
} catch (e) {
|
||||||
|
window.alert(
|
||||||
|
(e as Error)?.message ?? "No se pudo anular el movimiento.",
|
||||||
|
);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr style={m.voided ? { textDecoration: "line-through", opacity: 0.55 } : undefined}>
|
||||||
|
<td className="mono" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{formatDate(m.transactionDate)}
|
||||||
|
</td>
|
||||||
|
<td className="tx-domain-cell">
|
||||||
|
<span className={`tx-dot ${m.domain}`} />
|
||||||
|
{domainLabel(m.domain)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{txTypeLabel(m.type)}
|
||||||
|
{concept && <div className="tx-concept">{concept}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="tx-ref">
|
||||||
|
{m.reference || m.checkNumber || "—"}
|
||||||
|
<div className="tx-concept">{ledgerSourceLabel(m.source)}</div>
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
<span className={`tx-amount ${m.direction === "charge" ? "neg" : "pos"}`}>
|
||||||
|
{formatMoney(m.amount, m.currency)}
|
||||||
|
</span>
|
||||||
|
<div className="tx-cur">{directionLabel(m.direction)}</div>
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
<span className={`bal-running ${balanceTone(m.balanceAfter)}`}>
|
||||||
|
{formatMoney(m.balanceAfter, m.currency)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{canVoid && (
|
||||||
|
<td style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{!m.voided && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ padding: "4px 10px", fontSize: 12 }}
|
||||||
|
onClick={doVoid}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? "Anulando…" : "Anular"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHead({
|
||||||
|
rule,
|
||||||
|
title,
|
||||||
|
count,
|
||||||
|
countSuffix,
|
||||||
|
right,
|
||||||
|
}: {
|
||||||
|
rule: string;
|
||||||
|
title: string;
|
||||||
|
count?: number;
|
||||||
|
countSuffix?: string;
|
||||||
|
right?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="section-head"
|
||||||
|
style={
|
||||||
|
right
|
||||||
|
? { display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={`section-rule ${rule}`} aria-hidden />
|
||||||
|
<h2 className="section-title">{title}</h2>
|
||||||
|
{count != null && (
|
||||||
|
<span className="section-count">
|
||||||
|
{formatNumber(count)} {countSuffix ?? ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{right && (
|
||||||
|
<div style={{ marginLeft: "auto" }}>{right}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatementSkeleton() {
|
||||||
|
return (
|
||||||
|
<div aria-hidden>
|
||||||
|
<div className="skeleton" style={{ height: 18, width: 180 }} />
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{ height: 150, marginTop: 16, borderRadius: 16 }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{ height: 320, marginTop: 24, borderRadius: 16 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { Captura } from "@/components/Captura";
|
||||||
|
|
||||||
|
/** Daily capture, opened on the manual (key-by-hand) mode. */
|
||||||
|
export default function BatchCapturePage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<Captura initialMode="manual" />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,501 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import {
|
||||||
|
EXPIRY_WINDOW_DAYS,
|
||||||
|
getBankStats,
|
||||||
|
getBillingStats,
|
||||||
|
getPolicyStats,
|
||||||
|
getPropertyStats,
|
||||||
|
getStats,
|
||||||
|
listBankAccounts,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { useAuth } from "@/lib/abilities";
|
||||||
|
import {
|
||||||
|
balancePhrase,
|
||||||
|
formatDate,
|
||||||
|
formatMoney,
|
||||||
|
formatNumber,
|
||||||
|
policyStatusLabel,
|
||||||
|
trustStatusLabel,
|
||||||
|
} from "@/lib/labels";
|
||||||
|
import type {
|
||||||
|
BankAccount,
|
||||||
|
BankStats,
|
||||||
|
BillingStats,
|
||||||
|
CustomerStats,
|
||||||
|
PolicyStats,
|
||||||
|
PropertyStats,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
export default function InicioPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<HomeDashboard />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardData {
|
||||||
|
customers: CustomerStats | null;
|
||||||
|
policies: PolicyStats | null;
|
||||||
|
properties: PropertyStats | null;
|
||||||
|
billing: BillingStats | null;
|
||||||
|
/** Figures for ONE chequera — see `bankAccount` for which. */
|
||||||
|
bank: BankStats | null;
|
||||||
|
/**
|
||||||
|
* The chequera the card above is reading. The office keeps more than one, in
|
||||||
|
* different currencies, so this card shows the default account rather than a
|
||||||
|
* cross-account total, which would be a figure that never existed.
|
||||||
|
*/
|
||||||
|
bankAccount: BankAccount | null;
|
||||||
|
bankAccountCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same default as /banco, so the two screens agree on which chequera opens. */
|
||||||
|
function defaultAccount(accounts: BankAccount[]): BankAccount | null {
|
||||||
|
const remembered =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window.localStorage.getItem("banco.bankAccountId")
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
accounts.find((a) => a.id === remembered) ??
|
||||||
|
accounts.find((a) => a.active) ??
|
||||||
|
accounts[0] ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeDashboard() {
|
||||||
|
const user = useAuth();
|
||||||
|
const [data, setData] = useState<DashboardData>({
|
||||||
|
customers: null,
|
||||||
|
policies: null,
|
||||||
|
properties: null,
|
||||||
|
billing: null,
|
||||||
|
bank: null,
|
||||||
|
bankAccount: null,
|
||||||
|
bankAccountCount: 0,
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
// The chequera figures need an account id, so that read is a two-step:
|
||||||
|
// list the accounts, then ask the default one for its stats.
|
||||||
|
const bank = listBankAccounts().then(async (accounts) => {
|
||||||
|
const account = defaultAccount(accounts);
|
||||||
|
if (!account) return { account: null, stats: null, count: 0 };
|
||||||
|
return {
|
||||||
|
account,
|
||||||
|
stats: await getBankStats(account.id),
|
||||||
|
count: accounts.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.allSettled([
|
||||||
|
getStats(),
|
||||||
|
getPolicyStats(),
|
||||||
|
getPropertyStats(),
|
||||||
|
getBillingStats(),
|
||||||
|
bank,
|
||||||
|
]).then((results) => {
|
||||||
|
if (!alive) return;
|
||||||
|
const bankResult = results[4].status === "fulfilled" ? results[4].value : null;
|
||||||
|
setData({
|
||||||
|
customers: results[0].status === "fulfilled" ? results[0].value : null,
|
||||||
|
policies: results[1].status === "fulfilled" ? results[1].value : null,
|
||||||
|
properties: results[2].status === "fulfilled" ? results[2].value : null,
|
||||||
|
billing: results[3].status === "fulfilled" ? results[3].value : null,
|
||||||
|
bank: bankResult?.stats ?? null,
|
||||||
|
bankAccount: bankResult?.account ?? null,
|
||||||
|
bankAccountCount: bankResult?.count ?? 0,
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const greeting = greetingFor(user?.name);
|
||||||
|
const lastBillingMovement = data.billing?.lastMovement ?? null;
|
||||||
|
const lastBankMovement = data.bank?.lastMovement ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="home rise">
|
||||||
|
<div className="page-head">
|
||||||
|
<p className="eyebrow">Resumen general</p>
|
||||||
|
<h1 className="page-title">{greeting}</h1>
|
||||||
|
<p className="muted" style={{ marginTop: 6, maxWidth: 640 }}>
|
||||||
|
Vista rápida del estado de la cartera de clientes, las pólizas
|
||||||
|
activas, los fideicomisos y los movimientos recientes.
|
||||||
|
</p>
|
||||||
|
<LastSeenLine
|
||||||
|
billing={lastBillingMovement}
|
||||||
|
bank={lastBankMovement}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section aria-label="Indicadores principales" className="home-section">
|
||||||
|
<KpiCard
|
||||||
|
href="/clientes"
|
||||||
|
label="Clientes"
|
||||||
|
loading={loading}
|
||||||
|
primary={data.customers ? formatNumber(data.customers.customers) : "—"}
|
||||||
|
sub={
|
||||||
|
data.customers
|
||||||
|
? [
|
||||||
|
`${formatNumber(data.customers.withUtilities)} con servicios`,
|
||||||
|
`${formatNumber(data.customers.withInsurance)} con seguros`,
|
||||||
|
`${formatNumber(data.customers.bothLines)} en ambos ramos`,
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
href="/servicios"
|
||||||
|
label="Propiedades"
|
||||||
|
loading={loading}
|
||||||
|
primary={data.properties ? formatNumber(data.properties.properties) : "—"}
|
||||||
|
sub={
|
||||||
|
data.properties
|
||||||
|
? [
|
||||||
|
`${formatNumber(data.properties.services)} servicios`,
|
||||||
|
`${formatNumber(data.properties.trusts)} fideicomisos`,
|
||||||
|
`${formatNumber(data.properties.trustExpiring)} por vencer`,
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
href="/polizas"
|
||||||
|
label="Pólizas"
|
||||||
|
loading={loading}
|
||||||
|
primary={data.policies ? formatNumber(data.policies.total) : "—"}
|
||||||
|
sub={
|
||||||
|
data.policies
|
||||||
|
? [
|
||||||
|
`${formatNumber(data.policies.active)} vigentes`,
|
||||||
|
`${formatNumber(data.policies.expiring)} por vencer (${EXPIRY_WINDOW_DAYS} d)`,
|
||||||
|
`${formatNumber(data.policies.expired + data.policies.undated)} vencidas o sin fecha`,
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
href="/estado-cuenta"
|
||||||
|
label="Movimientos"
|
||||||
|
loading={loading}
|
||||||
|
primary={data.billing ? formatNumber(data.billing.movements) : "—"}
|
||||||
|
sub={
|
||||||
|
data.billing
|
||||||
|
? [
|
||||||
|
`${formatNumber(data.billing.ledgerCustomers)} clientes con cargo`,
|
||||||
|
`${formatNumber(data.billing.crossLineCustomers)} con ambos ramos`,
|
||||||
|
lastBillingMovement
|
||||||
|
? `Último: ${formatDate(lastBillingMovement)}`
|
||||||
|
: "Sin movimientos",
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="Atención" className="home-section">
|
||||||
|
<div className="section-head">
|
||||||
|
<h2 className="section-title">Atención</h2>
|
||||||
|
<span className="section-sub">
|
||||||
|
Lo que conviene revisar antes de cerrar el día
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="attention-grid">
|
||||||
|
<AttentionCard
|
||||||
|
href="/polizas?status=expiring"
|
||||||
|
tone="warn"
|
||||||
|
loading={loading}
|
||||||
|
title="Pólizas por vencer"
|
||||||
|
primary={
|
||||||
|
data.policies ? formatNumber(data.policies.expiring) : "—"
|
||||||
|
}
|
||||||
|
sub={
|
||||||
|
data.policies
|
||||||
|
? `En los próximos ${EXPIRY_WINDOW_DAYS} días — ventana: ${policyStatusLabel("expiring")}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
meta={
|
||||||
|
data.policies
|
||||||
|
? `${formatNumber(data.policies.active)} vigentes · ${formatNumber(data.policies.expired)} vencidas`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AttentionCard
|
||||||
|
href="/servicios?trust=expiring"
|
||||||
|
tone="warn"
|
||||||
|
loading={loading}
|
||||||
|
title="Fideicomisos por vencer"
|
||||||
|
primary={
|
||||||
|
data.properties ? formatNumber(data.properties.trustExpiring) : "—"
|
||||||
|
}
|
||||||
|
sub={
|
||||||
|
data.properties
|
||||||
|
? `Renueva en los próximos ${EXPIRY_WINDOW_DAYS} días (${trustStatusLabel("expiring")})`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
meta={
|
||||||
|
data.properties
|
||||||
|
? `${formatNumber(data.properties.trusts)} fideicomisos en cartera`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AttentionCard
|
||||||
|
href="/polizas?status=undated"
|
||||||
|
tone="muted"
|
||||||
|
loading={loading}
|
||||||
|
title="Pólizas sin vigencia"
|
||||||
|
primary={data.policies ? formatNumber(data.policies.undated) : "—"}
|
||||||
|
sub={
|
||||||
|
data.policies
|
||||||
|
? "Sin fecha de fin registrada — revisar y completar"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
meta={
|
||||||
|
data.policies
|
||||||
|
? `${formatNumber(data.policies.liquidated)} liquidadas`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AttentionCard
|
||||||
|
href="/estado-cuenta"
|
||||||
|
tone="info"
|
||||||
|
loading={loading}
|
||||||
|
title="Clientes con adeudo"
|
||||||
|
primary={
|
||||||
|
data.billing ? (
|
||||||
|
<CurrencyTotals
|
||||||
|
totals={data.billing.byCurrency}
|
||||||
|
field="owing"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sub={
|
||||||
|
data.billing
|
||||||
|
? `En ${formatNumber(data.billing.ledgerCustomers)} expedientes con cargo`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
meta={
|
||||||
|
data.billing
|
||||||
|
? `${formatNumber(data.billing.crossLineCustomers)} clientes con cargo en ambos ramos`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AttentionCard
|
||||||
|
href="/banco"
|
||||||
|
tone="info"
|
||||||
|
loading={loading}
|
||||||
|
title="Chequera del despacho"
|
||||||
|
primary={
|
||||||
|
data.bank && data.bankAccount ? (
|
||||||
|
<span className={data.bank.net.startsWith("-") ? "money-neg" : "money-pos"}>
|
||||||
|
{formatMoney(data.bank.net, data.bankAccount.currency)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Names the account, because this is one chequera's figure and the
|
||||||
|
// office has more than one — they are never added together.
|
||||||
|
sub={
|
||||||
|
data.bank && data.bankAccount
|
||||||
|
? `${data.bankAccount.label} · ${balancePhrase(data.bank.net)}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
meta={
|
||||||
|
data.bank
|
||||||
|
? `${formatNumber(data.bank.movements)} movimientos · ${formatNumber(data.bank.pending)} pendientes` +
|
||||||
|
(data.bankAccountCount > 1
|
||||||
|
? ` · ${formatNumber(data.bankAccountCount)} cuentas en total`
|
||||||
|
: "")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="Accesos rápidos" className="home-section">
|
||||||
|
<div className="section-head">
|
||||||
|
<h2 className="section-title">Accesos rápidos</h2>
|
||||||
|
<span className="section-sub">
|
||||||
|
Ir directo a cada módulo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="quick-grid">
|
||||||
|
<QuickLink href="/clientes" label="Clientes" sub="Directorio unificado" />
|
||||||
|
<QuickLink href="/servicios" label="Propiedades" sub="Servicios y fideicomisos" />
|
||||||
|
<QuickLink href="/polizas" label="Pólizas" sub="Vigencias y liquidaciones" />
|
||||||
|
<QuickLink href="/estado-cuenta" label="Estado de cuenta" sub="Cargos y abonos" />
|
||||||
|
<QuickLink href="/banco" label="Chequera" sub="Ingresos y egresos" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function greetingFor(name?: string): string {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
const partOfDay =
|
||||||
|
hour < 12 ? "Buenos días" : hour < 19 ? "Buenas tardes" : "Buenas noches";
|
||||||
|
const display = (name ?? "").trim().split(/\s+/)[0];
|
||||||
|
return display ? `${partOfDay}, ${display}` : partOfDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LastSeenLine({
|
||||||
|
billing,
|
||||||
|
bank,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
billing: string | null;
|
||||||
|
bank: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<p className="muted home-last-seen" aria-hidden="true">
|
||||||
|
<span className="skeleton" style={{ display: "inline-block", width: 220, height: 12 }} />
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!billing && !bank) return null;
|
||||||
|
return (
|
||||||
|
<p className="muted home-last-seen">
|
||||||
|
{billing && <>Último movimiento de cartera: <strong>{formatDate(billing)}</strong></>}
|
||||||
|
{billing && bank && <span aria-hidden="true"> · </span>}
|
||||||
|
{bank && <>Último movimiento de chequera: <strong>{formatDate(bank)}</strong></>}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({
|
||||||
|
href,
|
||||||
|
label,
|
||||||
|
primary,
|
||||||
|
sub,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
primary: React.ReactNode;
|
||||||
|
sub: string[];
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className="kpi-card card">
|
||||||
|
<div className="kpi-label">{label}</div>
|
||||||
|
<div className="kpi-primary">
|
||||||
|
{loading ? (
|
||||||
|
<span
|
||||||
|
className="skeleton"
|
||||||
|
style={{ display: "inline-block", width: 90, height: 30 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
primary
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ul className="kpi-sub">
|
||||||
|
{loading
|
||||||
|
? Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<li key={i} className="skeleton" style={{ height: 10 }} />
|
||||||
|
))
|
||||||
|
: sub.map((line) => <li key={line}>{line}</li>)}
|
||||||
|
</ul>
|
||||||
|
<span className="kpi-cta" aria-hidden="true">
|
||||||
|
Ver módulo →
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttentionCard({
|
||||||
|
href,
|
||||||
|
tone,
|
||||||
|
title,
|
||||||
|
primary,
|
||||||
|
sub,
|
||||||
|
meta,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
tone: "warn" | "info" | "muted";
|
||||||
|
title: string;
|
||||||
|
primary: React.ReactNode;
|
||||||
|
sub?: string;
|
||||||
|
meta?: string;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className={`attention-card tone-${tone} card`}>
|
||||||
|
<div className="attention-head">
|
||||||
|
<span className="attention-title">{title}</span>
|
||||||
|
<span className="attention-arrow" aria-hidden="true">→</span>
|
||||||
|
</div>
|
||||||
|
<div className="attention-primary">
|
||||||
|
{loading ? (
|
||||||
|
<span
|
||||||
|
className="skeleton"
|
||||||
|
style={{ display: "inline-block", width: 70, height: 28 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
primary
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{sub && !loading && <div className="attention-sub">{sub}</div>}
|
||||||
|
{meta && !loading && <div className="attention-meta">{meta}</div>}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickLink({
|
||||||
|
href,
|
||||||
|
label,
|
||||||
|
sub,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
sub: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className="quick-link card">
|
||||||
|
<span className="quick-label">{label}</span>
|
||||||
|
<span className="quick-sub">{sub}</span>
|
||||||
|
<span className="quick-arrow" aria-hidden="true">→</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CurrencyTotals({
|
||||||
|
totals,
|
||||||
|
field,
|
||||||
|
}: {
|
||||||
|
totals: BillingStats["byCurrency"];
|
||||||
|
field: "owing" | "inCredit";
|
||||||
|
}) {
|
||||||
|
if (!totals || totals.length === 0) return <>—</>;
|
||||||
|
return (
|
||||||
|
<span className="home-currency-totals">
|
||||||
|
{totals.map((c) => (
|
||||||
|
<span key={c.currency} className="home-currency-totals-row">
|
||||||
|
<span className="badge badge-count">{c.currency}</span>
|
||||||
|
<span className="home-currency-totals-num">
|
||||||
|
{formatNumber(c[field])}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,65 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import "./globals.css";
|
||||||
|
import { readBuildInfoFromEnv } from "@/lib/build-info";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Jorge Cuadros & Assoc.",
|
title: "Jorge Cuadros & Asociados — Plataforma",
|
||||||
description: "Unified customer, insurance, and utilities platform",
|
description:
|
||||||
|
"Plataforma interna unificada de clientes, servicios y seguros.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The browser talks to the API cross-origin, so it needs the API URL at
|
||||||
|
// runtime. NEXT_PUBLIC_* would bake it at build time (one URL per image); we
|
||||||
|
// want the URL to come from the deploy .env instead. So read it here on the
|
||||||
|
// server per request and inject it as window.__API_ORIGIN__ (see lib/api.ts).
|
||||||
|
// force-dynamic guarantees process.env is read at request time, never baked
|
||||||
|
// into a static prerender.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
|
const apiOrigin =
|
||||||
|
process.env.API_ORIGIN ??
|
||||||
|
process.env.NEXT_PUBLIC_API_ORIGIN ??
|
||||||
|
"http://localhost:3001";
|
||||||
|
// Same reason as the API origin: read on the server per request so the built
|
||||||
|
// image is not pinned to one build identity in its client bundle.
|
||||||
|
const build = readBuildInfoFromEnv();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
{/* Must run before the app bundle so lib/api.ts sees it at import. */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html:
|
||||||
|
`window.__API_ORIGIN__=${JSON.stringify(apiOrigin)};` +
|
||||||
|
`window.__APP_BUILD__=${JSON.stringify(build)};`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Text-size preference, applied before first paint so the page never
|
||||||
|
flashes at the default size. Mirrors lib/ui-scale.ts — keep the key
|
||||||
|
and the clamp in sync with it. */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html:
|
||||||
|
`try{var s=parseFloat(localStorage.getItem("jc.ui-scale"));` +
|
||||||
|
`if(isFinite(s))document.documentElement.style.setProperty(` +
|
||||||
|
`"--ui-scale",String(Math.min(1.5,Math.max(0.9,s))));}catch(e){}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Google Fonts via <link> so an offline build still runs with the
|
||||||
|
system fallback stacks defined in globals.css. */}
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link
|
||||||
|
rel="preconnect"
|
||||||
|
href="https://fonts.gstatic.com"
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,560;9..144,600&family=Work+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ApiError, login, me } from "@/lib/api";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [bootChecking, setBootChecking] = useState(true);
|
||||||
|
|
||||||
|
// If already signed in, skip straight to the customer browser.
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
me()
|
||||||
|
.then(() => router.replace("/inicio"))
|
||||||
|
.catch(() => {
|
||||||
|
if (alive) setBootChecking(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await login(email.trim(), password);
|
||||||
|
router.replace("/inicio");
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
setError("Correo o contraseña incorrectos");
|
||||||
|
} else if (err instanceof ApiError && err.status === 0) {
|
||||||
|
setError(err.message);
|
||||||
|
} else {
|
||||||
|
setError("No se pudo iniciar sesión. Inténtalo de nuevo.");
|
||||||
|
}
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bootChecking) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
color: "var(--brand-700)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="spinner" aria-label="Cargando" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-wrap">
|
||||||
|
{/* Brand / narrative panel */}
|
||||||
|
<aside className="login-aside" aria-hidden="false">
|
||||||
|
<div className="login-aside-top">
|
||||||
|
<div className="login-brand">
|
||||||
|
<img
|
||||||
|
src="/images/company_logo.png"
|
||||||
|
alt=""
|
||||||
|
className="brand-mark"
|
||||||
|
/>
|
||||||
|
<div className="brand-text">
|
||||||
|
<span className="brand-name" style={{ color: "#f6f3ec" }}>
|
||||||
|
Jorge Cuadros
|
||||||
|
</span>
|
||||||
|
<span className="brand-sub">& Asociados</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="login-aside-mid">
|
||||||
|
<p className="eyebrow" style={{ color: "rgba(242,239,231,0.6)" }}>
|
||||||
|
Plataforma interna
|
||||||
|
</p>
|
||||||
|
<h1 className="login-headline">
|
||||||
|
Un solo expediente para <em>Servicios</em> y <em>Seguros</em>.
|
||||||
|
</h1>
|
||||||
|
<p className="login-lede">
|
||||||
|
Consulta en un mismo lugar las propiedades, pólizas y el estado de
|
||||||
|
cuenta de cada cliente en Baja California.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="login-aside-foot">
|
||||||
|
<div className="login-lob">
|
||||||
|
<span className="badge badge-servicios">
|
||||||
|
<span className="dot" /> Servicios
|
||||||
|
</span>
|
||||||
|
<span className="badge badge-seguros">
|
||||||
|
<span className="dot" /> Seguros
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="login-foot-note">
|
||||||
|
Gestión de propiedades y correduría de seguros
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Form panel */}
|
||||||
|
<section className="login-form-panel">
|
||||||
|
<div className="login-form-inner rise">
|
||||||
|
<p className="eyebrow">Acceso del personal</p>
|
||||||
|
<h2 className="login-form-title">Iniciar sesión</h2>
|
||||||
|
<p className="muted" style={{ marginTop: 6, marginBottom: 28 }}>
|
||||||
|
Ingresa con tu cuenta para continuar.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Correo electrónico</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
className="input"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="nombre@jorgecuadros.local"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Contraseña</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="input"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="login-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary login-submit"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner" style={{ width: 15, height: 15 }} />
|
||||||
|
Entrando…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Entrar"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import {
|
||||||
|
OPS_KIND_LABELS,
|
||||||
|
OPS_STATUS_LABELS,
|
||||||
|
formatBytes,
|
||||||
|
formatDateTime,
|
||||||
|
} from "@/lib/labels";
|
||||||
|
import {
|
||||||
|
backupDownloadUrl,
|
||||||
|
deleteBackup,
|
||||||
|
deleteIngest,
|
||||||
|
getOpsJob,
|
||||||
|
listBackups,
|
||||||
|
listIngest,
|
||||||
|
listOpsJobs,
|
||||||
|
startOpsJob,
|
||||||
|
uploadIngest,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import type {
|
||||||
|
BackupFile,
|
||||||
|
IngestFile,
|
||||||
|
OpsJob,
|
||||||
|
OpsJobKind,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
const INGEST_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
export default function OperacionesPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<Operaciones />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfirmState =
|
||||||
|
| { kind: "REIMPORT" }
|
||||||
|
| { kind: "SYNC" }
|
||||||
|
| { kind: "RESTORE"; file: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
function Operaciones() {
|
||||||
|
const allowed = useCan("db:manage");
|
||||||
|
|
||||||
|
const [ingest, setIngest] = useState<IngestFile[] | null>(null);
|
||||||
|
const [backups, setBackups] = useState<BackupFile[] | null>(null);
|
||||||
|
const [jobs, setJobs] = useState<OpsJob[] | null>(null);
|
||||||
|
const [activeJob, setActiveJob] = useState<OpsJob | null>(null);
|
||||||
|
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||||
|
const [confirmText, setConfirmText] = useState("");
|
||||||
|
const [uploading, setUploading] = useState<string | null>(null);
|
||||||
|
const [starting, setStarting] = useState(false);
|
||||||
|
|
||||||
|
const fileInputs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
|
||||||
|
const refreshLists = useCallback(() => {
|
||||||
|
listIngest().then(setIngest).catch(() => setIngest([]));
|
||||||
|
listBackups().then(setBackups).catch(() => setBackups([]));
|
||||||
|
listOpsJobs()
|
||||||
|
.then((rows) => {
|
||||||
|
setJobs(rows);
|
||||||
|
const running = rows.find((j) => j.status === "RUNNING");
|
||||||
|
if (running) setActiveJob(running);
|
||||||
|
})
|
||||||
|
.catch(() => setJobs([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (allowed) refreshLists();
|
||||||
|
}, [allowed, refreshLists]);
|
||||||
|
|
||||||
|
// Poll the active job while it runs; refresh everything when it finishes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeJob || activeJob.status !== "RUNNING") return;
|
||||||
|
const id = activeJob.id;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
getOpsJob(id)
|
||||||
|
.then((job) => {
|
||||||
|
setActiveJob(job);
|
||||||
|
if (job.status !== "RUNNING") {
|
||||||
|
clearInterval(timer);
|
||||||
|
refreshLists();
|
||||||
|
setNotice(
|
||||||
|
job.status === "SUCCESS"
|
||||||
|
? `${OPS_KIND_LABELS[job.kind]} completada.`
|
||||||
|
: `${OPS_KIND_LABELS[job.kind]} terminó con error. Revise el registro.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* transient — keep polling */
|
||||||
|
});
|
||||||
|
}, 1500);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [activeJob, refreshLists]);
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
return (
|
||||||
|
<div className="page-head">
|
||||||
|
<h1 className="page-title">Operaciones</h1>
|
||||||
|
<div className="state-box state-error">
|
||||||
|
No tiene permisos para administrar la base de datos.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobRunning = activeJob?.status === "RUNNING";
|
||||||
|
|
||||||
|
async function handleUpload(name: string, file: File | undefined) {
|
||||||
|
if (!file) return;
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
setUploading(name);
|
||||||
|
try {
|
||||||
|
await uploadIngest(name, file);
|
||||||
|
setNotice(`${name} cargado.`);
|
||||||
|
refreshLists();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error)?.message ?? "No se pudo cargar el archivo.");
|
||||||
|
} finally {
|
||||||
|
setUploading(null);
|
||||||
|
const input = fileInputs.current[name];
|
||||||
|
if (input) input.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteIngest(name: string) {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await deleteIngest(name);
|
||||||
|
refreshLists();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error)?.message ?? "No se pudo eliminar.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteBackup(name: string) {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await deleteBackup(name);
|
||||||
|
refreshLists();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error)?.message ?? "No se pudo eliminar el respaldo.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(kind: OpsJobKind, file?: string) {
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
setStarting(true);
|
||||||
|
try {
|
||||||
|
const job = await startOpsJob(kind, file);
|
||||||
|
setActiveJob(job);
|
||||||
|
setJobs((prev) => (prev ? [job, ...prev] : [job]));
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error)?.message ?? "No se pudo iniciar la operación.");
|
||||||
|
} finally {
|
||||||
|
setStarting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function askConfirm(state: ConfirmState) {
|
||||||
|
setConfirm(state);
|
||||||
|
setConfirmText("");
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runConfirmed() {
|
||||||
|
if (!confirm) return;
|
||||||
|
const c = confirm;
|
||||||
|
setConfirm(null);
|
||||||
|
if (c.kind === "REIMPORT") await start("REIMPORT");
|
||||||
|
else if (c.kind === "SYNC") await start("SYNC");
|
||||||
|
else await start("RESTORE", c.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ingestReady = (ingest ?? []).every((f) => f.present);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<h1 className="page-title">Operaciones de base de datos</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
|
{notice && <div className="state-box">{notice}</div>}
|
||||||
|
|
||||||
|
{/* Active / running job with live log */}
|
||||||
|
{activeJob && (
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||||
|
<div className="row-actions" style={{ justifyContent: "space-between" }}>
|
||||||
|
<h2 className="section-title" style={{ margin: 0 }}>
|
||||||
|
{OPS_KIND_LABELS[activeJob.kind]}{" "}
|
||||||
|
<span
|
||||||
|
className={`badge ${
|
||||||
|
activeJob.status === "SUCCESS"
|
||||||
|
? "badge-positive"
|
||||||
|
: activeJob.status === "FAILED"
|
||||||
|
? "badge-negative"
|
||||||
|
: "badge-neutral"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{jobRunning && <span className="spinner" aria-hidden style={{ marginRight: 6 }} />}
|
||||||
|
{OPS_STATUS_LABELS[activeJob.status]}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
{!jobRunning && (
|
||||||
|
<button className="btn btn-ghost" type="button" onClick={() => setActiveJob(null)}>
|
||||||
|
Ocultar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<pre className="ops-log">{activeJob.log || "Iniciando…"}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ingest folder */}
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||||
|
<h2 className="section-title">Carpeta de ingesta</h2>
|
||||||
|
<p className="inline-form-note">
|
||||||
|
Los cuatro archivos originales de Access. La reimportación y la
|
||||||
|
sincronización leen de aquí. Tamaño máximo por archivo: {formatBytes(INGEST_MAX_BYTES)}.
|
||||||
|
</p>
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Archivo</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th className="num">Tamaño</th>
|
||||||
|
<th>Modificado</th>
|
||||||
|
<th className="num">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(ingest ?? []).map((f) => (
|
||||||
|
<tr key={f.name}>
|
||||||
|
<td className="mono">{f.name}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${f.present ? "badge-positive" : "badge-negative"}`}>
|
||||||
|
{f.present ? "Presente" : "Falta"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="num">{formatBytes(f.size)}</td>
|
||||||
|
<td>{formatDateTime(f.modifiedAt)}</td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<input
|
||||||
|
ref={(el) => {
|
||||||
|
fileInputs.current[f.name] = el;
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(e) => handleUpload(f.name, e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline"
|
||||||
|
type="button"
|
||||||
|
disabled={uploading === f.name}
|
||||||
|
onClick={() => fileInputs.current[f.name]?.click()}
|
||||||
|
>
|
||||||
|
{uploading === f.name ? "Cargando…" : f.present ? "Reemplazar" : "Cargar"}
|
||||||
|
</button>
|
||||||
|
{f.present && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteIngest(f.name)}
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Operations */}
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||||
|
<h2 className="section-title">Operaciones</h2>
|
||||||
|
<div className="form-grid">
|
||||||
|
<OpTile
|
||||||
|
title="Respaldo"
|
||||||
|
desc="Genera un volcado comprimido de la base de datos actual."
|
||||||
|
action="Crear respaldo"
|
||||||
|
tone="primary"
|
||||||
|
disabled={jobRunning || starting}
|
||||||
|
onClick={() => start("BACKUP")}
|
||||||
|
/>
|
||||||
|
<OpTile
|
||||||
|
title="Reimportar (purga)"
|
||||||
|
desc="Respalda, borra TODO y reconstruye desde los archivos de ingesta. Se pierden los datos capturados manualmente."
|
||||||
|
action="Reimportar"
|
||||||
|
tone="danger"
|
||||||
|
disabled={jobRunning || starting || !ingestReady}
|
||||||
|
onClick={() => askConfirm({ kind: "REIMPORT" })}
|
||||||
|
/>
|
||||||
|
<OpTile
|
||||||
|
title="Sincronizar"
|
||||||
|
desc="Respalda, luego importa lo nuevo del legado. Borra del sistema los registros del legado que ya no aparecen en los archivos de ingesta. Se conservan los datos capturados a mano."
|
||||||
|
action="Sincronizar"
|
||||||
|
tone="primary"
|
||||||
|
disabled={jobRunning || starting || !ingestReady}
|
||||||
|
onClick={() => askConfirm({ kind: "SYNC" })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!ingestReady && (
|
||||||
|
<p className="inline-form-note" style={{ marginTop: 12 }}>
|
||||||
|
La reimportación requiere que los cuatro archivos estén presentes.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Backups */}
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||||
|
<h2 className="section-title">Respaldos</h2>
|
||||||
|
<p className="inline-form-note">
|
||||||
|
Restaurar sobreescribe la base de datos completa con el respaldo elegido.
|
||||||
|
</p>
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Archivo</th>
|
||||||
|
<th className="num">Tamaño</th>
|
||||||
|
<th>Creado</th>
|
||||||
|
<th className="num">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{backups === null ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4}>
|
||||||
|
<span className="spinner" aria-label="Cargando" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : backups.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="muted">
|
||||||
|
Sin respaldos.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
backups.map((b) => (
|
||||||
|
<tr key={b.name}>
|
||||||
|
<td className="mono">{b.name}</td>
|
||||||
|
<td className="num">{formatBytes(b.size)}</td>
|
||||||
|
<td>{formatDateTime(b.createdAt)}</td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<a className="btn btn-outline" href={backupDownloadUrl(b.name)}>
|
||||||
|
Descargar
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline"
|
||||||
|
type="button"
|
||||||
|
disabled={jobRunning || starting}
|
||||||
|
onClick={() => askConfirm({ kind: "RESTORE", file: b.name })}
|
||||||
|
>
|
||||||
|
Restaurar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteBackup(b.name)}
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent jobs */}
|
||||||
|
<div className="card" style={{ padding: 20 }}>
|
||||||
|
<h2 className="section-title">Historial</h2>
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Operación</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th>Inicio</th>
|
||||||
|
<th>Fin</th>
|
||||||
|
<th className="num"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(jobs ?? []).map((j) => (
|
||||||
|
<tr key={j.id}>
|
||||||
|
<td>{OPS_KIND_LABELS[j.kind]}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className={`badge ${
|
||||||
|
j.status === "SUCCESS"
|
||||||
|
? "badge-positive"
|
||||||
|
: j.status === "FAILED"
|
||||||
|
? "badge-negative"
|
||||||
|
: "badge-neutral"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{OPS_STATUS_LABELS[j.status]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{formatDateTime(j.startedAt)}</td>
|
||||||
|
<td>{formatDateTime(j.finishedAt)}</td>
|
||||||
|
<td className="num">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveJob(j)}
|
||||||
|
>
|
||||||
|
Ver registro
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{jobs && jobs.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="muted">
|
||||||
|
Sin operaciones registradas.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Destructive-op confirm */}
|
||||||
|
{confirm && (
|
||||||
|
<div className="modal-backdrop" role="dialog" aria-modal="true">
|
||||||
|
<div className="card" style={{ padding: 24, maxWidth: 480 }}>
|
||||||
|
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||||
|
{confirm.kind === "REIMPORT"
|
||||||
|
? "Confirmar reimportación"
|
||||||
|
: confirm.kind === "SYNC"
|
||||||
|
? "Confirmar sincronización"
|
||||||
|
: "Confirmar restauración"}
|
||||||
|
</h2>
|
||||||
|
<p className="inline-form-note">
|
||||||
|
{confirm.kind === "REIMPORT"
|
||||||
|
? "Esto BORRA todos los datos actuales (incluidos los capturados a mano) y reconstruye desde los archivos de ingesta. Se creará un respaldo previo automático."
|
||||||
|
: confirm.kind === "SYNC"
|
||||||
|
? "Se creará un respaldo previo automático. Luego se importarán al sistema los registros nuevos del legado y se eliminarán los del legado que ya no aparezcan en los archivos de ingesta. Los datos capturados a mano NO se borran."
|
||||||
|
: `Esto sobreescribe la base de datos completa con “${confirm.file}”. Se recomienda crear un respaldo antes.`}
|
||||||
|
</p>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Escriba CONFIRMAR para continuar</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={confirmText}
|
||||||
|
onChange={(e) => setConfirmText(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
className={confirm.kind === "SYNC" ? "btn btn-primary" : "btn btn-danger"}
|
||||||
|
type="button"
|
||||||
|
disabled={confirmText !== "CONFIRMAR" || starting}
|
||||||
|
onClick={runConfirmed}
|
||||||
|
>
|
||||||
|
{confirm.kind === "REIMPORT"
|
||||||
|
? "Reimportar"
|
||||||
|
: confirm.kind === "SYNC"
|
||||||
|
? "Sincronizar"
|
||||||
|
: "Restaurar"}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline" type="button" onClick={() => setConfirm(null)}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpTile({
|
||||||
|
title,
|
||||||
|
desc,
|
||||||
|
action,
|
||||||
|
tone,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
action: string;
|
||||||
|
tone: "primary" | "danger" | "muted";
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const btnClass =
|
||||||
|
tone === "danger" ? "btn btn-danger" : tone === "muted" ? "btn btn-outline" : "btn btn-primary";
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 16 }}>
|
||||||
|
<h3 className="section-title" style={{ fontSize: 15, margin: "0 0 4px" }}>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="inline-form-note" style={{ minHeight: 48 }}>
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
|
<button className={btnClass} type="button" disabled={disabled} onClick={onClick}>
|
||||||
|
{action}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
redirect("/inicio");
|
||||||
<main>
|
|
||||||
<h1>Jorge Cuadros & Assoc.</h1>
|
|
||||||
<p>Unified customer platform — scaffold in progress.</p>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { PolicyForm } from "@/components/PolicyForm";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import { getPolicy } from "@/lib/api";
|
||||||
|
import type { PolicyDetail } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function EditarPolizaPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<EditarPoliza id={params.id} />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditarPoliza({ id }: { id: string }) {
|
||||||
|
const allowed = useCan("policy:update");
|
||||||
|
const [policy, setPolicy] = useState<PolicyDetail | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allowed) return;
|
||||||
|
getPolicy(id)
|
||||||
|
.then(setPolicy)
|
||||||
|
.catch((e) => setError(e?.message ?? "No se pudo cargar la póliza."));
|
||||||
|
}, [id, allowed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<Link href={`/polizas/${id}`} className="back-link">← Póliza</Link>
|
||||||
|
<h1 className="page-title">Editar póliza</h1>
|
||||||
|
</div>
|
||||||
|
{!allowed ? (
|
||||||
|
<div className="state-box state-error">
|
||||||
|
No tiene permisos para editar pólizas.
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="state-box state-error">{error}</div>
|
||||||
|
) : !policy ? (
|
||||||
|
<div className="empty-inline"><span className="spinner" aria-label="Cargando" /></div>
|
||||||
|
) : (
|
||||||
|
<PolicyForm policy={policy} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,850 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { ContextReports } from "@/components/ContextReports";
|
||||||
|
import {
|
||||||
|
addPolicyChild,
|
||||||
|
archivePolicy,
|
||||||
|
getLookups,
|
||||||
|
getPolicy,
|
||||||
|
policyDocumentDownloadUrl,
|
||||||
|
removePolicyChild,
|
||||||
|
removePolicyDocument,
|
||||||
|
restorePolicy,
|
||||||
|
updatePolicyChild,
|
||||||
|
uploadPolicyDocument,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import { ChildCollection, type ChildConfig } from "@/components/ChildCollection";
|
||||||
|
import {
|
||||||
|
expiryPhrase,
|
||||||
|
formatDate,
|
||||||
|
formatMoney,
|
||||||
|
policyStatusLabel,
|
||||||
|
premiumHeadline,
|
||||||
|
SIN_NOMBRE,
|
||||||
|
} from "@/lib/labels";
|
||||||
|
import type { AdjusterRow, Installment, PolicyDetail } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function PolizaDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
}) {
|
||||||
|
// Next 14 passes `params` as a plain object here — no `use()` unwrapping.
|
||||||
|
const { id } = params;
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<Detail id={id} />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Detail({ id }: { id: string }) {
|
||||||
|
const [data, setData] = useState<PolicyDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
getPolicy(id)
|
||||||
|
.then((d) => {
|
||||||
|
if (alive) {
|
||||||
|
setData(d);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) {
|
||||||
|
setError(
|
||||||
|
e?.status === 404
|
||||||
|
? "No encontramos esta póliza."
|
||||||
|
: e?.message ?? "No se pudo cargar la póliza.",
|
||||||
|
);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) return <DetailSkeleton />;
|
||||||
|
|
||||||
|
if (error)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BackLink />
|
||||||
|
<div className="state-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const reload = () => getPolicy(id).then(setData).catch(() => {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rise">
|
||||||
|
<div className="detail-actionbar">
|
||||||
|
<BackLink />
|
||||||
|
<ContextReports
|
||||||
|
entries={[
|
||||||
|
{
|
||||||
|
slug: "edo-cuenta-datos",
|
||||||
|
label: "Estado de cuenta del cliente",
|
||||||
|
params: { customerId: data.customer.id },
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<PolicyActions data={data} onChange={reload} />
|
||||||
|
</div>
|
||||||
|
<Hero data={data} />
|
||||||
|
<ClienteSection data={data} />
|
||||||
|
<CondicionesSection data={data} />
|
||||||
|
{data.installments.length > 0 && <PagosSection data={data} />}
|
||||||
|
{data.vehicles.length > 0 && <VehiculosSection data={data} />}
|
||||||
|
{(data.insuredDrivers.length > 0 || data.beneficiaries.length > 0) && (
|
||||||
|
<PersonasSection data={data} />
|
||||||
|
)}
|
||||||
|
{data.claims.length > 0 && <SiniestrosSection data={data} />}
|
||||||
|
<CoberturasSection data={data} />
|
||||||
|
<DocumentosSection data={data} onChange={reload} />
|
||||||
|
<ChildrenEditor data={data} onChange={reload} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Edit / archive controls for the policy header. */
|
||||||
|
function PolicyActions({
|
||||||
|
data,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
data: PolicyDetail;
|
||||||
|
onChange: () => void;
|
||||||
|
}) {
|
||||||
|
const canEdit = useCan("policy:update");
|
||||||
|
const canDelete = useCan("policy:delete");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const archived = data.archivedAt != null;
|
||||||
|
|
||||||
|
async function toggle() {
|
||||||
|
const verb = archived ? "restaurar" : "archivar";
|
||||||
|
if (!window.confirm(`¿Seguro que desea ${verb} esta póliza?`)) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (archived) await restorePolicy(data.id);
|
||||||
|
else await archivePolicy(data.id);
|
||||||
|
onChange();
|
||||||
|
} catch (e) {
|
||||||
|
window.alert((e as Error)?.message ?? "No se pudo completar la acción.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canEdit && !canDelete) return null;
|
||||||
|
return (
|
||||||
|
<div className="row-actions">
|
||||||
|
{archived && <span className="badge badge-negative">Archivada</span>}
|
||||||
|
{canEdit && (
|
||||||
|
<Link href={`/polizas/${data.id}/editar`} className="btn btn-outline">
|
||||||
|
Editar
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={toggle} disabled={busy}>
|
||||||
|
{archived ? "Restaurar" : "Archivar"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editable child collections — only shown to users who can edit the policy. */
|
||||||
|
function ChildrenEditor({
|
||||||
|
data,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
data: PolicyDetail;
|
||||||
|
onChange: () => void;
|
||||||
|
}) {
|
||||||
|
const canEdit = useCan("policy:update");
|
||||||
|
const [adjusters, setAdjusters] = useState<AdjusterRow[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canEdit) getLookups().then((l) => setAdjusters(l.adjusters)).catch(() => {});
|
||||||
|
}, [canEdit]);
|
||||||
|
|
||||||
|
if (!canEdit) return null;
|
||||||
|
|
||||||
|
const INSTALLMENTS: ChildConfig = {
|
||||||
|
apiKind: "installments",
|
||||||
|
title: "Pagos",
|
||||||
|
fields: [
|
||||||
|
{ key: "sequence", label: "Sec.", type: "number" },
|
||||||
|
{ key: "amount", label: "Monto", type: "number" },
|
||||||
|
{ key: "currency", label: "Moneda", type: "select",
|
||||||
|
options: [{ value: "MXN", label: "MXN" }, { value: "USD", label: "USD" }] },
|
||||||
|
{ key: "dueDate", label: "Vence", type: "date" },
|
||||||
|
{ key: "paidDate", label: "Pagado", type: "date" },
|
||||||
|
{ key: "checkNumber", label: "Cheque" },
|
||||||
|
{ key: "isCash", label: "Efectivo", type: "checkbox" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const VEHICLES: ChildConfig = {
|
||||||
|
apiKind: "vehicles",
|
||||||
|
title: "Vehículos",
|
||||||
|
fields: [
|
||||||
|
{ key: "make", label: "Marca" },
|
||||||
|
{ key: "model", label: "Modelo" },
|
||||||
|
{ key: "modelYear", label: "Año" },
|
||||||
|
{ key: "licensePlate", label: "Placa" },
|
||||||
|
{ key: "vinNumber", label: "VIN" },
|
||||||
|
{ key: "stateCode", label: "Estado" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const DRIVERS: ChildConfig = {
|
||||||
|
apiKind: "drivers",
|
||||||
|
title: "Conductores",
|
||||||
|
fields: [
|
||||||
|
{ key: "fullName", label: "Nombre" },
|
||||||
|
{ key: "birthDate", label: "Nacimiento", type: "date" },
|
||||||
|
{ key: "sex", label: "Sexo" },
|
||||||
|
{ key: "occupation", label: "Ocupación" },
|
||||||
|
{ key: "licenseNumber", label: "Licencia" },
|
||||||
|
{ key: "licenseState", label: "Estado" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const BENEFICIARIES: ChildConfig = {
|
||||||
|
apiKind: "beneficiaries",
|
||||||
|
title: "Beneficiarios",
|
||||||
|
fields: [
|
||||||
|
{ key: "name", label: "Nombre" },
|
||||||
|
{ key: "phone", label: "Teléfono" },
|
||||||
|
{ key: "email", label: "Correo" },
|
||||||
|
{ key: "address", label: "Dirección" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const CLAIMS: ChildConfig = {
|
||||||
|
apiKind: "claims",
|
||||||
|
title: "Siniestros",
|
||||||
|
fields: [
|
||||||
|
{ key: "claimType", label: "Tipo" },
|
||||||
|
{ key: "incidentDate", label: "Fecha", type: "date" },
|
||||||
|
{ key: "description", label: "Descripción" },
|
||||||
|
{ key: "adjusterId", label: "Ajustador", type: "select",
|
||||||
|
options: adjusters.map((a) => ({ value: a.id, label: a.name ?? a.company ?? a.id })) },
|
||||||
|
{ key: "claimedAmount", label: "Reclamado", type: "number" },
|
||||||
|
{ key: "settledAmount", label: "Pagado", type: "number" },
|
||||||
|
{ key: "resolved", label: "Resuelto", type: "checkbox" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const bind = (cfg: ChildConfig, rows: Record<string, unknown>[]) => (
|
||||||
|
<ChildCollection
|
||||||
|
config={cfg}
|
||||||
|
rows={rows}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onAdd={async (p) => { await addPolicyChild(data.id, cfg.apiKind, p); onChange(); }}
|
||||||
|
onSave={async (cid, p) => { await updatePolicyChild(data.id, cfg.apiKind, cid, p); onChange(); }}
|
||||||
|
onRemove={async (cid) => { await removePolicyChild(data.id, cfg.apiKind, cid); onChange(); }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<div className="section-head">
|
||||||
|
<span className="section-rule cuenta" aria-hidden />
|
||||||
|
<h2 className="section-title">Administrar detalles</h2>
|
||||||
|
</div>
|
||||||
|
{bind(INSTALLMENTS, data.installments as unknown as Record<string, unknown>[])}
|
||||||
|
{bind(VEHICLES, data.vehicles as unknown as Record<string, unknown>[])}
|
||||||
|
{bind(DRIVERS, data.insuredDrivers as unknown as Record<string, unknown>[])}
|
||||||
|
{bind(BENEFICIARIES, data.beneficiaries as unknown as Record<string, unknown>[])}
|
||||||
|
{bind(CLAIMS, data.claims as unknown as Record<string, unknown>[])}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackLink() {
|
||||||
|
return (
|
||||||
|
<Link href="/polizas" className="back-link">
|
||||||
|
← Volver a Pólizas
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ Hero */
|
||||||
|
function Hero({ data }: { data: PolicyDetail }) {
|
||||||
|
const premium = premiumHeadline(data);
|
||||||
|
const phrase = expiryPhrase(data.daysToExpiry);
|
||||||
|
const provenance = [data.legacySourceTable, data.legacyId]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" #");
|
||||||
|
|
||||||
|
const facts: { label: string; value: string }[] = [
|
||||||
|
{ label: "Vigencia desde", value: formatDate(data.policyFrom) },
|
||||||
|
{ label: "Vigencia hasta", value: formatDate(data.policyTo) },
|
||||||
|
{ label: premium.label, value: formatMoney(premium.value, data.currency) },
|
||||||
|
{ label: "Moneda", value: data.currency ?? "—" },
|
||||||
|
{ label: "Agente", value: data.agentName || "—" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="detail-hero">
|
||||||
|
<div className="hero-top">
|
||||||
|
<div>
|
||||||
|
<h1 className="hero-name mono">{data.policyNumber || "—"}</h1>
|
||||||
|
<div className="hero-provenance">
|
||||||
|
{[data.policyType?.name, data.insuranceProvider?.name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ") || "Sin ramo ni aseguradora registrados"}
|
||||||
|
</div>
|
||||||
|
{provenance && (
|
||||||
|
<div className="hero-provenance">Origen: {provenance}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="hero-badges">
|
||||||
|
<span className={`badge status-${data.status}`}>
|
||||||
|
{policyStatusLabel(data.status)}
|
||||||
|
{phrase && data.status !== "expired" ? ` · ${phrase}` : ""}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`badge ${
|
||||||
|
data.liquidated ? "badge-positive" : "badge-negative"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{data.liquidated ? "Liquidada" : "Sin liquidar"}
|
||||||
|
</span>
|
||||||
|
{data.endorsement && (
|
||||||
|
<span className="badge badge-on-dark">Endoso</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hero-facts">
|
||||||
|
{facts.map((f) => (
|
||||||
|
<div key={f.label}>
|
||||||
|
<div className="hero-fact-label">{f.label}</div>
|
||||||
|
<div className="hero-fact-value">{f.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- Cliente */
|
||||||
|
function ClienteSection({ data }: { data: PolicyDetail }) {
|
||||||
|
const c = data.customer;
|
||||||
|
const location = [c.city?.replace(/,\s*$/, ""), c.state]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="datos" title="Cliente" />
|
||||||
|
<div className="card">
|
||||||
|
<Link href={`/clientes/${c.id}`} className="owner-link">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={`owner-name${
|
||||||
|
c.name === SIN_NOMBRE ? " cust-name-missing" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</div>
|
||||||
|
<div className="cust-sub">
|
||||||
|
{location && <span>{location}</span>}
|
||||||
|
{location && (c.phone || c.email) && (
|
||||||
|
<span className="sep">·</span>
|
||||||
|
)}
|
||||||
|
{(c.phone || c.mobile) && <span>{c.phone || c.mobile}</span>}
|
||||||
|
{c.email && (
|
||||||
|
<>
|
||||||
|
<span className="sep">·</span>
|
||||||
|
<span>{c.email}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="owner-cta">Ver expediente →</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{data.properties.length > 0 && (
|
||||||
|
<div className="linked-props">
|
||||||
|
<div className="kv-label">Propiedades cubiertas</div>
|
||||||
|
{data.properties.map((p) => (
|
||||||
|
<Link
|
||||||
|
key={p.id}
|
||||||
|
href={`/servicios/${p.id}`}
|
||||||
|
className="linked-prop link"
|
||||||
|
>
|
||||||
|
{[p.addressLine1, p.addressLine2].filter(Boolean).join(", ") ||
|
||||||
|
"Propiedad"}
|
||||||
|
{p.zone && <span className="muted"> · Zona {p.zone}</span>}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------- Condiciones */
|
||||||
|
function CondicionesSection({ data }: { data: PolicyDetail }) {
|
||||||
|
const cur = data.currency;
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="seguros" title="Condiciones y primas" />
|
||||||
|
<div className="card">
|
||||||
|
<div className="kv-grid">
|
||||||
|
<KV label="Fecha de emisión" value={formatDate(data.policyDate)} />
|
||||||
|
<KV
|
||||||
|
label="Periodo de cobertura"
|
||||||
|
value={
|
||||||
|
data.coveragePeriodDays ? `${data.coveragePeriodDays} días` : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<KV label="Prima neta" value={formatMoney(data.netPremium, cur)} />
|
||||||
|
<KV label="Derecho de póliza" value={formatMoney(data.policyFee, cur)} />
|
||||||
|
<KV label="Comisión" value={formatMoney(data.commission, cur)} />
|
||||||
|
<KV label="Honorarios" value={formatMoney(data.brokerFee, cur)} />
|
||||||
|
{/* The legacy `total` is 0 or null on all but 2 of 2378 policies —
|
||||||
|
only show it when it actually carries a figure. */}
|
||||||
|
{data.total != null && Number(data.total) > 0 && (
|
||||||
|
<KV label="Total" value={formatMoney(data.total, cur)} />
|
||||||
|
)}
|
||||||
|
<KV
|
||||||
|
label="Liquidación"
|
||||||
|
value={
|
||||||
|
data.liquidated
|
||||||
|
? [
|
||||||
|
data.liquidationNumber
|
||||||
|
? `No. ${data.liquidationNumber}`
|
||||||
|
: null,
|
||||||
|
data.liquidationDate
|
||||||
|
? formatDate(data.liquidationDate)
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ") || "Liquidada"
|
||||||
|
: "Pendiente"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{data.observations && (
|
||||||
|
<div className="kv-block">
|
||||||
|
<div className="kv-label">Observaciones</div>
|
||||||
|
<div className="kv-value">{data.observations}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.notes && (
|
||||||
|
<div className="kv-block">
|
||||||
|
<div className="kv-label">Notas</div>
|
||||||
|
<div className="kv-value">{data.notes}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- Pagos */
|
||||||
|
function PagosSection({ data }: { data: PolicyDetail }) {
|
||||||
|
const paid = data.installments.filter((i) => i.paidDate).length;
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead
|
||||||
|
rule="cuenta"
|
||||||
|
title="Pagos"
|
||||||
|
count={data.installments.length}
|
||||||
|
countSuffix={`· ${paid} pagados`}
|
||||||
|
/>
|
||||||
|
<div className="card">
|
||||||
|
<div className="subpanel" style={{ margin: 16 }}>
|
||||||
|
{data.installments.map((inst) => (
|
||||||
|
<InstallmentRow key={inst.id} inst={inst} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstallmentRow({ inst }: { inst: Installment }) {
|
||||||
|
const method = inst.isCash
|
||||||
|
? "Efectivo"
|
||||||
|
: inst.checkNumber
|
||||||
|
? `Ref. ${inst.checkNumber}`
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div className="pay-row">
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||||
|
<span className="pay-seq">{inst.sequence}</span>
|
||||||
|
<span>
|
||||||
|
{inst.paidDate ? formatDate(inst.paidDate) : "Sin pagar"}
|
||||||
|
{inst.dueDate && !inst.paidDate && (
|
||||||
|
<span className="muted" style={{ fontSize: 11 }}>
|
||||||
|
{" "}
|
||||||
|
· vence {formatDate(inst.dueDate)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{method && (
|
||||||
|
<span className="muted" style={{ fontSize: 11 }}>
|
||||||
|
{" "}
|
||||||
|
· {method}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="mono" style={{ fontWeight: 600 }}>
|
||||||
|
{formatMoney(inst.amount, inst.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------- Vehículos */
|
||||||
|
function VehiculosSection({ data }: { data: PolicyDetail }) {
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead
|
||||||
|
rule="servicios"
|
||||||
|
title="Vehículos asegurados"
|
||||||
|
count={data.vehicles.length}
|
||||||
|
/>
|
||||||
|
<div className="card">
|
||||||
|
<div className="veh-grid">
|
||||||
|
{data.vehicles.map((v) => (
|
||||||
|
<div className="veh-card" key={v.id}>
|
||||||
|
<div className="veh-title">
|
||||||
|
{[v.make, v.model, v.modelYear].filter(Boolean).join(" ") ||
|
||||||
|
"Vehículo"}
|
||||||
|
</div>
|
||||||
|
<div className="veh-facts">
|
||||||
|
{v.bodyType && <span>{v.bodyType}</span>}
|
||||||
|
{v.licensePlate && (
|
||||||
|
<span>
|
||||||
|
Placa: <span className="mono">{v.licensePlate}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{v.vinNumber && (
|
||||||
|
<span>
|
||||||
|
Serie: <span className="mono">{v.vinNumber}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{v.engineNumber && (
|
||||||
|
<span>
|
||||||
|
Motor: <span className="mono">{v.engineNumber}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------- Asegurados y beneficiarios */
|
||||||
|
function PersonasSection({ data }: { data: PolicyDetail }) {
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="datos" title="Asegurados y beneficiarios" />
|
||||||
|
<div className="card">
|
||||||
|
<div className="policy-body">
|
||||||
|
{data.insuredDrivers.length > 0 && (
|
||||||
|
<div className="subpanel">
|
||||||
|
<div className="subpanel-title">
|
||||||
|
<span>Asegurados</span>
|
||||||
|
<span>{data.insuredDrivers.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mini-list">
|
||||||
|
{data.insuredDrivers.map((d) => (
|
||||||
|
<div key={d.id}>
|
||||||
|
{d.fullName || "—"}
|
||||||
|
{d.licenseNumber && (
|
||||||
|
<div className="mini-sub mono">Lic. {d.licenseNumber}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.beneficiaries.length > 0 && (
|
||||||
|
<div className="subpanel">
|
||||||
|
<div className="subpanel-title">
|
||||||
|
<span>Beneficiarios</span>
|
||||||
|
<span>{data.beneficiaries.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mini-list">
|
||||||
|
{data.beneficiaries.map((b) => (
|
||||||
|
<div key={b.id}>
|
||||||
|
{b.name || "—"}
|
||||||
|
{(b.phone || b.email) && (
|
||||||
|
<div className="mini-sub">
|
||||||
|
{[b.phone, b.email].filter(Boolean).join(" · ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------- Siniestros */
|
||||||
|
function SiniestrosSection({ data }: { data: PolicyDetail }) {
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="cuenta" title="Siniestros" count={data.claims.length} />
|
||||||
|
<div className="card">
|
||||||
|
{data.claims.map((c) => (
|
||||||
|
<div className="prop-card" key={c.id}>
|
||||||
|
<div className="prop-addr">{c.claimType || "Siniestro"}</div>
|
||||||
|
<div className="prop-meta">
|
||||||
|
{c.incidentDate && (
|
||||||
|
<span>Ocurrido: {formatDate(c.incidentDate)}</span>
|
||||||
|
)}
|
||||||
|
{c.reportedDate && (
|
||||||
|
<span>Reportado: {formatDate(c.reportedDate)}</span>
|
||||||
|
)}
|
||||||
|
{c.adjuster?.name && <span>Ajustador: {c.adjuster.name}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="kv-grid" style={{ marginTop: 12 }}>
|
||||||
|
<KV
|
||||||
|
label="Monto reclamado"
|
||||||
|
value={formatMoney(c.claimedAmount, data.currency)}
|
||||||
|
/>
|
||||||
|
<KV
|
||||||
|
label="Monto liquidado"
|
||||||
|
value={formatMoney(c.settledAmount, data.currency)}
|
||||||
|
/>
|
||||||
|
<KV label="Fecha de finiquito" value={formatDate(c.settlementDate)} />
|
||||||
|
{c.description && (
|
||||||
|
<div className="kv-block">
|
||||||
|
<div className="kv-label">Descripción</div>
|
||||||
|
<div className="kv-value">{c.description}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------- Coberturas */
|
||||||
|
/** The legacy tables carry per-line coverage columns the target schema does
|
||||||
|
* not model; the migration preserved them verbatim in `coveragesJson`. */
|
||||||
|
function CoberturasSection({ data }: { data: PolicyDetail }) {
|
||||||
|
const entries = Object.entries(data.coveragesJson ?? {}).filter(
|
||||||
|
([, v]) => v !== null && v !== "" && v !== 0,
|
||||||
|
);
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="seguros" title="Coberturas" count={entries.length} />
|
||||||
|
<div className="card">
|
||||||
|
<div className="kv-grid">
|
||||||
|
{entries.map(([k, v]) => (
|
||||||
|
<div key={k}>
|
||||||
|
<div className="kv-label">{k}</div>
|
||||||
|
<div className="kv-value">{String(v)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="section-note" style={{ padding: "0 22px 18px" }}>
|
||||||
|
Campos de cobertura conservados tal cual desde el sistema anterior.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------- Documentos */
|
||||||
|
function DocumentosSection({
|
||||||
|
data,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
data: PolicyDetail;
|
||||||
|
onChange: () => void;
|
||||||
|
}) {
|
||||||
|
const canEdit = useCan("policy:update");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [type, setType] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function upload() {
|
||||||
|
if (!file) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await uploadPolicyDocument(data.id, file, type.trim() || undefined);
|
||||||
|
setFile(null);
|
||||||
|
setType("");
|
||||||
|
onChange();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error)?.message ?? "No se pudo subir el archivo.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="docs" title="Documentos" count={data.documents.length} />
|
||||||
|
<div className="card">
|
||||||
|
{data.documents.length === 0 ? (
|
||||||
|
<div className="empty-inline">
|
||||||
|
No hay documentos registrados para esta póliza.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="doc-list">
|
||||||
|
{data.documents.map((d, i) => (
|
||||||
|
<div className="doc-item" key={d.id ?? i}>
|
||||||
|
<span className="doc-icon" aria-hidden>
|
||||||
|
▤
|
||||||
|
</span>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div className="doc-type">{d.documentType || "Documento"}</div>
|
||||||
|
<div className="doc-key">{d.storageKey || "—"}</div>
|
||||||
|
</div>
|
||||||
|
{d.id && (
|
||||||
|
<a
|
||||||
|
className="btn btn-ghost"
|
||||||
|
href={policyDocumentDownloadUrl(data.id, d.id)}
|
||||||
|
>
|
||||||
|
Descargar
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{canEdit && d.id && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm("¿Eliminar este documento?")) return;
|
||||||
|
try {
|
||||||
|
await removePolicyDocument(data.id, d.id!);
|
||||||
|
onChange();
|
||||||
|
} catch (e) {
|
||||||
|
window.alert((e as Error)?.message ?? "No se pudo eliminar.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<div style={{ padding: "0 22px 18px" }}>
|
||||||
|
{error && (
|
||||||
|
<div className="state-box state-error" style={{ marginBottom: 12 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="inline-form">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="Tipo (ej. CARATULA)"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="input"
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!file || busy}
|
||||||
|
onClick={upload}
|
||||||
|
>
|
||||||
|
{busy ? "Subiendo…" : "Subir"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ helpers */
|
||||||
|
function KV({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | null | undefined;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="kv-label">{label}</div>
|
||||||
|
<div className="kv-value">{value || "—"}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHead({
|
||||||
|
rule,
|
||||||
|
title,
|
||||||
|
count,
|
||||||
|
countSuffix,
|
||||||
|
}: {
|
||||||
|
rule: string;
|
||||||
|
title: string;
|
||||||
|
count?: number;
|
||||||
|
countSuffix?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="section-head">
|
||||||
|
<span className={`section-rule ${rule}`} aria-hidden />
|
||||||
|
<h2 className="section-title">{title}</h2>
|
||||||
|
{count != null && (
|
||||||
|
<span className="section-count">
|
||||||
|
{count} {countSuffix ?? ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{ height: 16, width: 140, marginBottom: 18 }}
|
||||||
|
/>
|
||||||
|
<div className="skeleton" style={{ height: 180, borderRadius: 16 }} />
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{ height: 200, borderRadius: 16, marginTop: 34 }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{ height: 260, borderRadius: 16, marginTop: 34 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { PolicyForm } from "@/components/PolicyForm";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
|
||||||
|
export default function NuevaPolizaPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<NuevaPoliza />
|
||||||
|
</Suspense>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NuevaPoliza() {
|
||||||
|
const allowed = useCan("policy:create");
|
||||||
|
const params = useSearchParams();
|
||||||
|
const customerId = params.get("customerId") ?? undefined;
|
||||||
|
const customerName = params.get("customerName") ?? undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<Link href="/polizas" className="back-link">← Pólizas</Link>
|
||||||
|
<h1 className="page-title">Nueva póliza</h1>
|
||||||
|
</div>
|
||||||
|
{allowed ? (
|
||||||
|
<PolicyForm fixedCustomerId={customerId} fixedCustomerName={customerName} />
|
||||||
|
) : (
|
||||||
|
<div className="state-box state-error">
|
||||||
|
No tiene permisos para crear pólizas.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { ContextReports } from "@/components/ContextReports";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import {
|
||||||
|
EXPIRY_WINDOW_DAYS,
|
||||||
|
getPolicyFacets,
|
||||||
|
getPolicyStats,
|
||||||
|
listPolicies,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import {
|
||||||
|
expiryPhrase,
|
||||||
|
formatDate,
|
||||||
|
formatMoney,
|
||||||
|
formatNumber,
|
||||||
|
policyStatusLabel,
|
||||||
|
premiumHeadline,
|
||||||
|
SIN_NOMBRE,
|
||||||
|
} from "@/lib/labels";
|
||||||
|
import type {
|
||||||
|
PolicyFacets,
|
||||||
|
PolicyListItem,
|
||||||
|
PolicyListResponse,
|
||||||
|
PolicySort,
|
||||||
|
PolicyStats,
|
||||||
|
PolicyStatus,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
type StatusFilter = "all" | PolicyStatus;
|
||||||
|
|
||||||
|
const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
|
||||||
|
{ key: "all", label: "Todas" },
|
||||||
|
{ key: "expiring", label: "Por vencer" },
|
||||||
|
{ key: "active", label: "Vigentes" },
|
||||||
|
{ key: "expired", label: "Vencidas" },
|
||||||
|
{ key: "undated", label: "Sin vigencia" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SORTS: { key: PolicySort; label: string }[] = [
|
||||||
|
{ key: "expiry_desc", label: "Vencimiento (más reciente)" },
|
||||||
|
{ key: "expiry_asc", label: "Vencimiento (más próximo)" },
|
||||||
|
{ key: "customer", label: "Cliente (A–Z)" },
|
||||||
|
{ key: "number", label: "Número de póliza" },
|
||||||
|
{ key: "premium_desc", label: "Prima (mayor a menor)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PolizasPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<PolizasBrowser />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolizasBrowser() {
|
||||||
|
const canCreate = useCan("policy:create");
|
||||||
|
const [stats, setStats] = useState<PolicyStats | null>(null);
|
||||||
|
const [facets, setFacets] = useState<PolicyFacets | null>(null);
|
||||||
|
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [status, setStatus] = useState<StatusFilter>("all");
|
||||||
|
const [typeId, setTypeId] = useState("");
|
||||||
|
const [providerId, setProviderId] = useState("");
|
||||||
|
const [sort, setSort] = useState<PolicySort>("expiry_desc");
|
||||||
|
|
||||||
|
const [data, setData] = useState<PolicyListResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPolicyStats().then(setStats).catch(() => setStats(null));
|
||||||
|
getPolicyFacets().then(setFacets).catch(() => setFacets(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runSearch = useCallback(
|
||||||
|
(p: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
listPolicies({
|
||||||
|
query: query || undefined,
|
||||||
|
status: status === "all" ? undefined : status,
|
||||||
|
typeId: typeId || undefined,
|
||||||
|
providerId: providerId || undefined,
|
||||||
|
sort,
|
||||||
|
days: EXPIRY_WINDOW_DAYS,
|
||||||
|
page: p,
|
||||||
|
pageSize: 25,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
setData(res);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setError(e?.message ?? "No se pudieron cargar las pólizas.");
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[query, status, typeId, providerId, sort],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Debounced re-query whenever any filter changes; always back to page 1.
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => runSearch(1), 280);
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, [runSearch]);
|
||||||
|
|
||||||
|
function goToPage(p: number) {
|
||||||
|
runSearch(p);
|
||||||
|
if (typeof window !== "undefined")
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered =
|
||||||
|
query !== "" || status !== "all" || typeId !== "" || providerId !== "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head rise">
|
||||||
|
<p className="eyebrow">Cartera de seguros</p>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||||
|
<h1 className="page-title" style={{ margin: 0 }}>Pólizas</h1>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<ContextReports
|
||||||
|
entries={[
|
||||||
|
{ slug: "vigente", label: "Por vencer (Lic.)", params: { typeName: "LICENCIAS" } },
|
||||||
|
{ slug: "vigente", label: "Por vencer (Mult.)", params: { typeName: "MULT" } },
|
||||||
|
{ slug: "vigente", label: "Por vencer (Incen.)", params: { typeName: "INCEN" } },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{canCreate && (
|
||||||
|
<Link href="/polizas/nuevo" className="btn btn-primary">+ Nueva póliza</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<StatStrip
|
||||||
|
stats={stats}
|
||||||
|
status={status}
|
||||||
|
onPickStatus={(s) => setStatus(s)}
|
||||||
|
/>
|
||||||
|
<PremiumStrip stats={stats} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
<div className="search-box">
|
||||||
|
<span className="search-icon" aria-hidden>
|
||||||
|
⌕
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input search-input"
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Buscar por póliza, cliente, placa, agente…"
|
||||||
|
aria-label="Buscar pólizas"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="seg" role="tablist" aria-label="Filtrar por vigencia">
|
||||||
|
{STATUS_FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={status === f.key}
|
||||||
|
className={`seg-btn ${status === f.key ? "active" : ""}`}
|
||||||
|
onClick={() => setStatus(f.key)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-row">
|
||||||
|
<label className="filter-field">
|
||||||
|
<span className="filter-label">Ramo</span>
|
||||||
|
<select
|
||||||
|
className="input select"
|
||||||
|
value={typeId}
|
||||||
|
onChange={(e) => setTypeId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Todos los ramos</option>
|
||||||
|
{facets?.types
|
||||||
|
.filter((t) => t.count > 0)
|
||||||
|
.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name} ({formatNumber(t.count)})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="filter-field">
|
||||||
|
<span className="filter-label">Aseguradora</span>
|
||||||
|
<select
|
||||||
|
className="input select"
|
||||||
|
value={providerId}
|
||||||
|
onChange={(e) => setProviderId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Todas las aseguradoras</option>
|
||||||
|
{facets?.providers
|
||||||
|
.filter((p) => p.count > 0)
|
||||||
|
.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name} ({formatNumber(p.count)})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="filter-field">
|
||||||
|
<span className="filter-label">Ordenar por</span>
|
||||||
|
<select
|
||||||
|
className="input select"
|
||||||
|
value={sort}
|
||||||
|
onChange={(e) => setSort(e.target.value as PolicySort)}
|
||||||
|
>
|
||||||
|
{SORTS.map((s) => (
|
||||||
|
<option key={s.key} value={s.key}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{filtered && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost filter-clear"
|
||||||
|
onClick={() => {
|
||||||
|
setQuery("");
|
||||||
|
setStatus("all");
|
||||||
|
setTypeId("");
|
||||||
|
setProviderId("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Limpiar filtros
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data && !loading && !error && (
|
||||||
|
<div className="result-meta" aria-live="polite">
|
||||||
|
{data.total === 0
|
||||||
|
? "Sin resultados"
|
||||||
|
: `${formatNumber(data.total)} ${
|
||||||
|
data.total === 1 ? "póliza" : "pólizas"
|
||||||
|
}`}
|
||||||
|
{query ? ` para “${query}”` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="state-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<ListSkeleton />
|
||||||
|
) : data && data.items.length === 0 ? (
|
||||||
|
<EmptyState query={query} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="cust-list">
|
||||||
|
{data?.items.map((p) => (
|
||||||
|
<PolicyRow key={p.id} p={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{data && data.pageCount > 1 && (
|
||||||
|
<Pager
|
||||||
|
page={data.page}
|
||||||
|
pageCount={data.pageCount}
|
||||||
|
onChange={goToPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counts double as filter shortcuts — clicking a cell applies that bucket. */
|
||||||
|
function StatStrip({
|
||||||
|
stats,
|
||||||
|
status,
|
||||||
|
onPickStatus,
|
||||||
|
}: {
|
||||||
|
stats: PolicyStats | null;
|
||||||
|
status: StatusFilter;
|
||||||
|
onPickStatus: (s: StatusFilter) => void;
|
||||||
|
}) {
|
||||||
|
if (!stats) {
|
||||||
|
return (
|
||||||
|
<div className="stat-strip" aria-hidden>
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div className="stat-cell" key={i}>
|
||||||
|
<div className="skeleton" style={{ height: 25, width: "60%" }} />
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{ height: 11, width: "80%", marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cells: {
|
||||||
|
key: StatusFilter;
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
accent?: boolean;
|
||||||
|
}[] = [
|
||||||
|
{ key: "all", value: stats.total, label: "Pólizas", accent: true },
|
||||||
|
{
|
||||||
|
key: "expiring",
|
||||||
|
value: stats.expiring,
|
||||||
|
label: `Vencen en ${stats.days} días`,
|
||||||
|
accent: true,
|
||||||
|
},
|
||||||
|
{ key: "active", value: stats.active, label: "Vigentes" },
|
||||||
|
{ key: "expired", value: stats.expired, label: "Vencidas" },
|
||||||
|
{ key: "undated", value: stats.undated, label: "Sin vigencia" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stat-strip">
|
||||||
|
{cells.map((c) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={c.label}
|
||||||
|
className={`stat-cell stat-cell-btn${c.accent ? " accent" : ""}${
|
||||||
|
status === c.key ? " selected" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => onPickStatus(c.key)}
|
||||||
|
aria-pressed={status === c.key}
|
||||||
|
>
|
||||||
|
<div className="stat-value">{formatNumber(c.value)}</div>
|
||||||
|
<div className="stat-label">{c.label}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="stat-cell">
|
||||||
|
<div className="stat-value">{formatNumber(stats.pending)}</div>
|
||||||
|
<div className="stat-label">Sin liquidar</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Premium in force, split by currency — MXN and USD can't be summed. */
|
||||||
|
function PremiumStrip({ stats }: { stats: PolicyStats | null }) {
|
||||||
|
if (!stats || stats.premiumInForce.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="premium-strip">
|
||||||
|
<span className="premium-caption">Prima neta vigente</span>
|
||||||
|
{stats.premiumInForce.map((row) => (
|
||||||
|
<span className="premium-chip" key={row.currency}>
|
||||||
|
<strong>{formatMoney(row.netPremium, row.currency)}</strong>
|
||||||
|
<span className="premium-chip-sub">
|
||||||
|
{row.currency} · {formatNumber(row.count)}{" "}
|
||||||
|
{row.count === 1 ? "póliza" : "pólizas"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyRow({ p }: { p: PolicyListItem }) {
|
||||||
|
const premium = premiumHeadline(p);
|
||||||
|
const phrase = expiryPhrase(p.daysToExpiry);
|
||||||
|
const showPhrase = p.status === "expiring" || p.status === "active";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/polizas/${p.id}`} className="cust-row pol-row">
|
||||||
|
<div className="cust-main">
|
||||||
|
<div className="cust-name">
|
||||||
|
<span className="mono pol-number">{p.policyNumber || "—"}</span>
|
||||||
|
{p.policyType?.name && (
|
||||||
|
<span className="badge badge-seguros">
|
||||||
|
<span className="dot" /> {p.policyType.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={`badge status-${p.status}`}>
|
||||||
|
{policyStatusLabel(p.status)}
|
||||||
|
</span>
|
||||||
|
{!p.liquidated && (
|
||||||
|
<span className="badge badge-neutral">Sin liquidar</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="cust-sub">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
p.customerName === SIN_NOMBRE ? "cust-name-missing" : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p.customerName}
|
||||||
|
</span>
|
||||||
|
{p.insuranceProvider?.name && (
|
||||||
|
<>
|
||||||
|
<span className="sep">·</span>
|
||||||
|
<span>{p.insuranceProvider.name}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{p.vehicleCount > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="sep">·</span>
|
||||||
|
<span>
|
||||||
|
{p.vehicleCount}{" "}
|
||||||
|
{p.vehicleCount === 1 ? "vehículo" : "vehículos"}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pol-side">
|
||||||
|
<div className="pol-premium">
|
||||||
|
{formatMoney(premium.value, p.currency)}
|
||||||
|
</div>
|
||||||
|
<div className="pol-dates mono">
|
||||||
|
{formatDate(p.policyFrom)} – {formatDate(p.policyTo)}
|
||||||
|
</div>
|
||||||
|
{showPhrase && phrase && (
|
||||||
|
<div className={`pol-phrase ${p.status}`}>{phrase}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pager({
|
||||||
|
page,
|
||||||
|
pageCount,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageCount: number;
|
||||||
|
onChange: (p: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<nav className="pager" aria-label="Paginación">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
onClick={() => onChange(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
← Anterior
|
||||||
|
</button>
|
||||||
|
<span className="pager-info">
|
||||||
|
Página <strong>{page}</strong> de {pageCount}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
onClick={() => onChange(page + 1)}
|
||||||
|
disabled={page >= pageCount}
|
||||||
|
>
|
||||||
|
Siguiente →
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="cust-list" aria-hidden>
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div className="skeleton skel-row" key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ query }: { query: string }) {
|
||||||
|
return (
|
||||||
|
<div className="state-box">
|
||||||
|
<div className="state-glyph" aria-hidden>
|
||||||
|
⌕
|
||||||
|
</div>
|
||||||
|
<h3>Sin resultados</h3>
|
||||||
|
<p>
|
||||||
|
{query
|
||||||
|
? `No encontramos pólizas para “${query}”.`
|
||||||
|
: "No hay pólizas que coincidan con los filtros."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user