1085 lines
40 KiB
JavaScript
1085 lines
40 KiB
JavaScript
import multer from 'multer';
|
|
import * as db from '../../db/delivery.js';
|
|
import { createAndAttachManifestPdf } from '../../services/manifestPdf.js';
|
|
import { writeErrorLog } from '../../util/errorLog.js';
|
|
import { emit } from '../sse.js';
|
|
|
|
// #region Helpers
|
|
|
|
// Shared memory storage for delivery attachments (max 50 MB)
|
|
const multerUpload = multer({
|
|
storage: multer.memoryStorage(),
|
|
limits: { fileSize: 50 * 1024 * 1024 },
|
|
fileFilter: (_req, file, cb) => {
|
|
const name = file.originalname?.toLowerCase() || '';
|
|
const isPdf = file.mimetype === 'application/pdf' || name.endsWith('.pdf');
|
|
const isText = file.mimetype.startsWith('text/') || /\.(txt|text|md|csv)$/.test(name);
|
|
if (isPdf || isText) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('Only PDF or text files are accepted as attachments'));
|
|
}
|
|
},
|
|
}).array('files');
|
|
|
|
function uploadDeliveryFiles(req, res, next) {
|
|
multerUpload(req, res, (err) => {
|
|
if (err) {
|
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
|
return res.status(413).json({ error: 'File too large (max 50 MB)' });
|
|
}
|
|
return res.status(400).json({ error: err.message });
|
|
}
|
|
next();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Helper to normalize payload from either multipart/form-data or application/json
|
|
*/
|
|
function parseDeliveryPayload(req) {
|
|
let payload = req.body ?? {};
|
|
// If sent as multipart/form-data with a JSON `data` field:
|
|
if (typeof payload.data === 'string') {
|
|
try {
|
|
payload = JSON.parse(payload.data);
|
|
} catch {
|
|
throw new Error('Invalid JSON in "data" field');
|
|
}
|
|
}
|
|
if (typeof payload !== 'object' || payload === null) {
|
|
throw new Error('Invalid delivery');
|
|
}
|
|
|
|
const { externalNumber, date, createdAt, supplierId, lines } = payload;
|
|
if (supplierId !== undefined && supplierId !== null && (typeof supplierId !== 'number' || !Number.isInteger(supplierId) || supplierId <= 0)) {
|
|
throw new Error('Invalid supplierId');
|
|
}
|
|
let files = payload.files;
|
|
// If binary files were uploaded via multer:
|
|
if (Array.isArray(req.files) && req.files.length > 0) {
|
|
const filesMeta = Array.isArray(payload.filesMeta)
|
|
? payload.filesMeta
|
|
: (Array.isArray(payload.files) ? payload.files : []);
|
|
const metaByName = new Map(
|
|
filesMeta.filter((m) => m?.name).map((m) => [m.name, m])
|
|
);
|
|
files = req.files.map((f, idx) => {
|
|
const metaObj = metaByName.get(f.originalname) || filesMeta[idx] || {};
|
|
const name = f.originalname || '';
|
|
const isPdf = f.mimetype === 'application/pdf' || name.toLowerCase().endsWith('.pdf');
|
|
const mimetype = isPdf ? 'application/pdf' : 'text/plain';
|
|
console.log(`[delivery upload] Received multipart file "${name}": ${f.size} bytes, mimetype=${mimetype}`);
|
|
return {
|
|
mimetype,
|
|
name: f.originalname,
|
|
content: f.buffer, // Raw Buffer directly accepted by libdashstore/attachments.js
|
|
size: f.size,
|
|
thumbavif: metaObj.thumbavif ?? null,
|
|
meta: metaObj.meta ?? null,
|
|
};
|
|
});
|
|
} else if (Array.isArray(files) && files.length > 0) {
|
|
files = files.map((f) => {
|
|
let content = f.content;
|
|
if (typeof content === 'string') {
|
|
const trimmed = content.trim();
|
|
if (f.mimetype === 'application/pdf' || trimmed.startsWith('JVBER') || /^[A-Za-z0-9+/=]+$/.test(trimmed.slice(0, 100))) {
|
|
try {
|
|
content = Buffer.from(trimmed, 'base64');
|
|
} catch {
|
|
content = Buffer.from(content, 'utf8');
|
|
}
|
|
} else {
|
|
content = Buffer.from(content, 'utf8');
|
|
}
|
|
}
|
|
const size = Buffer.isBuffer(content) ? content.length : (f.size ?? 0);
|
|
console.log(`[delivery upload] Received JSON file "${f.name}": ${size} bytes, mimetype=${f.mimetype}`);
|
|
return {
|
|
...f,
|
|
content,
|
|
size,
|
|
};
|
|
});
|
|
}
|
|
return { payload, externalNumber, date, createdAt, supplierId, lines, files };
|
|
}
|
|
|
|
function requireSelectedCompany(req) {
|
|
const companyId = req.user.selectedCompanyId;
|
|
if (companyId == null) {
|
|
return { error: 'No company selected' };
|
|
}
|
|
return { companyId };
|
|
}
|
|
|
|
/**
|
|
* Debug logging: dump per-file details before handing them to the store.
|
|
* Needed to diagnose MSSQL TDS errors on @content ("Data type 0xA5 has an
|
|
* invalid data length or metadata length").
|
|
*/
|
|
function logDeliveryFiles(files, label) {
|
|
if (!Array.isArray(files) || files.length === 0) {
|
|
console.log(`[delivery diag] ${label}: no files`);
|
|
return;
|
|
}
|
|
files.forEach((f, idx) => {
|
|
const content = f?.content;
|
|
const isBuf = Buffer.isBuffer(content);
|
|
const hex = isBuf
|
|
? [...content.subarray(0, 8)].map((b) => b.toString(16).padStart(2, '0')).join(' ')
|
|
: null;
|
|
console.log(
|
|
`[delivery diag] ${label} file[${idx}]:` +
|
|
` name=${JSON.stringify(f?.name)}` +
|
|
` mimetype=${JSON.stringify(f?.mimetype)}` +
|
|
` size=${JSON.stringify(f?.size)}` +
|
|
` contentType=${isBuf ? 'Buffer' : typeof content}` +
|
|
` contentLength=${isBuf ? content.length : typeof content === 'string' ? content.length : 'n/a'}` +
|
|
(hex !== null ? ` headHex=${hex}` : '') +
|
|
(typeof content === 'string' ? ` head=${JSON.stringify(content.slice(0, 16))}` : '')
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Debug logging: dump everything the SQL driver puts on a SQL error.
|
|
*/
|
|
function logStoreError(label, error) {
|
|
console.error(`[delivery diag] ${label} store error:`);
|
|
console.error(` message: ${error.message}`);
|
|
console.error(` code: ${error.code} number: ${error.number} state: ${error.state} class: ${error.class}`);
|
|
console.error(` lineNumber: ${error.lineNumber} serverName: ${error.serverName} procName: ${error.procName}`);
|
|
if (error.precedingErrors?.length) {
|
|
error.precedingErrors.forEach((e, i) => {
|
|
console.error(` precedingError[${i}]: ${e.message} (number=${e.number} state=${e.state})`);
|
|
});
|
|
}
|
|
console.error(error.stack);
|
|
}
|
|
|
|
function deliveryErrorStatus(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 === 'Delivery not found') return 404;
|
|
if (error.message === 'Manifest not found') return 404;
|
|
if (error.message === 'Attachment not found') return 404;
|
|
if (error.message === 'File not found') return 404;
|
|
if (error.message === 'Supplier not found') return 404;
|
|
if (error.message === 'Invalid supplierId') return 400;
|
|
if (error.message === 'Article not found') return 404;
|
|
if (error.message === 'Invalid delivery') return 400;
|
|
if (error.message === 'Invalid lines') return 400;
|
|
if (error.message === 'Invalid file') return 400;
|
|
if (error.message === 'Invalid JSON in "data" field') return 400;
|
|
if (error.message === 'Only PDF or text files are accepted as attachments') return 400;
|
|
if (error.message?.includes('parent article') || error.message?.includes('A parent article')) return 400;
|
|
if (
|
|
error.message === 'Delivery in use in ledger' ||
|
|
error.message === 'Delivery lines already booked on ledger' ||
|
|
error.message?.includes('FOREIGN KEY constraint failed') ||
|
|
error.message?.includes('foreign key constraint') ||
|
|
error.message?.includes('REFERENCE constraint') ||
|
|
error.code === 'SQLITE_CONSTRAINT_FOREIGNKEY' ||
|
|
error.number === 547
|
|
) return 409;
|
|
return 500;
|
|
}
|
|
|
|
// #endregion
|
|
|
|
/**
|
|
* Registers delivery (goods receipt) routes for the authenticated user's selected company.
|
|
* @param {import('express').Application} app
|
|
* @param {string} [basePath='']
|
|
*/
|
|
export function register(app, basePath = '') {
|
|
/**
|
|
* @openapi
|
|
* /delivery:
|
|
* get:
|
|
* tags: [Delivery]
|
|
* summary: List deliveries
|
|
* description: |
|
|
* Lists goods receipts (`manifest` rows with `type = 'delivery'`)
|
|
* for the authenticated user's selected company.
|
|
* security:
|
|
* - cookieAuth: []
|
|
* x-markdown: delivery/list.md
|
|
* responses:
|
|
* 200:
|
|
* description: Delivery list
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* deliveries:
|
|
* type: array
|
|
* items:
|
|
* $ref: '#/components/schemas/DeliverySummary'
|
|
* 400:
|
|
* description: No company selected
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 401:
|
|
* description: Unauthorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 403:
|
|
* description: Not authorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 404:
|
|
* description: Company not found
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* post:
|
|
* tags: [Delivery]
|
|
* summary: Create delivery
|
|
* description: |
|
|
* Creates a goods receipt with header and lines in one transaction.
|
|
* The manifest `number` is allocated automatically via
|
|
* `nextNumber('delivery')`; the user-supplied reference is stored as
|
|
* `externalNumber`. Each line requires `sku` or `name`, `amount`, `net`, and
|
|
* `taxrate` (rate). `pu` defaults to `1`; `rebate` defaults to `0`.
|
|
* security:
|
|
* - cookieAuth: []
|
|
* x-markdown: delivery/create.md
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [externalNumber, date, lines]
|
|
* properties:
|
|
* externalNumber:
|
|
* type: string
|
|
* description: User-supplied reference number
|
|
* date:
|
|
* type: string
|
|
* format: date-time
|
|
* supplierId:
|
|
* type: integer
|
|
* nullable: true
|
|
* lines:
|
|
* type: array
|
|
* items:
|
|
* $ref: '#/components/schemas/DeliveryLineInput'
|
|
* files:
|
|
* type: array
|
|
* description: |
|
|
* Attached documents (pdf or txt) stored with the delivery
|
|
* and returned in `files` on every delivery response.
|
|
* Thumbnails (AVIF) are generated server-side.
|
|
* items:
|
|
* $ref: '#/components/schemas/DeliveryFileInput'
|
|
* example:
|
|
* externalNumber: DHL-123
|
|
* date: '2026-08-10'
|
|
* supplierId: 1
|
|
* lines:
|
|
* - sku: '1'
|
|
* name: 'Sample Article'
|
|
* pu: 1
|
|
* amount: 10
|
|
* net: 99.5
|
|
* taxrate: 19
|
|
* files:
|
|
* - mimetype: application/pdf
|
|
* name: delivery-note.pdf
|
|
* content: '<base64 encoded content>'
|
|
* multipart/form-data:
|
|
* schema:
|
|
* type: object
|
|
* required: [data]
|
|
* properties:
|
|
* data:
|
|
* type: string
|
|
* description: |
|
|
* JSON string containing manifest header and line items ({ externalNumber, date, supplierId, lines, filesMeta? }).
|
|
* files:
|
|
* type: array
|
|
* items:
|
|
* type: string
|
|
* format: binary
|
|
* description: Raw binary PDF or text files attached natively via standard HTTP multipart upload.
|
|
* responses:
|
|
* 201:
|
|
* description: Created delivery
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* delivery:
|
|
* $ref: '#/components/schemas/Delivery'
|
|
* 400:
|
|
* description: Invalid delivery, invalid lines or files, or no company selected
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 401:
|
|
* description: Unauthorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 403:
|
|
* description: Not authorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 404:
|
|
* description: Company or supplier not found
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
// #region GET /delivery
|
|
|
|
app.get(`${basePath}/delivery`, async (req, res) => {
|
|
const selected = requireSelectedCompany(req);
|
|
if (selected.error) {
|
|
return res.status(400).json({ error: selected.error });
|
|
}
|
|
|
|
try {
|
|
const deliveries = await db.list(req.user.id);
|
|
res.status(200).json({ deliveries });
|
|
} catch (error) {
|
|
res.locals.error = error;
|
|
const status = deliveryErrorStatus(error);
|
|
if (status === 500) console.error('List deliveries failed:', error);
|
|
res.status(status).json({ error: status === 500 ? 'Internal server error' : error.message });
|
|
}
|
|
});
|
|
|
|
// #endregion
|
|
|
|
// #region POST /delivery
|
|
|
|
app.post(`${basePath}/delivery`, uploadDeliveryFiles, async (req, res) => {
|
|
const selected = requireSelectedCompany(req);
|
|
if (selected.error) {
|
|
return res.status(400).json({ error: selected.error });
|
|
}
|
|
|
|
try {
|
|
const { externalNumber, date, createdAt, supplierId, lines, files } = parseDeliveryPayload(req);
|
|
logDeliveryFiles(files, 'create');
|
|
const delivery = await db.create(req.user.id, {
|
|
externalNumber,
|
|
date,
|
|
createdAt,
|
|
supplierId,
|
|
lines,
|
|
files,
|
|
});
|
|
res.status(201).json({ delivery });
|
|
} catch (error) {
|
|
res.locals.error = error;
|
|
const status = deliveryErrorStatus(error);
|
|
if (status === 500) {
|
|
logStoreError('Create delivery failed', error);
|
|
// Dump the full parsed payload (including file metadata) to a log file.
|
|
try {
|
|
const { externalNumber, date, supplierId, lines, files } = parseDeliveryPayload(req);
|
|
writeErrorLog(`POST ${basePath}/delivery 500 (create delivery)`, {
|
|
payload: { externalNumber, date, supplierId, lines },
|
|
files: files?.map((f) => ({
|
|
name: f.name,
|
|
mimetype: f.mimetype,
|
|
size: f.size,
|
|
meta: f.meta,
|
|
})),
|
|
error: {
|
|
message: error.message,
|
|
code: error.code,
|
|
number: error.number,
|
|
state: error.state,
|
|
class: error.class,
|
|
stack: error.stack,
|
|
},
|
|
});
|
|
} catch (logErr) {
|
|
console.error('[delivery diag] Failed to dump payload:', logErr.message);
|
|
}
|
|
}
|
|
res.status(status).json({ error: status === 500 ? 'Internal server error' : error.message });
|
|
}
|
|
});
|
|
|
|
// #endregion
|
|
|
|
/**
|
|
* @openapi
|
|
* /delivery/{id}:
|
|
* get:
|
|
* tags: [Delivery]
|
|
* summary: Get delivery
|
|
* description: Returns a goods receipt with lines for the selected company.
|
|
* security:
|
|
* - cookieAuth: []
|
|
* x-markdown: delivery/get.md
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Delivery
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* delivery:
|
|
* $ref: '#/components/schemas/Delivery'
|
|
* 400:
|
|
* description: No company selected or invalid id
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 401:
|
|
* description: Unauthorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 403:
|
|
* description: Not authorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 404:
|
|
* description: Delivery or company not found
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* patch:
|
|
* tags: [Delivery]
|
|
* summary: Update delivery
|
|
* description: |
|
|
* Patches header fields that are present. The manifest `number` is
|
|
* auto-allocated and cannot be patched; only the user-supplied
|
|
* `externalNumber` reference is editable. If `lines` is present,
|
|
* replaces all lines; if omitted, leaves lines unchanged.
|
|
* security:
|
|
* - cookieAuth: []
|
|
* x-markdown: delivery/update.md
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* externalNumber:
|
|
* type: string
|
|
* nullable: true
|
|
* description: User-supplied reference number (empty/null clears it)
|
|
* date:
|
|
* type: string
|
|
* format: date-time
|
|
* supplierId:
|
|
* type: integer
|
|
* nullable: true
|
|
* lines:
|
|
* type: array
|
|
* items:
|
|
* $ref: '#/components/schemas/DeliveryLineInput'
|
|
* files:
|
|
* type: array
|
|
* description: |
|
|
* Appends attachments (pdf or txt) to the delivery.
|
|
* Omit to leave existing files unchanged.
|
|
* Thumbnails (AVIF) are generated server-side.
|
|
* items:
|
|
* $ref: '#/components/schemas/DeliveryFileInput'
|
|
* example:
|
|
* externalNumber: DHL-124
|
|
* lines: []
|
|
* multipart/form-data:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* data:
|
|
* type: string
|
|
* description: |
|
|
* JSON string containing patch fields ({ externalNumber, date, supplierId, lines, filesMeta? }).
|
|
* files:
|
|
* type: array
|
|
* items:
|
|
* type: string
|
|
* format: binary
|
|
* description: Raw binary PDF or text files to append to the delivery.
|
|
* responses:
|
|
* 200:
|
|
* description: Updated delivery
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* delivery:
|
|
* $ref: '#/components/schemas/Delivery'
|
|
* 400:
|
|
* description: Invalid delivery, invalid lines, 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'
|
|
* 403:
|
|
* description: Not authorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 404:
|
|
* description: Delivery, supplier, or company not found
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 409:
|
|
* description: Delivery lines already booked on ledger
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* delete:
|
|
* tags: [Delivery]
|
|
* summary: Delete delivery
|
|
* description: |
|
|
* Deletes the goods receipt (`manifestarticle` rows cascade).
|
|
* security:
|
|
* - cookieAuth: []
|
|
* x-markdown: delivery/delete.md
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 204:
|
|
* description: Deleted
|
|
* 400:
|
|
* description: No company selected or invalid id
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 401:
|
|
* description: Unauthorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 403:
|
|
* description: Not authorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 404:
|
|
* description: Delivery not found
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 409:
|
|
* description: Delivery in use in ledger
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
// #region GET /delivery/:id
|
|
|
|
app.get(`${basePath}/delivery/:id`, async (req, res) => {
|
|
const selected = requireSelectedCompany(req);
|
|
if (selected.error) {
|
|
return res.status(400).json({ error: selected.error });
|
|
}
|
|
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
return res.status(400).json({ error: 'id is required' });
|
|
}
|
|
|
|
try {
|
|
const delivery = await db.getById(req.user.id, id);
|
|
res.status(200).json({ delivery });
|
|
} catch (error) {
|
|
res.locals.error = error;
|
|
const status = deliveryErrorStatus(error);
|
|
if (status === 500) console.error('Get delivery failed:', error);
|
|
res.status(status).json({ error: status === 500 ? 'Internal server error' : error.message });
|
|
}
|
|
});
|
|
|
|
// #endregion
|
|
|
|
// #region PATCH /delivery/:id
|
|
|
|
app.patch(`${basePath}/delivery/:id`, uploadDeliveryFiles, async (req, res) => {
|
|
const selected = requireSelectedCompany(req);
|
|
if (selected.error) {
|
|
return res.status(400).json({ error: selected.error });
|
|
}
|
|
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
return res.status(400).json({ error: 'id is required' });
|
|
}
|
|
|
|
try {
|
|
const parsed = parseDeliveryPayload(req);
|
|
const patch = {};
|
|
if (Object.prototype.hasOwnProperty.call(parsed.payload, 'externalNumber')) {
|
|
patch.externalNumber = parsed.externalNumber;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(parsed.payload, 'date')) {
|
|
patch.date = parsed.date;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(parsed.payload, 'createdAt')) {
|
|
patch.createdAt = parsed.createdAt;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(parsed.payload, 'supplierId')) {
|
|
patch.supplierId = parsed.supplierId;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(parsed.payload, 'lines')) {
|
|
patch.lines = parsed.lines;
|
|
}
|
|
if (parsed.files !== undefined) {
|
|
patch.files = parsed.files;
|
|
}
|
|
|
|
logDeliveryFiles(patch.files, `update ${id}`);
|
|
const delivery = await db.update(req.user.id, id, patch);
|
|
res.status(200).json({ delivery });
|
|
} catch (error) {
|
|
res.locals.error = error;
|
|
const status = deliveryErrorStatus(error);
|
|
if (status === 500) {
|
|
logStoreError('Update delivery failed', error);
|
|
}
|
|
let errorMessage = error.message;
|
|
if (status === 500) {
|
|
errorMessage = 'Internal server error';
|
|
} else if (
|
|
status === 409 ||
|
|
error.message === 'Delivery lines already booked on ledger' ||
|
|
error.message?.includes('FOREIGN KEY constraint failed') ||
|
|
error.message?.includes('foreign key constraint') ||
|
|
error.message?.includes('REFERENCE constraint') ||
|
|
error.code === 'SQLITE_CONSTRAINT_FOREIGNKEY' ||
|
|
error.number === 547
|
|
) {
|
|
errorMessage = 'Delivery lines are already booked on the ledger and cannot be modified';
|
|
}
|
|
res.status(status).json({ error: errorMessage });
|
|
}
|
|
});
|
|
|
|
// #endregion
|
|
|
|
// #region DELETE /delivery/:id
|
|
|
|
app.delete(`${basePath}/delivery/:id`, async (req, res) => {
|
|
const selected = requireSelectedCompany(req);
|
|
if (selected.error) {
|
|
return res.status(400).json({ error: selected.error });
|
|
}
|
|
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
return res.status(400).json({ error: 'id is required' });
|
|
}
|
|
|
|
try {
|
|
await db.remove(req.user.id, id);
|
|
res.sendStatus(204);
|
|
} catch (error) {
|
|
res.locals.error = error;
|
|
const status = deliveryErrorStatus(error);
|
|
if (status === 500) console.error('Delete delivery failed:', error);
|
|
let errorMessage = error.message;
|
|
if (status === 500) {
|
|
errorMessage = 'Internal server error';
|
|
} else if (status === 409) {
|
|
errorMessage = 'Delivery in use in ledger';
|
|
}
|
|
res.status(status).json({ error: errorMessage });
|
|
}
|
|
});
|
|
|
|
// #endregion
|
|
|
|
/**
|
|
* @openapi
|
|
* /delivery/{id}/file/{fileId}:
|
|
* get:
|
|
* tags: [Delivery]
|
|
* summary: Get delivery attachment file content
|
|
* description: |
|
|
* Returns the full binary content of an attached file (e.g. PDF or text document)
|
|
* for the specified delivery. Supports HTTP caching via ETag (SHA-256 hash) and 304 Not Modified.
|
|
* security:
|
|
* - cookieAuth: []
|
|
* x-markdown: delivery/file.md
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Delivery (manifest) ID
|
|
* - in: path
|
|
* name: fileId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: File ID or attachment ID
|
|
* - in: query
|
|
* name: download
|
|
* required: false
|
|
* schema:
|
|
* type: boolean
|
|
* description: When true, sets Content-Disposition to attachment instead of inline
|
|
* responses:
|
|
* 200:
|
|
* description: Full binary file content
|
|
* headers:
|
|
* Content-Type:
|
|
* schema:
|
|
* type: string
|
|
* example: application/pdf
|
|
* Content-Disposition:
|
|
* schema:
|
|
* type: string
|
|
* example: inline; filename="document.pdf"
|
|
* ETag:
|
|
* schema:
|
|
* type: string
|
|
* Cache-Control:
|
|
* schema:
|
|
* type: string
|
|
* example: private, max-age=86400
|
|
* content:
|
|
* application/pdf:
|
|
* schema:
|
|
* type: string
|
|
* format: binary
|
|
* text/plain:
|
|
* schema:
|
|
* type: string
|
|
* format: binary
|
|
* application/octet-stream:
|
|
* schema:
|
|
* type: string
|
|
* format: binary
|
|
* 304:
|
|
* description: Not modified (If-None-Match matched ETag)
|
|
* 400:
|
|
* description: Invalid id, invalid fileId, or no company selected
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 401:
|
|
* description: Unauthorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 403:
|
|
* description: Not authorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 404:
|
|
* description: Delivery or file not found
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
const handleGetDeliveryFile = async (req, res) => {
|
|
const selected = requireSelectedCompany(req);
|
|
if (selected.error) {
|
|
return res.status(400).json({ error: selected.error });
|
|
}
|
|
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
return res.status(400).json({ error: 'id is required' });
|
|
}
|
|
|
|
const fileId = Number(req.params.fileId);
|
|
if (!Number.isInteger(fileId) || fileId <= 0) {
|
|
return res.status(400).json({ error: 'fileId is required' });
|
|
}
|
|
|
|
try {
|
|
const attachment = await db.getFile(req.user.id, id, fileId);
|
|
|
|
if (attachment.hash) {
|
|
const etag = `"${attachment.hash}"`;
|
|
res.setHeader('ETag', etag);
|
|
const ifNoneMatch = req.headers['if-none-match'];
|
|
if (ifNoneMatch === etag || ifNoneMatch === attachment.hash) {
|
|
return res.status(304).end();
|
|
}
|
|
}
|
|
|
|
const isDownload = req.query.download === '1' || req.query.download === 'true';
|
|
const dispositionType = isDownload ? 'attachment' : 'inline';
|
|
const filename = attachment.name || `file-${attachment.fileId}`;
|
|
const asciiName = filename.replace(/["\r\n\\]/g, '_');
|
|
const encodedName = encodeURIComponent(filename).replace(/['()]/g, escape);
|
|
|
|
res.setHeader('Content-Type', attachment.mimetype || 'application/octet-stream');
|
|
res.setHeader('Content-Disposition', `${dispositionType}; filename="${asciiName}"; filename*=UTF-8''${encodedName}`);
|
|
res.setHeader('Cache-Control', 'private, max-age=86400');
|
|
|
|
if (attachment.content && Buffer.isBuffer(attachment.content)) {
|
|
res.setHeader('Content-Length', String(attachment.content.length));
|
|
return res.status(200).send(attachment.content);
|
|
} else {
|
|
const buffer = Buffer.from(attachment.content || '');
|
|
res.setHeader('Content-Length', String(buffer.length));
|
|
return res.status(200).send(buffer);
|
|
}
|
|
} catch (error) {
|
|
res.locals.error = error;
|
|
const status = deliveryErrorStatus(error);
|
|
if (status === 500) console.error('Get delivery file failed:', error);
|
|
res.status(status).json({ error: status === 500 ? 'Internal server error' : error.message });
|
|
}
|
|
};
|
|
|
|
// #region GET /delivery/:id/file/:fileId
|
|
|
|
app.get(`${basePath}/delivery/:id/file/:fileId`, handleGetDeliveryFile);
|
|
|
|
// #endregion
|
|
|
|
// #region GET /delivery/:id/files/:fileId
|
|
|
|
app.get(`${basePath}/delivery/:id/files/:fileId`, handleGetDeliveryFile);
|
|
|
|
// #endregion
|
|
|
|
/**
|
|
* @openapi
|
|
* /delivery/{id}/pdf:
|
|
* post:
|
|
* tags: [Delivery]
|
|
* summary: Generate and attach delivery note PDF (goods receipt)
|
|
* description: |
|
|
* Generates an A4 PDF document for the goods receipt / delivery note with line items and quantities.
|
|
* Attaches the PDF as a `manifestattachment` to the delivery.
|
|
* security:
|
|
* - cookieAuth: []
|
|
* x-signals: [delivery]
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Delivery ID
|
|
* responses:
|
|
* 201:
|
|
* description: PDF generated and attached to delivery
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [attachment, fileUrl]
|
|
* properties:
|
|
* attachment:
|
|
* $ref: '#/components/schemas/DeliveryFile'
|
|
* fileUrl:
|
|
* type: string
|
|
* example: /delivery/1/file/5
|
|
* 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'
|
|
* 403:
|
|
* description: Not authorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 404:
|
|
* description: Delivery not found
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
// #region POST /delivery/:id/pdf
|
|
|
|
app.post(`${basePath}/delivery/:id/pdf`, async (req, res) => {
|
|
const selected = requireSelectedCompany(req);
|
|
if (selected.error) {
|
|
return res.status(400).json({ error: selected.error });
|
|
}
|
|
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
return res.status(400).json({ error: 'id is required' });
|
|
}
|
|
|
|
try {
|
|
const { attachment } = await createAndAttachManifestPdf(
|
|
req.user.id,
|
|
selected.companyId,
|
|
id,
|
|
'delivery'
|
|
);
|
|
|
|
res.status(201).json({
|
|
attachment,
|
|
fileUrl: `/delivery/${id}/file/${attachment.fileId}`,
|
|
});
|
|
} catch (error) {
|
|
const status = deliveryErrorStatus(error);
|
|
if (status === 500) console.error('Create delivery PDF failed:', error);
|
|
res.status(status).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// #endregion
|
|
|
|
/**
|
|
* @openapi
|
|
* /delivery/{id}/file/{fileId}:
|
|
* delete:
|
|
* tags: [Delivery]
|
|
* summary: Delete delivery file attachment
|
|
* description: |
|
|
* Removes a file attachment from the delivery and deletes the underlying file.
|
|
* security:
|
|
* - cookieAuth: []
|
|
* x-signals: [delivery]
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: Delivery ID
|
|
* - in: path
|
|
* name: fileId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* description: File ID or attachment ID
|
|
* responses:
|
|
* 204:
|
|
* description: Attachment deleted
|
|
* 400:
|
|
* description: Invalid parameters or no company selected
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 401:
|
|
* description: Unauthorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 403:
|
|
* description: Not authorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
* 404:
|
|
* description: Delivery or attachment not found
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/Error'
|
|
*/
|
|
// #region DELETE /delivery/:id/file/:fileId
|
|
|
|
app.delete(`${basePath}/delivery/:id/file/:fileId`, async (req, res) => {
|
|
const selected = requireSelectedCompany(req);
|
|
if (selected.error) {
|
|
return res.status(400).json({ error: selected.error });
|
|
}
|
|
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
return res.status(400).json({ error: 'id is required' });
|
|
}
|
|
|
|
const fileId = Number(req.params.fileId);
|
|
if (!Number.isInteger(fileId) || fileId <= 0) {
|
|
return res.status(400).json({ error: 'fileId is required' });
|
|
}
|
|
|
|
try {
|
|
await db.removeAttachment(req.user.id, id, fileId);
|
|
emit('delivery', { companyId: selected.companyId });
|
|
res.sendStatus(204);
|
|
} catch (error) {
|
|
const status = deliveryErrorStatus(error);
|
|
if (status === 500) console.error('Delete delivery attachment failed:', error);
|
|
res.status(status).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// #endregion
|
|
}
|
|
|
|
|