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] | PUT | `/grn/:id` | edit | Update GRN |
|
||||||
| [x] | POST | `/grn/:id/cancel` | edit | Cancel GRN |
|
| [x] | POST | `/grn/:id/cancel` | edit | Cancel GRN |
|
||||||
| [x] | GET | `/grn/:id/pdf` | view | PDF export |
|
| [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 |
|
| Masters | 70 | 70 | [x] Done |
|
||||||
| Vendors | 27 | 27 | [x] Done |
|
| Vendors | 27 | 27 | [x] Done |
|
||||||
| Purchase Orders | 11 | 11 | [x] Done |
|
| Purchase Orders | 11 | 11 | [x] Done |
|
||||||
| GRN | 6 | 6 | [x] Done |
|
| GRN | 11 | 11 | [x] Done |
|
||||||
| Assets | 24 | 24 | [x] Done |
|
| Assets | 24 | 24 | [x] Done |
|
||||||
| Settings | 5 | 5 | [x] Done |
|
| Settings | 5 | 5 | [x] Done |
|
||||||
| **Total** | **168** | **168** | **[x] Phase 1 APIs + Asset extensions** |
|
| **Total** | **168** | **168** | **[x] Phase 1 APIs + Asset extensions** |
|
||||||
|
|||||||
@ -665,6 +665,57 @@ stateDiagram-v2
|
|||||||
</pre>
|
</pre>
|
||||||
</div>
|
</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>
|
<h3>GRN auto-asset creation (when <code>items.is_asset_item = true</code>)</h3>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Field on Asset</th><th>Source</th></tr></thead>
|
<thead><tr><th>Field on Asset</th><th>Source</th></tr></thead>
|
||||||
@ -971,6 +1022,7 @@ erDiagram
|
|||||||
purchase_orders ||--o{ assets : sourced_from
|
purchase_orders ||--o{ assets : sourced_from
|
||||||
|
|
||||||
grn ||--o{ grn_items : lines
|
grn ||--o{ grn_items : lines
|
||||||
|
grn ||--o{ grn_attachments : documents
|
||||||
grn_items ||--o{ assets : auto_created
|
grn_items ||--o{ assets : auto_created
|
||||||
|
|
||||||
items ||--o{ purchase_order_items : ordered
|
items ||--o{ purchase_order_items : ordered
|
||||||
|
|||||||
@ -254,6 +254,63 @@ stateDiagram-v2
|
|||||||
APPROVED --> CANCELLED: cancel if no receipts
|
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`)
|
### GRN auto-asset creation (when `items.is_asset_item = true`)
|
||||||
|
|
||||||
| Field on Asset | Source |
|
| Field on Asset | Source |
|
||||||
@ -568,6 +625,7 @@ erDiagram
|
|||||||
purchase_orders ||--o{ assets : sourced_from
|
purchase_orders ||--o{ assets : sourced_from
|
||||||
|
|
||||||
grn ||--o{ grn_items : lines
|
grn ||--o{ grn_items : lines
|
||||||
|
grn ||--o{ grn_attachments : documents
|
||||||
grn_items ||--o{ assets : auto_created
|
grn_items ||--o{ assets : auto_created
|
||||||
|
|
||||||
items ||--o{ purchase_order_items : ordered
|
items ||--o{ purchase_order_items : ordered
|
||||||
@ -622,6 +680,7 @@ erDiagram
|
|||||||
| Create PO | Masters dropdowns + `POST /purchase-orders` |
|
| Create PO | Masters dropdowns + `POST /purchase-orders` |
|
||||||
| Approve PO | `POST /purchase-orders/:id/approve` |
|
| Approve PO | `POST /purchase-orders/:id/approve` |
|
||||||
| GRN receipt | `POST /grn` (auto assets for asset items) |
|
| 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 list/detail | `GET /assets`, `GET /assets/:id` |
|
||||||
| Asset create/edit form | Masters + `POST /depreciation/calculate` (preview) + `POST/PUT /assets` |
|
| Asset create/edit form | Masters + `POST /depreciation/calculate` (preview) + `POST/PUT /assets` |
|
||||||
| AMC tab | `/assets/:id/amc/*` + renew |
|
| AMC tab | `/assets/:id/amc/*` + renew |
|
||||||
|
|||||||
@ -60,6 +60,22 @@ components:
|
|||||||
required: [cancellation_reason]
|
required: [cancellation_reason]
|
||||||
properties:
|
properties:
|
||||||
cancellation_reason: { type: string, example: 'Posted against wrong PO' }
|
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:
|
paths:
|
||||||
/grn:
|
/grn:
|
||||||
@ -154,3 +170,98 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/pdf:
|
application/pdf:
|
||||||
schema: { type: string, format: binary }
|
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 asyncHandler = require('../../utils/asyncHandler');
|
||||||
const ApiResponse = require('../../utils/ApiResponse');
|
const ApiResponse = require('../../utils/ApiResponse');
|
||||||
const service = require('./grn.service');
|
const service = require('./grn.service');
|
||||||
|
const attachmentService = require('./grn.attachments.service');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
const create = asyncHandler(async (req, res) => {
|
const create = asyncHandler(async (req, res) => {
|
||||||
const data = await service.createGrn(req.body, req.user?.id, req.id);
|
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);
|
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 authorize = require('../../middlewares/rbac.middleware');
|
||||||
const validate = require('../../middlewares/validate.middleware');
|
const validate = require('../../middlewares/validate.middleware');
|
||||||
const controller = require('./grn.controller');
|
const controller = require('./grn.controller');
|
||||||
|
const { grnAttachmentUpload } = require('./grn.upload.middleware');
|
||||||
const {
|
const {
|
||||||
createGrnSchema,
|
createGrnSchema,
|
||||||
updateGrnSchema,
|
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.post('/:id/cancel', authorize('GRN', 'edit'), validate(cancelGrnSchema), controller.cancel);
|
||||||
router.get('/:id/pdf', authorize('GRN', 'view'), controller.pdf);
|
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.get('/:id', authorize('GRN', 'view'), controller.getOne);
|
||||||
router.put('/:id', authorize('GRN', 'edit'), validate(updateGrnSchema), controller.update);
|
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 { buildSimplePdf } = require('../../utils/simplePdf');
|
||||||
const repository = require('./grn.repository');
|
const repository = require('./grn.repository');
|
||||||
const { assertWarehouse } = require('../../utils/locations');
|
const { assertWarehouse } = require('../../utils/locations');
|
||||||
|
const { sanitizeAttachment } = require('./grn.attachments.service');
|
||||||
|
|
||||||
const grnListInclude = {
|
const grnListInclude = {
|
||||||
purchase_orders: { select: { id: true, po_number: true, status: true } },
|
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) => {
|
const toDateOnly = (value) => {
|
||||||
@ -54,6 +61,7 @@ const sanitizeGrn = (row) => {
|
|||||||
users_grn_updated_byTousers,
|
users_grn_updated_byTousers,
|
||||||
users_grn_cancelled_byTousers,
|
users_grn_cancelled_byTousers,
|
||||||
grn_items,
|
grn_items,
|
||||||
|
grn_attachments,
|
||||||
...rest
|
...rest
|
||||||
} = row;
|
} = row;
|
||||||
|
|
||||||
@ -75,7 +83,9 @@ const sanitizeGrn = (row) => {
|
|||||||
items: undefined,
|
items: undefined,
|
||||||
purchase_order_items: undefined,
|
purchase_order_items: undefined,
|
||||||
})),
|
})),
|
||||||
|
attachments: (grn_attachments || []).map(sanitizeAttachment),
|
||||||
grn_items: undefined,
|
grn_items: undefined,
|
||||||
|
grn_attachments: undefined,
|
||||||
purchase_orders: undefined,
|
purchase_orders: undefined,
|
||||||
vendors: undefined,
|
vendors: undefined,
|
||||||
warehouses: 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