feat: microservico documents — catalogo por UF, documentos com versoes, compliance e analise [skip ci]

Espelho do routines: NestJS 10 + Fastify + TypeORM, modulos catalog (override
por UF), document (versoes + storage GCS), compliance (mesmo score do painel),
alerts (regua de vencimento, envio e v1.2) e analise plugavel (KeywordAnalyzer
-> OCR/LLM). 38 testes. Deploy aguarda banco 'documents' e secrets no hml2 —
por isso o [skip ci] no bootstrap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Breno Pires 2026-08-07 01:06:47 -03:00
commit 9165f1e9ab
65 changed files with 11734 additions and 0 deletions

41
.env.example Normal file
View File

@ -0,0 +1,41 @@
# Copie para .env (que é gitignored) para rodar local.
PORT=3000
NODE_ENV=development
# Prefixo dos códigos de erro do @clubpetrodev/nestjs-mapped-exception
ERROR_CODE=DOC
# OBRIGATÓRIO. O JwtAuthGuard do authmodule libera a request quando
# `req.hostname.includes(INTERNAL_PATH)`; com o valor vazio, `''.includes('')`
# é true e a API inteira fica sem autenticação. O boot falha se não estiver setado.
INTERNAL_PATH=default.svc.cluster.local
# JWT compartilhado do core (no cluster vem do secret `secret-token`)
SECRET_TOKEN=
# Fuso usado para derivar o status (vencido/vence em breve) por data de calendário.
TZ=America/Sao_Paulo
DOCUMENTS_TIMEZONE=America/Sao_Paulo
# Bucket privado dos PDFs (leitura por URL assinada de vida curta)
DOCUMENTS_BUCKET=corepetro_store_documents
DOCUMENTS_URL_MINUTES=30
# Teto do upload em multipart (o controller ainda valida mime)
DOCUMENTS_MAX_FILE_BYTES=10485760
TYPEORM_CONNECTION=postgres
TYPEORM_HOST=localhost
TYPEORM_PORT=5432
TYPEORM_SLAVE=localhost
TYPEORM_SLAVE_PORT=5432
TYPEORM_USERNAME=postgres
TYPEORM_PASSWORD=password
TYPEORM_DATABASE=documents
TYPEORM_SYNCHRONIZE=false
TYPEORM_MIGRATIONS_RUN=true
TYPEORM_LOGGING=false
URL_DEVELOPMENT=http://localhost:3000
URL_HOMOLOGATION=https://lab.clubpetro.com/api/v2/documents

55
.gitea/workflows/cd.yml Normal file
View File

@ -0,0 +1,55 @@
name: CD
on:
push:
branches: [master, main]
pull_request:
env:
IMAGE_BASE: ${{ secrets.AR_LOCATION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT }}/${{ secrets.AR_REPO }}
jobs:
build:
runs-on: ubuntu-latest
# Sem `if: hashFiles(...)` no nível do job: hashFiles é avaliado ANTES do
# checkout (workspace vazio) → condição sempre falsa → job pulado com status
# success. Foi o que congelou o deploy do frontend por semanas.
steps:
- uses: actions/checkout@v4
- name: Auth GCP
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}
- name: Setup gcloud
uses: google-github-actions/setup-gcloud@v2
with:
project_id: ${{ secrets.GCP_PROJECT }}
# Build + push + deploy rodam no Google Cloud Build: o runner do Gitea não
# tem daemon Docker. Ver cloudbuild.yaml.
- name: Build + deploy via Cloud Build (apenas em push pra master/main)
if: github.event_name == 'push'
env:
IMG_TAG: lab-${{ gitea.run_number }}
GKE_CLUSTER: ${{ secrets.GKE_CLUSTER }}
GKE_REGION: ${{ secrets.GKE_REGION }}
GCP_PROJECT: ${{ secrets.GCP_PROJECT }}
K8S_NAMESPACE: ${{ secrets.K8S_NAMESPACE }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
IMG="${IMAGE_BASE}/${{ gitea.event.repository.name }}:${IMG_TAG}"
SUBS="_IMAGE=${IMG}"
SUBS="${SUBS},_NPM_TOKEN=${NPM_TOKEN}"
SUBS="${SUBS},_GKE_CLUSTER=${GKE_CLUSTER}"
SUBS="${SUBS},_GKE_REGION=${GKE_REGION}"
SUBS="${SUBS},_GCP_PROJECT=${GCP_PROJECT}"
SUBS="${SUBS},_K8S_NAMESPACE=${K8S_NAMESPACE}"
gcloud builds submit --project "${GCP_PROJECT}" \
--config cloudbuild.yaml \
--substitutions="${SUBS}" \
.

34
.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# dependências
/node_modules
/.pnp
.pnp.js
.yarn
# build
/dist
/build
*.tsbuildinfo
# credenciais — o .npmrc é gerado no build a partir de NPM_TOKEN.
# NUNCA versionar (o serviço `customers` commitou o dele em claro).
.npmrc
.env
.env.*
!.env.example
*.pem
credentials.json
# testes
/coverage
.nyc_output
# logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# editores / SO
.idea
.vscode
.DS_Store

46
AI-HISTORY.md Normal file
View File

@ -0,0 +1,46 @@
# Histórico de mudanças com IA
Registro das alterações feitas neste repositório com apoio de IA (Claude), incluindo tempo aproximado.
Ordem cronológica — **mais recente no topo**.
## Formato de entrada
```
### YYYY-MM-DD HH:MM — Título curto
- **Tempo:** ~X min
- **Prompt:** resumo do pedido
- **Mudanças:** o que foi feito
- **Artefatos:** branch, PR #, commit hash
```
---
### 2026-08-07 — Criação do serviço (espelho do routines)
- **Tempo:** ~60 min
- **Prompt:** criar o microserviço `documents` para a feature Documentos e Licenças do painel,
espelhando o `routines` (padrões, storage, infra), com catálogo por UF, documentos com versões,
conformidade, análise de upload e régua de alertas.
- **Mudanças:**
- Esqueleto NestJS 10 + Fastify + TypeORM copiado do `routines` (main/app/ormconfig/
interceptor/tenant DTOs/storage/mocks de teste idênticos, nomes adaptados, prefixo `DOC`).
- Módulos: `catalog` (tipos + override por UF aplicado na leitura), `document` (obrigação única
por tipo/loja com unique no banco; renovação = versão nova + protocolo zerado; upload
multipart com campos antes do arquivo e checagem de `truncated`, igual às fotos de Rotinas),
`compliance` (score ponderado — porte fiel do `buildComplianceSummary` do painel),
`alerts` (régua lead/30/15/7/1/vencido; protocolado não alerta; envio real é v1.2).
- Regra de status em `status.util.ts` — porte fiel do `resolveStatus` da simulação do painel;
datas comparadas em meio-dia UTC para não depender de fuso/horário de verão.
- Análise plugável: interface `DocumentAnalyzer` + `KeywordAnalyzer` (v1); OCR/LLM troca o
provider `DOCUMENT_ANALYZER` sem tocar controller/service.
- Migration única: DDL + seed dos 15 tipos (cópia exata do catálogo do frontend, inserts
parametrizados por causa dos acentos) + 3 overrides de exemplo (AVCB SP/GO, LO SP).
- 38 testes (status, score, analyzer, save/renovação/protocolo/tenant, régua). Desvio
consciente do jest.config do routines: `storage/**` fora da cobertura (testar seria mockar o
SDK do GCS; controllers e interceptors já ficavam fora no original).
- Infra: Dockerfile, k8s/hml (deployment com cloudsql-proxy, service, ingress com
`proxy-body-size: 12m` para PDFs), cloudbuild/cd.yml adaptados.
- **Verificação:** `yarn build` limpo; `yarn test` 6 suites / 38 testes verdes, cobertura
98,5% stmts / 80,4% branches (mínimos 73/65).
- **Artefatos:** repo novo, branch master, commit inicial.

21
CHANGELOG.md Normal file
View File

@ -0,0 +1,21 @@
# Changelog — documents
Formato baseado em [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); versionamento semântico.
## [1.0.0] - 2026-08-07
### Added
- Catálogo legal de postos com 15 obrigações semeadas por migration (Resolução ANP 948/2023,
CONAMA 273/2000, Portaria INMETRO 227/2022, IN IBAMA 22/2021, NRs 1/7/20/23) e variação por UF
(`document_type_uf_override`; exemplos: AVCB SP 36 meses, GO 12; LO CETESB 60 meses/120 dias).
- Documentos da loja com trilha de versões (renovação nunca sobrescreve) e upload de PDF em bucket
GCS privado com URL assinada de vida curta.
- Status derivado na leitura (`valid`/`expiringSoon`/`inRenewal`/`expired`) com a regra do
protocolo tempestivo: renovação protocolada mantém o documento operante.
- Resumo de conformidade com score 0100 ponderado por criticidade (interdição 3, multa 2,
administrativa 1) e contagem de pendências críticas.
- Análise de upload plugável (`DocumentAnalyzer`): v1 heurística por keywords do catálogo;
OCR/LLM entra no mesmo ponto na v1.1.
- Régua de alertas (lead do tipo/30/15/7/1/vencido) com `GET /alerts/preview`; envio real
(WhatsApp/e-mail) é v1.2.
- Infra completa: Dockerfile, k8s/hml, cd.yml via Cloud Build — mesmo desenho do `routines`.

40
Dockerfile Normal file
View File

@ -0,0 +1,40 @@
# Node 20, não 18: o `@apollo/gateway` (transitivo do @clubpetrodev/nestjs-mapped-exception)
# puxa make-fetch-happen@15, que exige "^20.17.0 || >=22.9.0". Com engine-strict
# o yarn falha o build no Node 18 — foi o que quebrou a primeira publicação do routines.
FROM node:20-slim as base
ENV NODE_OPTIONS=--max_old_space_size=2048
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
build-essential \
&& ln -s /usr/bin/python3 /usr/bin/python \
&& rm -rf /var/lib/apt/lists/*
FROM base as builder
WORKDIR /usr/src/app
# O .npmrc NÃO é versionado (as libs @clubpetrodev/* precisam de token privado).
# Ele é gerado a partir do build-arg e removido antes da imagem final.
ARG NPM_TOKEN
COPY package.json yarn.lock tsconfig.json tsconfig.build.json nest-cli.json ./
RUN printf '//registry.npmjs.org/:_authToken=%s\nengine-strict=true\n' "$NPM_TOKEN" > .npmrc
COPY src/ src/
RUN yarn install --immutable
RUN yarn build
RUN rm -f .npmrc
FROM base as runner
ENV NODE_OPTIONS=--max_old_space_size=2048
WORKDIR /usr/src/app
COPY --from=builder /usr/src/app/src/ormconfig.ts ./src/ormconfig.ts
COPY --from=builder /usr/src/app/package.json /usr/src/app/yarn.lock /usr/src/app/tsconfig.json /usr/src/app/tsconfig.build.json /usr/src/app/nest-cli.json ./
COPY --from=builder /usr/src/app/dist ./dist
COPY --from=builder /usr/src/app/node_modules ./node_modules
EXPOSE 3000
CMD [ "yarn", "start:prod" ]

42
README.md Normal file
View File

@ -0,0 +1,42 @@
# documents
Serviço de documentos e licenças dos postos: catálogo legal (parametrizável por UF), documentos
com trilha de versões, resumo de conformidade, análise de upload e régua de alertas de vencimento.
É o backend do menu **Documentos** do painel (`clubpetro-frontend`, `REACT_APP_DOCUMENTS_API=true`).
Espelho do serviço [`routines`](../routines) — NestJS 10 + Fastify + TypeORM/PostgreSQL, exceções
mapeadas com prefixo `DOC`, upload em bucket GCS privado com URL assinada.
## Endpoints
| Método | Rota | O que faz |
|---|---|---|
| GET | `/catalog?uf=SP` | Catálogo legal com variação estadual aplicada |
| GET | `/documents?programId&storeId` | Documentos da loja (status derivado na leitura) |
| POST | `/documents` (multipart) | Cria obrigação ou registra renovação (versão nova, trilha preservada) |
| POST | `/documents/analyze` (multipart) | Sugere tipo/datas do arquivo (v1: keywords; v1.1: OCR/LLM) |
| POST | `/documents/:id/protocol` | Registra o protocolo de renovação no órgão (status → em renovação) |
| PATCH | `/documents/:id/details` | Responsável e observações |
| GET | `/compliance/summary?programId&storeId` | Score 0100 ponderado por criticidade + totais |
| GET | `/compliance/upcoming?days=90` | Vencimentos na janela, ordenados |
| GET | `/alerts/preview` | O que a régua (lead/30/15/7/1/vencido) enviaria hoje |
| GET | `/health` | Probe do Kubernetes |
Regras de negócio centrais em `src/modules/common/utils/status.util.ts` (status) e
`src/modules/compliance/compliance.service.ts` (score) — as mesmas do painel, agora com o backend
como fonte da verdade. O detalhe jurídico que o modelo carrega: **renovação protocolada no prazo
mantém o documento operante** (na LO da CETESB, protocolo ≥120 dias antes prorroga a validade).
## Rodar local
```bash
cp .env.example .env # preencher SECRET_TOKEN
yarn && yarn start:dev # migrations rodam no boot (TYPEORM_MIGRATIONS_RUN=true)
yarn test # 38 testes, cobertura mínima 65/73/73/73
```
## Deploy (lab)
`k8s/hml/` uma vez à mão; depois `branch → PR → merge → cd.yml` (Cloud Build). Pré-requisitos
novos no ambiente: database `documents` na instância `clubpetro-homologation` e bucket privado
`corepetro_store_documents`.

65
cloudbuild.yaml Normal file
View File

@ -0,0 +1,65 @@
# Build + push + deploy do serviço documents, executado no Google Cloud Build.
# Motivo: o runner do Gitea Actions não tem Docker (`docker: command not found`).
# O cd.yml só dispara `gcloud builds submit`.
steps:
# 1) Build da imagem (NPM_TOKEN é build-arg: as libs @clubpetrodev/* são privadas)
- name: 'gcr.io/cloud-builders/docker'
id: build
args:
- build
- --platform=linux/amd64
- -t
- '${_IMAGE}'
- --build-arg=NPM_TOKEN=${_NPM_TOKEN}
- .
# 2) Push para o Artifact Registry
- name: 'gcr.io/cloud-builders/docker'
id: push
args: [push, '${_IMAGE}']
# 3) Deploy no GKE hml2
- name: 'gcr.io/cloud-builders/kubectl'
id: deploy
args:
- set
- image
- deployment/documents-deployment
- 'documents=${_IMAGE}'
- -n
- '${_K8S_NAMESPACE}'
env:
- 'CLOUDSDK_COMPUTE_REGION=${_GKE_REGION}'
- 'CLOUDSDK_CONTAINER_CLUSTER=${_GKE_CLUSTER}'
- 'CLOUDSDK_CORE_PROJECT=${_GCP_PROJECT}'
- name: 'gcr.io/cloud-builders/kubectl'
id: rollout
args:
- rollout
- status
- deployment/documents-deployment
- -n
- '${_K8S_NAMESPACE}'
- '--timeout=180s'
env:
- 'CLOUDSDK_COMPUTE_REGION=${_GKE_REGION}'
- 'CLOUDSDK_CONTAINER_CLUSTER=${_GKE_CLUSTER}'
- 'CLOUDSDK_CORE_PROJECT=${_GCP_PROJECT}'
# O default do Cloud Build é 10min; `yarn install` + `nest build` a frio passa disso.
timeout: 1800s
options:
substitution_option: 'ALLOW_LOOSE'
# LEGACY em vez de CLOUD_LOGGING_ONLY: com CLOUD_LOGGING_ONLY o
# `gcloud builds submit` não mostra o log e a falha fica cega.
logging: LEGACY
substitutions:
_IMAGE: ''
_NPM_TOKEN: ''
_GKE_CLUSTER: ''
_GKE_REGION: ''
_GCP_PROJECT: ''
_K8S_NAMESPACE: 'default'

46
jest.config.js Normal file
View File

@ -0,0 +1,46 @@
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.spec.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
testTimeout: 20000,
coverageDirectory: '../coverage',
testEnvironment: 'node',
collectCoverage: true,
collectCoverageFrom: [
'**/*.{ts,tsx}',
'!**/node_modules/**',
'!**/interfaces/**',
'!**/interface/**',
'!**/mock/**',
'!**/migrations/**',
'!**/scripts/**',
'!**/enum/**',
'!**/index.ts',
'!**/*.module.ts',
'!**/*.entity.ts',
'!**/*.dto.ts',
'!**/*.repository.ts',
// Cópia fiel do storage de routines (GCS SDK); testar aqui seria testar o
// mock do SDK. Fica fora da cobertura como controllers e interceptors.
'!**/storage/**',
'!**/documentAnalyzer.interface.ts',
'!<rootDir>/main.ts',
'!<rootDir>/ormconfig.ts',
'!<rootDir>/app.controller.ts',
'!<rootDir>/app.service.ts',
'!<rootDir>/modules/**/*.controller.ts',
'!<rootDir>/interceptors/**/*.interceptor.ts',
],
coverageThreshold: {
global: {
branches: 65,
functions: 73,
lines: 73,
statements: 73,
},
},
preset: 'ts-jest',
};

