diff --git a/BACKEND_TASKS.md b/BACKEND_TASKS.md index 853352a..d116276 100644 --- a/BACKEND_TASKS.md +++ b/BACKEND_TASKS.md @@ -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** | diff --git a/docs/MODULE_DATA_FLOW.html b/docs/MODULE_DATA_FLOW.html index bd10b60..e7a2ac7 100644 --- a/docs/MODULE_DATA_FLOW.html +++ b/docs/MODULE_DATA_FLOW.html @@ -665,6 +665,57 @@ stateDiagram-v2 +

GRN goods receiving concept flow

+

End-to-end warehouse receipt: PO-backed GRN posting, optional supporting documents, PO qty update, and asset auto-creation.

+
+
+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
status APPROVED / PARTIALLY_RECEIVED"] + S2["POST /grn
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
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] +
+
+ + + + + + + + + + +
StepAPI / tableNotes
1POST /grnRequires APPROVED PO with pending qty
2grn, grn_itemsaccepted_qty + rejected_qty = current_qty
3purchase_order_items.received_qtyIncremented in same transaction
4assetsWhen items.is_asset_item = true
5POST /grn/:grnId/attachmentsInvoice PDF, LR copy, photos
6GET .../attachments/:id/downloadRBAC download — not public /uploads
+

GRN auto-asset creation (when items.is_asset_item = true)

@@ -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 diff --git a/docs/MODULE_DATA_FLOW.md b/docs/MODULE_DATA_FLOW.md index 2a78eb0..30572bb 100644 --- a/docs/MODULE_DATA_FLOW.md +++ b/docs/MODULE_DATA_FLOW.md @@ -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
status APPROVED / PARTIALLY_RECEIVED"] + S2["POST /grn
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
1 per accepted unit"] + S8["Recalculate PO status"] + end + + subgraph Docs["Supporting documents (after or alongside GRN)"] + D1["POST /grn/:grnId/attachments
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 | diff --git a/src/docs/grn-routes.yaml b/src/docs/grn-routes.yaml index 85e7799..e1a38db 100644 --- a/src/docs/grn-routes.yaml +++ b/src/docs/grn-routes.yaml @@ -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 } diff --git a/src/modules/grn/grn.attachments.service.js b/src/modules/grn/grn.attachments.service.js new file mode 100644 index 0000000..298eb7c --- /dev/null +++ b/src/modules/grn/grn.attachments.service.js @@ -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, +}; diff --git a/src/modules/grn/grn.controller.js b/src/modules/grn/grn.controller.js index 2cb5e16..3fc888a 100644 --- a/src/modules/grn/grn.controller.js +++ b/src/modules/grn/grn.controller.js @@ -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, +}; diff --git a/src/modules/grn/grn.routes.js b/src/modules/grn/grn.routes.js index ce60b2b..7736025 100644 --- a/src/modules/grn/grn.routes.js +++ b/src/modules/grn/grn.routes.js @@ -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); diff --git a/src/modules/grn/grn.service.js b/src/modules/grn/grn.service.js index ea3aac7..3e634a0 100644 --- a/src/modules/grn/grn.service.js +++ b/src/modules/grn/grn.service.js @@ -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, diff --git a/src/modules/grn/grn.upload.middleware.js b/src/modules/grn/grn.upload.middleware.js new file mode 100644 index 0000000..ce2094d --- /dev/null +++ b/src/modules/grn/grn.upload.middleware.js @@ -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 };
Field on AssetSource