diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a2692e1..6cdc2e4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -560,6 +560,21 @@ model payment_terms { 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 { id BigInt @id @default(autoincrement()) module_id BigInt @@ -855,6 +870,8 @@ model users { password_reset_tokens password_reset_tokens[] 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") + 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_updated_byTousers locations[] @relation("locations_updated_byTousers") po_approvals po_approvals[] diff --git a/scripts/patch-terms-notes.sql b/scripts/patch-terms-notes.sql new file mode 100644 index 0000000..3c7ec8c --- /dev/null +++ b/scripts/patch-terms-notes.sql @@ -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; diff --git a/src/docs/completed-routes.yaml b/src/docs/completed-routes.yaml index 6e294d6..3b748ab 100644 --- a/src/docs/completed-routes.yaml +++ b/src/docs/completed-routes.yaml @@ -8,6 +8,7 @@ tags: - name: HSN Codes - name: Payment Terms - name: Delivery Terms + - name: Terms Notes - name: Departments - name: Designations - name: Locations @@ -301,6 +302,23 @@ components: name: { type: string, example: 'Free on Road' } description: { type: string, example: 'Delivered to site' } 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: type: object minProperties: 1 @@ -1174,6 +1192,103 @@ paths: description: Deleted content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } "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: get: tags: [Delivery Terms] diff --git a/src/docs/purchase-orders-routes.yaml b/src/docs/purchase-orders-routes.yaml index d69380f..a7211a2 100644 --- a/src/docs/purchase-orders-routes.yaml +++ b/src/docs/purchase-orders-routes.yaml @@ -110,6 +110,29 @@ paths: content: text/csv: 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: get: tags: [Purchase Orders] diff --git a/src/modules/masters/index.js b/src/modules/masters/index.js index 62c4be9..7cd76ef 100644 --- a/src/modules/masters/index.js +++ b/src/modules/masters/index.js @@ -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('/payment-terms', require('./payment-terms/payment-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('/designations', require('./designations/designations.routes')); router.use('/document-series', require('./document-series/document-series.routes')); diff --git a/src/modules/masters/terms-notes/terms-notes.constants.js b/src/modules/masters/terms-notes/terms-notes.constants.js new file mode 100644 index 0000000..1b58e84 --- /dev/null +++ b/src/modules/masters/terms-notes/terms-notes.constants.js @@ -0,0 +1,3 @@ +const TERMS_NOTE_TYPES = ['PO', 'INVOICE']; + +module.exports = { TERMS_NOTE_TYPES }; diff --git a/src/modules/masters/terms-notes/terms-notes.controller.js b/src/modules/masters/terms-notes/terms-notes.controller.js new file mode 100644 index 0000000..800ccdc --- /dev/null +++ b/src/modules/masters/terms-notes/terms-notes.controller.js @@ -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 }; diff --git a/src/modules/masters/terms-notes/terms-notes.routes.js b/src/modules/masters/terms-notes/terms-notes.routes.js new file mode 100644 index 0000000..39b34f0 --- /dev/null +++ b/src/modules/masters/terms-notes/terms-notes.routes.js @@ -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; diff --git a/src/modules/masters/terms-notes/terms-notes.service.js b/src/modules/masters/terms-notes/terms-notes.service.js new file mode 100644 index 0000000..8bf85fe --- /dev/null +++ b/src/modules/masters/terms-notes/terms-notes.service.js @@ -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, +}; diff --git a/src/modules/masters/terms-notes/terms-notes.validation.js b/src/modules/masters/terms-notes/terms-notes.validation.js new file mode 100644 index 0000000..7753db6 --- /dev/null +++ b/src/modules/masters/terms-notes/terms-notes.validation.js @@ -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, +}; diff --git a/src/modules/purchase-orders/purchase-orders.controller.js b/src/modules/purchase-orders/purchase-orders.controller.js index e7a1169..b298bac 100644 --- a/src/modules/purchase-orders/purchase-orders.controller.js +++ b/src/modules/purchase-orders/purchase-orders.controller.js @@ -14,6 +14,13 @@ const list = asyncHandler(async (req, res) => { 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 csv = await service.exportPurchaseOrders(req.query); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); @@ -117,6 +124,7 @@ const removeAttachment = asyncHandler(async (req, res) => { module.exports = { create, list, + listPendingApproval, exportCsv, getOne, update, diff --git a/src/modules/purchase-orders/purchase-orders.routes.js b/src/modules/purchase-orders/purchase-orders.routes.js index 2c581f5..106b5e3 100644 --- a/src/modules/purchase-orders/purchase-orders.routes.js +++ b/src/modules/purchase-orders/purchase-orders.routes.js @@ -24,6 +24,12 @@ router.get( validate(exportPurchaseOrdersQuerySchema, 'query'), controller.exportCsv ); +router.get( + '/pending-approval', + authorize('PURCHASE_ORDER', 'approve'), + validate(listPurchaseOrdersQuerySchema, 'query'), + controller.listPendingApproval +); router.get( '/', authorize('PURCHASE_ORDER', 'view'), diff --git a/src/modules/purchase-orders/purchase-orders.service.js b/src/modules/purchase-orders/purchase-orders.service.js index 986afc7..2e3a45c 100644 --- a/src/modules/purchase-orders/purchase-orders.service.js +++ b/src/modules/purchase-orders/purchase-orders.service.js @@ -289,7 +289,7 @@ const isInterStateSupply = (billingLocation, vendor) => { 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 billing = await assertAnyLocation(payload.billing_id, 'billing_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)); + 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 { po_date: toDateOnly(payload.po_date), vendor_id: BigInt(payload.vendor_id), @@ -325,7 +335,7 @@ const buildHeaderData = async (payload, builtItems, userId) => { expected_delivery_date: payload.expected_delivery_date ? toDateOnly(payload.expected_delivery_date) : null, - terms_and_conditions: payload.terms_and_conditions || null, + terms_and_conditions: termsAndConditions || null, remarks: payload.remarks || null, tds_applicable: tdsApplicable, tds_section_pct: tdsApplicable ? tdsSectionPct : null, @@ -449,7 +459,7 @@ const hasReceipts = (po) => const createPurchaseOrder = async (payload, userId, requestId) => { 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'); header.po_number = poNumber; @@ -521,6 +531,10 @@ const listPurchaseOrders = async (query) => { 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 rows = await prisma.purchase_orders.findMany({ where: buildPurchaseOrdersWhere(query), @@ -915,6 +929,7 @@ const getPurchaseOrderPdf = async (id) => { module.exports = { createPurchaseOrder, listPurchaseOrders, + listPendingApprovalPurchaseOrders, exportPurchaseOrders, getPurchaseOrderById, updatePurchaseOrder, diff --git a/src/utils/mapDbError.js b/src/utils/mapDbError.js index 63deeb1..015fd05 100644 --- a/src/utils/mapDbError.js +++ b/src/utils/mapDbError.js @@ -9,6 +9,7 @@ const PG_CODES = { const CONSTRAINT_MESSAGES = { 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 = {};