Compare commits
No commits in common. "08789cdce60c0985f70f6b4b20334be7aded4e10" and "80b01b176affa297796e2e2cf8ba0553d4e1eb12" have entirely different histories.
08789cdce6
...
80b01b176a
|
|
@ -398,6 +398,3 @@ Temporary Items
|
||||||
dist
|
dist
|
||||||
.webpack
|
.webpack
|
||||||
.serverless/**/*.zip
|
.serverless/**/*.zip
|
||||||
|
|
||||||
# Zed editor workspace
|
|
||||||
.zed/
|
|
||||||
|
|
|
||||||
|
|
@ -37,35 +37,33 @@ Retorna a lista de impressoras instaladas no servidor onde a API está rodando.
|
||||||
|
|
||||||
### 2. Imprimir HTML (Etiquetas/Layouts)
|
### 2. Imprimir HTML (Etiquetas/Layouts)
|
||||||
|
|
||||||
Converte um código HTML (com suporte a Tailwind CSS) para PDF e imprime na impressora selecionada via Fila de Processamento (BullMQ).
|
Converte um código HTML (com suporte a Tailwind CSS) para PDF e imprime na impressora selecionada. Ideal para etiquetas.
|
||||||
|
|
||||||
- **Método:** `POST`
|
- **Método:** `POST`
|
||||||
- **Rota:** `/printer/print-html` ou `/printer/:printerName/print-html`
|
- **Rota:** `/printer/print-html`
|
||||||
- **Corpo da Requisição (JSON):**
|
- **Corpo da Requisição (JSON):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"html": "<div class='text-xl font-bold'>Minha Etiqueta</div>...",
|
"html": "<div class='text-xl font-bold'>Minha Etiqueta</div>...",
|
||||||
"printerName": "POS-80", // Opcional se passado na URL
|
"printerName": "POS-80", // Nome exato da impressora (conforme retornado na listagem)
|
||||||
"width": "60mm", // Opcional. Largura do papel/etiqueta. Default: 80mm
|
"width": "60mm", // Opcional. Largura do papel/etiqueta. Default: 80mm
|
||||||
"height": "40mm", // Opcional. Altura da etiqueta. Default: auto
|
"height": "40mm" // Opcional. Altura da etiqueta. Default: auto
|
||||||
"jobId": "meu-id-unico-123" // Opcional. ID para idempotência na fila.
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Detalhes:**
|
- **Detalhes:**
|
||||||
- O backend injeta automaticamente o script do Tailwind CSS.
|
- O backend injeta automaticamente o script do Tailwind CSS.
|
||||||
- O HTML é renderizado via Puppeteer (Chrome headless).
|
- O HTML é renderizado via Puppeteer (Chrome headless).
|
||||||
- Tarefas são processadas de forma assíncrona via Redis/BullMQ.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. Imprimir Texto Simples (ESC/POS)
|
### 3. Imprimir Texto Simples (ESC/POS)
|
||||||
|
|
||||||
Envia comandos de texto diretamente para a impressora. Ideal para cupons simples e rápidos.
|
Envia comandos de texto diretamente para a impressora. Ideal para cupons simples e rápidos, sem formatação complexa.
|
||||||
|
|
||||||
- **Método:** `POST`
|
- **Método:** `POST`
|
||||||
- **Rota:** `/printer/print` ou `/printer/:printerName/print`
|
- **Rota:** `/printer/print`
|
||||||
- **Corpo da Requisição (JSON):**
|
- **Corpo da Requisição (JSON):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -78,7 +76,7 @@ Envia comandos de texto diretamente para a impressora. Ideal para cupons simples
|
||||||
],
|
],
|
||||||
"alignment": "center", // "left", "center", "right". Default: left
|
"alignment": "center", // "left", "center", "right". Default: left
|
||||||
"upsideDown": false, // Se true, imprime de cabeça para baixo
|
"upsideDown": false, // Se true, imprime de cabeça para baixo
|
||||||
"printerInterface": "POS-80" // Opcional se passado na URL. Suporta "tcp://192.168.1.100" ou nome da impressora.
|
"printerInterface": "POS-80" // Pode ser nome da impressora (USB) ou "tcp://192.168.1.100" (Rede)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -100,7 +98,6 @@ export interface PrintHtmlPayload {
|
||||||
printerName: string;
|
printerName: string;
|
||||||
width?: string;
|
width?: string;
|
||||||
height?: string;
|
height?: string;
|
||||||
jobId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrintTextPayload {
|
export interface PrintTextPayload {
|
||||||
|
|
@ -110,7 +107,3 @@ export interface PrintTextPayload {
|
||||||
printerInterface?: string;
|
printerInterface?: string;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Swagger
|
|
||||||
|
|
||||||
A documentação interativa completa (OpenAPI) está disponível em `/api`. Lá você pode testar todos os endpoints e ver os esquemas detalhados de cada DTO.
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,21 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { PrinterController } from './controller/printer.controller';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { PrinterService } from './services/printer.service';
|
import { typeOrmConfig } from './config/typeorm.config';
|
||||||
|
import { ListPrinterController } from './controller/list-printer.controller';
|
||||||
|
import { ListPrinterService } from './services/create-printer';
|
||||||
import { PrinterProcessor } from './services/printer.processor';
|
import { PrinterProcessor } from './services/printer.processor';
|
||||||
import { InfrastructureModule } from './infrastructure/infrastructure.module';
|
import { BullQueuesModule } from './config/BullModule';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [InfrastructureModule],
|
imports: [
|
||||||
controllers: [AppController, PrinterController],
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
providers: [AppService, PrinterService, PrinterProcessor],
|
TypeOrmModule.forRoot(typeOrmConfig),
|
||||||
|
BullQueuesModule
|
||||||
|
],
|
||||||
|
controllers: [AppController, ListPrinterController],
|
||||||
|
providers: [AppService, ListPrinterService, PrinterProcessor],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,15 @@ import { ExpressAdapter } from '@bull-board/express';
|
||||||
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
|
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
function stripWrappingQuotes(value: string | undefined): string | undefined {
|
|
||||||
if (value == null) return undefined;
|
|
||||||
const trimmed = value.trim();
|
|
||||||
return trimmed.replace(/^['"](.*)['"]$/, '$1');
|
|
||||||
}
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
BullModule.forRootAsync({
|
BullModule.forRootAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: async (configService: ConfigService) => ({
|
useFactory: async (configService: ConfigService) => ({
|
||||||
connection: {
|
connection: {
|
||||||
host: stripWrappingQuotes(configService.get<string>('REDIS_HOST')),
|
host: configService.get<string>('REDIS_HOST'),
|
||||||
port: parseInt(configService.get<string>('REDIS_PORT') ?? '6379', 10),
|
port: configService.get<number>('REDIS_PORT') || 6379,
|
||||||
password: stripWrappingQuotes(
|
password: configService.get<string>('REDIS_PASSWORD'),
|
||||||
configService.get<string>('REDIS_PASSWORD'),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,20 @@
|
||||||
import { Logger } from '@nestjs/common';
|
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
|
||||||
import { DataSourceOptions } from 'typeorm';
|
|
||||||
|
|
||||||
export function createTypeOrmOptions(
|
export const typeOrmConfig: DataSourceOptions = {
|
||||||
configService: ConfigService,
|
type: 'oracle',
|
||||||
): TypeOrmModuleOptions {
|
username: process.env.DB_USERNAME || 'teste',
|
||||||
const baseOptions: DataSourceOptions = {
|
password: process.env.DB_PASSWORD || 'teste',
|
||||||
type: 'oracle',
|
connectString:
|
||||||
username: configService.get<string>('DB_USERNAME') || 'teste',
|
process.env.DB_CONNECT_STRING ||
|
||||||
password: configService.get<string>('DB_PASSWORD') || 'teste',
|
'(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=10.1.1.241)(PORT=1521))(CONNECT_DATA=(SERVER=DEDICATED)(SERVICE_NAME=BDTESTE)))',
|
||||||
connectString:
|
synchronize: false,
|
||||||
configService.get<string>('DB_CONNECT_STRING') ||
|
logging: true,
|
||||||
'(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=10.1.1.241)(PORT=1521))(CONNECT_DATA=(SERVER=DEDICATED)(SERVICE_NAME=BDTESTE)))',
|
entities: [__dirname + '/../**/*.{entity,view}.{js,ts}'],
|
||||||
synchronize: false,
|
subscribers: [process.env.DB_SUBSCRIBERS_PATH || 'dist/subscriber/*.js'],
|
||||||
logging: true,
|
extra: {
|
||||||
entities: [__dirname + '/../**/*.{entity,view}.{js,ts}'],
|
poolSize: parseInt(process.env.DB_POOL_SIZE || '10', 10),
|
||||||
subscribers: [
|
maxRows: parseInt(process.env.DB_MAX_ROWS || '1000', 10),
|
||||||
configService.get<string>('DB_SUBSCRIBERS_PATH') ||
|
},
|
||||||
'dist/subscriber/*.js',
|
};
|
||||||
],
|
|
||||||
extra: {
|
|
||||||
poolSize: parseInt(configService.get<string>('DB_POOL_SIZE') || '10', 10),
|
|
||||||
maxRows: parseInt(configService.get<string>('DB_MAX_ROWS') || '1000', 10),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const retryAttempts = parseInt(
|
export const AppDataSource = new DataSource(typeOrmConfig);
|
||||||
configService.get<string>('DB_RETRY_ATTEMPTS') || '5',
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
const retryDelayMs = parseInt(
|
|
||||||
configService.get<string>('DB_RETRY_DELAY_MS') || '2000',
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
|
|
||||||
const logger = new Logger('TypeORM');
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`TypeORM configured for manual initialization (retryAttempts=${retryAttempts}, retryDelayMs=${retryDelayMs}).`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...baseOptions,
|
|
||||||
|
|
||||||
manualInitialization: true,
|
|
||||||
|
|
||||||
retryAttempts: 0,
|
|
||||||
retryDelay: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,6 @@
|
||||||
import {
|
import { Controller, Post, Body, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
Post,
|
|
||||||
Body,
|
|
||||||
Get,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Param,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
import { PrinterService } from '../services/printer.service';
|
import { ListPrinterService } from '../services/create-printer';
|
||||||
import { PrintDataDto } from '../dto/print-data.dto';
|
import { PrintDataDto } from '../dto/print-data.dto';
|
||||||
import { PrintHtmlDto } from '../dto/print-html.dto';
|
import { PrintHtmlDto } from '../dto/print-html.dto';
|
||||||
import { PrinterDto } from '../dto/printer.dto';
|
import { PrinterDto } from '../dto/printer.dto';
|
||||||
|
|
@ -16,18 +8,16 @@ import { getPrinters } from '../services/get-printer';
|
||||||
|
|
||||||
@ApiTags('Printer')
|
@ApiTags('Printer')
|
||||||
@Controller('printer')
|
@Controller('printer')
|
||||||
export class PrinterController {
|
export class ListPrinterController {
|
||||||
constructor(private readonly printerService: PrinterService) {}
|
constructor(private readonly printerService: ListPrinterService) {}
|
||||||
|
|
||||||
@Post(':printerName/print')
|
@Post(':printerName/print')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Envia um comando de impressão de texto simples (ESC/POS)' })
|
||||||
summary: 'Envia um comando de impressão de texto simples (ESC/POS)',
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 200, description: 'Impressão enviada com sucesso' })
|
@ApiResponse({ status: 200, description: 'Impressão enviada com sucesso' })
|
||||||
async print(
|
async print(
|
||||||
@Param('printerName') printerName: string,
|
@Param('printerName') printerName: string,
|
||||||
@Body() printData: PrintDataDto,
|
@Body() printData: PrintDataDto
|
||||||
) {
|
) {
|
||||||
printData.printerInterface = printerName;
|
printData.printerInterface = printerName;
|
||||||
return this.printerService.printReceipt(printData);
|
return this.printerService.printReceipt(printData);
|
||||||
|
|
@ -42,14 +32,11 @@ export class PrinterController {
|
||||||
|
|
||||||
@Post(':printerName/print-html')
|
@Post(':printerName/print-html')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Converte HTML para PDF e imprime na impressora especificada na URL' })
|
||||||
summary:
|
|
||||||
'Converte HTML para PDF e imprime na impressora especificada na URL',
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 200, description: 'HTML enviado para impressão' })
|
@ApiResponse({ status: 200, description: 'HTML enviado para impressão' })
|
||||||
async printHtml(
|
async printHtml(
|
||||||
@Param('printerName') printerName: string,
|
@Param('printerName') printerName: string,
|
||||||
@Body() printHtmlDto: PrintHtmlDto,
|
@Body() printHtmlDto: PrintHtmlDto
|
||||||
) {
|
) {
|
||||||
printHtmlDto.printerName = printerName;
|
printHtmlDto.printerName = printerName;
|
||||||
return this.printerService.printHtml(printHtmlDto);
|
return this.printerService.printHtml(printHtmlDto);
|
||||||
|
|
@ -67,7 +54,7 @@ export class PrinterController {
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: 'Lista de impressoras encontradas',
|
description: 'Lista de impressoras encontradas',
|
||||||
type: [PrinterDto],
|
type: [PrinterDto]
|
||||||
})
|
})
|
||||||
async listPrinters() {
|
async listPrinters() {
|
||||||
return getPrinters();
|
return getPrinters();
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { DynamicModule, Module } from '@nestjs/common';
|
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { createTypeOrmOptions } from '../config/typeorm.config';
|
|
||||||
import { parseBooleanEnv } from '../helpers/env.helper';
|
|
||||||
import { OracleDbInitializer } from './oracle-db.initializer';
|
|
||||||
|
|
||||||
@Module({})
|
|
||||||
export class DatabaseModule {
|
|
||||||
static register(): DynamicModule {
|
|
||||||
const dbEnabled = parseBooleanEnv(process.env.DB_ENABLED, true);
|
|
||||||
|
|
||||||
if (!dbEnabled) {
|
|
||||||
return { module: DatabaseModule };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
module: DatabaseModule,
|
|
||||||
imports: [
|
|
||||||
TypeOrmModule.forRootAsync({
|
|
||||||
imports: [ConfigModule],
|
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: (configService: ConfigService) =>
|
|
||||||
createTypeOrmOptions(configService),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
providers: [OracleDbInitializer],
|
|
||||||
exports: [TypeOrmModule],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { DataSource } from 'typeorm';
|
|
||||||
import { parseBooleanEnv } from '../helpers/env.helper';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class OracleDbInitializer implements OnApplicationBootstrap {
|
|
||||||
private readonly logger = new Logger(OracleDbInitializer.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly dataSource: DataSource,
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
onApplicationBootstrap(): void {
|
|
||||||
const enabled = parseBooleanEnv(
|
|
||||||
this.configService.get<string>('DB_ENABLED'),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if (!enabled) {
|
|
||||||
this.logger.log('DB initialization skipped (DB_ENABLED=false).');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.dataSource.isInitialized) return;
|
|
||||||
|
|
||||||
void this.initializeWithRetry();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async initializeWithRetry(): Promise<void> {
|
|
||||||
const attempts = parseInt(
|
|
||||||
this.configService.get<string>('DB_RETRY_ATTEMPTS') || '5',
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
const baseDelayMs = parseInt(
|
|
||||||
this.configService.get<string>('DB_RETRY_DELAY_MS') || '2000',
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
||||||
try {
|
|
||||||
await this.dataSource.initialize();
|
|
||||||
this.logger.log(
|
|
||||||
`Oracle DB connected (attempt ${attempt}/${attempts}).`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.warn(
|
|
||||||
`Oracle DB connection failed (attempt ${attempt}/${attempts}): ${message}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (attempt < attempts) {
|
|
||||||
const delay = baseDelayMs * attempt;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.error(
|
|
||||||
`Oracle DB unreachable after ${attempts} attempts; continuing without DB (degraded mode).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
export function parseBooleanEnv(
|
|
||||||
value: string | undefined,
|
|
||||||
defaultValue: boolean,
|
|
||||||
): boolean {
|
|
||||||
if (value == null) return defaultValue;
|
|
||||||
|
|
||||||
const normalized = value
|
|
||||||
.trim()
|
|
||||||
.replace(/^['"]|['"]$/g, '')
|
|
||||||
.toLowerCase();
|
|
||||||
|
|
||||||
if (normalized === 'true') return true;
|
|
||||||
if (normalized === 'false') return false;
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { Global, Module } from '@nestjs/common';
|
|
||||||
import { ConfigModule } from '@nestjs/config';
|
|
||||||
import { BullQueuesModule } from '../config/BullModule';
|
|
||||||
import { DatabaseModule } from '../database/database.module';
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
|
||||||
DatabaseModule.register(),
|
|
||||||
BullQueuesModule,
|
|
||||||
],
|
|
||||||
exports: [BullQueuesModule, DatabaseModule],
|
|
||||||
})
|
|
||||||
export class InfrastructureModule {}
|
|
||||||
12
src/main.ts
12
src/main.ts
|
|
@ -1,21 +1,11 @@
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
import { json, urlencoded } from 'express';
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { initializeOracleClient } from './helpers/oracle.helper';
|
import { initializeOracleClient } from './helpers/oracle.helper';
|
||||||
|
|
||||||
function getBodyLimit(): string {
|
|
||||||
const raw = process.env.BODY_LIMIT || '2mb';
|
|
||||||
return raw.trim().replace(/^['"]|['"]$/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
initializeOracleClient();
|
initializeOracleClient();
|
||||||
const app = await NestFactory.create(AppModule, { bodyParser: false });
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
const bodyLimit = getBodyLimit();
|
|
||||||
app.use(json({ limit: bodyLimit }));
|
|
||||||
app.use(urlencoded({ extended: true, limit: bodyLimit }));
|
|
||||||
|
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
.setTitle('API Documentation')
|
.setTitle('API Documentation')
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,12 @@ import { PrintHtmlDto } from '../dto/print-html.dto';
|
||||||
import { ThermalPrinter, PrinterTypes } from 'node-thermal-printer';
|
import { ThermalPrinter, PrinterTypes } from 'node-thermal-printer';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrinterService {
|
export class ListPrinterService {
|
||||||
private readonly printerType = PrinterTypes.EPSON;
|
private readonly printerType = PrinterTypes.EPSON;
|
||||||
|
|
||||||
constructor(@InjectQueue('printer') private printerQueue: Queue) {}
|
constructor(@InjectQueue('printer') private printerQueue: Queue) {}
|
||||||
|
|
||||||
async printReceipt(
|
async printReceipt(data: PrintDataDto): Promise<{ success: boolean; message: string }> {
|
||||||
data: PrintDataDto,
|
|
||||||
): Promise<{ success: boolean; message: string }> {
|
|
||||||
if (!data.lines?.length) {
|
if (!data.lines?.length) {
|
||||||
return { success: false, message: 'No lines provided' };
|
return { success: false, message: 'No lines provided' };
|
||||||
}
|
}
|
||||||
|
|
@ -21,11 +19,7 @@ export class PrinterService {
|
||||||
try {
|
try {
|
||||||
let printerInterface = data.printerInterface;
|
let printerInterface = data.printerInterface;
|
||||||
|
|
||||||
if (
|
if (printerInterface && !printerInterface.includes('://') && !printerInterface.startsWith('printer:')) {
|
||||||
printerInterface &&
|
|
||||||
!printerInterface.includes('://') &&
|
|
||||||
!printerInterface.startsWith('printer:')
|
|
||||||
) {
|
|
||||||
printerInterface = `printer:${printerInterface}`;
|
printerInterface = `printer:${printerInterface}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,7 +49,7 @@ export class PrinterService {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `Print failed: ${error.message || error}`,
|
message: `Print failed: ${error.message || error}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -72,15 +66,11 @@ export class PrinterService {
|
||||||
return printer.alignLeft();
|
return printer.alignLeft();
|
||||||
}
|
}
|
||||||
|
|
||||||
async printHtml(
|
async printHtml(data: PrintHtmlDto): Promise<{ success: boolean; message: string; jobId?: string }> {
|
||||||
data: PrintHtmlDto,
|
|
||||||
): Promise<{ success: boolean; message: string; jobId?: string }> {
|
|
||||||
const jobOptions: any = {};
|
const jobOptions: any = {};
|
||||||
|
|
||||||
if (data.jobId) {
|
if (data.jobId) {
|
||||||
const safeJobId = /^\d+$/.test(data.jobId)
|
const safeJobId = /^\d+$/.test(data.jobId) ? `print-${data.jobId}` : data.jobId;
|
||||||
? `print-${data.jobId}`
|
|
||||||
: data.jobId;
|
|
||||||
jobOptions.jobId = safeJobId;
|
jobOptions.jobId = safeJobId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,7 +79,7 @@ export class PrinterService {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Tarefa enviada para a fila de impressão',
|
message: 'Tarefa enviada para a fila de impressão',
|
||||||
jobId: job.id,
|
jobId: job.id
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,113 +1,26 @@
|
||||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||||
import { Logger, OnApplicationShutdown } from '@nestjs/common';
|
|
||||||
import { Job } from 'bullmq';
|
import { Job } from 'bullmq';
|
||||||
import { performance } from 'node:perf_hooks';
|
|
||||||
import * as puppeteer from 'puppeteer';
|
import * as puppeteer from 'puppeteer';
|
||||||
import * as pdfPrinter from 'pdf-to-printer';
|
import * as pdfPrinter from 'pdf-to-printer';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import { PrintHtmlDto } from '../dto/print-html.dto';
|
|
||||||
|
|
||||||
@Processor('printer')
|
@Processor('printer')
|
||||||
export class PrinterProcessor
|
export class PrinterProcessor extends WorkerHost {
|
||||||
extends WorkerHost
|
|
||||||
implements OnApplicationShutdown
|
|
||||||
{
|
|
||||||
private readonly logger = new Logger(PrinterProcessor.name);
|
|
||||||
private browser: puppeteer.Browser | undefined;
|
|
||||||
private browserLaunching: Promise<puppeteer.Browser> | undefined;
|
|
||||||
|
|
||||||
private async getBrowser(): Promise<puppeteer.Browser> {
|
async process(job: Job<any>): Promise<any> {
|
||||||
if (this.browser?.isConnected()) return this.browser;
|
const data = job.data;
|
||||||
if (this.browserLaunching) return this.browserLaunching;
|
const tempFilePath = path.join(os.tmpdir(), `print-${job.id}-${Date.now()}.pdf`);
|
||||||
|
let browser;
|
||||||
|
|
||||||
this.browserLaunching = puppeteer
|
try {
|
||||||
.launch({
|
browser = await puppeteer.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||||
})
|
|
||||||
.then((browser) => {
|
|
||||||
this.browser = browser;
|
|
||||||
return browser;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.browserLaunching = undefined;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.browserLaunching;
|
const page = await browser.newPage();
|
||||||
}
|
|
||||||
|
|
||||||
async onApplicationShutdown(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.browser?.close();
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Failed to close Puppeteer browser: ${String(err)}`);
|
|
||||||
} finally {
|
|
||||||
this.browser = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private logJob(
|
|
||||||
level: 'log' | 'warn' | 'error' | 'debug',
|
|
||||||
payload: Record<string, unknown>,
|
|
||||||
error?: unknown,
|
|
||||||
): void {
|
|
||||||
const message = JSON.stringify(payload);
|
|
||||||
if (level === 'error') {
|
|
||||||
const stack = error instanceof Error ? error.stack : undefined;
|
|
||||||
this.logger.error(message, stack);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (level === 'warn') {
|
|
||||||
this.logger.warn(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (level === 'debug') {
|
|
||||||
this.logger.debug(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
async process(job: Job<PrintHtmlDto>): Promise<any> {
|
|
||||||
const data = job.data;
|
|
||||||
const printerName = data?.printerName;
|
|
||||||
const jobCtx = {
|
|
||||||
jobId: job.id,
|
|
||||||
jobName: job.name,
|
|
||||||
printerName,
|
|
||||||
};
|
|
||||||
|
|
||||||
const tempFilePath = path.join(
|
|
||||||
os.tmpdir(),
|
|
||||||
`print-${job.id}-${Date.now()}.pdf`,
|
|
||||||
);
|
|
||||||
let page: puppeteer.Page | undefined;
|
|
||||||
let phase: 'render' | 'pdf' | 'spool' | 'unknown' = 'unknown';
|
|
||||||
|
|
||||||
const durationsMs: {
|
|
||||||
render?: number;
|
|
||||||
pdf?: number;
|
|
||||||
spool?: number;
|
|
||||||
total?: number;
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
const totalStart = performance.now();
|
|
||||||
|
|
||||||
this.logJob('log', {
|
|
||||||
event: 'print_job_start',
|
|
||||||
...jobCtx,
|
|
||||||
width: data?.width,
|
|
||||||
height: data?.height,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const browser = await this.getBrowser();
|
|
||||||
page = await browser.newPage();
|
|
||||||
const width = data.width || '80mm';
|
const width = data.width || '80mm';
|
||||||
const height = data.height;
|
const height = data.height;
|
||||||
|
|
||||||
|
|
@ -127,97 +40,30 @@ export class PrinterProcessor
|
||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
phase = 'render';
|
|
||||||
const renderStart = performance.now();
|
|
||||||
await page.setContent(fullHtml, { waitUntil: 'networkidle0' });
|
await page.setContent(fullHtml, { waitUntil: 'networkidle0' });
|
||||||
durationsMs.render = performance.now() - renderStart;
|
|
||||||
this.logJob('log', {
|
|
||||||
event: 'print_job_phase',
|
|
||||||
phase,
|
|
||||||
durationMs: durationsMs.render,
|
|
||||||
...jobCtx,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pdfOptions: any = {
|
const pdfOptions: any = {
|
||||||
path: tempFilePath,
|
path: tempFilePath,
|
||||||
printBackground: true,
|
printBackground: true,
|
||||||
width: width,
|
width: width,
|
||||||
margin: { top: '0px', right: '0px', bottom: '0px', left: '0px' },
|
margin: { top: '0px', right: '0px', bottom: '0px', left: '0px' }
|
||||||
};
|
};
|
||||||
|
|
||||||
if (height) pdfOptions.height = height;
|
if (height) pdfOptions.height = height;
|
||||||
|
|
||||||
phase = 'pdf';
|
|
||||||
const pdfStart = performance.now();
|
|
||||||
await page.pdf(pdfOptions);
|
await page.pdf(pdfOptions);
|
||||||
durationsMs.pdf = performance.now() - pdfStart;
|
await browser.close();
|
||||||
this.logJob('log', {
|
|
||||||
event: 'print_job_phase',
|
|
||||||
phase,
|
|
||||||
durationMs: durationsMs.pdf,
|
|
||||||
...jobCtx,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.close();
|
|
||||||
page = undefined;
|
|
||||||
|
|
||||||
phase = 'spool';
|
|
||||||
const spoolStart = performance.now();
|
|
||||||
await pdfPrinter.print(tempFilePath, {
|
await pdfPrinter.print(tempFilePath, {
|
||||||
printer: data.printerName,
|
printer: data.printerName,
|
||||||
silent: true,
|
silent: true
|
||||||
});
|
|
||||||
durationsMs.spool = performance.now() - spoolStart;
|
|
||||||
this.logJob('log', {
|
|
||||||
event: 'print_job_phase',
|
|
||||||
phase,
|
|
||||||
durationMs: durationsMs.spool,
|
|
||||||
...jobCtx,
|
|
||||||
});
|
|
||||||
|
|
||||||
durationsMs.total = performance.now() - totalStart;
|
|
||||||
this.logJob('log', {
|
|
||||||
event: 'print_job_success',
|
|
||||||
...jobCtx,
|
|
||||||
durationsMs,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { status: 'success', jobId: job.id };
|
return { status: 'success', jobId: job.id };
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (page) {
|
if (browser) await browser.close();
|
||||||
try {
|
console.error(`Erro no Job ${job.id}:`, error);
|
||||||
await page.close();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
durationsMs.total = performance.now() - totalStart;
|
|
||||||
this.logJob(
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
event: 'print_job_error',
|
|
||||||
...jobCtx,
|
|
||||||
phase,
|
|
||||||
durationsMs,
|
|
||||||
errorMessage: message,
|
|
||||||
errorType: error instanceof Error ? error.name : typeof error,
|
|
||||||
},
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
|
|
||||||
// If the shared browser crashed/closed, reset it so next job can relaunch.
|
|
||||||
if (/Target closed|Browser closed|Protocol error/i.test(message)) {
|
|
||||||
try {
|
|
||||||
await this.browser?.close();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
this.browser = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (fs.existsSync(tempFilePath)) {
|
if (fs.existsSync(tempFilePath)) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue