u
This commit is contained in:
@@ -2,6 +2,7 @@ import express from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
import * as db from '../../db/printeragent.js';
|
||||
import * as agentprinterDb from '../../db/agentprinter.js';
|
||||
import * as printerjobDb from '../../db/printerjob.js';
|
||||
import { agentAuthMiddleware, extractAgentVersion } from '../middleware/agentAuth.js';
|
||||
import { emit } from '../sse.js';
|
||||
import { createLogger } from '../../util/logger.js';
|
||||
@@ -202,6 +203,36 @@ async function handlePair(req, res) {
|
||||
agentRouter.post('/pair', handlePair);
|
||||
agentRouter.get('/pair', handlePair);
|
||||
|
||||
const activeAgentEvents = new Map();
|
||||
|
||||
/**
|
||||
* Notifies a connected agent of a new or cancelled printer job.
|
||||
* @param {number} agentId
|
||||
* @param {'created'|'cancelled'} action
|
||||
* @param {object} job
|
||||
* @returns {boolean} True if delivered to at least one active agent connection.
|
||||
*/
|
||||
export function notifyAgentJob(agentId, action, job) {
|
||||
const aId = Number(agentId);
|
||||
const conns = activeAgentEvents.get(aId);
|
||||
if (!conns || conns.size === 0) return false;
|
||||
const payload = JSON.stringify({
|
||||
action,
|
||||
jobId: job.id,
|
||||
agentprinterId: job.agentprinterId,
|
||||
type: job.type,
|
||||
state: job.state,
|
||||
createdAt: job.createdAt,
|
||||
});
|
||||
for (const clientRes of conns) {
|
||||
if (!clientRes.writableEnded) {
|
||||
clientRes.write(`event: job\ndata: ${payload}\n\n`);
|
||||
clientRes.write(`event: printerjob\ndata: ${payload}\n\n`);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /agent/events:
|
||||
@@ -256,6 +287,12 @@ agentRouter.get('/events', agentAuthMiddleware, async (req, res) => {
|
||||
setAgentState(req.agent.id, 'online');
|
||||
}
|
||||
|
||||
const aId = Number(req.agent.id);
|
||||
if (!activeAgentEvents.has(aId)) {
|
||||
activeAgentEvents.set(aId, new Set());
|
||||
}
|
||||
activeAgentEvents.get(aId).add(res);
|
||||
|
||||
res.write(`event: connected\ndata: ${JSON.stringify({ agentId: req.agent.id, name: req.agent.name })}\n\n`);
|
||||
|
||||
const keepAlive = setInterval(() => {
|
||||
@@ -269,6 +306,13 @@ agentRouter.get('/events', agentAuthMiddleware, async (req, res) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
clearInterval(keepAlive);
|
||||
|
||||
const conns = activeAgentEvents.get(aId);
|
||||
if (conns) {
|
||||
conns.delete(res);
|
||||
if (conns.size === 0) activeAgentEvents.delete(aId);
|
||||
}
|
||||
|
||||
log.info(`Agent disconnected: id=${req.agent.id} ("${agentName}")`);
|
||||
setAgentState(req.agent.id, 'offline');
|
||||
if (req.agent?.companyId) {
|
||||
@@ -393,3 +437,78 @@ agentRouter.get('/state', agentAuthMiddleware, (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// #region Agent Jobs
|
||||
|
||||
agentRouter.get('/jobs', agentAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const jobs = await printerjobDb.list({ agentId: req.agent.id }, req.query);
|
||||
res.status(200).json({ jobs });
|
||||
} catch (err) {
|
||||
log.error('Agent get jobs failed:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
agentRouter.get('/jobs/:id', agentAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const job = await printerjobDb.getById({ agentId: req.agent.id }, req.params.id);
|
||||
if (!job) {
|
||||
return res.status(404).json({ error: 'Printerjob not found' });
|
||||
}
|
||||
res.status(200).json({ job });
|
||||
} catch (err) {
|
||||
log.error('Agent get job failed:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
agentRouter.post('/jobs/:id/processing', agentAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const job = await printerjobDb.updateState({ agentId: req.agent.id }, req.params.id, 'processing');
|
||||
if (req.agent?.companyId) {
|
||||
emit('printerjob', { companyId: req.agent.companyId, data: { job } });
|
||||
}
|
||||
res.status(200).json({ success: true, job });
|
||||
} catch (err) {
|
||||
if (err.message === 'Printerjob not found') return res.status(404).json({ error: err.message });
|
||||
log.error('Agent set job processing failed:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
agentRouter.post('/jobs/:id/completed', agentAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const job = await printerjobDb.updateState({ agentId: req.agent.id }, req.params.id, 'completed');
|
||||
if (req.agent?.companyId) {
|
||||
emit('printerjob', { companyId: req.agent.companyId, data: { job } });
|
||||
}
|
||||
res.status(200).json({ success: true, job });
|
||||
} catch (err) {
|
||||
if (err.message === 'Printerjob not found') return res.status(404).json({ error: err.message });
|
||||
log.error('Agent set job completed failed:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
agentRouter.patch('/jobs/:id', agentAuthMiddleware, async (req, res) => {
|
||||
const state = req.body?.state;
|
||||
if (!state) {
|
||||
return res.status(400).json({ error: 'state is required' });
|
||||
}
|
||||
try {
|
||||
const job = await printerjobDb.updateState({ agentId: req.agent.id }, req.params.id, state);
|
||||
if (req.agent?.companyId) {
|
||||
emit('printerjob', { companyId: req.agent.companyId, data: { job } });
|
||||
}
|
||||
res.status(200).json({ success: true, job });
|
||||
} catch (err) {
|
||||
if (err.message === 'Printerjob not found') return res.status(404).json({ error: err.message });
|
||||
if (err.message?.startsWith('Invalid printerjob state')) return res.status(400).json({ error: err.message });
|
||||
log.error('Agent patch job failed:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export const authMiddleware = async (req, res, next) => {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/api/agent')) {
|
||||
if (req.path === '/api/agent' || req.path.startsWith('/api/agent/')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
|
||||
@@ -1774,6 +1774,59 @@
|
||||
* type: integer
|
||||
* description: Display order (ascending)
|
||||
* default: 0
|
||||
* open:
|
||||
* type: boolean
|
||||
* description: Whether the printer head/cover is open
|
||||
* default: false
|
||||
* pause:
|
||||
* type: boolean
|
||||
* description: Whether the printer is paused
|
||||
* default: false
|
||||
* error:
|
||||
* type: boolean
|
||||
* description: Whether the printer is in an error state
|
||||
* default: false
|
||||
* online:
|
||||
* type: boolean
|
||||
* description: Whether the printer is online and reachable
|
||||
* default: false
|
||||
* labelLength:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* description: Discovered label length in dots/pixels reported by the printer
|
||||
* default: null
|
||||
* Printerjob:
|
||||
* type: object
|
||||
* required: [id, agentprinterId, type, state, data, createdAt]
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* agentprinterId:
|
||||
* type: integer
|
||||
* description: Target agent printer ID
|
||||
* printeragentId:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* description: Parent printer agent ID
|
||||
* type:
|
||||
* type: string
|
||||
* description: Job format type ("zpl", "pdf", "epos")
|
||||
* example: "zpl"
|
||||
* state:
|
||||
* type: string
|
||||
* enum: [pending, processing, completed, cancelled, failed]
|
||||
* description: Current processing state of the job
|
||||
* example: "pending"
|
||||
* data:
|
||||
* type: string
|
||||
* description: Raw job payload
|
||||
* createdAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* completedAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* nullable: true
|
||||
* LabelObject:
|
||||
* type: object
|
||||
* required: [id, type, x, y]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as db from '../../db/agentprinter.js';
|
||||
import { emit } from '../sse.js';
|
||||
import { enrichAgentPrinter } from '../../services/companyPrinterService.js';
|
||||
|
||||
// #region Helpers
|
||||
|
||||
@@ -91,7 +92,8 @@ export function register(app, basePath = '') {
|
||||
try {
|
||||
const printeragentId = req.query?.printeragentId ? Number(req.query.printeragentId) : undefined;
|
||||
const agentprinters = await db.list(req.user.id, printeragentId);
|
||||
res.status(200).json({ agentprinters });
|
||||
const enriched = agentprinters.map((p) => enrichAgentPrinter(selected.companyId, p));
|
||||
res.status(200).json({ agentprinters: enriched });
|
||||
} catch (error) {
|
||||
const status = agentprinterErrorStatus(error);
|
||||
if (status === 500) console.error('List agentprinters failed:', error);
|
||||
@@ -236,7 +238,7 @@ export function register(app, basePath = '') {
|
||||
if (!agentprinter) {
|
||||
return res.status(404).json({ error: 'Agentprinter not found' });
|
||||
}
|
||||
res.status(200).json({ agentprinter });
|
||||
res.status(200).json({ agentprinter: enrichAgentPrinter(selected.companyId, agentprinter) });
|
||||
} catch (error) {
|
||||
const status = agentprinterErrorStatus(error);
|
||||
if (status === 500) console.error('Get agentprinter failed:', error);
|
||||
@@ -333,7 +335,7 @@ export function register(app, basePath = '') {
|
||||
try {
|
||||
const agentprinter = await db.update(req.user.id, payload);
|
||||
emit('agentprinter', { companyId: selected.companyId });
|
||||
res.status(200).json({ agentprinter });
|
||||
res.status(200).json({ agentprinter: enrichAgentPrinter(selected.companyId, agentprinter) });
|
||||
} catch (error) {
|
||||
const status = agentprinterErrorStatus(error);
|
||||
if (status === 500) console.error('Update agentprinter failed:', error);
|
||||
|
||||
@@ -57,6 +57,7 @@ export function register(app, basePath = '') {
|
||||
* | `printer` | `{}` | `POST /printer`, `PUT /printer/{id}`, `PATCH /printer/{id}`, `DELETE /printer/{id}` |
|
||||
* | `printeragent` | `{}` | `PUT /printeragent`, `PATCH /printeragent/{id}`, `DELETE /printeragent/{id}`, printer agent connect/disconnect/state |
|
||||
* | `agentprinter` | `{ printers }` or `{}` | `PATCH /agentprinter/{id}`, `PUT /agentprinter/order`, `DELETE /agentprinter/{id}`, agent state updates / printer discovery |
|
||||
* | `printerjob` | `{ job }` or `{ jobId, state }` | `POST /zpljob`, `POST /printerjob`, `POST /printerjob/{id}/cancel`, `DELETE /printerjob/{id}`, agent job state updates |
|
||||
* | `warehouse` | `{}` | `POST /warehouse`, `PUT /warehouse/{id}`, `PATCH /warehouse/{id}`, `DELETE /warehouse/{id}` |
|
||||
* | `supplier` | `{}` | `POST /supplier`, `PATCH /supplier/{id}`, `DELETE /supplier/{id}` |
|
||||
* | `vfs` | `{}` | VFS uploads and filesystem mutations such as `POST /vfs/upload`, `POST /vfs/mkdir`, `POST /vfs/rename`, `PUT /vfs/{*path}`, `DELETE /vfs/{*path}` |
|
||||
@@ -99,6 +100,12 @@ export function register(app, basePath = '') {
|
||||
* payload: "{}"
|
||||
* - name: printer
|
||||
* payload: "{}"
|
||||
* - name: printeragent
|
||||
* payload: "{}"
|
||||
* - name: agentprinter
|
||||
* payload: "{ printers } or {}"
|
||||
* - name: printerjob
|
||||
* payload: "{ job } or { jobId, state }"
|
||||
* - name: warehouse
|
||||
* payload: "{}"
|
||||
* - name: supplier
|
||||
|
||||
388
src/api/routes/printerjob.js
Normal file
388
src/api/routes/printerjob.js
Normal file
@@ -0,0 +1,388 @@
|
||||
import * as printerjobDb from '../../db/printerjob.js';
|
||||
import { emit } from '../sse.js';
|
||||
import { notifyAgentJob } from '../agent/index.js';
|
||||
|
||||
// #region Helpers
|
||||
|
||||
function requireSelectedCompany(req) {
|
||||
const companyId = req.user?.selectedCompanyId;
|
||||
if (companyId == null) {
|
||||
return { error: 'No company selected' };
|
||||
}
|
||||
return { companyId: Number(companyId) };
|
||||
}
|
||||
|
||||
function parsePositiveInt(value, field) {
|
||||
const id = Number(value);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return { error: `${field} must be a positive integer` };
|
||||
}
|
||||
return { id };
|
||||
}
|
||||
|
||||
function printerjobErrorStatus(error) {
|
||||
if (error.message === 'No company selected') return 400;
|
||||
if (error.message === 'Not authorized') return 403;
|
||||
if (error.message === 'Company not found') return 404;
|
||||
if (error.message === 'Agentprinter not found') return 404;
|
||||
if (error.message === 'Printerjob not found') return 404;
|
||||
if (error.message?.includes('is required')) return 400;
|
||||
if (error.message?.startsWith('Invalid printerjob')) return 400;
|
||||
return 500;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* Registers printerjob routes.
|
||||
* @param {import('express').Application} app
|
||||
* @param {string} [basePath='']
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @openapi
|
||||
* /printerjob:
|
||||
* get:
|
||||
* tags: [PrinterJob]
|
||||
* summary: List printer jobs
|
||||
* description: Lists printer jobs for the selected company, optionally filtered by agent printer or state.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: agentprinterId
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Optional agent printer ID filter
|
||||
* - in: query
|
||||
* name: state
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [pending, processing, completed, cancelled, failed]
|
||||
* description: Optional job state filter
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of printer jobs
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* printerjobs:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Printerjob'
|
||||
* 400:
|
||||
* description: No company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
// #region GET /printerjob
|
||||
|
||||
app.get(`${basePath}/printerjob`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const agentprinterId = req.query?.agentprinterId ? Number(req.query.agentprinterId) : undefined;
|
||||
const state = req.query?.state ? String(req.query.state).trim().toLowerCase() : undefined;
|
||||
const printerjobs = await printerjobDb.list(req.user.id, { agentprinterId, state });
|
||||
res.status(200).json({ printerjobs });
|
||||
} catch (error) {
|
||||
const status = printerjobErrorStatus(error);
|
||||
if (status === 500) console.error('List printerjobs failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /printerjob/{id}:
|
||||
* get:
|
||||
* tags: [PrinterJob]
|
||||
* summary: Get printer job
|
||||
* description: Retrieves details of a specific printer job by ID.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Printer job details
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* printerjob:
|
||||
* $ref: '#/components/schemas/Printerjob'
|
||||
* 400:
|
||||
* description: Invalid ID or no company selected
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Printer job not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
// #region GET /printerjob/:id
|
||||
|
||||
app.get(`${basePath}/printerjob/:id`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const parsed = parsePositiveInt(req.params.id, 'id');
|
||||
if (parsed.error) {
|
||||
return res.status(400).json({ error: parsed.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const printerjob = await printerjobDb.getById(req.user.id, parsed.id);
|
||||
if (!printerjob) {
|
||||
return res.status(404).json({ error: 'Printerjob not found' });
|
||||
}
|
||||
res.status(200).json({ printerjob });
|
||||
} catch (error) {
|
||||
const status = printerjobErrorStatus(error);
|
||||
if (status === 500) console.error('Get printerjob failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /printerjob:
|
||||
* post:
|
||||
* tags: [PrinterJob]
|
||||
* summary: Create print job
|
||||
* description: Creates a new print job for an agent printer. Notifies the agent in real time.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* x-signals: [printerjob]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [agentprinterId, data]
|
||||
* properties:
|
||||
* agentprinterId:
|
||||
* type: integer
|
||||
* type:
|
||||
* type: string
|
||||
* example: "zpl"
|
||||
* data:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Printer job created
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* printerjob:
|
||||
* $ref: '#/components/schemas/Printerjob'
|
||||
*/
|
||||
// #region POST /printerjob
|
||||
|
||||
app.post(`${basePath}/printerjob`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const rawApId = req.body?.agentprinterId ?? req.body?.agentprinterid;
|
||||
const parsedApId = parsePositiveInt(rawApId, 'agentprinterId');
|
||||
if (parsedApId.error) {
|
||||
return res.status(400).json({ error: parsedApId.error });
|
||||
}
|
||||
|
||||
const rawData = req.body?.data;
|
||||
if (!rawData || typeof rawData !== 'string' || !rawData.trim()) {
|
||||
return res.status(400).json({ error: 'data is required' });
|
||||
}
|
||||
|
||||
const type = typeof req.body?.type === 'string' && req.body.type.trim()
|
||||
? req.body.type.trim().toLowerCase()
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const job = await printerjobDb.create(req.user.id, {
|
||||
agentprinterId: parsedApId.id,
|
||||
type,
|
||||
data: rawData.trim(),
|
||||
});
|
||||
|
||||
if (job.printeragentId) {
|
||||
notifyAgentJob(job.printeragentId, 'created', job);
|
||||
}
|
||||
|
||||
emit('printerjob', { companyId: selected.companyId, data: { job } });
|
||||
res.status(201).json({ printerjob: job });
|
||||
} catch (error) {
|
||||
const status = printerjobErrorStatus(error);
|
||||
if (status === 500) console.error('Create printerjob failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /printerjob/{id}/cancel:
|
||||
* post:
|
||||
* tags: [PrinterJob]
|
||||
* summary: Cancel printer job
|
||||
* description: Marks a print job as cancelled and notifies the connected agent in real time.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* x-signals: [printerjob]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Printer job cancelled
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* printerjob:
|
||||
* $ref: '#/components/schemas/Printerjob'
|
||||
*/
|
||||
// #region POST /printerjob/:id/cancel
|
||||
|
||||
app.post(`${basePath}/printerjob/:id/cancel`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const parsed = parsePositiveInt(req.params.id, 'id');
|
||||
if (parsed.error) {
|
||||
return res.status(400).json({ error: parsed.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await printerjobDb.updateState(req.user.id, parsed.id, 'cancelled');
|
||||
|
||||
if (job.printeragentId) {
|
||||
notifyAgentJob(job.printeragentId, 'cancelled', job);
|
||||
}
|
||||
|
||||
emit('printerjob', { companyId: selected.companyId, data: { job } });
|
||||
res.status(200).json({ success: true, printerjob: job });
|
||||
} catch (error) {
|
||||
const status = printerjobErrorStatus(error);
|
||||
if (status === 500) console.error('Cancel printerjob failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /printerjob/{id}:
|
||||
* delete:
|
||||
* tags: [PrinterJob]
|
||||
* summary: Cancel and delete printer job
|
||||
* description: |
|
||||
* Cancels and deletes a print job. If the job was pending or processing, sends a cancellation
|
||||
* event to the connected printer agent before deletion.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* x-signals: [printerjob]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Job cancelled and deleted
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* deletedJob:
|
||||
* $ref: '#/components/schemas/Printerjob'
|
||||
*/
|
||||
// #region DELETE /printerjob/:id
|
||||
|
||||
app.delete(`${basePath}/printerjob/:id`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const parsed = parsePositiveInt(req.params.id, 'id');
|
||||
if (parsed.error) {
|
||||
return res.status(400).json({ error: parsed.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await printerjobDb.deleteJob(req.user.id, parsed.id);
|
||||
|
||||
if (job.printeragentId) {
|
||||
notifyAgentJob(job.printeragentId, 'cancelled', job);
|
||||
}
|
||||
|
||||
emit('printerjob', {
|
||||
companyId: selected.companyId,
|
||||
data: { jobId: job.id, state: 'cancelled' },
|
||||
});
|
||||
res.status(200).json({ success: true, deletedJob: job });
|
||||
} catch (error) {
|
||||
const status = printerjobErrorStatus(error);
|
||||
if (status === 500) console.error('Delete printerjob failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
}
|
||||
|
||||
176
src/api/routes/zpljob.js
Normal file
176
src/api/routes/zpljob.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import * as printerjobDb from '../../db/printerjob.js';
|
||||
import { emit } from '../sse.js';
|
||||
import { notifyAgentJob } from '../agent/index.js';
|
||||
|
||||
// #region Helpers
|
||||
|
||||
function requireSelectedCompany(req) {
|
||||
const companyId = req.user?.selectedCompanyId;
|
||||
if (companyId == null) {
|
||||
return { error: 'No company selected' };
|
||||
}
|
||||
return { companyId: Number(companyId) };
|
||||
}
|
||||
|
||||
function parsePositiveInt(value, field) {
|
||||
const id = Number(value);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return { error: `${field} must be a positive integer` };
|
||||
}
|
||||
return { id };
|
||||
}
|
||||
|
||||
function printerjobErrorStatus(error) {
|
||||
if (error.message === 'No company selected') return 400;
|
||||
if (error.message === 'Not authorized') return 403;
|
||||
if (error.message === 'Company not found') return 404;
|
||||
if (error.message === 'Agentprinter not found') return 404;
|
||||
if (error.message === 'Printerjob not found') return 404;
|
||||
if (error.message?.includes('is required')) return 400;
|
||||
if (error.message?.startsWith('Invalid printerjob')) return 400;
|
||||
return 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts or inserts the ZPL print quantity command (^PQ) in the ZPL payload.
|
||||
*
|
||||
* @param {string} data - Raw ZPL string.
|
||||
* @param {number|string} [copiescount=1] - Number of copies.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function applyZplCopies(data, copiescount) {
|
||||
if (!data || typeof data !== 'string') return data;
|
||||
const copies = Number(copiescount);
|
||||
if (!Number.isInteger(copies) || copies <= 1) return data;
|
||||
|
||||
if (/\^PQ\d+/i.test(data)) {
|
||||
return data.replace(/\^PQ\d+/gi, `^PQ${copies}`);
|
||||
}
|
||||
|
||||
if (/\^XZ/i.test(data)) {
|
||||
return data.replace(/\^XZ/gi, `^PQ${copies}\n^XZ`);
|
||||
}
|
||||
|
||||
return `${data}\n^PQ${copies}`;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* Registers zpljob routes.
|
||||
* @param {import('express').Application} app
|
||||
* @param {string} [basePath='']
|
||||
*/
|
||||
export function register(app, basePath = '') {
|
||||
/**
|
||||
* @openapi
|
||||
* /zpljob:
|
||||
* post:
|
||||
* tags: [PrinterJob]
|
||||
* summary: Create ZPL print job
|
||||
* description: |
|
||||
* Issues a new ZPL print job to an agent printer. If copiescount > 1, the ZPL quantity command (^PQ)
|
||||
* is automatically applied. The connected printer agent receives an immediate real-time event.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* x-signals: [printerjob]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [agentprinterId, data]
|
||||
* properties:
|
||||
* agentprinterId:
|
||||
* type: integer
|
||||
* description: Target agent printer ID
|
||||
* example: 1
|
||||
* copiescount:
|
||||
* type: integer
|
||||
* description: Optional copy count (defaults to 1)
|
||||
* default: 1
|
||||
* example: 2
|
||||
* data:
|
||||
* type: string
|
||||
* description: Raw ZPL string
|
||||
* example: "^XA^FO50,50^ADN,36,20^FDHello Zebra^FS^XZ"
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Print job created
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* printerjob:
|
||||
* $ref: '#/components/schemas/Printerjob'
|
||||
* 400:
|
||||
* description: Invalid input or missing fields
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 404:
|
||||
* description: Agent printer not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
// #region POST /zpljob
|
||||
|
||||
app.post(`${basePath}/zpljob`, async (req, res) => {
|
||||
const selected = requireSelectedCompany(req);
|
||||
if (selected.error) {
|
||||
return res.status(400).json({ error: selected.error });
|
||||
}
|
||||
|
||||
const rawApId = req.body?.agentprinterId ?? req.body?.agentprinterid;
|
||||
const parsedApId = parsePositiveInt(rawApId, 'agentprinterId');
|
||||
if (parsedApId.error) {
|
||||
return res.status(400).json({ error: parsedApId.error });
|
||||
}
|
||||
|
||||
const rawData = req.body?.data;
|
||||
if (!rawData || typeof rawData !== 'string' || !rawData.trim()) {
|
||||
return res.status(400).json({ error: 'data is required' });
|
||||
}
|
||||
|
||||
const rawCopies = req.body?.copiescount ?? req.body?.copiesCount ?? req.body?.copies;
|
||||
const copies = rawCopies != null ? Number(rawCopies) : 1;
|
||||
if (!Number.isInteger(copies) || copies <= 0) {
|
||||
return res.status(400).json({ error: 'copiescount must be a positive integer' });
|
||||
}
|
||||
|
||||
const finalData = applyZplCopies(rawData.trim(), copies);
|
||||
|
||||
try {
|
||||
const job = await printerjobDb.create(req.user.id, {
|
||||
agentprinterId: parsedApId.id,
|
||||
type: 'zpl',
|
||||
data: finalData,
|
||||
});
|
||||
|
||||
if (job.printeragentId) {
|
||||
notifyAgentJob(job.printeragentId, 'created', job);
|
||||
}
|
||||
|
||||
emit('printerjob', { companyId: selected.companyId, data: { job } });
|
||||
res.status(201).json({ printerjob: job });
|
||||
} catch (error) {
|
||||
const status = printerjobErrorStatus(error);
|
||||
if (status === 500) console.error('Create zpljob failed:', error);
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// #endregion
|
||||
}
|
||||
|
||||
27
src/db/printerjob.js
Normal file
27
src/db/printerjob.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getStore } from './store.js';
|
||||
|
||||
export async function create(googleAuthId, input) {
|
||||
const store = await getStore();
|
||||
return store.printerjob.create(googleAuthId, input);
|
||||
}
|
||||
|
||||
export async function getById(auth, id) {
|
||||
const store = await getStore();
|
||||
return store.printerjob.getById(auth, id);
|
||||
}
|
||||
|
||||
export async function list(auth, filters) {
|
||||
const store = await getStore();
|
||||
return store.printerjob.list(auth, filters);
|
||||
}
|
||||
|
||||
export async function updateState(auth, id, state) {
|
||||
const store = await getStore();
|
||||
return store.printerjob.updateState(auth, id, state);
|
||||
}
|
||||
|
||||
export async function deleteJob(googleAuthId, id) {
|
||||
const store = await getStore();
|
||||
return store.printerjob.delete(googleAuthId, id);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ const STORE_NAMESPACES = [
|
||||
'ledger',
|
||||
'warehouse',
|
||||
'printer',
|
||||
'printeragent',
|
||||
'agentprinter',
|
||||
'printerjob',
|
||||
'labeltemplate',
|
||||
'stock',
|
||||
'propertyname',
|
||||
|
||||
@@ -48,12 +48,16 @@ export function updateAgentPrinters(companyId, agentId, agentName, rawPrinters)
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const printerList = Array.isArray(rawPrinters) ? rawPrinters : [];
|
||||
const normalized = printerList.map((p) => ({
|
||||
...p,
|
||||
agentId: aId,
|
||||
agentName: agentName || null,
|
||||
updatedAt: now,
|
||||
}));
|
||||
const normalized = printerList.map((p) => {
|
||||
const liveStatus = extractLiveStatus(p);
|
||||
return {
|
||||
...p,
|
||||
agentId: aId,
|
||||
agentName: agentName || null,
|
||||
updatedAt: now,
|
||||
...liveStatus,
|
||||
};
|
||||
});
|
||||
|
||||
companyAgents.get(cId).set(aId, normalized);
|
||||
|
||||
@@ -155,6 +159,106 @@ export function broadcastCompanyPrinters(companyId, printers) {
|
||||
emit('agentprinter', { companyId: cId, data: payload });
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts normalized boolean live status indicators and telemetry from live printer telemetry.
|
||||
*
|
||||
* @param {object|null|undefined} live
|
||||
* @returns {{ open: boolean, pause: boolean, error: boolean, online: boolean, labelLength: number|null }}
|
||||
*/
|
||||
export function extractLiveStatus(live) {
|
||||
if (!live) {
|
||||
return {
|
||||
open: false,
|
||||
pause: false,
|
||||
error: false,
|
||||
online: false,
|
||||
labelLength: null,
|
||||
};
|
||||
}
|
||||
|
||||
const errors = live.errors || {};
|
||||
const isOffline = live.status === 'offline' || live.unreachable === true || errors.unreachable === true;
|
||||
const online = !isOffline;
|
||||
|
||||
const open = Boolean(live.open ?? live.headOpen ?? errors.headOpen ?? false);
|
||||
const pause = Boolean(live.pause ?? live.paused ?? errors.pause ?? false);
|
||||
|
||||
const hasOtherError = Boolean(
|
||||
live.error ||
|
||||
errors.other ||
|
||||
errors.error ||
|
||||
Object.entries(errors).some(([key, val]) => !['headOpen', 'pause', 'unreachable'].includes(key) && Boolean(val))
|
||||
);
|
||||
|
||||
const rawLabelLength = live.labelLength ?? live.labellength;
|
||||
const labelLength = rawLabelLength != null && Number.isFinite(Number(rawLabelLength))
|
||||
? Number(rawLabelLength)
|
||||
: null;
|
||||
|
||||
return {
|
||||
open,
|
||||
pause,
|
||||
error: hasOtherError,
|
||||
online,
|
||||
labelLength,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a matching live printer from memory for a given database agentprinter record.
|
||||
*
|
||||
* @param {number} companyId
|
||||
* @param {object} agentprinter
|
||||
* @returns {LivePrinter|null}
|
||||
*/
|
||||
export function getLivePrinterForAgentPrinter(companyId, agentprinter) {
|
||||
const printers = getCompanyPrinters(companyId);
|
||||
if (!agentprinter || printers.length === 0) return null;
|
||||
|
||||
if (agentprinter.id != null) {
|
||||
const match = printers.find((p) => p.agentprinterId === agentprinter.id);
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
if (agentprinter.printeragentId && agentprinter.serial) {
|
||||
const match = printers.find((p) =>
|
||||
Number(p.agentId) === Number(agentprinter.printeragentId) &&
|
||||
p.serial &&
|
||||
String(p.serial).trim().toLowerCase() === String(agentprinter.serial).trim().toLowerCase()
|
||||
);
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
if (agentprinter.printeragentId && agentprinter.model) {
|
||||
const match = printers.find((p) =>
|
||||
Number(p.agentId) === Number(agentprinter.printeragentId) &&
|
||||
p.model &&
|
||||
String(p.model).trim().toLowerCase() === String(agentprinter.model).trim().toLowerCase() &&
|
||||
(!agentprinter.type || !p.type || String(p.type).trim().toLowerCase() === String(agentprinter.type).trim().toLowerCase())
|
||||
);
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches an agentprinter database record with live operational status (open, pause, error, online).
|
||||
*
|
||||
* @param {number} companyId
|
||||
* @param {object} agentprinter
|
||||
* @returns {object}
|
||||
*/
|
||||
export function enrichAgentPrinter(companyId, agentprinter) {
|
||||
if (!agentprinter) return agentprinter;
|
||||
const live = getLivePrinterForAgentPrinter(companyId, agentprinter);
|
||||
const status = extractLiveStatus(live);
|
||||
return {
|
||||
...agentprinter,
|
||||
...status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all live printers for a company (e.g. on company deletion).
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user