128
k8s/hml/deployment.yaml Normal file
View File

@ -0,0 +1,128 @@
# Deployment do serviço documents no hml2 (lab).
# Aplicar uma vez à mão: kubectl --context=hml2-corepetro apply -f k8s/hml/
# Depois disso o cd.yml só faz `kubectl set image` na imagem nova.
#
# Pré-requisitos no cluster (mesmos do routines, nenhum secret novo):
# - secret `secret-token` → JWT compartilhado do core
# - secret `sqluserhomolgeneric` → usuário/senha do Cloud SQL
# - secret `cloudsql-instance-credentials`→ credencial do cloudsql-proxy
# - database `documents` criado na instância clubpetro-homologation
# - bucket privado `corepetro_store_documents` (PDFs; URL assinada na leitura)
apiVersion: apps/v1
kind: Deployment
metadata:
name: documents-deployment
labels:
app: documents
spec:
replicas: 1
selector:
matchLabels:
app: documents
template:
metadata:
labels:
app: documents
spec:
containers:
- name: documents
image: us-central1-docker.pkg.dev/corepetro/clubpetro-lab/documents:lab-1
imagePullPolicy: Always
ports:
- containerPort: 3000
resources:
requests:
cpu: 10m
memory: 128Mi
limits:
memory: 512Mi
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 15
periodSeconds: 15
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 45
periodSeconds: 30
env:
- name: PORT
value: '3000'
- name: NODE_ENV
value: homologation
- name: ERROR_CODE
value: DOC
# SEM isto o JwtAuthGuard do authmodule libera TODA request
# (`''.includes('')` === true). O boot falha se estiver vazio.
- name: INTERNAL_PATH
value: default.svc.cluster.local
- name: TZ
value: America/Sao_Paulo
- name: DOCUMENTS_TIMEZONE
value: America/Sao_Paulo
- name: DOCUMENTS_BUCKET
value: corepetro_store_documents
- name: URL_HOMOLOGATION
value: https://lab.clubpetro.com/api/v2/documents
- name: SECRET_TOKEN
valueFrom:
secretKeyRef:
name: secret-token
key: secret
- name: TYPEORM_CONNECTION
value: postgres
# 127.0.0.1 = sidecar cloudsql-proxy abaixo
- name: TYPEORM_HOST
value: 127.0.0.1
- name: TYPEORM_PORT
value: '5432'
- name: TYPEORM_SLAVE
value: 127.0.0.1
- name: TYPEORM_SLAVE_PORT
value: '5432'
- name: TYPEORM_DATABASE
value: documents
- name: TYPEORM_USERNAME
valueFrom:
secretKeyRef:
name: sqluserhomolgeneric
key: username
- name: TYPEORM_PASSWORD
valueFrom:
secretKeyRef:
name: sqluserhomolgeneric
key: passphrase
- name: TYPEORM_SYNCHRONIZE
value: 'false'
- name: TYPEORM_MIGRATIONS_RUN
value: 'true'
- name: TYPEORM_LOGGING
value: 'false'
- name: cloudsql-proxy
image: gcr.io/cloudsql-docker/gce-proxy:1.14
resources:
requests:
cpu: 2m
memory: 32Mi
command:
[
'/cloud_sql_proxy',
'-instances=corepetro:us-central1:clubpetro-homologation=tcp:5432',
'-credential_file=/secrets/cloudsql/credentials.json',
]
securityContext:
runAsUser: 2
allowPrivilegeEscalation: false
volumeMounts:
- name: cloudsql-instance-credentials
mountPath: /secrets/cloudsql
readOnly: true
volumes:
- name: cloudsql-instance-credentials
secret:
secretName: cloudsql-instance-credentials

43
k8s/hml/ingress.yaml Normal file
View File

@ -0,0 +1,43 @@
# Espelha o ingress do `routines` no hml2: os dois hosts são servidos
# (homologation.clubpetro.com e lab.clubpetro.com), cada um com seu certificado.
# O rewrite tira o prefixo, então os controllers NÃO repetem `/documents`.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: documents
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /$2
nginx.ingress.kubernetes.io/use-regex: "true"
# PDFs de licença chegam a alguns MB; o default do nginx é 1m.
nginx.ingress.kubernetes.io/proxy-body-size: 12m
spec:
ingressClassName: nginx
rules:
- host: homologation.clubpetro.com
http:
paths:
- path: /api/v2/documents(/|$)(.*)
pathType: Prefix
backend:
service:
name: documents
port:
number: 80
- host: lab.clubpetro.com
http:
paths:
- path: /api/v2/documents(/|$)(.*)
pathType: Prefix
backend:
service:
name: documents
port:
number: 80
tls:
- hosts:
- homologation.clubpetro.com
- "*.homologation.clubpetro.com"
secretName: root-cert
- hosts:
- lab.clubpetro.com
secretName: lab-tls

14
k8s/hml/service.yaml Normal file
View File

@ -0,0 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: documents
labels:
app: documents
spec:
type: NodePort
ports:
- port: 80
targetPort: 3000
name: http
selector:
app: documents

9
nest-cli.json Normal file
View File

@ -0,0 +1,9 @@
{
"language": "ts",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"plugins": ["@nestjs/swagger/plugin"],
"assets": ["**/*.proto"]
}
}

74
package.json Normal file
View File

@ -0,0 +1,74 @@
{
"name": "documents",
"version": "1.0.0",
"description": "Serviço de documentos e licenças dos postos (catálogo legal por UF, documentos com versões, conformidade e análise de upload)",
"author": "ClubPetro",
"private": true,
"license": "UNLICENSED",
"engines": {
"node": ">=20.17.0"
},
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"prestart:prod": "yarn migration:run",
"start:prod": "node dist/main",
"lint": "eslint 'src/**/*.{js,jsx,ts,tsx,json}'",
"lint:fix": "eslint 'src/**/*.{js,jsx,ts,tsx,json}' --fix",
"test": "jest --runInBand",
"test:watch": "jest --watch",
"typeorm:common": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
"migration:create": "yarn typeorm:common migration:create ./src/migrations/migration",
"migration:generate": "yarn typeorm:common migration:generate ./src/migrations/migration -d ./src/ormconfig.ts",
"migration:run": "yarn typeorm:common migration:run -d ./src/ormconfig.ts",
"migration:revert": "yarn typeorm:common migration:revert -d ./src/ormconfig.ts"
},
"dependencies": {
"@clubpetrodev/authmodule": "^1.3.1",
"@clubpetrodev/nestjs-mapped-exception": "^1.6.1",
"@fastify/multipart": "^8.1.0",
"@fastify/static": "^7.0.1",
"@google-cloud/storage": "^7.7.0",
"@nestjs/common": "^10.3.3",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.3.3",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-fastify": "^10.3.3",
"@nestjs/swagger": "^7.3.0",
"@nestjs/typeorm": "^10.0.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"date-fns": "^3.4.0",
"dotenv": "^16.4.5",
"grpc": "npm:@grpc/grpc-js@^1.10.2",
"pg": "^8.11.3",
"reflect-metadata": "^0.2.1",
"rimraf": "^5.0.5",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20",
"uuid": "^9.0.1"
},
"devDependencies": {
"@nestjs/cli": "^10.3.2",
"@nestjs/schematics": "^10.1.1",
"@nestjs/testing": "^10.3.3",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.27",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"eslint": "^8.57.0",
"jest": "^29.7.0",
"prettier": "^3.2.5",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.4.2"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

18
src/app.controller.ts Normal file
View File

@ -0,0 +1,18 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { AppService, IHealthResponse } from './app.service';
@ApiTags('Health')
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('health')
@ApiOperation({
summary: 'Health check do serviço (usado pelas probes do Kubernetes)',
})
async health(): Promise<IHealthResponse> {
return this.appService.health();
}
}

34
src/app.module.ts Normal file
View File

@ -0,0 +1,34 @@
import { MappedExceptionModule } from '@clubpetrodev/nestjs-mapped-exception';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AlertsModule } from './modules/alerts/alerts.module';
import { DocumentTypeException } from './modules/catalog/catalog.exception';
import { CatalogModule } from './modules/catalog/catalog.module';
import { StorageModule } from './modules/common/storage/storage.module';
import { ComplianceModule } from './modules/compliance/compliance.module';
import { StoreDocumentException } from './modules/document/document.exception';
import { DocumentModule } from './modules/document/document.module';
import { config } from './ormconfig';
@Module({
imports: [
ConfigModule.forRoot(),
TypeOrmModule.forRoot({ ...config, poolSize: 20 }),
MappedExceptionModule.forRoot(
[DocumentTypeException, StoreDocumentException],
{ prefix: 'DOC' },
),
StorageModule,
CatalogModule,
DocumentModule,
ComplianceModule,
AlertsModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

35
src/app.service.ts Normal file
View File

@ -0,0 +1,35 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
export interface IHealthResponse {
status: 'ok' | 'degraded';
database: boolean;
version: string;
timeZone: string;
}
@Injectable()
export class AppService {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async health(): Promise<IHealthResponse> {
const { version } = require('../package.json');
let database = false;
try {
await this.dataSource.query('SELECT 1');
database = true;
} catch {
database = false;
}
return {
status: database ? 'ok' : 'degraded',
database,
version,
timeZone: process.env.DOCUMENTS_TIMEZONE || 'America/Sao_Paulo',
};
}
}

View File

@ -0,0 +1,42 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
/**
* Copiado do serviço `customers`: em requests GET, promove os headers
* `programid` / `storeid` (enviados pelo painel) para dentro de `request.query`,
* onde os DTOs de filtro conseguem validá-los.
*
* Em POST/PATCH o interceptor não age nesses casos programId/storeId vêm no
* body. Em ambos os casos o valor é controlado pelo cliente: o service ainda
* precisa validar o tenant contra o token (ver `assertTenant`).
*/
@Injectable()
export class FiltersInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
if (
context.getType() === 'http' &&
context.switchToHttp().getRequest().raw.method === 'GET'
) {
const request = context.switchToHttp().getRequest();
const { headers } = request;
let paramsToAdd = {};
if (headers.programid) {
paramsToAdd = { ...paramsToAdd, programId: headers.programid };
}
if (headers.storeid) {
paramsToAdd = { ...paramsToAdd, storeId: headers.storeid };
}
request.query = { ...request.query, ...paramsToAdd };
}
return next.handle();
}
}

83
src/main.ts Normal file
View File

@ -0,0 +1,83 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import * as dotenv from 'dotenv';
import { AppModule } from './app.module';
dotenv.config();
const swaggerURLS = {
development: `${process.env.URL_DEVELOPMENT}`,
homologation: `${process.env.URL_HOMOLOGATION}`,
production: `${process.env.URL_PRODUCTION}`,
};
function getSwaggerServerUrl() {
return (
swaggerURLS[process.env.NODE_ENV] ||
`http://localhost:${process.env.PORT || 3000}`
);
}
/**
* O JwtAuthGuard do @clubpetrodev/authmodule libera a request quando
* `req.hostname.includes(process.env.INTERNAL_PATH)`. Com INTERNAL_PATH vazio,
* `''.includes('')` é true e a API inteira fica aberta por isso o boot falha
* cedo em vez de subir um serviço sem autenticação.
*/
function assertInternalPath() {
if (!process.env.INTERNAL_PATH) {
throw new Error(
'INTERNAL_PATH não definido: sem ele o JwtAuthGuard libera todas as requests.',
);
}
}
async function bootstrap() {
assertInternalPath();
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
// PDFs de licença sobem em multipart. O teto aqui é a última barreira: o
// controller ainda valida tamanho e mime. 10 MB cobre um AVCB escaneado em
// 300 dpi; acima disso é scan mal configurado.
await app.register(require('@fastify/multipart'), {
limits: {
fileSize: Number(
process.env.DOCUMENTS_MAX_FILE_BYTES || 10 * 1024 * 1024,
),
files: 1,
},
});
const { version } = require('../package.json');
const options = new DocumentBuilder()
.setTitle('Documents')
.setDescription(
'Serviço de documentos e licenças dos postos: catálogo legal por UF, documentos com versões, conformidade, análise de upload e alertas de vencimento',
)
.setVersion(version)
.addTag('Documents')
.addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' })
.addServer(getSwaggerServerUrl())
.build();
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
app.enableCors();
const document = SwaggerModule.createDocument(app, options);
SwaggerModule.setup('api', app, document);
const PORT = Number(process.env.PORT) || 3000;
await app.listen(PORT, '0.0.0.0');
}
bootstrap();

View File

@ -0,0 +1,177 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import {
DOCUMENT_TYPES_SEED,
UF_OVERRIDES_SEED,
} from './seed/documentTypes.seed';
/**
* DDL inicial do serviço de documentos + seed do catálogo legal.
*
* Notas de projeto (mesmas do serviço routines):
* - `gen_random_uuid()` nativo do PG13+, sem extensão;
* - unique em (programId, storeId, typeCode): UMA obrigação por tipo por
* loja renovação é versão nova, nunca linha nova;
* - o status NÃO tem coluna: é derivado na leitura, porque muda com o tempo
* sem ninguém escrever nada;
* - seed via parâmetros (INSERT ... $1) para não brigar com acentos/aspas.
*/
export class migration1786147200000 implements MigrationInterface {
name = 'migration1786147200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'issuing_body_enum') THEN
CREATE TYPE public.issuing_body_enum AS ENUM
('anp','orgaoAmbiental','corpoDeBombeiros','prefeitura','vigilanciaSanitaria',
'ibama','ipem','ministerioDoTrabalho','receitaEstadual','empresaEspecializada');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'document_category_enum') THEN
CREATE TYPE public.document_category_enum AS ENUM
('regulatorio','ambiental','seguranca','metrologia','municipal','trabalhista','fiscal');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'document_criticality_enum') THEN
CREATE TYPE public.document_criticality_enum AS ENUM
('interdicao','multa','administrativa');
END IF;
END $$;
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS public.document_type (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
create_date timestamp NOT NULL DEFAULT LOCALTIMESTAMP,
update_date timestamp NOT NULL DEFAULT LOCALTIMESTAMP,
"code" varchar(80) NOT NULL,
"name" varchar(160) NOT NULL,
"issuingBody" public.issuing_body_enum NOT NULL,
category public.document_category_enum NOT NULL,
criticality public.document_criticality_enum NOT NULL,
"legalBasis" text NOT NULL,
description text NOT NULL,
"defaultValidityMonths" int,
"renewalLeadDays" int NOT NULL,
keywords text[] NOT NULL DEFAULT '{}',
"sortOrder" int NOT NULL DEFAULT 0,
active boolean NOT NULL DEFAULT true,
"programId" uuid
);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_document_type_code_global
ON public.document_type ("code") WHERE "programId" IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_document_type_code_program
ON public.document_type ("programId","code") WHERE "programId" IS NOT NULL;
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS public.document_type_uf_override (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
create_date timestamp NOT NULL DEFAULT LOCALTIMESTAMP,
update_date timestamp NOT NULL DEFAULT LOCALTIMESTAMP,
"typeCode" varchar(80) NOT NULL,
uf char(2) NOT NULL,
"validityMonths" int,
"renewalLeadDays" int,
"legalNote" text
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_document_type_uf
ON public.document_type_uf_override ("typeCode", uf);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS public.store_document (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
create_date timestamp NOT NULL DEFAULT LOCALTIMESTAMP,
update_date timestamp NOT NULL DEFAULT LOCALTIMESTAMP,
"programId" uuid NOT NULL,
"storeId" uuid NOT NULL,
"typeCode" varchar(80) NOT NULL,
"issueDate" date,
"expiryDate" date,
"documentNumber" varchar(120),
responsible varchar(160),
notes text,
"renewalProtocolNumber" varchar(120),
"renewalProtocolDate" date
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_store_document_obligation
ON public.store_document ("programId","storeId","typeCode");
CREATE INDEX IF NOT EXISTS idx_store_document_expiry
ON public.store_document ("expiryDate");
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS public.document_version (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
create_date timestamp NOT NULL DEFAULT LOCALTIMESTAMP,
update_date timestamp NOT NULL DEFAULT LOCALTIMESTAMP,
"storeDocumentId" uuid NOT NULL REFERENCES public.store_document(id) ON DELETE CASCADE,
"fileName" varchar(255) NOT NULL,
"fileSize" int NOT NULL,
"filePath" varchar(500),
"issueDate" date NOT NULL,
"expiryDate" date,
"documentNumber" varchar(120),
"uploadedBy" varchar(160)
);
CREATE INDEX IF NOT EXISTS idx_document_version_document
ON public.document_version ("storeDocumentId");
`);
for (const [index, seed] of DOCUMENT_TYPES_SEED.entries()) {
await queryRunner.query(
`INSERT INTO public.document_type
("code","name","issuingBody",category,criticality,"legalBasis",description,
"defaultValidityMonths","renewalLeadDays",keywords,"sortOrder")
VALUES ($1,$2,$3::public.issuing_body_enum,$4::public.document_category_enum,
$5::public.document_criticality_enum,$6,$7,$8,$9,$10,$11)
ON CONFLICT DO NOTHING`,
[
seed.code,
seed.name,
seed.issuingBody,
seed.category,
seed.criticality,
seed.legalBasis,
seed.description,
seed.defaultValidityMonths,
seed.renewalLeadDays,
seed.keywords,
index,
],
);
}
for (const override of UF_OVERRIDES_SEED) {
await queryRunner.query(
`INSERT INTO public.document_type_uf_override
("typeCode", uf, "validityMonths", "renewalLeadDays", "legalNote")
VALUES ($1,$2,$3,$4,$5)
ON CONFLICT DO NOTHING`,
[
override.typeCode,
override.uf,
override.validityMonths,
override.renewalLeadDays,
override.legalNote,
],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS public.document_version;`);
await queryRunner.query(`DROP TABLE IF EXISTS public.store_document;`);
await queryRunner.query(
`DROP TABLE IF EXISTS public.document_type_uf_override;`,
);
await queryRunner.query(`DROP TABLE IF EXISTS public.document_type;`);
await queryRunner.query(`
DROP TYPE IF EXISTS public.document_criticality_enum;
DROP TYPE IF EXISTS public.document_category_enum;
DROP TYPE IF EXISTS public.issuing_body_enum;
`);
}
}

