po pending approval list , tearms and notes
This commit is contained in:
parent
355f5a9f6d
commit
1cd5826ea5
@ -560,6 +560,21 @@ model payment_terms {
|
|||||||
vendors vendors[]
|
vendors vendors[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Default terms & conditions / notes text per document type (one active row per type).
|
||||||
|
model terms_notes {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
type String @unique @db.VarChar(30)
|
||||||
|
notes String
|
||||||
|
is_active Boolean @default(true)
|
||||||
|
created_by BigInt?
|
||||||
|
updated_by BigInt?
|
||||||
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
|
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
|
deleted_at DateTime? @db.Timestamptz(6)
|
||||||
|
users_terms_notes_created_byTousers users? @relation("terms_notes_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
|
||||||
|
users_terms_notes_updated_byTousers users? @relation("terms_notes_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
|
||||||
|
}
|
||||||
|
|
||||||
model permissions {
|
model permissions {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
module_id BigInt
|
module_id BigInt
|
||||||
@ -855,6 +870,8 @@ model users {
|
|||||||
password_reset_tokens password_reset_tokens[]
|
password_reset_tokens password_reset_tokens[]
|
||||||
payment_terms_payment_terms_created_byTousers payment_terms[] @relation("payment_terms_created_byTousers")
|
payment_terms_payment_terms_created_byTousers payment_terms[] @relation("payment_terms_created_byTousers")
|
||||||
payment_terms_payment_terms_updated_byTousers payment_terms[] @relation("payment_terms_updated_byTousers")
|
payment_terms_payment_terms_updated_byTousers payment_terms[] @relation("payment_terms_updated_byTousers")
|
||||||
|
terms_notes_terms_notes_created_byTousers terms_notes[] @relation("terms_notes_created_byTousers")
|
||||||
|
terms_notes_terms_notes_updated_byTousers terms_notes[] @relation("terms_notes_updated_byTousers")
|
||||||
locations_locations_created_byTousers locations[] @relation("locations_created_byTousers")
|
locations_locations_created_byTousers locations[] @relation("locations_created_byTousers")
|
||||||
locations_locations_updated_byTousers locations[] @relation("locations_updated_byTousers")
|
locations_locations_updated_byTousers locations[] @relation("locations_updated_byTousers")
|
||||||
po_approvals po_approvals[]
|
po_approvals po_approvals[]
|
||||||
|
|||||||
35
scripts/patch-terms-notes.sql
Normal file
35
scripts/patch-terms-notes.sql
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
-- terms_notes: default terms & conditions / notes per document type.
|
||||||
|
-- One row per type (PO, INVOICE, ...). Idempotent.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS terms_notes (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
type VARCHAR(30) NOT NULL,
|
||||||
|
notes TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_by BIGINT,
|
||||||
|
updated_by BIGINT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS terms_notes_type_key ON terms_notes(type);
|
||||||
|
|
||||||
|
ALTER TABLE terms_notes DROP CONSTRAINT IF EXISTS terms_notes_type_check;
|
||||||
|
ALTER TABLE terms_notes
|
||||||
|
ADD CONSTRAINT terms_notes_type_check
|
||||||
|
CHECK (type IN ('PO', 'INVOICE'));
|
||||||
|
|
||||||
|
ALTER TABLE terms_notes DROP CONSTRAINT IF EXISTS fk_terms_notes_created_by;
|
||||||
|
ALTER TABLE terms_notes
|
||||||
|
ADD CONSTRAINT fk_terms_notes_created_by FOREIGN KEY (created_by) REFERENCES users(id);
|
||||||
|
ALTER TABLE terms_notes DROP CONSTRAINT IF EXISTS fk_terms_notes_updated_by;
|
||||||
|
ALTER TABLE terms_notes
|
||||||
|
ADD CONSTRAINT fk_terms_notes_updated_by FOREIGN KEY (updated_by) REFERENCES users(id);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- Verify:
|
||||||
|
-- SELECT * FROM terms_notes;
|
||||||
@ -8,6 +8,7 @@ tags:
|
|||||||
- name: HSN Codes
|
- name: HSN Codes
|
||||||
- name: Payment Terms
|
- name: Payment Terms
|
||||||
- name: Delivery Terms
|
- name: Delivery Terms
|
||||||
|
- name: Terms Notes
|
||||||
- name: Departments
|
- name: Departments
|
||||||
- name: Designations
|
- name: Designations
|
||||||
- name: Locations
|
- name: Locations
|
||||||
@ -301,6 +302,23 @@ components:
|
|||||||
name: { type: string, example: 'Free on Road' }
|
name: { type: string, example: 'Free on Road' }
|
||||||
description: { type: string, example: 'Delivered to site' }
|
description: { type: string, example: 'Delivered to site' }
|
||||||
is_active: { type: boolean, example: true }
|
is_active: { type: boolean, example: true }
|
||||||
|
TermsNotesCreateBody:
|
||||||
|
type: object
|
||||||
|
required: [type, notes]
|
||||||
|
properties:
|
||||||
|
type: { type: string, enum: [PO, INVOICE], example: PO }
|
||||||
|
notes:
|
||||||
|
type: string
|
||||||
|
example: |
|
||||||
|
1. Payment as per agreed terms.
|
||||||
|
2. Goods must match PO specification.
|
||||||
|
is_active: { type: boolean, example: true }
|
||||||
|
TermsNotesUpdateBody:
|
||||||
|
type: object
|
||||||
|
minProperties: 1
|
||||||
|
properties:
|
||||||
|
notes: { type: string }
|
||||||
|
is_active: { type: boolean }
|
||||||
DeliveryTermsUpdateBody:
|
DeliveryTermsUpdateBody:
|
||||||
type: object
|
type: object
|
||||||
minProperties: 1
|
minProperties: 1
|
||||||
@ -1174,6 +1192,103 @@ paths:
|
|||||||
description: Deleted
|
description: Deleted
|
||||||
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
|
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
|
||||||
"404": { description: Not found }
|
"404": { description: Not found }
|
||||||
|
/masters/terms-notes/types:
|
||||||
|
get:
|
||||||
|
tags: [Terms Notes]
|
||||||
|
summary: List terms_notes type options (PO, INVOICE)
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Types fetched
|
||||||
|
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
|
||||||
|
/masters/terms-notes/by-type/{type}:
|
||||||
|
get:
|
||||||
|
tags: [Terms Notes]
|
||||||
|
summary: Get terms notes by document type
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: type
|
||||||
|
required: true
|
||||||
|
schema: { type: string, enum: [PO, INVOICE] }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Fetched
|
||||||
|
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
|
||||||
|
"404": { description: Not found for type }
|
||||||
|
/masters/terms-notes:
|
||||||
|
get:
|
||||||
|
tags: [Terms Notes]
|
||||||
|
summary: List terms notes (one row per document type)
|
||||||
|
description: |
|
||||||
|
Default terms & conditions text used when creating documents.
|
||||||
|
On PO create, if `terms_and_conditions` is empty, the active `PO` notes are applied automatically.
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: page
|
||||||
|
schema: { type: integer, default: 1 }
|
||||||
|
- in: query
|
||||||
|
name: limit
|
||||||
|
schema: { type: integer, default: 20, maximum: 100 }
|
||||||
|
- in: query
|
||||||
|
name: type
|
||||||
|
schema: { type: string, enum: [PO, INVOICE] }
|
||||||
|
- in: query
|
||||||
|
name: search
|
||||||
|
schema: { type: string }
|
||||||
|
- in: query
|
||||||
|
name: is_active
|
||||||
|
schema: { type: boolean }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: List fetched
|
||||||
|
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
|
||||||
|
post:
|
||||||
|
tags: [Terms Notes]
|
||||||
|
summary: Create terms notes for a type (one entry per type)
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/TermsNotesCreateBody" }
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Created
|
||||||
|
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
|
||||||
|
"409": { description: Type already exists }
|
||||||
|
/masters/terms-notes/{id}:
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
get:
|
||||||
|
tags: [Terms Notes]
|
||||||
|
summary: Get terms notes by id
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Fetched
|
||||||
|
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
|
||||||
|
"404": { description: Not found }
|
||||||
|
put:
|
||||||
|
tags: [Terms Notes]
|
||||||
|
summary: Update terms notes
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/TermsNotesUpdateBody" }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Updated
|
||||||
|
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
|
||||||
|
"404": { description: Not found }
|
||||||
|
delete:
|
||||||
|
tags: [Terms Notes]
|
||||||
|
summary: Soft-delete terms notes
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Deleted
|
||||||
|
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
|
||||||
|
"404": { description: Not found }
|
||||||
/masters/delivery-terms:
|
/masters/delivery-terms:
|
||||||
get:
|
get:
|
||||||
tags: [Delivery Terms]
|
tags: [Delivery Terms]
|
||||||
|
|||||||
@ -110,6 +110,29 @@ paths:
|
|||||||
content:
|
content:
|
||||||
text/csv:
|
text/csv:
|
||||||
schema: { type: string }
|
schema: { type: string }
|
||||||
|
/purchase-orders/pending-approval:
|
||||||
|
get:
|
||||||
|
tags: [Purchase Orders]
|
||||||
|
summary: List POs pending approval (approver inbox)
|
||||||
|
description: |
|
||||||
|
Requires `PURCHASE_ORDER` → `approve`.
|
||||||
|
Returns only POs with status `PENDING_APPROVAL`.
|
||||||
|
Same filters as the main list except `status` is forced to PENDING_APPROVAL.
|
||||||
|
parameters:
|
||||||
|
- { name: page, in: query, schema: { type: integer, example: 1 } }
|
||||||
|
- { name: limit, in: query, schema: { type: integer, example: 20 } }
|
||||||
|
- { name: search, in: query, schema: { type: string, example: 'PO/2026' } }
|
||||||
|
- { name: vendor_type, in: query, schema: { type: string, example: RAW_MATERIAL } }
|
||||||
|
- { name: vendor_id, in: query, schema: { type: integer, example: 1 } }
|
||||||
|
- { name: billing_id, in: query, schema: { type: integer, example: 1 } }
|
||||||
|
- { name: shipping_id, in: query, schema: { type: integer, example: 2 } }
|
||||||
|
- { name: date_from, in: query, schema: { type: string, format: date } }
|
||||||
|
- { name: date_to, in: query, schema: { type: string, format: date } }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Pending approval POs fetched
|
||||||
|
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
|
||||||
|
'403': { description: Missing PURCHASE_ORDER approve permission }
|
||||||
/purchase-orders:
|
/purchase-orders:
|
||||||
get:
|
get:
|
||||||
tags: [Purchase Orders]
|
tags: [Purchase Orders]
|
||||||
|
|||||||
@ -8,6 +8,7 @@ router.use('/gst-rates', require('./gst-rates/gst-rates.routes'));
|
|||||||
router.use('/hsn-codes', require('./hsn-codes/hsn-codes.routes'));
|
router.use('/hsn-codes', require('./hsn-codes/hsn-codes.routes'));
|
||||||
router.use('/payment-terms', require('./payment-terms/payment-terms.routes'));
|
router.use('/payment-terms', require('./payment-terms/payment-terms.routes'));
|
||||||
router.use('/delivery-terms', require('./delivery-terms/delivery-terms.routes'));
|
router.use('/delivery-terms', require('./delivery-terms/delivery-terms.routes'));
|
||||||
|
router.use('/terms-notes', require('./terms-notes/terms-notes.routes'));
|
||||||
router.use('/departments', require('./departments/departments.routes'));
|
router.use('/departments', require('./departments/departments.routes'));
|
||||||
router.use('/designations', require('./designations/designations.routes'));
|
router.use('/designations', require('./designations/designations.routes'));
|
||||||
router.use('/document-series', require('./document-series/document-series.routes'));
|
router.use('/document-series', require('./document-series/document-series.routes'));
|
||||||
|
|||||||
3
src/modules/masters/terms-notes/terms-notes.constants.js
Normal file
3
src/modules/masters/terms-notes/terms-notes.constants.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const TERMS_NOTE_TYPES = ['PO', 'INVOICE'];
|
||||||
|
|
||||||
|
module.exports = { TERMS_NOTE_TYPES };
|
||||||
50
src/modules/masters/terms-notes/terms-notes.controller.js
Normal file
50
src/modules/masters/terms-notes/terms-notes.controller.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
const asyncHandler = require('../../../utils/asyncHandler');
|
||||||
|
const ApiResponse = require('../../../utils/ApiResponse');
|
||||||
|
const service = require('./terms-notes.service');
|
||||||
|
|
||||||
|
const create = asyncHandler(async (req, res) => {
|
||||||
|
const data = await service.createTermsNote(req.body, req.user?.id, req.id);
|
||||||
|
res.status(201).json(new ApiResponse(201, data, 'terms_notes created successfully'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = asyncHandler(async (req, res) => {
|
||||||
|
const result = await service.listTermsNotes(req.query);
|
||||||
|
res.json(new ApiResponse(200, result.data, 'terms_notes list fetched', result.meta));
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportCsv = asyncHandler(async (req, res) => {
|
||||||
|
const csv = await service.exportTermsNotes(req.query);
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="terms-notes-export.csv"');
|
||||||
|
res.send(csv);
|
||||||
|
});
|
||||||
|
|
||||||
|
const listTypes = asyncHandler(async (_req, res) => {
|
||||||
|
const data = service.listTermsNoteTypes();
|
||||||
|
res.json(new ApiResponse(200, data, 'terms_notes types fetched'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const getByType = asyncHandler(async (req, res) => {
|
||||||
|
const data = await service.getTermsNoteByType(req.params.type);
|
||||||
|
if (!data) {
|
||||||
|
return res.status(404).json(new ApiResponse(404, null, 'terms_notes not found for type'));
|
||||||
|
}
|
||||||
|
res.json(new ApiResponse(200, data, 'terms_notes fetched'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const getOne = asyncHandler(async (req, res) => {
|
||||||
|
const data = await service.getTermsNoteById(req.params.id);
|
||||||
|
res.json(new ApiResponse(200, data, 'terms_notes fetched'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = asyncHandler(async (req, res) => {
|
||||||
|
const data = await service.updateTermsNote(req.params.id, req.body, req.user?.id, req.id);
|
||||||
|
res.json(new ApiResponse(200, data, 'terms_notes updated successfully'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = asyncHandler(async (req, res) => {
|
||||||
|
await service.deleteTermsNote(req.params.id, req.user?.id, req.id);
|
||||||
|
res.json(new ApiResponse(200, null, 'terms_notes deleted successfully'));
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = { create, list, exportCsv, listTypes, getByType, getOne, update, remove };
|
||||||
35
src/modules/masters/terms-notes/terms-notes.routes.js
Normal file
35
src/modules/masters/terms-notes/terms-notes.routes.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const authenticate = require('../../../middlewares/auth.middleware');
|
||||||
|
const authorize = require('../../../middlewares/rbac.middleware');
|
||||||
|
const validate = require('../../../middlewares/validate.middleware');
|
||||||
|
const controller = require('./terms-notes.controller');
|
||||||
|
const {
|
||||||
|
createSchema,
|
||||||
|
updateSchema,
|
||||||
|
listQuerySchema,
|
||||||
|
exportQuerySchema,
|
||||||
|
} = require('./terms-notes.validation');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
router.get('/types', authorize('MASTERS', 'view'), controller.listTypes);
|
||||||
|
router.get(
|
||||||
|
'/by-type/:type',
|
||||||
|
authorize('MASTERS', 'view'),
|
||||||
|
controller.getByType
|
||||||
|
);
|
||||||
|
router.get('/', authorize('MASTERS', 'view'), validate(listQuerySchema, 'query'), controller.list);
|
||||||
|
router.get(
|
||||||
|
'/export',
|
||||||
|
authorize('MASTERS', 'export'),
|
||||||
|
validate(exportQuerySchema, 'query'),
|
||||||
|
controller.exportCsv
|
||||||
|
);
|
||||||
|
router.get('/:id', authorize('MASTERS', 'view'), controller.getOne);
|
||||||
|
router.post('/', authorize('MASTERS', 'create'), validate(createSchema), controller.create);
|
||||||
|
router.put('/:id', authorize('MASTERS', 'edit'), validate(updateSchema), controller.update);
|
||||||
|
router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
222
src/modules/masters/terms-notes/terms-notes.service.js
Normal file
222
src/modules/masters/terms-notes/terms-notes.service.js
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
const prisma = require('../../../config/prisma');
|
||||||
|
const ApiError = require('../../../utils/ApiError');
|
||||||
|
const auditLog = require('../../../utils/auditLog');
|
||||||
|
const { getPagination, isDropdownCall } = require('../../../utils/pagination');
|
||||||
|
const { parseId } = require('../../../utils/parseId');
|
||||||
|
const { rowsToCsv } = require('../../../utils/csv');
|
||||||
|
const { TERMS_NOTE_TYPES } = require('./terms-notes.constants');
|
||||||
|
|
||||||
|
const TABLE_NAME = 'terms_notes';
|
||||||
|
|
||||||
|
const sanitize = (row) => {
|
||||||
|
if (!row) return null;
|
||||||
|
return { ...row };
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildWhere = (query) => ({
|
||||||
|
deleted_at: null,
|
||||||
|
...(query.type ? { type: String(query.type).toUpperCase() } : {}),
|
||||||
|
...(query.is_active !== undefined ? { is_active: query.is_active } : {}),
|
||||||
|
...(query.search
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ type: { contains: query.search, mode: 'insensitive' } },
|
||||||
|
{ notes: { contains: query.search, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTermsNote = async (payload, userId, requestId) => {
|
||||||
|
const type = String(payload.type).toUpperCase();
|
||||||
|
if (!TERMS_NOTE_TYPES.includes(type)) {
|
||||||
|
throw new ApiError(422, `type must be one of: ${TERMS_NOTE_TYPES.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.terms_notes.findFirst({
|
||||||
|
where: { type, deleted_at: null },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ApiError(409, `terms_notes already exists for type ${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revive soft-deleted row for the same type if present
|
||||||
|
const softDeleted = await prisma.terms_notes.findFirst({
|
||||||
|
where: { type, deleted_at: { not: null } },
|
||||||
|
});
|
||||||
|
|
||||||
|
let created;
|
||||||
|
if (softDeleted) {
|
||||||
|
created = await prisma.terms_notes.update({
|
||||||
|
where: { id: softDeleted.id },
|
||||||
|
data: {
|
||||||
|
notes: payload.notes,
|
||||||
|
is_active: payload.is_active ?? true,
|
||||||
|
deleted_at: null,
|
||||||
|
updated_by: userId ? BigInt(userId) : null,
|
||||||
|
created_by: userId ? BigInt(userId) : softDeleted.created_by,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
created = await prisma.terms_notes.create({
|
||||||
|
data: {
|
||||||
|
type,
|
||||||
|
notes: payload.notes,
|
||||||
|
is_active: payload.is_active ?? true,
|
||||||
|
created_by: userId ? BigInt(userId) : null,
|
||||||
|
updated_by: userId ? BigInt(userId) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = sanitize(created);
|
||||||
|
await auditLog({
|
||||||
|
tableName: TABLE_NAME,
|
||||||
|
recordId: created.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
oldValue: null,
|
||||||
|
newValue: mapped,
|
||||||
|
userId,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
return mapped;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listTermsNotes = async (query) => {
|
||||||
|
const where = buildWhere(query);
|
||||||
|
|
||||||
|
if (isDropdownCall(query)) {
|
||||||
|
where.is_active = true;
|
||||||
|
const rows = await prisma.terms_notes.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { type: 'asc' },
|
||||||
|
});
|
||||||
|
const data = rows.map(sanitize);
|
||||||
|
return { data, meta: { page: 1, limit: data.length, total: data.length, dropdown: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page, limit, skip } = getPagination(query);
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
prisma.terms_notes.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { type: 'asc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.terms_notes.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { data: rows.map(sanitize), meta: { page, limit, total } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportTermsNotes = async (query) => {
|
||||||
|
const rows = await prisma.terms_notes.findMany({
|
||||||
|
where: buildWhere(query),
|
||||||
|
orderBy: { type: 'asc' },
|
||||||
|
});
|
||||||
|
return rowsToCsv(
|
||||||
|
[
|
||||||
|
{ key: 'type', header: 'Type' },
|
||||||
|
{ key: 'notes', header: 'Notes' },
|
||||||
|
{ key: 'is_active', header: 'Active' },
|
||||||
|
{ key: 'created_at', header: 'Created At', type: 'datetime' },
|
||||||
|
],
|
||||||
|
rows.map(sanitize)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTermsNoteById = async (id) => {
|
||||||
|
const row = await prisma.terms_notes.findFirst({
|
||||||
|
where: { id: parseId(id), deleted_at: null },
|
||||||
|
});
|
||||||
|
if (!row) throw new ApiError(404, 'terms_notes not found');
|
||||||
|
return sanitize(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTermsNoteByType = async (type, { requireActive = true } = {}) => {
|
||||||
|
const normalized = String(type || '').toUpperCase();
|
||||||
|
if (!TERMS_NOTE_TYPES.includes(normalized)) {
|
||||||
|
throw new ApiError(422, `type must be one of: ${TERMS_NOTE_TYPES.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await prisma.terms_notes.findFirst({
|
||||||
|
where: {
|
||||||
|
type: normalized,
|
||||||
|
deleted_at: null,
|
||||||
|
...(requireActive ? { is_active: true } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return sanitize(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Returns notes text for a type, or null if none configured. */
|
||||||
|
const getDefaultNotesText = async (type) => {
|
||||||
|
const normalized = String(type || '').toUpperCase();
|
||||||
|
if (!TERMS_NOTE_TYPES.includes(normalized)) return null;
|
||||||
|
|
||||||
|
const row = await prisma.terms_notes.findFirst({
|
||||||
|
where: { type: normalized, deleted_at: null, is_active: true },
|
||||||
|
});
|
||||||
|
return row?.notes || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTermsNote = async (id, payload, userId, requestId) => {
|
||||||
|
const existing = await getTermsNoteById(id);
|
||||||
|
const updated = await prisma.terms_notes.update({
|
||||||
|
where: { id: parseId(id) },
|
||||||
|
data: {
|
||||||
|
...(payload.notes !== undefined ? { notes: payload.notes } : {}),
|
||||||
|
...(payload.is_active !== undefined ? { is_active: payload.is_active } : {}),
|
||||||
|
updated_by: userId ? BigInt(userId) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapped = sanitize(updated);
|
||||||
|
await auditLog({
|
||||||
|
tableName: TABLE_NAME,
|
||||||
|
recordId: id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
oldValue: existing,
|
||||||
|
newValue: mapped,
|
||||||
|
userId,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
return mapped;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTermsNote = async (id, userId, requestId) => {
|
||||||
|
const existing = await getTermsNoteById(id);
|
||||||
|
await prisma.terms_notes.update({
|
||||||
|
where: { id: parseId(id) },
|
||||||
|
data: {
|
||||||
|
deleted_at: new Date(),
|
||||||
|
is_active: false,
|
||||||
|
updated_by: userId ? BigInt(userId) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditLog({
|
||||||
|
tableName: TABLE_NAME,
|
||||||
|
recordId: id,
|
||||||
|
action: 'DELETE',
|
||||||
|
oldValue: existing,
|
||||||
|
newValue: { deleted_at: new Date() },
|
||||||
|
userId,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const listTermsNoteTypes = () =>
|
||||||
|
TERMS_NOTE_TYPES.map((value) => ({ value, label: value }));
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createTermsNote,
|
||||||
|
listTermsNotes,
|
||||||
|
exportTermsNotes,
|
||||||
|
getTermsNoteById,
|
||||||
|
getTermsNoteByType,
|
||||||
|
getDefaultNotesText,
|
||||||
|
updateTermsNote,
|
||||||
|
deleteTermsNote,
|
||||||
|
listTermsNoteTypes,
|
||||||
|
};
|
||||||
32
src/modules/masters/terms-notes/terms-notes.validation.js
Normal file
32
src/modules/masters/terms-notes/terms-notes.validation.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
const Joi = require('joi');
|
||||||
|
const { listQuerySchema, toExportQuerySchema } = require('../_shared/masters.validation');
|
||||||
|
const { TERMS_NOTE_TYPES } = require('./terms-notes.constants');
|
||||||
|
|
||||||
|
const createSchema = Joi.object({
|
||||||
|
type: Joi.string()
|
||||||
|
.valid(...TERMS_NOTE_TYPES)
|
||||||
|
.required(),
|
||||||
|
notes: Joi.string().trim().min(1).required(),
|
||||||
|
is_active: Joi.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSchema = Joi.object({
|
||||||
|
notes: Joi.string().trim().min(1).optional(),
|
||||||
|
is_active: Joi.boolean().optional(),
|
||||||
|
}).min(1);
|
||||||
|
|
||||||
|
const listTermsNotesQuerySchema = listQuerySchema.keys({
|
||||||
|
type: Joi.string()
|
||||||
|
.valid(...TERMS_NOTE_TYPES)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportQuerySchema = toExportQuerySchema(listTermsNotesQuerySchema);
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createSchema,
|
||||||
|
updateSchema,
|
||||||
|
listQuerySchema: listTermsNotesQuerySchema,
|
||||||
|
exportQuerySchema,
|
||||||
|
TERMS_NOTE_TYPES,
|
||||||
|
};
|
||||||
@ -14,6 +14,13 @@ const list = asyncHandler(async (req, res) => {
|
|||||||
res.json(new ApiResponse(200, result.data, 'Purchase orders fetched', result.meta));
|
res.json(new ApiResponse(200, result.data, 'Purchase orders fetched', result.meta));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const listPendingApproval = asyncHandler(async (req, res) => {
|
||||||
|
const result = await service.listPendingApprovalPurchaseOrders(req.query);
|
||||||
|
res.json(
|
||||||
|
new ApiResponse(200, result.data, 'Purchase orders pending approval fetched', result.meta)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const exportCsv = asyncHandler(async (req, res) => {
|
const exportCsv = asyncHandler(async (req, res) => {
|
||||||
const csv = await service.exportPurchaseOrders(req.query);
|
const csv = await service.exportPurchaseOrders(req.query);
|
||||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
@ -117,6 +124,7 @@ const removeAttachment = asyncHandler(async (req, res) => {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
create,
|
create,
|
||||||
list,
|
list,
|
||||||
|
listPendingApproval,
|
||||||
exportCsv,
|
exportCsv,
|
||||||
getOne,
|
getOne,
|
||||||
update,
|
update,
|
||||||
|
|||||||
@ -24,6 +24,12 @@ router.get(
|
|||||||
validate(exportPurchaseOrdersQuerySchema, 'query'),
|
validate(exportPurchaseOrdersQuerySchema, 'query'),
|
||||||
controller.exportCsv
|
controller.exportCsv
|
||||||
);
|
);
|
||||||
|
router.get(
|
||||||
|
'/pending-approval',
|
||||||
|
authorize('PURCHASE_ORDER', 'approve'),
|
||||||
|
validate(listPurchaseOrdersQuerySchema, 'query'),
|
||||||
|
controller.listPendingApproval
|
||||||
|
);
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
authorize('PURCHASE_ORDER', 'view'),
|
authorize('PURCHASE_ORDER', 'view'),
|
||||||
|
|||||||
@ -289,7 +289,7 @@ const isInterStateSupply = (billingLocation, vendor) => {
|
|||||||
return placeOfSupply !== vendorState;
|
return placeOfSupply !== vendorState;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildHeaderData = async (payload, builtItems, userId) => {
|
const buildHeaderData = async (payload, builtItems, userId, { applyDefaultTerms = false } = {}) => {
|
||||||
const vendor = await assertReference('vendors', payload.vendor_id, 'vendor_id');
|
const vendor = await assertReference('vendors', payload.vendor_id, 'vendor_id');
|
||||||
const billing = await assertAnyLocation(payload.billing_id, 'billing_id');
|
const billing = await assertAnyLocation(payload.billing_id, 'billing_id');
|
||||||
await assertAnyLocation(payload.shipping_id, 'shipping_id');
|
await assertAnyLocation(payload.shipping_id, 'shipping_id');
|
||||||
@ -315,6 +315,16 @@ const buildHeaderData = async (payload, builtItems, userId) => {
|
|||||||
|
|
||||||
const gstSplit = splitGst(totals.tax_total, isInterStateSupply(billing, vendor));
|
const gstSplit = splitGst(totals.tax_total, isInterStateSupply(billing, vendor));
|
||||||
|
|
||||||
|
let termsAndConditions =
|
||||||
|
payload.terms_and_conditions !== undefined && payload.terms_and_conditions !== null
|
||||||
|
? String(payload.terms_and_conditions).trim()
|
||||||
|
: '';
|
||||||
|
if (applyDefaultTerms && !termsAndConditions) {
|
||||||
|
const { getDefaultNotesText } = require('../masters/terms-notes/terms-notes.service');
|
||||||
|
const defaultNotes = await getDefaultNotesText('PO');
|
||||||
|
if (defaultNotes) termsAndConditions = defaultNotes;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
po_date: toDateOnly(payload.po_date),
|
po_date: toDateOnly(payload.po_date),
|
||||||
vendor_id: BigInt(payload.vendor_id),
|
vendor_id: BigInt(payload.vendor_id),
|
||||||
@ -325,7 +335,7 @@ const buildHeaderData = async (payload, builtItems, userId) => {
|
|||||||
expected_delivery_date: payload.expected_delivery_date
|
expected_delivery_date: payload.expected_delivery_date
|
||||||
? toDateOnly(payload.expected_delivery_date)
|
? toDateOnly(payload.expected_delivery_date)
|
||||||
: null,
|
: null,
|
||||||
terms_and_conditions: payload.terms_and_conditions || null,
|
terms_and_conditions: termsAndConditions || null,
|
||||||
remarks: payload.remarks || null,
|
remarks: payload.remarks || null,
|
||||||
tds_applicable: tdsApplicable,
|
tds_applicable: tdsApplicable,
|
||||||
tds_section_pct: tdsApplicable ? tdsSectionPct : null,
|
tds_section_pct: tdsApplicable ? tdsSectionPct : null,
|
||||||
@ -449,7 +459,7 @@ const hasReceipts = (po) =>
|
|||||||
|
|
||||||
const createPurchaseOrder = async (payload, userId, requestId) => {
|
const createPurchaseOrder = async (payload, userId, requestId) => {
|
||||||
const builtItems = await validateAndBuildItems(payload.items);
|
const builtItems = await validateAndBuildItems(payload.items);
|
||||||
const header = await buildHeaderData(payload, builtItems, userId);
|
const header = await buildHeaderData(payload, builtItems, userId, { applyDefaultTerms: true });
|
||||||
const poNumber = await nextDocumentNumber('PO');
|
const poNumber = await nextDocumentNumber('PO');
|
||||||
|
|
||||||
header.po_number = poNumber;
|
header.po_number = poNumber;
|
||||||
@ -521,6 +531,10 @@ const listPurchaseOrders = async (query) => {
|
|||||||
return { data: rows.map(sanitizePo), meta: { page, limit, total } };
|
return { data: rows.map(sanitizePo), meta: { page, limit, total } };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Approver inbox — only POs in PENDING_APPROVAL. */
|
||||||
|
const listPendingApprovalPurchaseOrders = async (query) =>
|
||||||
|
listPurchaseOrders({ ...query, status: 'PENDING_APPROVAL' });
|
||||||
|
|
||||||
const exportPurchaseOrders = async (query) => {
|
const exportPurchaseOrders = async (query) => {
|
||||||
const rows = await prisma.purchase_orders.findMany({
|
const rows = await prisma.purchase_orders.findMany({
|
||||||
where: buildPurchaseOrdersWhere(query),
|
where: buildPurchaseOrdersWhere(query),
|
||||||
@ -915,6 +929,7 @@ const getPurchaseOrderPdf = async (id) => {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
createPurchaseOrder,
|
createPurchaseOrder,
|
||||||
listPurchaseOrders,
|
listPurchaseOrders,
|
||||||
|
listPendingApprovalPurchaseOrders,
|
||||||
exportPurchaseOrders,
|
exportPurchaseOrders,
|
||||||
getPurchaseOrderById,
|
getPurchaseOrderById,
|
||||||
updatePurchaseOrder,
|
updatePurchaseOrder,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ const PG_CODES = {
|
|||||||
|
|
||||||
const CONSTRAINT_MESSAGES = {
|
const CONSTRAINT_MESSAGES = {
|
||||||
item_categories_category_type_check: 'Invalid category type. Allowed values: STOCK, ASSET.',
|
item_categories_category_type_check: 'Invalid category type. Allowed values: STOCK, ASSET.',
|
||||||
|
terms_notes_type_check: 'Invalid terms_notes type. Allowed values: PO, INVOICE.',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TABLE_MESSAGES = {};
|
const TABLE_MESSAGES = {};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user