GWM : grn attachement api's
This commit is contained in:
parent
a434216fdb
commit
71bd4e8354
@ -246,6 +246,11 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
|
||||
| [x] | PUT | `/grn/:id` | edit | Update GRN |
|
||||
| [x] | POST | `/grn/:id/cancel` | edit | Cancel GRN |
|
||||
| [x] | GET | `/grn/:id/pdf` | view | PDF export |
|
||||
| [x] | GET | `/grn/:grnId/attachments` | view | List GRN attachments |
|
||||
| [x] | POST | `/grn/:grnId/attachments` | edit | Upload file (multipart `file`) |
|
||||
| [x] | GET | `/grn/:grnId/attachments/:attachmentId` | view | Attachment metadata |
|
||||
| [x] | GET | `/grn/:grnId/attachments/:attachmentId/download` | view | Download file (authenticated) |
|
||||
| [x] | DELETE | `/grn/:grnId/attachments/:attachmentId` | delete | Delete attachment + file |
|
||||
|
||||
---
|
||||
|
||||
@ -329,7 +334,7 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
|
||||
| Masters | 70 | 70 | [x] Done |
|
||||
| Vendors | 27 | 27 | [x] Done |
|
||||
| Purchase Orders | 11 | 11 | [x] Done |
|
||||
| GRN | 6 | 6 | [x] Done |
|
||||
| GRN | 11 | 11 | [x] Done |
|
||||
| Assets | 24 | 24 | [x] Done |
|
||||
| Settings | 5 | 5 | [x] Done |
|
||||
| **Total** | **168** | **168** | **[x] Phase 1 APIs + Asset extensions** |
|
||||
|
||||
@ -665,6 +665,57 @@ stateDiagram-v2
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<h3>GRN goods receiving concept flow</h3>
|
||||
<p>End-to-end warehouse receipt: PO-backed GRN posting, optional supporting documents, PO qty update, and asset auto-creation.</p>
|
||||
<div class="diagram">
|
||||
<pre class="mermaid">
|
||||
flowchart TD
|
||||
subgraph Physical["Physical receipt at warehouse"]
|
||||
P1[Vendor delivery arrives]
|
||||
P2[Verify PO / invoice / LR / vehicle]
|
||||
P3[Inspect qty quality batch]
|
||||
end
|
||||
|
||||
subgraph System["System — GRN posting"]
|
||||
S1["GET open PO<br/>status APPROVED / PARTIALLY_RECEIVED"]
|
||||
S2["POST /grn<br/>header + line items"]
|
||||
S3["GRN txn: grn POSTED"]
|
||||
S4["grn_items per accepted/rejected qty"]
|
||||
S5["PO received_qty += accepted_qty"]
|
||||
S6{Item is_asset_item?}
|
||||
S7["Auto-create assets<br/>1 per accepted unit"]
|
||||
S8["Recalculate PO status"]
|
||||
end
|
||||
|
||||
subgraph Docs["Supporting documents"]
|
||||
D1["POST /grn/:grnId/attachments"]
|
||||
D2["grn_attachments row"]
|
||||
D3["File in uploads/grn/{grnId}/"]
|
||||
D4["GET .../download authenticated"]
|
||||
end
|
||||
|
||||
P1 --> P2 --> P3 --> S1 --> S2 --> S3 --> S4 --> S5 --> S6
|
||||
S6 -->|Yes| S7 --> S8
|
||||
S6 -->|No| S8
|
||||
S3 --> D1 --> D2 --> D3 --> D4
|
||||
|
||||
S8 --> OUT1{More qty pending?}
|
||||
OUT1 -->|Yes| PARTIAL[PO PARTIALLY_RECEIVED]
|
||||
OUT1 -->|No| FULL[PO FULLY_RECEIVED]
|
||||
</pre>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Step</th><th>API / table</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>1</td><td><code>POST /grn</code></td><td>Requires APPROVED PO with pending qty</td></tr>
|
||||
<tr><td>2</td><td><code>grn</code>, <code>grn_items</code></td><td><code>accepted_qty + rejected_qty = current_qty</code></td></tr>
|
||||
<tr><td>3</td><td><code>purchase_order_items.received_qty</code></td><td>Incremented in same transaction</td></tr>
|
||||
<tr><td>4</td><td><code>assets</code></td><td>When <code>items.is_asset_item = true</code></td></tr>
|
||||
<tr><td>5</td><td><code>POST /grn/:grnId/attachments</code></td><td>Invoice PDF, LR copy, photos</td></tr>
|
||||
<tr><td>6</td><td><code>GET .../attachments/:id/download</code></td><td>RBAC download — not public <code>/uploads</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>GRN auto-asset creation (when <code>items.is_asset_item = true</code>)</h3>
|
||||
<table>
|
||||
<thead><tr><th>Field on Asset</th><th>Source</th></tr></thead>
|
||||
@ -971,6 +1022,7 @@ erDiagram
|
||||
purchase_orders ||--o{ assets : sourced_from
|
||||
|
||||
grn ||--o{ grn_items : lines
|
||||
grn ||--o{ grn_attachments : documents
|
||||
grn_items ||--o{ assets : auto_created
|
||||
|
||||
items ||--o{ purchase_order_items : ordered
|
||||
|
||||
@ -254,6 +254,63 @@ stateDiagram-v2
|
||||
APPROVED --> CANCELLED: cancel if no receipts
|
||||
```
|
||||
|
||||
### GRN goods receiving concept flow
|
||||
|
||||
End-to-end warehouse receipt: PO-backed GRN posting, optional supporting documents, PO qty update, and asset auto-creation.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Physical["Physical receipt at warehouse"]
|
||||
P1[Vendor delivery arrives]
|
||||
P2[Verify PO / invoice / LR / vehicle]
|
||||
P3[Inspect qty quality batch]
|
||||
end
|
||||
|
||||
subgraph System["System — GRN posting"]
|
||||
S1["GET open PO<br/>status APPROVED / PARTIALLY_RECEIVED"]
|
||||
S2["POST /grn<br/>header + line items"]
|
||||
S3["GRN txn: grn POSTED"]
|
||||
S4["grn_items per accepted/rejected qty"]
|
||||
S5["PO received_qty += accepted_qty"]
|
||||
S6{Item is_asset_item?}
|
||||
S7["Auto-create assets<br/>1 per accepted unit"]
|
||||
S8["Recalculate PO status"]
|
||||
end
|
||||
|
||||
subgraph Docs["Supporting documents (after or alongside GRN)"]
|
||||
D1["POST /grn/:grnId/attachments<br/>multipart file"]
|
||||
D2["grn_attachments row"]
|
||||
D3["File stored under uploads/grn/{grnId}/"]
|
||||
D4["GET .../download — authenticated"]
|
||||
end
|
||||
|
||||
P1 --> P2 --> P3 --> S1 --> S2 --> S3 --> S4 --> S5 --> S6
|
||||
S6 -->|Yes| S7 --> S8
|
||||
S6 -->|No| S8
|
||||
S3 --> D1 --> D2 --> D3
|
||||
D3 --> D4
|
||||
|
||||
S8 --> OUT1{More qty pending?}
|
||||
OUT1 -->|Yes| PARTIAL[PO PARTIALLY_RECEIVED]
|
||||
OUT1 -->|No| FULL[PO FULLY_RECEIVED]
|
||||
|
||||
CANCEL["POST /grn/:id/cancel"] --> REV1[Reverse PO received_qty]
|
||||
REV1 --> REV2[Soft-delete linked assets]
|
||||
REV2 --> REV3[GRN CANCELLED — attachments remain on record]
|
||||
```
|
||||
|
||||
| Step | Actor | API / table | Notes |
|
||||
|------|-------|-------------|-------|
|
||||
| 1 | Store / procurement | `POST /grn` | Requires APPROVED PO with pending qty |
|
||||
| 2 | System | `grn`, `grn_items` | `accepted_qty + rejected_qty = current_qty` per line |
|
||||
| 3 | System | `purchase_order_items.received_qty` | Incremented in same transaction |
|
||||
| 4 | System | `assets` | Created when `items.is_asset_item = true` |
|
||||
| 5 | Store user | `POST /grn/:grnId/attachments` | Invoice PDF, LR copy, photos (PDF/JPEG/PNG/WebP) |
|
||||
| 6 | Any authorized user | `GET .../attachments/:id/download` | RBAC-protected download — not public `/uploads` |
|
||||
| 7 | GRN detail | `GET /grn/:id` | Includes `attachments[]` metadata |
|
||||
|
||||
**Typical attachment types:** vendor tax invoice, LR/eway bill, packing list, QC rejection photos, delivery challan.
|
||||
|
||||
### GRN auto-asset creation (when `items.is_asset_item = true`)
|
||||
|
||||
| Field on Asset | Source |
|
||||
@ -568,6 +625,7 @@ erDiagram
|
||||
purchase_orders ||--o{ assets : sourced_from
|
||||
|
||||
grn ||--o{ grn_items : lines
|
||||
grn ||--o{ grn_attachments : documents
|
||||
grn_items ||--o{ assets : auto_created
|
||||
|
||||
items ||--o{ purchase_order_items : ordered
|
||||
@ -622,6 +680,7 @@ erDiagram
|
||||
| Create PO | Masters dropdowns + `POST /purchase-orders` |
|
||||
| Approve PO | `POST /purchase-orders/:id/approve` |
|
||||
| GRN receipt | `POST /grn` (auto assets for asset items) |
|
||||
| GRN attachments | `POST /grn/:grnId/attachments`, `GET .../download` |
|
||||
| Asset list/detail | `GET /assets`, `GET /assets/:id` |
|
||||
| Asset create/edit form | Masters + `POST /depreciation/calculate` (preview) + `POST/PUT /assets` |
|
||||
| AMC tab | `/assets/:id/amc/*` + renew |
|
||||
|
||||
@ -60,6 +60,22 @@ components:
|
||||
required: [cancellation_reason]
|
||||
properties:
|
||||
cancellation_reason: { type: string, example: 'Posted against wrong PO' }
|
||||
GrnAttachmentResponse:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string, example: '1' }
|
||||
grn_id: { type: string, example: '1' }
|
||||
file_name: { type: string, example: 'vendor-invoice.pdf' }
|
||||
file_type: { type: string, example: 'application/pdf' }
|
||||
file_size: { type: integer, example: 245760 }
|
||||
uploaded_by_user:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
id: { type: string }
|
||||
full_name: { type: string }
|
||||
employee_code: { type: string, nullable: true }
|
||||
created_at: { type: string, format: date-time }
|
||||
|
||||
paths:
|
||||
/grn:
|
||||
@ -154,3 +170,98 @@ paths:
|
||||
content:
|
||||
application/pdf:
|
||||
schema: { type: string, format: binary }
|
||||
/grn/{grnId}/attachments:
|
||||
get:
|
||||
tags: [GRN]
|
||||
summary: List GRN attachments
|
||||
description: Returns supporting documents for a GRN (vendor invoice, LR copy, delivery photos, etc.)
|
||||
parameters:
|
||||
- { name: grnId, in: path, required: true, schema: { type: string, example: '1' } }
|
||||
responses:
|
||||
'200':
|
||||
description: Attachments fetched
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ApiResponse' }
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/GrnAttachmentResponse' }
|
||||
'404': { description: GRN not found }
|
||||
post:
|
||||
tags: [GRN]
|
||||
summary: Upload GRN attachment
|
||||
description: Multipart upload. Allowed types PDF, JPEG, PNG, WebP. Only POSTED GRN accepts uploads.
|
||||
parameters:
|
||||
- { name: grnId, in: path, required: true, schema: { type: string, example: '1' } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: Vendor invoice, LR copy, packing list, or receipt photo
|
||||
responses:
|
||||
'201':
|
||||
description: Attachment uploaded
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ApiResponse' }
|
||||
'400': { description: Missing file or unsupported MIME type }
|
||||
'409': { description: GRN is not POSTED }
|
||||
/grn/{grnId}/attachments/{attachmentId}:
|
||||
get:
|
||||
tags: [GRN]
|
||||
summary: Get GRN attachment metadata
|
||||
parameters:
|
||||
- { name: grnId, in: path, required: true, schema: { type: string, example: '1' } }
|
||||
- { name: attachmentId, in: path, required: true, schema: { type: string, example: '1' } }
|
||||
responses:
|
||||
'200':
|
||||
description: Attachment fetched
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ApiResponse' }
|
||||
'404': { description: Not found }
|
||||
delete:
|
||||
tags: [GRN]
|
||||
summary: Delete GRN attachment
|
||||
description: Removes DB record and file from disk. Only POSTED GRN.
|
||||
parameters:
|
||||
- { name: grnId, in: path, required: true, schema: { type: string, example: '1' } }
|
||||
- { name: attachmentId, in: path, required: true, schema: { type: string, example: '1' } }
|
||||
responses:
|
||||
'200':
|
||||
description: Attachment deleted
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ApiResponse' }
|
||||
'404': { description: Not found }
|
||||
'409': { description: GRN is not POSTED }
|
||||
/grn/{grnId}/attachments/{attachmentId}/download:
|
||||
get:
|
||||
tags: [GRN]
|
||||
summary: Download GRN attachment file
|
||||
description: Authenticated file download (do not use public /uploads URL)
|
||||
parameters:
|
||||
- { name: grnId, in: path, required: true, schema: { type: string, example: '1' } }
|
||||
- { name: attachmentId, in: path, required: true, schema: { type: string, example: '1' } }
|
||||
responses:
|
||||
'200':
|
||||
description: File stream
|
||||
content:
|
||||
application/pdf:
|
||||
schema: { type: string, format: binary }
|
||||
image/jpeg:
|
||||
schema: { type: string, format: binary }
|
||||
image/png:
|
||||
schema: { type: string, format: binary }
|
||||
'404': { description: Not found }
|
||||
|
||||
150
src/modules/grn/grn.attachments.service.js
Normal file
150
src/modules/grn/grn.attachments.service.js
Normal file
@ -0,0 +1,150 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const prisma = require('../../config/prisma');
|
||||
const ApiError = require('../../utils/ApiError');
|
||||
const auditLog = require('../../utils/auditLog');
|
||||
const env = require('../../config/env');
|
||||
|
||||
const attachmentInclude = {
|
||||
users: { select: { id: true, full_name: true, employee_code: true } },
|
||||
};
|
||||
|
||||
const sanitizeAttachment = (row) => {
|
||||
if (!row) return null;
|
||||
const { users, ...rest } = row;
|
||||
return {
|
||||
...rest,
|
||||
uploaded_by_user: users || null,
|
||||
users: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const getGrnOrThrow = async (grnId) => {
|
||||
const grn = await prisma.grn.findFirst({
|
||||
where: { id: BigInt(grnId), deleted_at: null },
|
||||
});
|
||||
if (!grn) throw new ApiError(404, 'GRN not found');
|
||||
return grn;
|
||||
};
|
||||
|
||||
const getAttachmentOrThrow = async (grnId, attachmentId) => {
|
||||
const row = await prisma.grn_attachments.findFirst({
|
||||
where: {
|
||||
id: BigInt(attachmentId),
|
||||
grn_id: BigInt(grnId),
|
||||
},
|
||||
include: attachmentInclude,
|
||||
});
|
||||
if (!row) throw new ApiError(404, 'GRN attachment not found');
|
||||
return row;
|
||||
};
|
||||
|
||||
const assertGrnAllowsAttachments = (grn) => {
|
||||
if (grn.status !== 'POSTED') {
|
||||
throw new ApiError(409, 'Attachments can only be managed on POSTED GRN');
|
||||
}
|
||||
};
|
||||
|
||||
const resolveFilePath = (storedPath) => {
|
||||
const absolute = path.isAbsolute(storedPath)
|
||||
? storedPath
|
||||
: path.resolve(env.UPLOAD_DIR, storedPath);
|
||||
const uploadRoot = path.resolve(env.UPLOAD_DIR);
|
||||
if (!absolute.startsWith(uploadRoot)) {
|
||||
throw new ApiError(400, 'Invalid attachment file path');
|
||||
}
|
||||
return absolute;
|
||||
};
|
||||
|
||||
const listGrnAttachments = async (grnId) => {
|
||||
await getGrnOrThrow(grnId);
|
||||
const rows = await prisma.grn_attachments.findMany({
|
||||
where: { grn_id: BigInt(grnId) },
|
||||
include: attachmentInclude,
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
return rows.map(sanitizeAttachment);
|
||||
};
|
||||
|
||||
const uploadGrnAttachment = async (grnId, file, userId, requestId) => {
|
||||
const grn = await getGrnOrThrow(grnId);
|
||||
assertGrnAllowsAttachments(grn);
|
||||
|
||||
if (!file) throw new ApiError(400, 'file is required');
|
||||
|
||||
const relativePath = path.relative(path.resolve(env.UPLOAD_DIR), file.path);
|
||||
const created = await prisma.grn_attachments.create({
|
||||
data: {
|
||||
grn_id: BigInt(grnId),
|
||||
file_name: file.originalname,
|
||||
file_path: relativePath.split(path.sep).join('/'),
|
||||
file_type: file.mimetype,
|
||||
file_size: file.size,
|
||||
uploaded_by: userId ? BigInt(userId) : null,
|
||||
},
|
||||
include: attachmentInclude,
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
tableName: 'grn_attachments',
|
||||
recordId: created.id,
|
||||
action: 'CREATE',
|
||||
oldValue: null,
|
||||
newValue: sanitizeAttachment(created),
|
||||
userId,
|
||||
requestId,
|
||||
});
|
||||
|
||||
return sanitizeAttachment(created);
|
||||
};
|
||||
|
||||
const getGrnAttachmentById = async (grnId, attachmentId) =>
|
||||
sanitizeAttachment(await getAttachmentOrThrow(grnId, attachmentId));
|
||||
|
||||
const downloadGrnAttachment = async (grnId, attachmentId) => {
|
||||
await getGrnOrThrow(grnId);
|
||||
const attachment = await getAttachmentOrThrow(grnId, attachmentId);
|
||||
const absolutePath = resolveFilePath(attachment.file_path);
|
||||
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
throw new ApiError(404, 'Attachment file not found on server');
|
||||
}
|
||||
|
||||
return {
|
||||
attachment: sanitizeAttachment(attachment),
|
||||
absolutePath,
|
||||
};
|
||||
};
|
||||
|
||||
const deleteGrnAttachment = async (grnId, attachmentId, userId, requestId) => {
|
||||
const grn = await getGrnOrThrow(grnId);
|
||||
assertGrnAllowsAttachments(grn);
|
||||
|
||||
const existing = await getAttachmentOrThrow(grnId, attachmentId);
|
||||
const absolutePath = resolveFilePath(existing.file_path);
|
||||
|
||||
await prisma.grn_attachments.delete({ where: { id: existing.id } });
|
||||
|
||||
if (fs.existsSync(absolutePath)) {
|
||||
fs.unlinkSync(absolutePath);
|
||||
}
|
||||
|
||||
await auditLog({
|
||||
tableName: 'grn_attachments',
|
||||
recordId: attachmentId,
|
||||
action: 'DELETE',
|
||||
oldValue: sanitizeAttachment(existing),
|
||||
newValue: null,
|
||||
userId,
|
||||
requestId,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
listGrnAttachments,
|
||||
uploadGrnAttachment,
|
||||
getGrnAttachmentById,
|
||||
downloadGrnAttachment,
|
||||
deleteGrnAttachment,
|
||||
sanitizeAttachment,
|
||||
};
|
||||
@ -1,6 +1,8 @@
|
||||
const asyncHandler = require('../../utils/asyncHandler');
|
||||
const ApiResponse = require('../../utils/ApiResponse');
|
||||
const service = require('./grn.service');
|
||||
const attachmentService = require('./grn.attachments.service');
|
||||
const path = require('path');
|
||||
|
||||
const create = asyncHandler(async (req, res) => {
|
||||
const data = await service.createGrn(req.body, req.user?.id, req.id);
|
||||
@ -34,4 +36,62 @@ const pdf = asyncHandler(async (req, res) => {
|
||||
res.send(buffer);
|
||||
});
|
||||
|
||||
module.exports = { create, list, getOne, update, cancel, pdf };
|
||||
const listAttachments = asyncHandler(async (req, res) => {
|
||||
const data = await attachmentService.listGrnAttachments(req.params.grnId);
|
||||
res.json(new ApiResponse(200, data, 'GRN attachments fetched'));
|
||||
});
|
||||
|
||||
const uploadAttachment = asyncHandler(async (req, res) => {
|
||||
const data = await attachmentService.uploadGrnAttachment(
|
||||
req.params.grnId,
|
||||
req.file,
|
||||
req.user?.id,
|
||||
req.id
|
||||
);
|
||||
res.status(201).json(new ApiResponse(201, data, 'GRN attachment uploaded successfully'));
|
||||
});
|
||||
|
||||
const getAttachment = asyncHandler(async (req, res) => {
|
||||
const data = await attachmentService.getGrnAttachmentById(
|
||||
req.params.grnId,
|
||||
req.params.attachmentId
|
||||
);
|
||||
res.json(new ApiResponse(200, data, 'GRN attachment fetched'));
|
||||
});
|
||||
|
||||
const downloadAttachment = asyncHandler(async (req, res) => {
|
||||
const { attachment, absolutePath } = await attachmentService.downloadGrnAttachment(
|
||||
req.params.grnId,
|
||||
req.params.attachmentId
|
||||
);
|
||||
res.setHeader('Content-Type', attachment.file_type || 'application/octet-stream');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${path.basename(attachment.file_name)}"`
|
||||
);
|
||||
res.sendFile(absolutePath);
|
||||
});
|
||||
|
||||
const removeAttachment = asyncHandler(async (req, res) => {
|
||||
await attachmentService.deleteGrnAttachment(
|
||||
req.params.grnId,
|
||||
req.params.attachmentId,
|
||||
req.user?.id,
|
||||
req.id
|
||||
);
|
||||
res.json(new ApiResponse(200, null, 'GRN attachment deleted successfully'));
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
create,
|
||||
list,
|
||||
getOne,
|
||||
update,
|
||||
cancel,
|
||||
pdf,
|
||||
listAttachments,
|
||||
uploadAttachment,
|
||||
getAttachment,
|
||||
downloadAttachment,
|
||||
removeAttachment,
|
||||
};
|
||||
|
||||
@ -3,6 +3,7 @@ const authenticate = require('../../middlewares/auth.middleware');
|
||||
const authorize = require('../../middlewares/rbac.middleware');
|
||||
const validate = require('../../middlewares/validate.middleware');
|
||||
const controller = require('./grn.controller');
|
||||
const { grnAttachmentUpload } = require('./grn.upload.middleware');
|
||||
const {
|
||||
createGrnSchema,
|
||||
updateGrnSchema,
|
||||
@ -20,6 +21,29 @@ router.post('/', authorize('GRN', 'create'), validate(createGrnSchema), controll
|
||||
router.post('/:id/cancel', authorize('GRN', 'edit'), validate(cancelGrnSchema), controller.cancel);
|
||||
router.get('/:id/pdf', authorize('GRN', 'view'), controller.pdf);
|
||||
|
||||
router.get('/:grnId/attachments', authorize('GRN', 'view'), controller.listAttachments);
|
||||
router.post(
|
||||
'/:grnId/attachments',
|
||||
authorize('GRN', 'edit'),
|
||||
grnAttachmentUpload.single('file'),
|
||||
controller.uploadAttachment
|
||||
);
|
||||
router.get(
|
||||
'/:grnId/attachments/:attachmentId',
|
||||
authorize('GRN', 'view'),
|
||||
controller.getAttachment
|
||||
);
|
||||
router.get(
|
||||
'/:grnId/attachments/:attachmentId/download',
|
||||
authorize('GRN', 'view'),
|
||||
controller.downloadAttachment
|
||||
);
|
||||
router.delete(
|
||||
'/:grnId/attachments/:attachmentId',
|
||||
authorize('GRN', 'delete'),
|
||||
controller.removeAttachment
|
||||
);
|
||||
|
||||
router.get('/:id', authorize('GRN', 'view'), controller.getOne);
|
||||
router.put('/:id', authorize('GRN', 'edit'), validate(updateGrnSchema), controller.update);
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ const { nextDocumentNumber } = require('../../utils/generateCode');
|
||||
const { buildSimplePdf } = require('../../utils/simplePdf');
|
||||
const repository = require('./grn.repository');
|
||||
const { assertWarehouse } = require('../../utils/locations');
|
||||
const { sanitizeAttachment } = require('./grn.attachments.service');
|
||||
|
||||
const grnListInclude = {
|
||||
purchase_orders: { select: { id: true, po_number: true, status: true } },
|
||||
@ -30,6 +31,12 @@ const grnDetailInclude = {
|
||||
},
|
||||
},
|
||||
},
|
||||
grn_attachments: {
|
||||
orderBy: { created_at: 'desc' },
|
||||
include: {
|
||||
users: { select: { id: true, full_name: true, employee_code: true } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const toDateOnly = (value) => {
|
||||
@ -54,6 +61,7 @@ const sanitizeGrn = (row) => {
|
||||
users_grn_updated_byTousers,
|
||||
users_grn_cancelled_byTousers,
|
||||
grn_items,
|
||||
grn_attachments,
|
||||
...rest
|
||||
} = row;
|
||||
|
||||
@ -75,7 +83,9 @@ const sanitizeGrn = (row) => {
|
||||
items: undefined,
|
||||
purchase_order_items: undefined,
|
||||
})),
|
||||
attachments: (grn_attachments || []).map(sanitizeAttachment),
|
||||
grn_items: undefined,
|
||||
grn_attachments: undefined,
|
||||
purchase_orders: undefined,
|
||||
vendors: undefined,
|
||||
warehouses: undefined,
|
||||
|
||||
31
src/modules/grn/grn.upload.middleware.js
Normal file
31
src/modules/grn/grn.upload.middleware.js
Normal file
@ -0,0 +1,31 @@
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const ApiError = require('../../utils/ApiError');
|
||||
const env = require('../../config/env');
|
||||
|
||||
const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
const grnAttachmentUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, _file, cb) => {
|
||||
const dir = path.join(env.UPLOAD_DIR, 'grn', String(req.params.grnId));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const unique = crypto.randomBytes(16).toString('hex');
|
||||
cb(null, `${unique}${path.extname(file.originalname).toLowerCase()}`);
|
||||
},
|
||||
}),
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!ALLOWED_MIME.includes(file.mimetype)) {
|
||||
return cb(new ApiError(400, `Unsupported file type: ${file.mimetype}`), false);
|
||||
}
|
||||
return cb(null, true);
|
||||
},
|
||||
limits: { fileSize: env.MAX_FILE_SIZE_MB * 1024 * 1024 },
|
||||
});
|
||||
|
||||
module.exports = { grnAttachmentUpload };
|
||||
Loading…
Reference in New Issue
Block a user