View File

@ -0,0 +1,248 @@
/**
* Seed do catálogo legal cópia EXATA de
* `clubpetro-frontend/src/services/Documents/catalog.ts` (2026-08-06).
*
* Fonte da verdade a partir daqui é o banco; o arquivo do frontend vira
* fallback da simulação do LAB. Mudanças de legislação entram por migration
* nova, nunca editando esta.
*/
export interface IDocumentTypeSeed {
code: string;
name: string;
issuingBody: string;
category: string;
criticality: string;
legalBasis: string;
description: string;
defaultValidityMonths: number | null;
renewalLeadDays: number;
keywords: string[];
}
export const DOCUMENT_TYPES_SEED: IDocumentTypeSeed[] = [
{
code: 'anp-autorizacao',
name: 'Autorização de Revendedor Varejista — ANP',
issuingBody: 'anp',
category: 'regulatorio',
criticality: 'interdicao',
legalBasis: 'Resolução ANP nº 948/2023 + Lei nº 9.847/1999',
description:
'Autoriza o exercício da atividade de revenda varejista de combustíveis. Sem ela o posto não pode operar nem comprar combustível de distribuidoras. Não tem prazo de validade, mas exige atualização cadastral a cada alteração societária, de bandeira ou de tancagem — cassação impede os sócios de atuar na revenda por 5 anos.',
defaultValidityMonths: null,
renewalLeadDays: 30,
keywords: ['anp', 'autorizacao', 'revendedor', 'varejista'],
},
{
code: 'licenca-operacao',
name: 'Licença de Operação (LO)',
issuingBody: 'orgaoAmbiental',
category: 'ambiental',
criticality: 'interdicao',
legalBasis: 'Resolução CONAMA nº 273/2000 + norma do órgão estadual',
description:
'Licença ambiental que autoriza a operação do posto. A validade varia por estado (em geral 3 a 10 anos). Protocolar o pedido de renovação com pelo menos 120 dias de antecedência prorroga automaticamente a validade até a decisão do órgão.',
defaultValidityMonths: null,
renewalLeadDays: 120,
keywords: ['licenca', 'operacao', 'ambiental', 'cetesb', 'inea', 'lo'],
},
{
code: 'avcb',
name: 'AVCB — Auto de Vistoria do Corpo de Bombeiros',
issuingBody: 'corpoDeBombeiros',
category: 'seguranca',
criticality: 'interdicao',
legalBasis: 'Legislação estadual de segurança contra incêndio',
description:
'Atesta que a edificação atende às exigências de prevenção e combate a incêndio. Validade definida por norma estadual (1 a 5 anos). Requisito para o alvará de funcionamento e para o seguro.',
defaultValidityMonths: null,
renewalLeadDays: 60,
keywords: ['avcb', 'bombeiros', 'vistoria', 'clcb', 'incendio'],
},
{
code: 'alvara-funcionamento',
name: 'Alvará de Funcionamento',
issuingBody: 'prefeitura',
category: 'municipal',
criticality: 'interdicao',
legalBasis: 'Código de posturas do município',
description:
'Licença municipal para exercer a atividade no endereço. Na maioria dos municípios é renovado anualmente junto com a taxa de fiscalização.',
defaultValidityMonths: 12,
renewalLeadDays: 45,
keywords: ['alvara', 'funcionamento', 'localizacao', 'prefeitura'],
},
{
code: 'teste-estanqueidade',
name: 'Laudo de Estanqueidade dos Tanques (SASC)',
issuingBody: 'empresaEspecializada',
category: 'ambiental',
criticality: 'multa',
legalBasis: 'ABNT NBR 13.784 + exigência do órgão ambiental estadual',
description:
'Ensaio que comprova que tanques e tubulações subterrâneas não vazam. Condicionante da LO: a cada 12 meses para tanques com mais de 10 anos e a cada 24 meses para os mais novos. O laudo só vale acompanhado da ART do responsável técnico.',
defaultValidityMonths: 12,
renewalLeadDays: 30,
keywords: ['estanqueidade', 'tanque', 'sasc', 'nbr 13784', 'laudo'],
},
{
code: 'afericao-bombas',
name: 'Verificação Metrológica das Bombas — INMETRO/IPEM',
issuingBody: 'ipem',
category: 'metrologia',
criticality: 'multa',
legalBasis: 'Portaria INMETRO nº 227/2022 + Lei nº 9.933/1999',
description:
'Verificação periódica anual dos medidores de combustível (bombas) pelo IPEM. Bomba sem verificação válida é interditada no ato; qualquer manutenção que rompa o selo exige nova verificação eventual.',
defaultValidityMonths: 12,
renewalLeadDays: 30,
keywords: ['inmetro', 'ipem', 'afericao', 'metrologica', 'bomba'],
},
{
code: 'ctf-ibama',
name: 'Certificado de Regularidade — CTF/IBAMA',
issuingBody: 'ibama',
category: 'ambiental',
criticality: 'multa',
legalBasis: 'Lei nº 6.938/1981, art. 17 + IN IBAMA 13/2021',
description:
'Comprova a inscrição no Cadastro Técnico Federal e o pagamento da TCFA (taxa trimestral). O certificado é emitido por trimestre — vence a cada 3 meses.',
defaultValidityMonths: 3,
renewalLeadDays: 15,
keywords: ['ctf', 'ibama', 'regularidade', 'tcfa'],
},
{
code: 'rapp-ibama',
name: 'RAPP — Relatório Anual de Atividades (IBAMA)',
issuingBody: 'ibama',
category: 'ambiental',
criticality: 'multa',
legalBasis: 'IN IBAMA nº 22/2021',
description:
'Relatório anual de atividades potencialmente poluidoras, entregue na janela de 1º de fevereiro a 31 de março (ano-base anterior). Não entregar gera multa de 20% da TCFA anual e bloqueia o Certificado de Regularidade.',
defaultValidityMonths: 12,
renewalLeadDays: 45,
keywords: ['rapp', 'relatorio anual', 'ibama'],
},
{
code: 'alvara-sanitario',
name: 'Alvará Sanitário (loja de conveniência)',
issuingBody: 'vigilanciaSanitaria',
category: 'municipal',
criticality: 'multa',
legalBasis: 'Lei nº 6.437/1977 + código sanitário estadual/municipal',
description:
'Obrigatório quando o posto tem loja de conveniência com venda de alimentos. Renovação em geral anual, com vistoria da vigilância.',
defaultValidityMonths: 12,
renewalLeadDays: 45,
keywords: ['sanitario', 'vigilancia', 'conveniencia'],
},
{
code: 'destinacao-residuos',
name: 'Comprovante de Destinação de Resíduos (CADRI/MTR)',
issuingBody: 'orgaoAmbiental',
category: 'ambiental',
criticality: 'multa',
legalBasis: 'Lei nº 12.305/2010 + Resolução CONAMA nº 362/2005',
description:
'Comprova a destinação correta de óleo usado, embalagens e resíduos contaminados por empresa licenciada. Condicionante recorrente da LO; manter o comprovante mais recente sempre vigente.',
defaultValidityMonths: 12,
renewalLeadDays: 30,
keywords: ['cadri', 'mtr', 'residuo', 'oleo usado', 'destinacao'],
},
{
code: 'pgr',
name: 'PGR — Programa de Gerenciamento de Riscos',
issuingBody: 'ministerioDoTrabalho',
category: 'trabalhista',
criticality: 'multa',
legalBasis: 'NR-1 (Portaria SEPRT nº 6.730/2020)',
description:
'Inventário de riscos e plano de ação de segurança do trabalho. Revisão obrigatória a cada 2 anos ou quando houver mudança no ambiente de trabalho.',
defaultValidityMonths: 24,
renewalLeadDays: 60,
keywords: ['pgr', 'gerenciamento de riscos', 'gro'],
},
{
code: 'pcmso',
name: 'PCMSO — Relatório Analítico Anual',
issuingBody: 'ministerioDoTrabalho',
category: 'trabalhista',
criticality: 'multa',
legalBasis: 'NR-7',
description:
'Programa de controle médico de saúde ocupacional, com relatório analítico anual e ASOs em dia para todos os frentistas.',
defaultValidityMonths: 12,
renewalLeadDays: 30,
keywords: ['pcmso', 'aso', 'ocupacional'],
},
{
code: 'nr20-treinamento',
name: 'Certificados NR-20 (inflamáveis)',
issuingBody: 'ministerioDoTrabalho',
category: 'trabalhista',
criticality: 'multa',
legalBasis: 'NR-20 (posto = instalação Classe I)',
description:
'Treinamento obrigatório de segurança com inflamáveis para quem trabalha na pista. Reciclagem do curso básico a cada 36 meses e prontuário da instalação permanentemente atualizado.',
defaultValidityMonths: 36,
renewalLeadDays: 60,
keywords: ['nr-20', 'nr20', 'inflamaveis', 'treinamento'],
},
{
code: 'brigada-incendio',
name: 'Treinamento de Brigada de Incêndio',
issuingBody: 'corpoDeBombeiros',
category: 'seguranca',
criticality: 'administrativa',
legalBasis: 'NR-23 + IT-17 (SP) e equivalentes estaduais',
description:
'Certificado de formação e reciclagem anual da brigada de incêndio. É conferido na vistoria do AVCB e agrava a responsabilidade da empresa em caso de sinistro.',
defaultValidityMonths: 12,
renewalLeadDays: 45,
keywords: ['brigada', 'incendio', 'nr-23', 'nr23'],
},
{
code: 'certidoes-negativas',
name: 'Certidões Negativas de Débitos (CNDs)',
issuingBody: 'receitaEstadual',
category: 'fiscal',
criticality: 'administrativa',
legalBasis: 'Exigidas em licitações, financiamentos e pela bandeira',
description:
'Certidões federal, estadual e municipal. Validade típica de 180 dias. Não impedem a operação, mas travam crédito, licitações e contratos com a distribuidora.',
defaultValidityMonths: 6,
renewalLeadDays: 30,
keywords: ['certidao', 'negativa', 'cnd', 'debitos'],
},
];
/**
* Variações estaduais de exemplo (documentadas na proposta):
* - SP: AVCB de uso comercial/alto risco vale 3 anos (Decreto 63.911/2018);
* LO da CETESB para postos vale 5 anos, renovação protocolada 120 dias antes.
* - GO: o CERCON do CBM-GO é anual.
*/
export const UF_OVERRIDES_SEED = [
{
typeCode: 'avcb',
uf: 'SP',
validityMonths: 36,
renewalLeadDays: null,
legalNote: 'Decreto estadual 63.911/2018 — AVCB comercial: 3 anos',
},
{
typeCode: 'avcb',
uf: 'GO',
validityMonths: 12,
renewalLeadDays: null,
legalNote: 'CBM-GO — CERCON com validade de 1 ano',
},
{
typeCode: 'licenca-operacao',
uf: 'SP',
validityMonths: 60,
renewalLeadDays: 120,
legalNote: 'CETESB — LO de postos: 5 anos; protocolo 120 dias antes prorroga',
},
];

View File

@ -0,0 +1,74 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { StoreDocumentStatus } from '../../common/enum/document.enum';
import { AlertsService } from '../alerts.service';
const PROGRAM_ID = '47031ca8-7586-40ba-acf6-f7af26d56a13';
const STORE_ID = '6b4f2dfa-2400-4b6e-a587-18978677ef20';
const upcoming = (
typeCode: string,
daysToExpiry: number,
overrides: Record<string, any> = {},
) => ({
typeCode,
typeName: typeCode,
criticality: 'multa',
status: StoreDocumentStatus.ExpiringSoon,
expiryDate: '2026-12-01',
daysToExpiry,
renewalLeadDays: 30,
...overrides,
});
const setup = (items: any[]) => {
const complianceService = {
getUpcoming: jest.fn().mockResolvedValue(items),
};
return new AlertsService(complianceService as any);
};
describe('AlertsService.computeDueAlerts — a régua', () => {
it('escolhe o degrau mais urgente já atingido', async () => {
const service = setup([
upcoming('vencido', -3),
upcoming('d1', 1),
upcoming('d7', 6),
upcoming('d15', 12),
upcoming('d30', 25),
upcoming('lead-lo', 100, { renewalLeadDays: 120 }),
]);
const alerts = await service.computeDueAlerts({
programId: PROGRAM_ID,
storeId: STORE_ID,
});
const byCode = Object.fromEntries(
alerts.map(alert => [alert.typeCode, alert.trigger]),
);
expect(byCode).toEqual({
vencido: 'expired',
d1: 'd1',
d7: 'd7',
d15: 'd15',
d30: 'd30',
'lead-lo': 'lead',
});
});
it('fora de qualquer degrau não alerta', async () => {
const service = setup([upcoming('longe', 200)]);
expect(
await service.computeDueAlerts({ programId: PROGRAM_ID, storeId: STORE_ID }),
).toHaveLength(0);
});
it('renovação já protocolada NÃO alerta — a pendência do gestor acabou', async () => {
const service = setup([
upcoming('lo', 5, { status: StoreDocumentStatus.InRenewal }),
]);
expect(
await service.computeDueAlerts({ programId: PROGRAM_ID, storeId: STORE_ID }),
).toHaveLength(0);
});
});

View File

