GWM : grn attachement api's

This commit is contained in:
Gowtham M 2026-07-10 15:06:09 +05:30
parent a434216fdb
commit 71bd4e8354
9 changed files with 504 additions and 2 deletions

View File

@ -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** |

View File

@ -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

View File

@ -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 |

View File

@ -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 }

View 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,
};

View File

@ -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,
};

View File

@ -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);

View File

@ -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,

View 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 };