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:
commit
9165f1e9ab
41
.env.example
Normal file
41
.env.example
Normal 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
55
.gitea/workflows/cd.yml
Normal 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
34
.gitignore
vendored
Normal 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
46
AI-HISTORY.md
Normal 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
21
CHANGELOG.md
Normal 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 0–100 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
40
Dockerfile
Normal 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
42
README.md
Normal 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 0–100 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
65
cloudbuild.yaml
Normal 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
46
jest.config.js
Normal 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
128
k8s/hml/deployment.yaml
Normal 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
43
k8s/hml/ingress.yaml
Normal 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
14
k8s/hml/service.yaml
Normal 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
9
nest-cli.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"language": "ts",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"plugins": ["@nestjs/swagger/plugin"],
|
||||
"assets": ["**/*.proto"]
|
||||
}
|
||||
}
|
||||
74
package.json
Normal file
74
package.json
Normal 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
18
src/app.controller.ts
Normal 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
34
src/app.module.ts
Normal 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
35
src/app.service.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
42
src/interceptors/filters.interceptor.ts
Normal file
42
src/interceptors/filters.interceptor.ts
Normal 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
83
src/main.ts
Normal 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();
|
||||
177
src/migrations/1786147200000-migration.ts
Normal file
177
src/migrations/1786147200000-migration.ts
Normal 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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
248
src/migrations/seed/documentTypes.seed.ts
Normal file
248
src/migrations/seed/documentTypes.seed.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
74
src/modules/alerts/__tests__/alerts.service.spec.ts
Normal file
74
src/modules/alerts/__tests__/alerts.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
36
src/modules/alerts/alerts.controller.ts
Normal file
36
src/modules/alerts/alerts.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
src/modules/alerts/alerts.module.ts
Normal file
17
src/modules/alerts/alerts.module.ts
Normal 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 {}
|
||||
75
src/modules/alerts/alerts.service.ts
Normal file
75
src/modules/alerts/alerts.service.ts
Normal 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 já 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 só fecha com documento novo anexado. Este
|
||||
* service já 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;
|
||||
}
|
||||
}
|
||||
110
src/modules/catalog/__tests__/catalog.service.spec.ts
Normal file
110
src/modules/catalog/__tests__/catalog.service.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
33
src/modules/catalog/catalog.controller.ts
Normal file
33
src/modules/catalog/catalog.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
src/modules/catalog/catalog.exception.ts
Normal file
10
src/modules/catalog/catalog.exception.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
20
src/modules/catalog/catalog.module.ts
Normal file
20
src/modules/catalog/catalog.module.ts
Normal 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 {}
|
||||
87
src/modules/catalog/catalog.service.ts
Normal file
87
src/modules/catalog/catalog.service.ts
Normal 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 ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
90
src/modules/catalog/documentType.entity.ts
Normal file
90
src/modules/catalog/documentType.entity.ts
Normal 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;
|
||||
}
|
||||
39
src/modules/catalog/documentTypeUfOverride.entity.ts
Normal file
39
src/modules/catalog/documentTypeUfOverride.entity.ts
Normal 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: 1–5 anos conforme o estado;
|
||||
* LO: 3–10). 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;
|
||||
}
|
||||
22
src/modules/catalog/dto/request/getCatalog.dto.ts
Normal file
22
src/modules/catalog/dto/request/getCatalog.dto.ts
Normal 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;
|
||||
}
|
||||
40
src/modules/catalog/dto/response/documentTypeResponse.dto.ts
Normal file
40
src/modules/catalog/dto/response/documentTypeResponse.dto.ts
Normal 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[];
|
||||
}
|
||||
92
src/modules/common/__tests__/mock/repository.mock.ts
Normal file
92
src/modules/common/__tests__/mock/repository.mock.ts
Normal 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 }>,
|
||||
),
|
||||
});
|
||||
103
src/modules/common/__tests__/status.util.spec.ts
Normal file
103
src/modules/common/__tests__/status.util.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
15
src/modules/common/dto/httpExceptionResponse.dto.ts
Normal file
15
src/modules/common/dto/httpExceptionResponse.dto.ts
Normal 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;
|
||||
}
|
||||
58
src/modules/common/dto/tenant.dto.ts
Normal file
58
src/modules/common/dto/tenant.dto.ts
Normal 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;
|
||||
}
|
||||
26
src/modules/common/entities/base.entity.ts
Normal file
26
src/modules/common/entities/base.entity.ts
Normal 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;
|
||||
}
|
||||
52
src/modules/common/enum/document.enum.ts
Normal file
52
src/modules/common/enum/document.enum.ts
Normal 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',
|
||||
}
|
||||
10
src/modules/common/storage/storage.module.ts
Normal file
10
src/modules/common/storage/storage.module.ts
Normal 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 {}
|
||||
85
src/modules/common/storage/storage.service.ts
Normal file
85
src/modules/common/storage/storage.service.ts
Normal 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 — só 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
src/modules/common/utils/status.util.ts
Normal file
73
src/modules/common/utils/status.util.ts
Normal 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` já 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;
|
||||
};
|
||||
178
src/modules/compliance/__tests__/compliance.service.spec.ts
Normal file
178
src/modules/compliance/__tests__/compliance.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
48
src/modules/compliance/compliance.controller.ts
Normal file
48
src/modules/compliance/compliance.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
21
src/modules/compliance/compliance.module.ts
Normal file
21
src/modules/compliance/compliance.module.ts
Normal 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 {}
|
||||
173
src/modules/compliance/compliance.service.ts
Normal file
173
src/modules/compliance/compliance.service.ts
Normal 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,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
15
src/modules/compliance/dto/request/getUpcoming.dto.ts
Normal file
15
src/modules/compliance/dto/request/getUpcoming.dto.ts
Normal 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;
|
||||
}
|
||||
63
src/modules/compliance/dto/response/compliance.dto.ts
Normal file
63
src/modules/compliance/dto/response/compliance.dto.ts
Normal 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 0–100 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;
|
||||
}
|
||||
288
src/modules/document/__tests__/document.service.spec.ts
Normal file
288
src/modules/document/__tests__/document.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
97
src/modules/document/__tests__/keywordAnalyzer.spec.ts
Normal file
97
src/modules/document/__tests__/keywordAnalyzer.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
33
src/modules/document/analyzer/documentAnalyzer.interface.ts
Normal file
33
src/modules/document/analyzer/documentAnalyzer.interface.ts
Normal 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');
|
||||
80
src/modules/document/analyzer/keywordAnalyzer.ts
Normal file
80
src/modules/document/analyzer/keywordAnalyzer.ts
Normal 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, '');
|
||||
}
|
||||
}
|
||||
182
src/modules/document/document.controller.ts
Normal file
182
src/modules/document/document.controller.ts
Normal 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 só 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);
|
||||
}
|
||||
}
|
||||
29
src/modules/document/document.exception.ts
Normal file
29
src/modules/document/document.exception.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
28
src/modules/document/document.module.ts
Normal file
28
src/modules/document/document.module.ts
Normal 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 {}
|
||||
296
src/modules/document/document.service.ts
Normal file
296
src/modules/document/document.service.ts
Normal 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 só 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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
54
src/modules/document/documentVersion.entity.ts
Normal file
54
src/modules/document/documentVersion.entity.ts
Normal 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;
|
||||
}
|
||||
35
src/modules/document/dto/request/document.dto.ts
Normal file
35
src/modules/document/dto/request/document.dto.ts
Normal 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;
|
||||
}
|
||||
108
src/modules/document/dto/response/documentResponse.dto.ts
Normal file
108
src/modules/document/dto/response/documentResponse.dto.ts
Normal 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[];
|
||||
}
|
||||
67
src/modules/document/storeDocument.entity.ts
Normal file
67
src/modules/document/storeDocument.entity.ts
Normal 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
49
src/ormconfig.ts
Normal 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
5
tsconfig.build.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user