@ -0,0 +1,36 @@
import { JwtAuthGuard } from '@clubpetrodev/authmodule';
import { MappedExceptionFilter } from '@clubpetrodev/nestjs-mapped-exception';
import {
Controller,
Get,
HttpStatus,
Query,
UseFilters,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { FiltersInterceptor } from '../../interceptors/filters.interceptor';
import { TenantWithStoreDto } from '../common/dto/tenant.dto';
import { AlertsService } from './alerts.service';
@ApiTags('Alertas')
@Controller('alerts')
@UseFilters(MappedExceptionFilter)
@UseInterceptors(FiltersInterceptor)
@UseGuards(JwtAuthGuard)
export class AlertsController {
constructor(private readonly service: AlertsService) {}
@Get('preview')
@ApiBearerAuth()
@ApiOperation({
summary:
'Alertas devidos hoje pela régua (lead/30/15/7/1/vencido) — o que o job diário enviaria',
})
@ApiResponse({ status: HttpStatus.OK })
async preview(@Query() filters: TenantWithStoreDto) {
return this.service.computeDueAlerts(filters);
}
}

View File

@ -0,0 +1,17 @@
import { AuthModule } from '@clubpetrodev/authmodule';
import { Module } from '@nestjs/common';
import { ComplianceModule } from '../compliance/compliance.module';
import { AlertsController } from './alerts.controller';
import { AlertsService } from './alerts.service';
@Module({
imports: [
ComplianceModule,
// O AuthModule precisa ser importado em cada módulo que usa o JwtAuthGuard.
AuthModule,
],
providers: [AlertsService],
controllers: [AlertsController],
})
export class AlertsModule {}

View File

@ -0,0 +1,75 @@
import { Injectable } from '@nestjs/common';
import { ComplianceService } from '../compliance/compliance.service';
import { UpcomingExpirationDto } from '../compliance/dto/response/compliance.dto';
import { StoreDocumentStatus } from '../common/enum/document.enum';
export interface IDueAlert {
typeCode: string;
typeName: string;
/** Qual degrau da régua disparou (lead | 30 | 15 | 7 | 1 | vencido). */
trigger: 'lead' | 'd30' | 'd15' | 'd7' | 'd1' | 'expired';
daysToExpiry: number;
expiryDate: string;
}
/**
* Régua de alertas de vencimento.
*
* Degraus fixos (30/15/7/1/vencido) + o degrau específico do tipo
* (`renewalLeadDays` na LO, 120 dias, porque protocolar no prazo prorroga a
* validade). Documento com renovação protocolada NÃO alerta: a pendência
* do gestor acabou; acompanhar o órgão é outro fluxo (v3).
*
* v1.2 (TODO): envio de verdade WhatsApp como canal principal (o dono de
* posto vive nele; reusar a infraestrutura de WhatsApp das campanhas),
* e-mail como secundário, escalonamento para o superior perto do vencimento
* e pendência rastreável que fecha com documento novo anexado. Este
* service entrega a lista pronta para o job diário consumir.
*/
@Injectable()
export class AlertsService {
constructor(private readonly complianceService: ComplianceService) {}
async computeDueAlerts(params: {
programId: string;
storeId: string;
}): Promise<IDueAlert[]> {
const upcoming = await this.complianceService.getUpcoming({
...params,
days: 365,
});
return upcoming
.filter(item => item.status !== StoreDocumentStatus.InRenewal)
.map(item => this.toAlert(item))
.filter((alert): alert is IDueAlert => alert !== null);
}
private toAlert(item: UpcomingExpirationDto): IDueAlert | null {
const trigger = this.resolveTrigger(item.daysToExpiry, item.renewalLeadDays);
if (!trigger) return null;
return {
typeCode: item.typeCode,
typeName: item.typeName,
trigger,
daysToExpiry: item.daysToExpiry,
expiryDate: item.expiryDate,
};
}
/** O degrau mais urgente que já foi atingido; nenhum → sem alerta hoje. */
private resolveTrigger(
daysToExpiry: number,
renewalLeadDays: number,
): IDueAlert['trigger'] | null {
if (daysToExpiry < 0) return 'expired';
if (daysToExpiry <= 1) return 'd1';
if (daysToExpiry <= 7) return 'd7';
if (daysToExpiry <= 15) return 'd15';
if (daysToExpiry <= 30) return 'd30';
if (daysToExpiry <= renewalLeadDays) return 'lead';
return null;
}
}

View File

@ -0,0 +1,110 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
mappedExceptionMock,
repositoryMock,
selectQueryBuilderMock,
} from '../../common/__tests__/mock/repository.mock';
import { DocumentTypeException } from '../catalog.exception';
import { CatalogService } from '../catalog.service';
const avcb = {
code: 'avcb',
name: 'AVCB',
issuingBody: 'corpoDeBombeiros',
category: 'seguranca',
criticality: 'interdicao',
legalBasis: 'Legislação estadual',
description: 'x',
defaultValidityMonths: null,
renewalLeadDays: 60,
keywords: ['avcb'],
};
const alvara = {
code: 'alvara-funcionamento',
name: 'Alvará',
issuingBody: 'prefeitura',
category: 'municipal',
criticality: 'interdicao',
legalBasis: 'Código de posturas',
description: 'x',
defaultValidityMonths: 12,
renewalLeadDays: 45,
keywords: ['alvara'],
};
const setup = ({
types = [avcb, alvara] as any[],
overrides = [] as any[],
} = {}) => {
const typeRepository = repositoryMock({
createQueryBuilder: jest.fn().mockReturnValue(
selectQueryBuilderMock({ getMany: jest.fn().mockResolvedValue(types) }),
),
find: jest.fn().mockResolvedValue(types),
findOne: jest
.fn()
.mockImplementation(({ where }) =>
Promise.resolve(types.find(type => type.code === where.code) ?? null),
),
});
const overrideRepository = repositoryMock({
find: jest.fn().mockResolvedValue(overrides),
});
const exception = mappedExceptionMock(new DocumentTypeException());
const service = new CatalogService(
typeRepository as any,
overrideRepository as any,
exception as any,
);
return { service, overrideRepository };
};
describe('CatalogService.getAll', () => {
it('sem UF devolve os valores nacionais e nem consulta overrides', async () => {
const { service, overrideRepository } = setup();
const catalog = await service.getAll({});
expect(catalog).toHaveLength(2);
expect(catalog[0].defaultValidityMonths).toBeNull();
expect(overrideRepository.find).not.toHaveBeenCalled();
});
it('com UF aplica o override e herda o que o override não define', async () => {
const { service } = setup({
overrides: [
{
typeCode: 'avcb',
uf: 'SP',
validityMonths: 36,
renewalLeadDays: null,
},
],
});
const catalog = await service.getAll({ uf: 'sp' }); // minúscula de propósito
const avcbSp = catalog.find(type => type.code === 'avcb')!;
const alvaraSp = catalog.find(
type => type.code === 'alvara-funcionamento',
)!;
// validade veio do override; lead herdou o nacional
expect(avcbSp.defaultValidityMonths).toBe(36);
expect(avcbSp.renewalLeadDays).toBe(60);
// tipo sem override fica intacto
expect(alvaraSp.defaultValidityMonths).toBe(12);
});
});
describe('CatalogService.getByCode', () => {
it('devolve o tipo global', async () => {
const { service } = setup();
expect((await service.getByCode('avcb')).code).toBe('avcb');
});
it('lança TYPE_NOT_FOUND para código desconhecido', async () => {
const { service } = setup({ types: [] });
await expect(service.getByCode('nope')).rejects.toThrow('TYPE_NOT_FOUND');
});
});

View File

@ -0,0 +1,33 @@
import { JwtAuthGuard } from '@clubpetrodev/authmodule';
import { MappedExceptionFilter } from '@clubpetrodev/nestjs-mapped-exception';
import {
Controller,
Get,
HttpStatus,
Query,
UseFilters,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiResponse, ApiTags } from '@nestjs/swagger';
import { FiltersInterceptor } from '../../interceptors/filters.interceptor';
import { CatalogService } from './catalog.service';
import { GetCatalogDto } from './dto/request/getCatalog.dto';
import { DocumentTypeResponseDto } from './dto/response/documentTypeResponse.dto';
@ApiTags('Catálogo de documentos')
@Controller('catalog')
@UseFilters(MappedExceptionFilter)
@UseInterceptors(FiltersInterceptor)
@UseGuards(JwtAuthGuard)
export class CatalogController {
constructor(private readonly service: CatalogService) {}
@Get()
@ApiBearerAuth()
@ApiResponse({ status: HttpStatus.OK, type: [DocumentTypeResponseDto] })
async getAll(@Query() filters: GetCatalogDto) {
return this.service.getAll(filters);
}
}

View File

@ -0,0 +1,10 @@
import { MappedExceptionItem } from '@clubpetrodev/nestjs-mapped-exception';
import { HttpStatus } from '@nestjs/common';
export class DocumentTypeException {
TYPE_NOT_FOUND: MappedExceptionItem = {
message: 'Tipo de documento não encontrado no catálogo',
code: 1,
statusCode: HttpStatus.NOT_FOUND,
};
}

View File

@ -0,0 +1,20 @@
import { AuthModule } from '@clubpetrodev/authmodule';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CatalogController } from './catalog.controller';
import { CatalogService } from './catalog.service';
import { DocumentType } from './documentType.entity';
import { DocumentTypeUfOverride } from './documentTypeUfOverride.entity';
@Module({
imports: [
TypeOrmModule.forFeature([DocumentType, DocumentTypeUfOverride]),
// O AuthModule precisa ser importado em cada módulo que usa o JwtAuthGuard.
AuthModule,
],
providers: [CatalogService],
controllers: [CatalogController],
exports: [CatalogService],
})
export class CatalogModule {}

View File

@ -0,0 +1,87 @@
import { MappedException } from '@clubpetrodev/nestjs-mapped-exception';
import { Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Repository } from 'typeorm';
import { DocumentTypeException } from './catalog.exception';
import { DocumentType } from './documentType.entity';
import { DocumentTypeUfOverride } from './documentTypeUfOverride.entity';
import { DocumentTypeResponseDto } from './dto/response/documentTypeResponse.dto';
@Injectable()
export class CatalogService {
constructor(
@InjectRepository(DocumentType)
private readonly typeRepository: Repository<DocumentType>,
@InjectRepository(DocumentTypeUfOverride)
private readonly overrideRepository: Repository<DocumentTypeUfOverride>,
@Inject(DocumentTypeException)
private readonly exception: MappedException<DocumentTypeException>,
) {}
/**
* Catálogo visível: tipos globais + os do programa, com a variação estadual
* aplicada quando `uf` vem na chamada. O painel não sabe que overrides
* existem recebe o valor certo para a loja e pronto.
*/
async getAll(filters: {
programId?: string;
uf?: string;
}): Promise<DocumentTypeResponseDto[]> {
const query = this.typeRepository
.createQueryBuilder('type')
.where('type.active = true')
.andWhere(
'(type."programId" IS NULL OR type."programId" = :programId)',
{ programId: filters.programId ?? null },
)
.orderBy('type."sortOrder"', 'ASC')
.addOrderBy('type.name', 'ASC');
const types = await query.getMany();
const overrides = filters.uf
? await this.overrideRepository.find({
where: {
uf: filters.uf.toUpperCase(),
typeCode: In(types.map(type => type.code)),
},
})
: [];
return types.map(type => this.applyOverride(type, overrides));
}
async getByCode(code: string): Promise<DocumentType> {
const type = await this.typeRepository.findOne({
where: { code, programId: IsNull() },
});
if (!type) this.exception.ERRORS.TYPE_NOT_FOUND.throw();
return type!;
}
async getActiveTypes(): Promise<DocumentType[]> {
return this.typeRepository.find({ where: { active: true } });
}
applyOverride(
type: DocumentType,
overrides: DocumentTypeUfOverride[],
): DocumentTypeResponseDto {
const override = overrides.find(item => item.typeCode === type.code);
return {
code: type.code,
name: type.name,
issuingBody: type.issuingBody,
category: type.category,
criticality: type.criticality,
legalBasis: type.legalBasis,
description: type.description,
defaultValidityMonths:
override?.validityMonths ?? type.defaultValidityMonths ?? null,
renewalLeadDays: override?.renewalLeadDays ?? type.renewalLeadDays,
keywords: type.keywords ?? [],
};
}
}

View File

@ -0,0 +1,90 @@
import { ApiProperty } from '@nestjs/swagger';
import { Column, Entity } from 'typeorm';
import { BaseCollection } from '../common/entities/base.entity';
import {
DocumentCategory,
DocumentCriticality,
IssuingBody,
} from '../common/enum/document.enum';
/**
* Obrigação documental do catálogo legal de postos.
*
* `programId` nulo = catálogo global do ClubPetro (semeado por migration).
* A validade/lead aqui é a nacional típica; variações estaduais moram em
* `document_type_uf_override` e são aplicadas na leitura.
*/
@Entity('document_type')
export class DocumentType extends BaseCollection {
@ApiProperty({ description: 'Identificador estável usado no seed' })
@Column({ type: 'varchar', length: 80 })
code: string;
@ApiProperty({ description: 'Nome da obrigação' })
@Column({ type: 'varchar', length: 160 })
name: string;
@ApiProperty({ enum: IssuingBody })
@Column({ type: 'enum', enum: IssuingBody, enumName: 'issuing_body_enum' })
issuingBody: IssuingBody;
@ApiProperty({ enum: DocumentCategory })
@Column({
type: 'enum',
enum: DocumentCategory,
enumName: 'document_category_enum',
})
category: DocumentCategory;
@ApiProperty({ enum: DocumentCriticality })
@Column({
type: 'enum',
enum: DocumentCriticality,
enumName: 'document_criticality_enum',
})
criticality: DocumentCriticality;
@ApiProperty({ description: 'Resumo da base legal' })
@Column({ type: 'text' })
legalBasis: string;
@ApiProperty({ description: 'O que é e por que importa' })
@Column({ type: 'text' })
description: string;
@ApiProperty({
description: 'Validade típica em meses; nulo = varia por estado/órgão',
nullable: true,
})
@Column({ type: 'int', nullable: true })
defaultValidityMonths?: number | null;
@ApiProperty({
description: 'Dias antes do vencimento em que a renovação precisa começar',
})
@Column({ type: 'int' })
renewalLeadDays: number;
@ApiProperty({
description: 'Palavras que a análise usa para reconhecer o tipo',
type: [String],
})
@Column({ type: 'text', array: true, default: '{}' })
keywords: string[];
@ApiProperty({ description: 'Ordem de exibição' })
@Column({ type: 'int', default: 0 })
sortOrder: number;
@ApiProperty({ type: 'boolean' })
@Column({ type: 'boolean', default: true })
active: boolean;
@ApiProperty({
description: 'Programa dono do tipo; nulo = catálogo global',
nullable: true,
})
@Column({ type: 'uuid', nullable: true })
programId?: string | null;
}

View File

@ -0,0 +1,39 @@
import { ApiProperty } from '@nestjs/swagger';
import { Column, Entity } from 'typeorm';
import { BaseCollection } from '../common/entities/base.entity';
/**
* Variação estadual de uma obrigação do catálogo.
*
* A legislação define validade por UF (AVCB: 15 anos conforme o estado;
* LO: 310). Campo nulo = herda o valor nacional do tipo.
*/
@Entity('document_type_uf_override')
export class DocumentTypeUfOverride extends BaseCollection {
@ApiProperty({ description: 'Código do tipo no catálogo' })
@Column({ type: 'varchar', length: 80 })
typeCode: string;
@ApiProperty({ description: 'UF (sigla de 2 letras)' })
@Column({ type: 'char', length: 2 })
uf: string;
@ApiProperty({
description: 'Validade em meses nesta UF; nulo herda o nacional',
nullable: true,
})
@Column({ type: 'int', nullable: true })
validityMonths?: number | null;
@ApiProperty({
description: 'Lead de renovação nesta UF; nulo herda o nacional',
nullable: true,
})
@Column({ type: 'int', nullable: true })
renewalLeadDays?: number | null;
@ApiProperty({ description: 'Fonte da regra estadual (norma, órgão)' })
@Column({ type: 'text', nullable: true })
legalNote?: string | null;
}

View File

@ -0,0 +1,22 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID, Length } from 'class-validator';
/**
* `programId` é opcional aqui de propósito: o catálogo global serve qualquer
* rede, e o painel chama /catalog antes de escolher loja. Com programId,
* tipos do próprio programa entram junto.
*/
export class GetCatalogDto {
@ApiPropertyOptional({ description: 'Programa (rede) em contexto' })
@IsUUID()
@IsOptional()
programId?: string;
@ApiPropertyOptional({
description: 'UF da loja — aplica validade/lead estaduais',
})
@IsString()
@Length(2, 2)
@IsOptional()
uf?: string;
}

View File

@ -0,0 +1,40 @@
import { ApiProperty } from '@nestjs/swagger';
import {
DocumentCategory,
DocumentCriticality,
IssuingBody,
} from '../../../common/enum/document.enum';
/** Contrato com o painel: IExternalDocumentType. */
export class DocumentTypeResponseDto {
@ApiProperty()
code: string;
@ApiProperty()
name: string;
@ApiProperty({ enum: IssuingBody })
issuingBody: IssuingBody;
@ApiProperty({ enum: DocumentCategory })
category: DocumentCategory;
@ApiProperty({ enum: DocumentCriticality })
criticality: DocumentCriticality;
@ApiProperty()
legalBasis: string;
@ApiProperty()
description: string;
@ApiProperty({ nullable: true, type: 'number' })
defaultValidityMonths: number | null;
@ApiProperty()
renewalLeadDays: number;
@ApiProperty({ type: [String] })
keywords: string[];
}

View File

@ -0,0 +1,92 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/** Mock de QueryBuilder que devolve `this` em todo encadeamento. */
export const selectQueryBuilderMock = (overrides: Record<string, any> = {}) => {
const builder: Record<string, any> = {
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
leftJoinAndSelect: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(null),
getMany: jest.fn().mockResolvedValue([]),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
getRawMany: jest.fn().mockResolvedValue([]),
getRawOne: jest.fn().mockResolvedValue(null),
...overrides,
};
return builder;
};
export const repositoryMock = (overrides: Record<string, any> = {}) => ({
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn().mockImplementation(entity => Promise.resolve(entity)),
create: jest.fn().mockImplementation(entity => entity),
update: jest.fn().mockResolvedValue({ affected: 1 }),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
count: jest.fn().mockResolvedValue(0),
createQueryBuilder: jest.fn().mockReturnValue(selectQueryBuilderMock()),
...overrides,
});
/**
* EntityManager de transação: `create` devolve o objeto e `save` simula o
* insert atribuindo um id quando não houver.
*/
export const entityManagerMock = (
overrides: Record<string, any> = {},
): Record<string, any> => ({
create: jest.fn().mockImplementation((_entity, data) => data),
find: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
save: jest.fn().mockImplementation((_entity, data?) => {
const value = data ?? _entity;
if (Array.isArray(value)) {
return Promise.resolve(
value.map((item, index) => ({
id: item.id ?? `mock-id-${index}`,
...item,
})),
);
}
return Promise.resolve({ id: value.id ?? 'mock-id', ...value });
}),
findOne: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue({ affected: 1 }),
query: jest.fn().mockResolvedValue([]),
...overrides,
});
export const dataSourceMock = (manager = entityManagerMock()) => ({
transaction: jest.fn().mockImplementation(callback => callback(manager)),
query: jest.fn().mockResolvedValue([]),
manager,
});
/**
* Dublê da MappedException: `throw()` de fato lança, para os testes poderem
* afirmar o erro pelo nome da chave.
*/
export const mappedExceptionMock = <T extends object>(exception: T) => ({
ERRORS: Object.keys(exception).reduce(
(acc, key) => ({
...acc,
[key]: {
throw: jest.fn(() => {
throw new Error(key);
}),
},
}),
{} as Record<keyof T, { throw: () => never }>,
),
});

View File

@ -0,0 +1,103 @@
import { StoreDocumentStatus } from '../enum/document.enum';
import {
addMonths,
daysBetween,
resolveStatus,
todayKey,
} from '../utils/status.util';
const TODAY = '2026-08-07';
describe('daysBetween', () => {
it('conta dias corridos, negativo para o passado', () => {
expect(daysBetween(TODAY, '2026-08-17')).toBe(10);
expect(daysBetween(TODAY, '2026-08-07')).toBe(0);
expect(daysBetween(TODAY, '2026-08-01')).toBe(-6);
});
it('atravessa virada de mês e ano sem sustos de fuso', () => {
expect(daysBetween('2026-12-31', '2027-01-01')).toBe(1);
expect(daysBetween('2026-02-28', '2026-03-01')).toBe(1);
});
});
describe('addMonths', () => {
it('soma meses ancorando no fim do mês quando o dia não existe', () => {
expect(addMonths('2026-01-31', 1)).toBe('2026-02-28');
expect(addMonths('2026-01-15', 12)).toBe('2027-01-15');
expect(addMonths('2026-08-31', 1)).toBe('2026-09-30');
});
});
describe('todayKey', () => {
it('formata YYYY-MM-DD', () => {
expect(todayKey(new Date(2026, 7, 7))).toBe('2026-08-07');
});
});
describe('resolveStatus — a regra compartilhada com o painel', () => {
const base = { hasRenewalProtocol: false, renewalLeadDays: 30 };
it('sem validade é sempre em dia (autorização ANP)', () => {
expect(
resolveStatus({ ...base, expiryDate: null }, TODAY),
).toBe(StoreDocumentStatus.Valid);
expect(
resolveStatus({ ...base, expiryDate: undefined }, TODAY),
).toBe(StoreDocumentStatus.Valid);
});
it('vencido sem protocolo é vencido', () => {
expect(
resolveStatus({ ...base, expiryDate: '2026-08-01' }, TODAY),
).toBe(StoreDocumentStatus.Expired);
});
it('vencido COM protocolo fica em renovação — o protocolo tempestivo prorroga (caso LO)', () => {
expect(
resolveStatus(
{ ...base, expiryDate: '2026-08-01', hasRenewalProtocol: true },
TODAY,
),
).toBe(StoreDocumentStatus.InRenewal);
});
it('dentro da janela de renovação: vence-em-breve sem protocolo, em renovação com', () => {
const inWindow = { ...base, expiryDate: '2026-08-20' }; // 13 dias
expect(resolveStatus(inWindow, TODAY)).toBe(
StoreDocumentStatus.ExpiringSoon,
);
expect(
resolveStatus({ ...inWindow, hasRenewalProtocol: true }, TODAY),
).toBe(StoreDocumentStatus.InRenewal);
});
it('o limite da janela é inclusivo e respeita o lead do tipo', () => {
// lead 120 (LO): 100 dias à frente ainda está NA janela
expect(
resolveStatus(
{ ...base, renewalLeadDays: 120, expiryDate: '2026-11-15' },
TODAY,
),
).toBe(StoreDocumentStatus.ExpiringSoon);
// exatamente no lead
expect(
resolveStatus(
{ ...base, renewalLeadDays: 30, expiryDate: '2026-09-06' },
TODAY,
),
).toBe(StoreDocumentStatus.ExpiringSoon);
});
it('fora da janela é em dia', () => {
expect(
resolveStatus({ ...base, expiryDate: '2026-12-01' }, TODAY),
).toBe(StoreDocumentStatus.Valid);
});
it('vence hoje ainda não é vencido — está na janela', () => {
expect(
resolveStatus({ ...base, expiryDate: TODAY }, TODAY),
).toBe(StoreDocumentStatus.ExpiringSoon);
});
});

View File

@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
export class HttpExceptionResponseDto {
@ApiProperty({ type: 'number' })
statusCode: number;
@ApiProperty({ type: 'string' })
message: string;
@ApiProperty({
description: 'Código mapeado do erro, ex.: ROT-1',
type: 'string',
})
code?: string;
}

View File

@ -0,0 +1,58 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsInt,
IsNotEmpty,
IsOptional,
IsUUID,
Max,
Min,
} from 'class-validator';
/**
* Em GET, `programId`/`storeId` chegam pelos headers `programid`/`storeid` e são
* promovidos para a query pelo FiltersInterceptor. Em POST/PATCH vêm no body.
*
* Nos dois casos o valor é escolhido pelo cliente o service ainda precisa
* conferir contra o token (ver RoutineTenantGuardService.assertTenant).
*/
export class TenantDto {
@ApiProperty({
description: 'Programa (rede) dono do recurso',
type: 'string',
})
@IsUUID()
@IsNotEmpty()
programId: string;
}
export class TenantWithStoreDto extends TenantDto {
@ApiProperty({ description: 'Loja em contexto', type: 'string' })
@IsUUID()
@IsNotEmpty()
storeId: string;
}
export class OptionalStoreTenantDto extends TenantDto {
@ApiPropertyOptional({ description: 'Loja em contexto', type: 'string' })
@IsUUID()
@IsOptional()
storeId?: string;
}
export class PaginationDto {
@ApiPropertyOptional({ type: 'number', default: 20 })
@Transform(({ value }) => (value === undefined ? 20 : Number(value)))
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
take?: number = 20;
@ApiPropertyOptional({ type: 'number', default: 0 })
@Transform(({ value }) => (value === undefined ? 0 : Number(value)))
@IsInt()
@Min(0)
@IsOptional()
skip?: number = 0;
}

View File

@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
import {
CreateDateColumn,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
export abstract class BaseCollection {
@ApiProperty({ description: 'Id da entidade' })
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id?: string;
@CreateDateColumn({
type: 'timestamp',
name: 'create_date',
default: () => 'LOCALTIMESTAMP',
})
createDate?: string;
@UpdateDateColumn({
type: 'timestamp',
name: 'update_date',
default: () => 'LOCALTIMESTAMP',
})
updateDate?: string;
}

View File

@ -0,0 +1,52 @@
/**
* Enums do domínio os VALORES são o contrato com o painel
* (`clubpetro-frontend/src/services/Documents/types.ts`). Não renomear sem
* versionar a API.
*/
export enum IssuingBody {
Anp = 'anp',
OrgaoAmbiental = 'orgaoAmbiental',
CorpoDeBombeiros = 'corpoDeBombeiros',
Prefeitura = 'prefeitura',
VigilanciaSanitaria = 'vigilanciaSanitaria',
Ibama = 'ibama',
Ipem = 'ipem',
MinisterioDoTrabalho = 'ministerioDoTrabalho',
ReceitaEstadual = 'receitaEstadual',
EmpresaEspecializada = 'empresaEspecializada',
}
export enum DocumentCategory {
Regulatorio = 'regulatorio',
Ambiental = 'ambiental',
Seguranca = 'seguranca',
Metrologia = 'metrologia',
Municipal = 'municipal',
Trabalhista = 'trabalhista',
Fiscal = 'fiscal',
}
/**
* O que acontece com o posto se o documento vencer. Define o peso no score
* de conformidade (interdição 3 > multa 2 > administrativa 1).
*/
export enum DocumentCriticality {
Interdicao = 'interdicao',
Multa = 'multa',
Administrativa = 'administrativa',
}
/**
* Situação de UMA obrigação (tipo × loja). `inRenewal` existe porque
* protocolar a renovação no prazo tem efeito jurídico (na LO, prorroga a
* validade). `missing` é derivado na leitura: obrigação do catálogo sem
* registro nunca é persistido.
*/
export enum StoreDocumentStatus {
Valid = 'valid',
ExpiringSoon = 'expiringSoon',
InRenewal = 'inRenewal',
Expired = 'expired',
Missing = 'missing',
}

View File

@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { StorageService } from './storage.service';
@Global()
@Module({
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View File

@ -0,0 +1,85 @@
import { Storage } from '@google-cloud/storage';
import { Injectable, Logger } from '@nestjs/common';
/**
* Bucket privado dos documentos (PDFs de licenças, alvarás e laudos).
*
* Mesma decisão das evidências de Rotinas e pelo mesmo motivo, agravado:
* licença tem CNPJ, endereço, número de processo e assinatura. Bucket
* privado, upload direto, leitura por URL assinada de vida curta gerada na
* hora. Nunca persistimos URL o caminho do objeto.
*/
@Injectable()
export class StorageService {
private readonly logger = new Logger(StorageService.name);
private readonly storage = new Storage();
private readonly bucketName =
process.env.DOCUMENTS_BUCKET || 'corepetro_store_documents';
private readonly signedUrlMinutes = Number(
process.env.DOCUMENTS_URL_MINUTES || 30,
);
buildPath(parts: {
programId: string;
storeId: string;
documentId: string;
fileName: string;
}): string {
return `documents/${parts.programId}/${parts.storeId}/${parts.documentId}/${parts.fileName}`;
}
async upload(
storagePath: string,
buffer: Buffer,
contentType: string,
): Promise<void> {
await this.storage
.bucket(this.bucketName)
.file(storagePath)
.save(buffer, { resumable: false, contentType, public: false });
}
/**
* URL de leitura para cada caminho.
*
* Falha de assinatura **não derruba a resposta**: sem credencial de
* assinatura (rodando local, por exemplo) a biblioteca ainda precisa
* listar as obrigações. O arquivo vem sem URL e o resto funciona.
*/
async signMany(paths: string[]): Promise<Map<string, string>> {
const urls = new Map<string, string>();
if (!paths.length) return urls;
const expires = Date.now() + this.signedUrlMinutes * 60 * 1000;
await Promise.all(
[...new Set(paths)].map(async storagePath => {
try {
const [url] = await this.storage
.bucket(this.bucketName)
.file(storagePath)
.getSignedUrl({ version: 'v4', action: 'read', expires });
urls.set(storagePath, url);
} catch (error) {
this.logger.warn(
`Não foi possível assinar ${storagePath}: ${(error as Error).message}`,
);
}
}),
);
return urls;
}
async remove(storagePath: string): Promise<void> {
try {
await this.storage.bucket(this.bucketName).file(storagePath).delete();
} catch (error) {
// O registro no banco é a fonte da verdade. Objeto órfão no bucket é
// resolvido pela regra de ciclo de vida.
this.logger.warn(
`Objeto ${storagePath} não removido do bucket: ${(error as Error).message}`,
);
}
}
}

View File

@ -0,0 +1,73 @@
import { StoreDocumentStatus } from '../enum/document.enum';
/** Chave de dia YYYY-MM-DD no fuso do serviço (TZ=America/Sao_Paulo). */
export const todayKey = (now: Date = new Date()): string => {
const year = now.getFullYear();
const month = `${now.getMonth() + 1}`.padStart(2, '0');
const day = `${now.getDate()}`.padStart(2, '0');
return `${year}-${month}-${day}`;
};
/**
* Dias entre duas chaves de dia; negativo quando `date` passou.
*
* Parse em meio-dia UTC de propósito: diferença de calendário não pode mudar
* com horário de verão nem com o fuso do container.
*/
export const daysBetween = (from: string, date: string): number => {
const at = (key: string) => Date.parse(`${key.slice(0, 10)}T12:00:00Z`);
return Math.round((at(date) - at(from)) / (1000 * 60 * 60 * 24));
};
export const addMonths = (dateKey: string, months: number): string => {
const [year, month, day] = dateKey.slice(0, 10).split('-').map(Number);
const base = new Date(Date.UTC(year, month - 1 + months, 1, 12));
// Ancora no dia 1 e depois ajusta: 2026-01-31 + 1 mês deve dar 2026-02-28,
// não 2026-03-03 (overflow do Date).
const lastDay = new Date(
Date.UTC(base.getUTCFullYear(), base.getUTCMonth() + 1, 0, 12),
).getUTCDate();
base.setUTCDate(Math.min(day, lastDay));
return base.toISOString().slice(0, 10);
};
export interface IStatusInput {
expiryDate?: string | null;
hasRenewalProtocol: boolean;
renewalLeadDays: number;
}
/**
* A regra de situação a MESMA do painel (`simulation.ts#resolveStatus`),
* agora com o backend como fonte da verdade:
*
* - sem validade em dia (ex.: autorização ANP);
* - vencido com protocolo de renovação em renovação (o protocolo
* tempestivo mantém o documento operante o caso clássico é a LO);
* - vencido sem protocolo vencido;
* - dentro da janela de renovação em renovação (com protocolo) ou
* vence-em-breve (sem);
* - fora da janela em dia.
*/
export const resolveStatus = (
input: IStatusInput,
today: string = todayKey(),
): StoreDocumentStatus => {
if (!input.expiryDate) return StoreDocumentStatus.Valid;
const days = daysBetween(today, input.expiryDate);
if (days < 0) {
return input.hasRenewalProtocol
? StoreDocumentStatus.InRenewal
: StoreDocumentStatus.Expired;
}
if (days <= input.renewalLeadDays) {
return input.hasRenewalProtocol
? StoreDocumentStatus.InRenewal
: StoreDocumentStatus.ExpiringSoon;
}
return StoreDocumentStatus.Valid;
};

View File

@ -0,0 +1,178 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { repositoryMock } from '../../common/__tests__/mock/repository.mock';
import {
DocumentCriticality,
StoreDocumentStatus,
} from '../../common/enum/document.enum';
import { todayKey } from '../../common/utils/status.util';
import { ComplianceService } from '../compliance.service';
const PROGRAM_ID = '47031ca8-7586-40ba-acf6-f7af26d56a13';
const STORE_ID = '6b4f2dfa-2400-4b6e-a587-18978677ef20';
const futureDate = (days: number): string => {
const date = new Date();
date.setDate(date.getDate() + days);
return todayKey(date);
};
const type = (code: string, criticality: DocumentCriticality, lead = 30) => ({
code,
name: code,
criticality,
renewalLeadDays: lead,
});
const setup = ({
types = [] as any[],
documents = [] as any[],
} = {}) => {
const documentRepository = repositoryMock({
find: jest.fn().mockResolvedValue(documents),
});
const catalogService = {
getActiveTypes: jest.fn().mockResolvedValue(types),
};
const service = new ComplianceService(
documentRepository as any,
catalogService as any,
);
return { service };
};
describe('ComplianceService.getSummary', () => {
it('tudo em dia dá 100 e catálogo vazio também (não divide por zero)', async () => {
const empty = setup();
expect((await empty.service.getSummary({ programId: PROGRAM_ID, storeId: STORE_ID })).score).toBe(100);
const allValid = setup({
types: [type('a', DocumentCriticality.Interdicao)],
documents: [
{ typeCode: 'a', expiryDate: futureDate(200), renewalProtocolNumber: null },
],
});
const summary = await allValid.service.getSummary({
programId: PROGRAM_ID,
storeId: STORE_ID,
});
expect(summary.score).toBe(100);
expect(summary.totals.valid).toBe(1);
});
it('pondera por criticidade: interdição vencida derruba mais que administrativa', async () => {
// interdição (3) vencida + administrativa (1) em dia → 1/4 = 25
const heavy = setup({
types: [
type('lo', DocumentCriticality.Interdicao),
type('cnd', DocumentCriticality.Administrativa),
],
documents: [
{ typeCode: 'lo', expiryDate: futureDate(-10), renewalProtocolNumber: null },
{ typeCode: 'cnd', expiryDate: futureDate(200), renewalProtocolNumber: null },
],
});
const heavySummary = await heavy.service.getSummary({
programId: PROGRAM_ID,
storeId: STORE_ID,
});
expect(heavySummary.score).toBe(25);
// administrativa (1) vencida + interdição (3) em dia → 3/4 = 75
const light = setup({
types: [
type('lo', DocumentCriticality.Interdicao),
type('cnd', DocumentCriticality.Administrativa),
],
documents: [
{ typeCode: 'lo', expiryDate: futureDate(200), renewalProtocolNumber: null },
{ typeCode: 'cnd', expiryDate: futureDate(-10), renewalProtocolNumber: null },
],
});
expect(
(await light.service.getSummary({ programId: PROGRAM_ID, storeId: STORE_ID })).score,
).toBe(75);
});
it('vence-em-breve vale meio peso; em renovação vale 0,75', async () => {
const { service } = setup({
types: [type('a', DocumentCriticality.Multa, 30)],
documents: [
{ typeCode: 'a', expiryDate: futureDate(10), renewalProtocolNumber: null },
],
});
expect(
(await service.getSummary({ programId: PROGRAM_ID, storeId: STORE_ID })).score,
).toBe(50);
const renewing = setup({
types: [type('a', DocumentCriticality.Multa, 30)],
documents: [
{ typeCode: 'a', expiryDate: futureDate(10), renewalProtocolNumber: 'P-1' },
],
});
expect(
(await renewing.service.getSummary({ programId: PROGRAM_ID, storeId: STORE_ID })).score,
).toBe(75);
});
it('obrigação sem documento entra como missing e conta em criticalPending quando interdita', async () => {
const { service } = setup({
types: [
type('lo', DocumentCriticality.Interdicao),
type('cnd', DocumentCriticality.Administrativa),
],
documents: [],
});
const summary = await service.getSummary({
programId: PROGRAM_ID,
storeId: STORE_ID,
});
expect(summary.totals.missing).toBe(2);
expect(summary.criticalPending).toBe(1); // só a LO interdita
expect(summary.score).toBe(0);
});
});
describe('ComplianceService.getUpcoming', () => {
it('só devolve o que tem vencimento dentro da janela, ordenado', async () => {
const { service } = setup({
types: [
type('a', DocumentCriticality.Multa),
type('b', DocumentCriticality.Multa),
type('c', DocumentCriticality.Multa),
type('d', DocumentCriticality.Multa),
],
documents: [
{ typeCode: 'a', expiryDate: futureDate(60), renewalProtocolNumber: null },
{ typeCode: 'b', expiryDate: futureDate(5), renewalProtocolNumber: null },
{ typeCode: 'c', expiryDate: futureDate(200), renewalProtocolNumber: null },
// sem documento para 'd' — não entra
],
});
const upcoming = await service.getUpcoming({
programId: PROGRAM_ID,
storeId: STORE_ID,
days: 90,
});
expect(upcoming.map(item => item.typeCode)).toEqual(['b', 'a']);
expect(upcoming[0].daysToExpiry).toBe(5);
});
it('vencidos (dias negativos) entram na janela — são os mais urgentes', async () => {
const { service } = setup({
types: [type('a', DocumentCriticality.Multa)],
documents: [
{ typeCode: 'a', expiryDate: futureDate(-3), renewalProtocolNumber: null },
],
});
const upcoming = await service.getUpcoming({
programId: PROGRAM_ID,
storeId: STORE_ID,
days: 90,
});
expect(upcoming[0].daysToExpiry).toBe(-3);
expect(upcoming[0].status).toBe(StoreDocumentStatus.Expired);
});
});

View File

@ -0,0 +1,48 @@
import { JwtAuthGuard } from '@clubpetrodev/authmodule';
import { MappedExceptionFilter } from '@clubpetrodev/nestjs-mapped-exception';
import {
Controller,
Get,
HttpStatus,
Query,
UseFilters,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiResponse, ApiTags } from '@nestjs/swagger';
import { FiltersInterceptor } from '../../interceptors/filters.interceptor';
import { TenantWithStoreDto } from '../common/dto/tenant.dto';
import { ComplianceService } from './compliance.service';
import {
ComplianceSummaryResponseDto,
UpcomingExpirationDto,
} from './dto/response/compliance.dto';
import { GetUpcomingDto } from './dto/request/getUpcoming.dto';
@ApiTags('Conformidade')
@Controller('compliance')
@UseFilters(MappedExceptionFilter)
@UseInterceptors(FiltersInterceptor)
@UseGuards(JwtAuthGuard)
export class ComplianceController {
constructor(private readonly service: ComplianceService) {}
@Get('summary')
@ApiBearerAuth()
@ApiResponse({ status: HttpStatus.OK, type: ComplianceSummaryResponseDto })
async getSummary(@Query() filters: TenantWithStoreDto) {
return this.service.getSummary(filters);
}
@Get('upcoming')
@ApiBearerAuth()
@ApiResponse({ status: HttpStatus.OK, type: [UpcomingExpirationDto] })
async getUpcoming(@Query() filters: GetUpcomingDto) {
return this.service.getUpcoming({
programId: filters.programId,
storeId: filters.storeId,
days: filters.days ?? 90,
});
}
}

View File

@ -0,0 +1,21 @@
import { AuthModule } from '@clubpetrodev/authmodule';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CatalogModule } from '../catalog/catalog.module';
import { StoreDocument } from '../document/storeDocument.entity';
import { ComplianceController } from './compliance.controller';
import { ComplianceService } from './compliance.service';
@Module({
imports: [
TypeOrmModule.forFeature([StoreDocument]),
CatalogModule,
// O AuthModule precisa ser importado em cada módulo que usa o JwtAuthGuard.
AuthModule,
],
providers: [ComplianceService],
controllers: [ComplianceController],
exports: [ComplianceService],
})
export class ComplianceModule {}

View File

@ -0,0 +1,173 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DocumentType } from '../catalog/documentType.entity';
import { CatalogService } from '../catalog/catalog.service';
import {
DocumentCriticality,
StoreDocumentStatus,
} from '../common/enum/document.enum';
import {
daysBetween,
resolveStatus,
todayKey,
} from '../common/utils/status.util';
import { StoreDocument } from '../document/storeDocument.entity';
import {
ComplianceSummaryResponseDto,
UpcomingExpirationDto,
} from './dto/response/compliance.dto';
/** Peso da criticidade no score — o mesmo do painel. */
const CRITICALITY_WEIGHT: Record<DocumentCriticality, number> = {
[DocumentCriticality.Interdicao]: 3,
[DocumentCriticality.Multa]: 2,
[DocumentCriticality.Administrativa]: 1,
};
interface IObligation {
type: DocumentType;
status: StoreDocumentStatus;
document?: StoreDocument;
}
@Injectable()
export class ComplianceService {
constructor(
@InjectRepository(StoreDocument)
private readonly documentRepository: Repository<StoreDocument>,
private readonly catalogService: CatalogService,
) {}
/**
* Score ponderado por criticidade a MESMA régua do painel
* (`mapping/Mapping.tsx#buildComplianceSummary`), agora com o backend como
* fonte da verdade: interdição pesa 3, multa 2, administrativa 1; "vence em
* breve" ainda vale meio peso e "em renovação" 0,75 (protocolado no prazo
* mantém o posto operante).
*/
async getSummary(params: {
programId: string;
storeId: string;
}): Promise<ComplianceSummaryResponseDto> {
const obligations = await this.getObligations(params);
const totals = {
valid: 0,
expiringSoon: 0,
inRenewal: 0,
expired: 0,
missing: 0,
};
let earnedWeight = 0;
let totalWeight = 0;
let criticalPending = 0;
obligations.forEach(({ type, status }) => {
const weight = CRITICALITY_WEIGHT[type.criticality] ?? 1;
totalWeight += weight;
switch (status) {
case StoreDocumentStatus.Valid:
totals.valid += 1;
earnedWeight += weight;
break;
case StoreDocumentStatus.ExpiringSoon:
totals.expiringSoon += 1;
earnedWeight += weight / 2;
break;
case StoreDocumentStatus.InRenewal:
totals.inRenewal += 1;
earnedWeight += weight * 0.75;
break;
case StoreDocumentStatus.Expired:
totals.expired += 1;
break;
case StoreDocumentStatus.Missing:
default:
totals.missing += 1;
break;
}
const isPending =
status === StoreDocumentStatus.Expired ||
status === StoreDocumentStatus.Missing;
if (isPending && type.criticality === DocumentCriticality.Interdicao) {
criticalPending += 1;
}
});
return {
storeId: params.storeId,
score: totalWeight
? Math.round((earnedWeight / totalWeight) * 100)
: 100,
totals,
criticalPending,
};
}
/** Vencimentos na janela, ordenados — a matéria-prima dos alertas. */
async getUpcoming(params: {
programId: string;
storeId: string;
days: number;
}): Promise<UpcomingExpirationDto[]> {
const obligations = await this.getObligations(params);
const today = todayKey();
return obligations
.filter(({ document }) => !!document?.expiryDate)
.map(({ type, status, document }) => ({
typeCode: type.code,
typeName: type.name,
criticality: type.criticality,
status,
expiryDate: document!.expiryDate!,
daysToExpiry: daysBetween(today, document!.expiryDate!),
renewalLeadDays: type.renewalLeadDays,
}))
.filter(item => item.daysToExpiry <= params.days)
.sort((a, b) => a.daysToExpiry - b.daysToExpiry);
}
/**
* Uma linha por obrigação do catálogo obrigação sem documento entra como
* `missing`. É o cruzamento que o painel fazia no client, agora no dono do
* dado.
*/
async getObligations(params: {
programId: string;
storeId: string;
}): Promise<IObligation[]> {
const [types, documents] = await Promise.all([
this.catalogService.getActiveTypes(),
this.documentRepository.find({
where: { programId: params.programId, storeId: params.storeId },
}),
]);
const today = todayKey();
return types.map(type => {
const document = documents.find(item => item.typeCode === type.code);
if (!document) {
return { type, status: StoreDocumentStatus.Missing };
}
return {
type,
document,
status: resolveStatus(
{
expiryDate: document.expiryDate,
hasRenewalProtocol: !!document.renewalProtocolNumber,
renewalLeadDays: type.renewalLeadDays,
},
today,
),
};
});
}
}

View File

@ -0,0 +1,15 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { TenantWithStoreDto } from '../../../common/dto/tenant.dto';
export class GetUpcomingDto extends TenantWithStoreDto {
@ApiPropertyOptional({ type: 'number', default: 90 })
@Transform(({ value }) => (value === undefined ? 90 : Number(value)))
@IsInt()
@Min(1)
@Max(365)
@IsOptional()
days?: number = 90;
}

View File

@ -0,0 +1,63 @@
import { ApiProperty } from '@nestjs/swagger';
import {
DocumentCriticality,
StoreDocumentStatus,
} from '../../../common/enum/document.enum';
export class ComplianceTotalsDto {
@ApiProperty()
valid: number;
@ApiProperty()
expiringSoon: number;
@ApiProperty()
inRenewal: number;
@ApiProperty()
expired: number;
@ApiProperty()
missing: number;
}
/** Contrato com o painel: IExternalComplianceSummary. */
export class ComplianceSummaryResponseDto {
@ApiProperty()
storeId: string;
@ApiProperty({ description: 'Score 0100 ponderado por criticidade' })
score: number;
@ApiProperty({ type: ComplianceTotalsDto })
totals: ComplianceTotalsDto;
@ApiProperty({
description: 'Vencidos/faltantes com risco de interdição',
})
criticalPending: number;
}
export class UpcomingExpirationDto {
@ApiProperty()
typeCode: string;
@ApiProperty()
typeName: string;
@ApiProperty({ enum: DocumentCriticality })
criticality: DocumentCriticality;
@ApiProperty({ enum: StoreDocumentStatus })
status: StoreDocumentStatus;
@ApiProperty()
expiryDate: string;
@ApiProperty({ description: 'Negativo quando já venceu' })
daysToExpiry: number;
@ApiProperty()
renewalLeadDays: number;
}

View File

@ -0,0 +1,288 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
dataSourceMock,
entityManagerMock,
mappedExceptionMock,
repositoryMock,
} from '../../common/__tests__/mock/repository.mock';
import { StoreDocumentStatus } from '../../common/enum/document.enum';
import { todayKey } from '../../common/utils/status.util';
import { StoreDocumentException } from '../document.exception';
import { DocumentService } from '../document.service';
const PROGRAM_ID = '47031ca8-7586-40ba-acf6-f7af26d56a13';
const STORE_ID = '6b4f2dfa-2400-4b6e-a587-18978677ef20';
const DOC_ID = '10000000-0000-4000-8000-000000000001';
const futureDate = (days: number): string => {
const date = new Date();
date.setDate(date.getDate() + days);
return todayKey(date);
};
const avcbType = {
code: 'avcb',
name: 'AVCB',
renewalLeadDays: 60,
keywords: ['avcb'],
defaultValidityMonths: null,
};
interface ISetup {
documents?: any[];
manager?: Record<string, any>;
}
const setup = ({ documents = [], manager = entityManagerMock() }: ISetup = {}) => {
const documentRepository = repositoryMock({
find: jest.fn().mockResolvedValue(documents),
findOne: jest
.fn()
.mockImplementation(({ where }) =>
Promise.resolve(
documents.find(
document =>
document.id === where.id &&
(!where.programId || document.programId === where.programId),
) ?? null,
),
),
save: jest.fn().mockImplementation(entity => Promise.resolve(entity)),
});
const versionRepository = repositoryMock();
const dataSource = dataSourceMock(manager);
const catalogService = {
getActiveTypes: jest.fn().mockResolvedValue([avcbType]),
getByCode: jest.fn().mockResolvedValue(avcbType),
};
const storageService = {
buildPath: jest.fn().mockReturnValue('documents/p/s/d/file.pdf'),
upload: jest.fn().mockResolvedValue(undefined),
signMany: jest
.fn()
.mockResolvedValue(
new Map([['documents/p/s/d/file.pdf', 'https://signed.example']]),
),
};
const analyzer = { analyze: jest.fn().mockResolvedValue({}) };
const exception = mappedExceptionMock(new StoreDocumentException());
const service = new DocumentService(
documentRepository as any,
versionRepository as any,
dataSource as any,
catalogService as any,
storageService as any,
analyzer as any,
exception as any,
);
return {
service,
documentRepository,
storageService,
manager,
catalogService,
};
};
describe('DocumentService.getAll', () => {
it('deriva o status na leitura e assina as URLs das versões', async () => {
const documents = [
{
id: DOC_ID,
programId: PROGRAM_ID,
storeId: STORE_ID,
typeCode: 'avcb',
expiryDate: futureDate(10), // dentro do lead 60
renewalProtocolNumber: null,
versions: [
{
id: 'v1',
fileName: 'avcb.pdf',
fileSize: 1000,
filePath: 'documents/p/s/d/file.pdf',
issueDate: '2026-01-01',
createDate: '2026-01-01T10:00:00',
},
],
},
];
const { service } = setup({ documents });
const response = await service.getAll({
programId: PROGRAM_ID,
storeId: STORE_ID,
});
expect(response.count).toBe(1);
expect(response.data[0].status).toBe(StoreDocumentStatus.ExpiringSoon);
expect(response.data[0].versions[0].fileUrl).toBe('https://signed.example');
});
});
describe('DocumentService.save', () => {
const input = {
programId: PROGRAM_ID,
storeId: STORE_ID,
typeCode: 'avcb',
issueDate: '2026-08-01',
expiryDate: futureDate(300),
uploadedBy: 'Gestor',
file: {
fileName: 'avcb.pdf',
buffer: Buffer.from('pdf'),
contentType: 'application/pdf',
},
};
it('cria obrigação nova com a primeira versão e sobe o arquivo', async () => {
const manager = entityManagerMock({
findOne: jest.fn().mockResolvedValue(null),
});
const { service, storageService, documentRepository } = setup({ manager });
documentRepository.findOne = jest.fn().mockResolvedValue({
id: 'mock-id',
programId: PROGRAM_ID,
storeId: STORE_ID,
typeCode: 'avcb',
expiryDate: input.expiryDate,
renewalProtocolNumber: null,
versions: [],
});
const saved = await service.save(input);
expect(manager.save).toHaveBeenCalledTimes(2); // documento + versão
expect(storageService.upload).toHaveBeenCalledTimes(1);
expect(saved.status).toBe(StoreDocumentStatus.Valid);
});
it('renovação preserva a trilha (versão nova) e ZERA o protocolo do ciclo anterior', async () => {
const existing = {
id: DOC_ID,
programId: PROGRAM_ID,
storeId: STORE_ID,
typeCode: 'avcb',
expiryDate: futureDate(-5),
renewalProtocolNumber: 'PROT-1',
renewalProtocolDate: '2026-07-01',
};
const manager = entityManagerMock({
findOne: jest.fn().mockResolvedValue(existing),
});
const { service, documentRepository } = setup({ manager });
documentRepository.findOne = jest.fn().mockResolvedValue({
...existing,
expiryDate: input.expiryDate,
renewalProtocolNumber: null,
versions: [{ id: 'v2', fileName: 'novo.pdf', fileSize: 1, issueDate: input.issueDate, createDate: '2026-08-07' }],
});
await service.save(input);
// o documento salvo dentro da transação zera o protocolo
const savedDocument = manager.save.mock.calls.find(
([entity]: any[]) => entity?.name === 'StoreDocument' || true,
);
expect(savedDocument).toBeTruthy();
expect(existing.renewalProtocolNumber).toBeNull();
expect(existing.renewalProtocolDate).toBeNull();
});
it('recusa tipo inexistente antes de gravar qualquer coisa', async () => {
const { service, catalogService, manager } = setup();
catalogService.getByCode = jest.fn().mockRejectedValue(new Error('TYPE_NOT_FOUND'));
await expect(
service.save({ ...input, typeCode: 'nope' }),
).rejects.toThrow('TYPE_NOT_FOUND');
expect(manager.save).not.toHaveBeenCalled();
});
});
describe('DocumentService.registerProtocol', () => {
it('grava número e data e o status vira em renovação', async () => {
const documents = [
{
id: DOC_ID,
programId: PROGRAM_ID,
storeId: STORE_ID,
typeCode: 'avcb',
expiryDate: futureDate(-2),
renewalProtocolNumber: null,
versions: [],
},
];
const { service } = setup({ documents });
const response = await service.registerProtocol(DOC_ID, {
programId: PROGRAM_ID,
protocolNumber: 'PROT-42',
protocolDate: '2026-08-07',
});
expect(response.renewalProtocol?.protocolNumber).toBe('PROT-42');
expect(response.status).toBe(StoreDocumentStatus.InRenewal);
});
it('não acha documento de outro programa (tenant)', async () => {
const documents = [
{
id: DOC_ID,
programId: 'outro-programa',
typeCode: 'avcb',
versions: [],
},
];
const { service } = setup({ documents });
await expect(
service.registerProtocol(DOC_ID, {
programId: PROGRAM_ID,
protocolNumber: 'P',
protocolDate: '2026-08-07',
}),
).rejects.toThrow('DOCUMENT_NOT_FOUND');
});
});
describe('DocumentService.updateDetails', () => {
it('atualiza responsável/notas e string vazia limpa o campo', async () => {
const documents = [
{
id: DOC_ID,
programId: PROGRAM_ID,
storeId: STORE_ID,
typeCode: 'avcb',
expiryDate: futureDate(300),
responsible: 'Antigo',
notes: 'nota',
renewalProtocolNumber: null,
versions: [],
},
];
const { service } = setup({ documents });
const response = await service.updateDetails(DOC_ID, {
programId: PROGRAM_ID,
responsible: 'Novo Gestor',
notes: '',
});
expect(response.responsible).toBe('Novo Gestor');
expect(response.notes).toBeNull();
});
});
describe('DocumentService.analyze', () => {
it('delega ao analyzer com o catálogo ativo', async () => {
const { service, catalogService } = setup();
await service.analyze({
fileName: 'avcb.pdf',
buffer: Buffer.from(''),
contentType: 'application/pdf',
});
expect(catalogService.getActiveTypes).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,97 @@
import { DocumentType } from '../../catalog/documentType.entity';
import { KeywordAnalyzer } from '../analyzer/keywordAnalyzer';
const TODAY = '2026-08-07';
const type = (partial: Partial<DocumentType>): DocumentType =>
({
code: 'x',
keywords: [],
defaultValidityMonths: null,
renewalLeadDays: 30,
...partial,
}) as DocumentType;
const TYPES = [
type({
code: 'avcb',
keywords: ['avcb', 'bombeiros', 'vistoria'],
defaultValidityMonths: null,
}),
type({
code: 'afericao-bombas',
keywords: ['inmetro', 'ipem', 'afericao', 'bomba'],
defaultValidityMonths: 12,
}),
type({
code: 'licenca-operacao',
keywords: ['licenca', 'operacao', 'cetesb', 'lo'],
defaultValidityMonths: null,
}),
];
describe('KeywordAnalyzer', () => {
const analyzer = new KeywordAnalyzer();
it('reconhece o tipo por keyword, ignorando acento e caixa', async () => {
const result = await analyzer.analyze(
{ fileName: 'AVCB_Vistoria_Bombeiros.pdf' },
TYPES,
TODAY,
);
expect(result.suggestedTypeCode).toBe('avcb');
expect(result.confidence).toBeGreaterThan(0.9); // 3 hits
});
it('mais hits vencem o desempate entre tipos', async () => {
const result = await analyzer.analyze(
{ fileName: 'afericao-inmetro-bomba-ipem.pdf' },
TYPES,
TODAY,
);
expect(result.suggestedTypeCode).toBe('afericao-bombas');
});
it('sugere vencimento pela validade típica quando o tipo tem uma', async () => {
const result = await analyzer.analyze(
{ fileName: 'inmetro-2026-03-10.pdf' },
TYPES,
TODAY,
);
expect(result.detectedFields.issueDate).toBe('2026-03-10');
expect(result.detectedFields.expiryDate).toBe('2027-03-10');
expect(result.warnings).toHaveLength(0);
});
it('avisa quando a validade varia por estado (não inventa data)', async () => {
const result = await analyzer.analyze(
{ fileName: 'licenca operacao cetesb.pdf' },
TYPES,
TODAY,
);
expect(result.suggestedTypeCode).toBe('licenca-operacao');
expect(result.detectedFields.expiryDate).toBeUndefined();
expect(result.warnings).toContain('validityVariesByState');
});
it('ano solto no nome vira emissão em 1º de janeiro', async () => {
const result = await analyzer.analyze(
{ fileName: 'avcb 2025.pdf' },
TYPES,
TODAY,
);
expect(result.detectedFields.issueDate).toBe('2025-01-01');
});
it('sem match: confiança baixa, warning e emissão = hoje', async () => {
const result = await analyzer.analyze(
{ fileName: 'digitalizacao001.pdf' },
TYPES,
TODAY,
);
expect(result.suggestedTypeCode).toBeNull();
expect(result.confidence).toBeLessThan(0.5);
expect(result.warnings).toContain('typeNotRecognized');
expect(result.detectedFields.issueDate).toBe(TODAY);
});
});

View File

@ -0,0 +1,33 @@
import { DocumentType } from '../../catalog/documentType.entity';
export interface IAnalysisResult {
suggestedTypeCode: string | null;
confidence: number;
detectedFields: {
issueDate?: string | null;
expiryDate?: string | null;
documentNumber?: string | null;
};
warnings: string[];
}
export interface IAnalyzerInput {
fileName: string;
/** Conteúdo do arquivo — o KeywordAnalyzer ignora; OCR/LLM vai usar. */
buffer?: Buffer;
contentType?: string;
}
/**
* Ponto de troca da análise: o v1 é heurístico (nome do arquivo × keywords
* do catálogo); o v1.1 pluga OCR/LLM aqui sem tocar controller nem service.
*/
export interface DocumentAnalyzer {
analyze(
input: IAnalyzerInput,
types: DocumentType[],
today: string,
): Promise<IAnalysisResult>;
}
export const DOCUMENT_ANALYZER = Symbol('DOCUMENT_ANALYZER');

View File

@ -0,0 +1,80 @@
import { Injectable } from '@nestjs/common';
import { DocumentType } from '../../catalog/documentType.entity';
import { addMonths } from '../../common/utils/status.util';
import {
DocumentAnalyzer,
IAnalysisResult,
IAnalyzerInput,
} from './documentAnalyzer.interface';
/**
* Análise heurística v1 a mesma regra que rodava no painel (simulation.ts):
* casa keywords do catálogo com o nome do arquivo e sugere datas pela
* validade típica do tipo. Datas no nome (AAAA ou AAAA-MM-DD) refinam a
* emissão. Confiança nunca chega a 1: o humano SEMPRE confirma.
*/
@Injectable()
export class KeywordAnalyzer implements DocumentAnalyzer {
async analyze(
input: IAnalyzerInput,
types: DocumentType[],
today: string,
): Promise<IAnalysisResult> {
const normalized = this.normalize(input.fileName);
let bestCode: string | null = null;
let bestHits = 0;
types.forEach(type => {
const hits = (type.keywords ?? []).filter(keyword =>
normalized.includes(this.normalize(keyword)),
).length;
if (hits > bestHits) {
bestHits = hits;
bestCode = type.code;
}
});
const warnings: string[] = [];
const detectedFields: IAnalysisResult['detectedFields'] = {};
const dateMatch = normalized.match(
/(20\d{2})(?:[-_.]?(\d{2})[-_.]?(\d{2}))?/,
);
let issueDate = today;
if (dateMatch) {
const [, year, month, day] = dateMatch;
issueDate = `${year}-${month ?? '01'}-${day ?? '01'}`;
}
detectedFields.issueDate = issueDate;
if (bestCode) {
const type = types.find(item => item.code === bestCode);
if (type?.defaultValidityMonths) {
detectedFields.expiryDate = addMonths(
issueDate,
type.defaultValidityMonths,
);
} else {
warnings.push('validityVariesByState');
}
} else {
warnings.push('typeNotRecognized');
}
return {
suggestedTypeCode: bestCode,
confidence: bestCode ? Math.min(0.55 + bestHits * 0.2, 0.95) : 0.2,
detectedFields,
warnings,
};
}
/** Minúsculas e sem acento: "Licença_Operação.pdf" casa com "licenca". */
private normalize(value: string): string {
return value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
}

View File

@ -0,0 +1,182 @@
import { JwtAuthGuard } from '@clubpetrodev/authmodule';
import {
MappedException,
MappedExceptionFilter,
} from '@clubpetrodev/nestjs-mapped-exception';
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Inject,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
Req,
UseFilters,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiConsumes,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { FiltersInterceptor } from '../../interceptors/filters.interceptor';
import { HttpExceptionResponseDto } from '../common/dto/httpExceptionResponse.dto';
import { TenantWithStoreDto } from '../common/dto/tenant.dto';
import { StoreDocumentException } from './document.exception';
import { DocumentService } from './document.service';
import {
RegisterProtocolDto,
UpdateDetailsDto,
} from './dto/request/document.dto';
import {
AnalysisResultResponseDto,
StoreDocumentListResponseDto,
StoreDocumentResponseDto,
} from './dto/response/documentResponse.dto';
const ACCEPTED_FILE_TYPES = ['application/pdf', 'image/png', 'image/jpeg'];
@ApiTags('Documentos da loja')
@Controller('documents')
@UseFilters(MappedExceptionFilter)
@UseInterceptors(FiltersInterceptor)
@UseGuards(JwtAuthGuard)
export class DocumentController {
constructor(
private readonly service: DocumentService,
@Inject(StoreDocumentException)
private readonly exception: MappedException<StoreDocumentException>,
) {}
@Get()
@ApiBearerAuth()
@ApiResponse({ status: HttpStatus.OK, type: StoreDocumentListResponseDto })
async getAll(@Query() filters: TenantWithStoreDto) {
return this.service.getAll(filters);
}
/**
* Upload em multipart mesmo desenho das fotos de Rotinas: os campos de
* texto vêm ANTES do arquivo no form (o Fastify entrega junto do file os
* campos que chegaram antes dele), e o arquivo não passa pelo
* ValidationPipe, então a validação é manual aqui.
*/
@Post()
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiConsumes('multipart/form-data')
@ApiResponse({ status: HttpStatus.CREATED, type: StoreDocumentResponseDto })
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: 'Arquivo ou campos obrigatórios ausentes/ inválidos',
type: HttpExceptionResponseDto,
})
async save(@Req() request) {
const upload = await request.file();
if (!upload) this.exception.ERRORS.FILE_INVALID.throw();
const contentType = upload.mimetype;
if (!ACCEPTED_FILE_TYPES.includes(contentType)) {
this.exception.ERRORS.FILE_INVALID.throw();
}
const buffer: Buffer = await upload.toBuffer();
// `truncated` é como o @fastify/multipart avisa que estourou o limite:
// corta o stream em silêncio. Sem a checagem gravaríamos meio PDF.
if (upload.file?.truncated || !buffer.length) {
this.exception.ERRORS.FILE_INVALID.throw();
}
const field = (name: string): string | undefined =>
upload.fields?.[name]?.value || undefined;
const programId = field('programId');
const storeId = field('storeId');
const typeCode = field('typeCode');
const issueDate = field('issueDate');
if (!programId || !storeId || !typeCode || !issueDate) {
this.exception.ERRORS.REQUIRED_FIELDS_MISSING.throw();
}
return this.service.save({
programId: programId!,
storeId: storeId!,
typeCode: typeCode!,
issueDate: issueDate!,
expiryDate: field('expiryDate'),
documentNumber: field('documentNumber'),
responsible: field('responsible'),
notes: field('notes'),
uploadedBy: field('uploadedBy'),
file: {
fileName: upload.filename || 'documento.pdf',
buffer,
contentType,
},
});
}
/** Análise do arquivo: sugere tipo e datas para o gestor confirmar. */
@Post('analyze')
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiConsumes('multipart/form-data')
@ApiResponse({ status: HttpStatus.OK, type: AnalysisResultResponseDto })
async analyze(@Req() request) {
const upload = await request.file();
if (!upload) this.exception.ERRORS.FILE_INVALID.throw();
const contentType = upload.mimetype;
if (!ACCEPTED_FILE_TYPES.includes(contentType)) {
this.exception.ERRORS.FILE_INVALID.throw();
}
const buffer: Buffer = await upload.toBuffer();
if (upload.file?.truncated || !buffer.length) {
this.exception.ERRORS.FILE_INVALID.throw();
}
return this.service.analyze({
fileName: upload.filename || '',
buffer,
contentType,
});
}
@Post(':id/protocol')
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiResponse({ status: HttpStatus.OK, type: StoreDocumentResponseDto })
@ApiResponse({
status: HttpStatus.NOT_FOUND,
type: HttpExceptionResponseDto,
})
async registerProtocol(
@Param('id', ParseUUIDPipe) id: string,
@Body() data: RegisterProtocolDto,
) {
return this.service.registerProtocol(id, data);
}
@Patch(':id/details')
@ApiBearerAuth()
@ApiResponse({ status: HttpStatus.OK, type: StoreDocumentResponseDto })
@ApiResponse({
status: HttpStatus.NOT_FOUND,
type: HttpExceptionResponseDto,
})
async updateDetails(
@Param('id', ParseUUIDPipe) id: string,
@Body() data: UpdateDetailsDto,
) {
return this.service.updateDetails(id, data);
}
}

View File

@ -0,0 +1,29 @@
import { MappedExceptionItem } from '@clubpetrodev/nestjs-mapped-exception';
import { HttpStatus } from '@nestjs/common';
export class StoreDocumentException {
DOCUMENT_NOT_FOUND: MappedExceptionItem = {
message: 'Documento não encontrado',
code: 1,
statusCode: HttpStatus.NOT_FOUND,
};
FILE_INVALID: MappedExceptionItem = {
message:
'Arquivo ausente, tipo não suportado (PDF/PNG/JPEG) ou acima do limite',
code: 2,
statusCode: HttpStatus.BAD_REQUEST,
};
TYPE_UNKNOWN: MappedExceptionItem = {
message: 'Tipo de documento não existe no catálogo',
code: 3,
statusCode: HttpStatus.BAD_REQUEST,
};
REQUIRED_FIELDS_MISSING: MappedExceptionItem = {
message: 'programId, storeId, typeCode e issueDate são obrigatórios',
code: 4,
statusCode: HttpStatus.BAD_REQUEST,
};
}

View File

@ -0,0 +1,28 @@
import { AuthModule } from '@clubpetrodev/authmodule';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CatalogModule } from '../catalog/catalog.module';
import { DOCUMENT_ANALYZER } from './analyzer/documentAnalyzer.interface';
import { KeywordAnalyzer } from './analyzer/keywordAnalyzer';
import { DocumentController } from './document.controller';
import { DocumentService } from './document.service';
import { DocumentVersion } from './documentVersion.entity';
import { StoreDocument } from './storeDocument.entity';
@Module({
imports: [
TypeOrmModule.forFeature([StoreDocument, DocumentVersion]),
CatalogModule,
// O AuthModule precisa ser importado em cada módulo que usa o JwtAuthGuard.
AuthModule,
],
providers: [
DocumentService,
// Ponto de troca da análise: v1.1 substitui por OCR/LLM sem tocar no resto
{ provide: DOCUMENT_ANALYZER, useClass: KeywordAnalyzer },
],
controllers: [DocumentController],
exports: [DocumentService],
})
export class DocumentModule {}

View File

@ -0,0 +1,296 @@
import { MappedException } from '@clubpetrodev/nestjs-mapped-exception';
import { Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { CatalogService } from '../catalog/catalog.service';
import { StorageService } from '../common/storage/storage.service';
import { resolveStatus, todayKey } from '../common/utils/status.util';
import {
DOCUMENT_ANALYZER,
DocumentAnalyzer,
} from './analyzer/documentAnalyzer.interface';
import { StoreDocumentException } from './document.exception';
import { DocumentVersion } from './documentVersion.entity';
import {
AnalysisResultResponseDto,
StoreDocumentListResponseDto,
StoreDocumentResponseDto,
} from './dto/response/documentResponse.dto';
import { StoreDocument } from './storeDocument.entity';
export interface ISaveDocumentInput {
programId: string;
storeId: string;
typeCode: string;
issueDate: string;
expiryDate?: string;
documentNumber?: string;
responsible?: string;
notes?: string;
uploadedBy?: string;
file: {
fileName: string;
buffer: Buffer;
contentType: string;
};
}
@Injectable()
export class DocumentService {
constructor(
@InjectRepository(StoreDocument)
private readonly documentRepository: Repository<StoreDocument>,
@InjectRepository(DocumentVersion)
private readonly versionRepository: Repository<DocumentVersion>,
private readonly dataSource: DataSource,
private readonly catalogService: CatalogService,
private readonly storageService: StorageService,
@Inject(DOCUMENT_ANALYZER)
private readonly analyzer: DocumentAnalyzer,
@Inject(StoreDocumentException)
private readonly exception: MappedException<StoreDocumentException>,
) {}
/**
* Documentos da loja com status derivado na leitura.
*
* Devolve o que EXISTE o cruzamento com o catálogo (linhas `missing`)
* é papel do cliente ou do resumo de conformidade, que conhecem o catálogo.
*/
async getAll(params: {
programId: string;
storeId: string;
}): Promise<StoreDocumentListResponseDto> {
const documents = await this.documentRepository.find({
where: { programId: params.programId, storeId: params.storeId },
relations: { versions: true },
order: { typeCode: 'ASC' },
});
const types = await this.catalogService.getActiveTypes();
const leadByCode = new Map(
types.map(type => [type.code, type.renewalLeadDays]),
);
const paths = documents
.flatMap(document => document.versions ?? [])
.map(version => version.filePath)
.filter((path): path is string => !!path);
const urls = await this.storageService.signMany(paths);
const today = todayKey();
const data = documents.map(document =>
this.toResponse(document, leadByCode.get(document.typeCode) ?? 30, {
today,
urls,
}),
);
return { data, count: data.length };
}
/** Cria a obrigação ou registra renovação — versão nova, trilha preservada. */
async save(input: ISaveDocumentInput): Promise<StoreDocumentResponseDto> {
// Confirma que o tipo existe antes de gravar qualquer coisa
const type = await this.catalogService.getByCode(input.typeCode);
const saved = await this.dataSource.transaction(async manager => {
let document = await manager.findOne(StoreDocument, {
where: {
programId: input.programId,
storeId: input.storeId,
typeCode: input.typeCode,
},
});
if (!document) {
document = manager.create(StoreDocument, {
programId: input.programId,
storeId: input.storeId,
typeCode: input.typeCode,
});
}
document.issueDate = input.issueDate;
document.expiryDate = input.expiryDate ?? null;
document.documentNumber =
input.documentNumber ?? document.documentNumber ?? null;
document.responsible = input.responsible ?? document.responsible ?? null;
document.notes = input.notes ?? document.notes ?? null;
// Ciclo novo: o protocolo da renovação anterior não vale para esta
document.renewalProtocolNumber = null;
document.renewalProtocolDate = null;
document = await manager.save(StoreDocument, document);
const storagePath = this.storageService.buildPath({
programId: input.programId,
storeId: input.storeId,
documentId: document.id!,
fileName: `${Date.now()}-${input.file.fileName}`,
});
const version = manager.create(DocumentVersion, {
storeDocumentId: document.id!,
fileName: input.file.fileName,
fileSize: input.file.buffer.length,
filePath: storagePath,
issueDate: input.issueDate,
expiryDate: input.expiryDate ?? null,
documentNumber: input.documentNumber ?? null,
uploadedBy: input.uploadedBy ?? null,
});
await manager.save(DocumentVersion, version);
// Upload DENTRO da transação: se o bucket falhar, o banco desfaz e o
// cliente pode tentar de novo — nunca fica versão apontando para nada.
await this.storageService.upload(
storagePath,
input.file.buffer,
input.file.contentType,
);
return document.id!;
});
return this.getOne(saved, type.renewalLeadDays);
}
async registerProtocol(
documentId: string,
params: {
programId: string;
protocolNumber: string;
protocolDate: string;
},
): Promise<StoreDocumentResponseDto> {
const document = await this.findOwned(documentId, params.programId);
document.renewalProtocolNumber = params.protocolNumber;
document.renewalProtocolDate = params.protocolDate;
await this.documentRepository.save(document);
return this.getOneWithLead(documentId);
}
async updateDetails(
documentId: string,
params: { programId: string; responsible?: string; notes?: string },
): Promise<StoreDocumentResponseDto> {
const document = await this.findOwned(documentId, params.programId);
if (params.responsible !== undefined) {
document.responsible = params.responsible || null;
}
if (params.notes !== undefined) {
document.notes = params.notes || null;
}
await this.documentRepository.save(document);
return this.getOneWithLead(documentId);
}
async analyze(input: {
fileName: string;
buffer: Buffer;
contentType: string;
}): Promise<AnalysisResultResponseDto> {
const types = await this.catalogService.getActiveTypes();
return this.analyzer.analyze(input, types, todayKey());
}
/* ------------------------------------------------------------------ */
private async findOwned(
documentId: string,
programId: string,
): Promise<StoreDocument> {
const document = await this.documentRepository.findOne({
where: { id: documentId, programId },
});
if (!document) this.exception.ERRORS.DOCUMENT_NOT_FOUND.throw();
return document!;
}
private async getOneWithLead(
documentId: string,
): Promise<StoreDocumentResponseDto> {
const document = await this.documentRepository.findOne({
where: { id: documentId },
});
if (!document) this.exception.ERRORS.DOCUMENT_NOT_FOUND.throw();
const type = await this.catalogService.getByCode(document!.typeCode);
return this.getOne(documentId, type.renewalLeadDays);
}
private async getOne(
documentId: string,
renewalLeadDays: number,
): Promise<StoreDocumentResponseDto> {
const document = await this.documentRepository.findOne({
where: { id: documentId },
relations: { versions: true },
});
if (!document) this.exception.ERRORS.DOCUMENT_NOT_FOUND.throw();
const paths = (document!.versions ?? [])
.map(version => version.filePath)
.filter((path): path is string => !!path);
const urls = await this.storageService.signMany(paths);
return this.toResponse(document!, renewalLeadDays, {
today: todayKey(),
urls,
});
}
/** Mapeia entidade → contrato do painel (IExternalStoreDocument). */
toResponse(
document: StoreDocument,
renewalLeadDays: number,
context: { today: string; urls: Map<string, string> },
): StoreDocumentResponseDto {
const versions = [...(document.versions ?? [])].sort((a, b) =>
(b.createDate ?? '').localeCompare(a.createDate ?? ''),
);
return {
id: document.id!,
typeCode: document.typeCode,
storeId: document.storeId,
status: resolveStatus(
{
expiryDate: document.expiryDate,
hasRenewalProtocol: !!document.renewalProtocolNumber,
renewalLeadDays,
},
context.today,
),
issueDate: document.issueDate ?? null,
expiryDate: document.expiryDate ?? null,
documentNumber: document.documentNumber ?? null,
responsible: document.responsible ?? null,
notes: document.notes ?? null,
renewalProtocol: document.renewalProtocolNumber
? {
protocolNumber: document.renewalProtocolNumber,
protocolDate: document.renewalProtocolDate ?? '',
}
: null,
versions: versions.map(version => ({
id: version.id!,
fileName: version.fileName,
fileSize: version.fileSize,
issueDate: version.issueDate,
expiryDate: version.expiryDate ?? null,
documentNumber: version.documentNumber ?? null,
uploadedAt: (version.createDate ?? '').slice(0, 10),
uploadedBy: version.uploadedBy ?? null,
fileUrl: version.filePath
? context.urls.get(version.filePath) ?? null
: null,
})),
};
}
}

View File

@ -0,0 +1,54 @@
import { ApiProperty } from '@nestjs/swagger';
import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
import { BaseCollection } from '../common/entities/base.entity';
import { StoreDocument } from './storeDocument.entity';
/**
* Uma versão do arquivo da obrigação a trilha de renovação.
*
* Renovação NUNCA sobrescreve: cada upload é uma versão nova, e a anterior
* fica auditável (quem fiscaliza pergunta pelo histórico). `filePath` é o
* caminho no bucket privado; URL assinada é gerada na leitura e nunca
* persistida.
*/
@Entity('document_version')
export class DocumentVersion extends BaseCollection {
@ApiProperty({ description: 'Obrigação a que a versão pertence' })
@Column({ type: 'uuid' })
storeDocumentId: string;
@ManyToOne(() => StoreDocument, document => document.versions, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'storeDocumentId' })
document?: StoreDocument;
@ApiProperty()
@Column({ type: 'varchar', length: 255 })
fileName: string;
@ApiProperty({ description: 'Tamanho em bytes' })
@Column({ type: 'int' })
fileSize: number;
@ApiProperty({ description: 'Caminho do objeto no bucket privado' })
@Column({ type: 'varchar', length: 500, nullable: true })
filePath?: string | null;
@ApiProperty()
@Column({ type: 'date' })
issueDate: string;
@ApiProperty({ nullable: true })
@Column({ type: 'date', nullable: true })
expiryDate?: string | null;
@ApiProperty({ nullable: true })
@Column({ type: 'varchar', length: 120, nullable: true })
documentNumber?: string | null;
@ApiProperty({ description: 'Quem enviou (nome ou accountId)' })
@Column({ type: 'varchar', length: 160, nullable: true })
uploadedBy?: string | null;
}

View File

@ -0,0 +1,35 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { TenantWithStoreDto } from '../../../common/dto/tenant.dto';
export class RegisterProtocolDto extends TenantWithStoreDto {
@ApiProperty({ description: 'Número do protocolo no órgão' })
@IsString()
@IsNotEmpty()
@MaxLength(120)
protocolNumber: string;
@ApiProperty({ description: 'Data do protocolo (YYYY-MM-DD)' })
@IsDateString()
protocolDate: string;
}
export class UpdateDetailsDto extends TenantWithStoreDto {
@ApiPropertyOptional({ description: 'Responsável pelo acompanhamento' })
@IsString()
@IsOptional()
@MaxLength(160)
responsible?: string;
@ApiPropertyOptional({ description: 'Observações' })
@IsString()
@IsOptional()
notes?: string;
}

View File

@ -0,0 +1,108 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { StoreDocumentStatus } from '../../../common/enum/document.enum';
/** Contrato com o painel: IExternalDocumentVersion. */
export class DocumentVersionResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
fileName: string;
@ApiProperty()
fileSize: number;
@ApiProperty()
issueDate: string;
@ApiProperty({ nullable: true, type: 'string' })
expiryDate: string | null;
@ApiPropertyOptional({ nullable: true, type: 'string' })
documentNumber?: string | null;
@ApiProperty()
uploadedAt: string;
@ApiPropertyOptional({ nullable: true, type: 'string' })
uploadedBy?: string | null;
@ApiPropertyOptional({
description: 'URL assinada de vida curta — nunca persistir',
nullable: true,
type: 'string',
})
fileUrl?: string | null;
}
export class RenewalProtocolDto {
@ApiProperty()
protocolNumber: string;
@ApiProperty()
protocolDate: string;
}
/** Contrato com o painel: IExternalStoreDocument. */
export class StoreDocumentResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
typeCode: string;
@ApiProperty()
storeId: string;
@ApiProperty({ enum: StoreDocumentStatus })
status: StoreDocumentStatus;
@ApiProperty({ nullable: true, type: 'string' })
issueDate: string | null;
@ApiProperty({ nullable: true, type: 'string' })
expiryDate: string | null;
@ApiPropertyOptional({ nullable: true, type: 'string' })
documentNumber?: string | null;
@ApiPropertyOptional({ nullable: true, type: 'string' })
responsible?: string | null;
@ApiPropertyOptional({ nullable: true, type: 'string' })
notes?: string | null;
@ApiPropertyOptional({ nullable: true, type: RenewalProtocolDto })
renewalProtocol?: RenewalProtocolDto | null;
@ApiProperty({ type: [DocumentVersionResponseDto] })
versions: DocumentVersionResponseDto[];
}
export class StoreDocumentListResponseDto {
@ApiProperty({ type: [StoreDocumentResponseDto] })
data: StoreDocumentResponseDto[];
@ApiProperty()
count: number;
}
/** Contrato com o painel: IExternalAnalysisResult. */
export class AnalysisResultResponseDto {
@ApiProperty({ nullable: true, type: 'string' })
suggestedTypeCode: string | null;
@ApiProperty()
confidence: number;
@ApiProperty()
detectedFields: {
issueDate?: string | null;
expiryDate?: string | null;
documentNumber?: string | null;
};
@ApiProperty({ type: [String] })
warnings: string[];
}

View File

@ -0,0 +1,67 @@
import { ApiProperty } from '@nestjs/swagger';
import { Column, Entity, OneToMany } from 'typeorm';
import { BaseCollection } from '../common/entities/base.entity';
import { DocumentVersion } from './documentVersion.entity';
/**
* Uma obrigação da loja (tipo do catálogo × loja) e seu estado corrente.
*
* As datas correntes espelham a versão mais recente desnormalização
* consciente para a listagem não precisar de join com versões. O status NÃO é
* persistido: é derivado na leitura (resolveStatus), porque muda com a
* passagem do tempo sem ninguém escrever nada.
*/
@Entity('store_document')
export class StoreDocument extends BaseCollection {
@ApiProperty({ description: 'Programa (rede) dono do documento' })
@Column({ type: 'uuid' })
programId: string;
@ApiProperty({ description: 'Loja dona do documento' })
@Column({ type: 'uuid' })
storeId: string;
@ApiProperty({ description: 'Código do tipo no catálogo' })
@Column({ type: 'varchar', length: 80 })
typeCode: string;
@ApiProperty({ nullable: true })
@Column({ type: 'date', nullable: true })
issueDate?: string | null;
@ApiProperty({
description: 'Nulo = documento sem vencimento (ex.: autorização ANP)',
nullable: true,
})
@Column({ type: 'date', nullable: true })
expiryDate?: string | null;
@ApiProperty({ nullable: true })
@Column({ type: 'varchar', length: 120, nullable: true })
documentNumber?: string | null;
@ApiProperty({ nullable: true })
@Column({ type: 'varchar', length: 160, nullable: true })
responsible?: string | null;
@ApiProperty({ nullable: true })
@Column({ type: 'text', nullable: true })
notes?: string | null;
@ApiProperty({
description: 'Protocolo de renovação no órgão (zera a cada ciclo novo)',
nullable: true,
})
@Column({ type: 'varchar', length: 120, nullable: true })
renewalProtocolNumber?: string | null;
@ApiProperty({ nullable: true })
@Column({ type: 'date', nullable: true })
renewalProtocolDate?: string | null;
@OneToMany(() => DocumentVersion, version => version.document, {
cascade: true,
})
versions?: DocumentVersion[];
}

49
src/ormconfig.ts Normal file
View File

@ -0,0 +1,49 @@
import * as dotenv from 'dotenv';
import * as path from 'path';
import { DataSource } from 'typeorm';
dotenv.config();
const database = process.env.TYPEORM_DATABASE || 'documents';
const username = process.env.TYPEORM_USERNAME || 'postgres';
const password = process.env.TYPEORM_PASSWORD || 'password';
export const config = {
type: 'postgres' as const,
synchronize: process.env.TYPEORM_SYNCHRONIZE
? JSON.parse(process.env.TYPEORM_SYNCHRONIZE)
: false,
migrationsRun: process.env.TYPEORM_MIGRATIONS_RUN
? JSON.parse(process.env.TYPEORM_MIGRATIONS_RUN)
: true,
logging: process.env.TYPEORM_LOGGING
? JSON.parse(process.env.TYPEORM_LOGGING)
: false,
entities: [`${__dirname}/**/*.entity{.ts,.js}`],
migrations: [path.join(__dirname, '/migrations/*{.ts,.js}')],
migrationsTableName: 'migrations',
replication: {
defaultMode: 'master',
master: {
host: process.env.TYPEORM_HOST || 'localhost',
port: Number(process.env.TYPEORM_PORT) || 5432,
username,
password,
database,
},
slaves: [
{
host:
process.env.TYPEORM_SLAVE || process.env.TYPEORM_HOST || 'localhost',
port: Number(process.env.TYPEORM_SLAVE_PORT) || 5432,
username,
password,
database,
},
],
},
} as any;
const datasource = new DataSource(config);
export default datasource;

5
tsconfig.build.json Normal file
View File

@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*"],
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

18
tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es2017",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

7405
yarn.lock Normal file

File diff suppressed because it is too large Load Diff