From 27483495cb7c568004090c4a6a704cad26102d48 Mon Sep 17 00:00:00 2001 From: "a.bouhuolia" Date: Mon, 14 Dec 2020 20:25:38 +0200 Subject: [PATCH] feat: closed sale receipt status. feat: approve and reject sale estimate. feat: initial receipts, invoices, estimates and bills views. --- .../api/controllers/Sales/SalesEstimates.ts | 87 +++++++++- .../api/controllers/Sales/SalesInvoices.ts | 6 +- .../api/controllers/Sales/SalesReceipts.ts | 41 +++++ .../20190822214903_create_views_table.js | 1 + ...0713192127_create_sales_estimates_table.js | 3 + ...200713213303_create_sales_receipt_table.js | 5 +- .../seeds/core/20200810121807_seed_views.js | 30 +++- .../core/20200810121808_seed_views_roles.js | 48 ++++-- server/src/interfaces/SaleReceipt.ts | 2 + .../lib/DynamicFilter/DynamicFilterViews.ts | 5 +- server/src/lib/ViewRolesBuilder/index.ts | 6 + server/src/models/Bill.js | 85 +++++++++- server/src/models/PaymentReceive.js | 50 +++++- server/src/models/SaleEstimate.js | 129 +++++++++++++- server/src/models/SaleInvoice.js | 158 +++++++++++++++++- server/src/models/SaleReceipt.js | 26 +++ server/src/repositories/ViewRepository.ts | 2 +- .../DynamicListing/DynamicListService.ts | 2 +- server/src/services/Purchases/BillPayments.ts | 1 - server/src/services/Sales/SalesEstimate.ts | 62 ++++++- server/src/services/Sales/SalesInvoices.ts | 11 +- server/src/services/Sales/SalesReceipts.ts | 101 +++++++---- server/src/services/Views/ViewsService.ts | 26 ++- 23 files changed, 801 insertions(+), 86 deletions(-) diff --git a/server/src/api/controllers/Sales/SalesEstimates.ts b/server/src/api/controllers/Sales/SalesEstimates.ts index 9be4fbb60..ba2ef006c 100644 --- a/server/src/api/controllers/Sales/SalesEstimates.ts +++ b/server/src/api/controllers/Sales/SalesEstimates.ts @@ -40,6 +40,24 @@ export default class SalesEstimatesController extends BaseController { asyncMiddleware(this.deliverSaleEstimate.bind(this)), this.handleServiceErrors, ); + router.post( + '/:id/approve', + [ + this.validateSpecificEstimateSchema, + ], + this.validationResult, + asyncMiddleware(this.approveSaleEstimate.bind(this)), + this.handleServiceErrors + ); + router.post( + '/:id/reject', + [ + this.validateSpecificEstimateSchema, + ], + this.validationResult, + asyncMiddleware(this.rejectSaleEstimate.bind(this)), + this.handleServiceErrors, + ) router.post( '/:id', [ ...this.validateSpecificEstimateSchema, @@ -202,8 +220,55 @@ export default class SalesEstimatesController extends BaseController { } } + /** + * Marks the sale estimate as approved. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async approveSaleEstimate(req: Request, res: Response, next: NextFunction) { + const { id: estimateId } = req.params; + const { tenantId } = req; + + try { + await this.saleEstimateService.approveSaleEstimate(tenantId, estimateId); + + return res.status(200).send({ + id: estimateId, + message: 'The sale estimate has been approved successfully.', + }); + } catch (error) { + next(error); + } + } + + /** + * Marks the sale estimate as rejected. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async rejectSaleEstimate(req: Request, res: Response, next: NextFunction) { + const { id: estimateId } = req.params; + const { tenantId } = req; + + try { + await this.saleEstimateService.rejectSaleEstimate(tenantId, estimateId); + + return res.status(200).send({ + id: estimateId, + message: 'The sale estimate has been rejected successfully.', + }); + } catch (error) { + next(error); + } + } + /** * Retrieve the given estimate with associated entries. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next */ async getEstimate(req: Request, res: Response, next: NextFunction) { const { id: estimateId } = req.params; @@ -302,11 +367,31 @@ export default class SalesEstimatesController extends BaseController { errors: [{ type: 'NOT_SELL_ABLE_ITEMS', code: 800 }], }); } - if (error.errorType === 'contact_not_found') { + if (error.errorType === 'SALE_ESTIMATE_ALREADY_APPROVED') { return res.boom.badRequest(null, { errors: [{ type: 'CUSTOMER_NOT_FOUND', code: 900 }], }); } + if (error.errorType === 'SALE_ESTIMATE_ALREADY_APPROVED') { + return res.boom.badRequest(null, { + errors: [{ type: 'CUSTOMER_NOT_FOUND', code: 1000 }], + }); + } + if (error.errorType === 'SALE_ESTIMATE_NOT_DELIVERED') { + return res.boom.badRequest(null, { + errors: [{ type: 'SALE_ESTIMATE_NOT_DELIVERED', code: 1100 }], + }); + } + if (error.errorType === 'SALE_ESTIMATE_ALREADY_REJECTED') { + return res.boom.badRequest(null, { + errors: [{ type: 'SALE_ESTIMATE_ALREADY_REJECTED', code: 1200 }], + }); + } + if (error.errorType === 'contact_not_found') { + return res.boom.badRequest(null, { + errors: [{ type: 'CUSTOMER_NOT_FOUND', code: 1300 }], + }); + } } next(error); } diff --git a/server/src/api/controllers/Sales/SalesInvoices.ts b/server/src/api/controllers/Sales/SalesInvoices.ts index 7a49699fc..c2d286401 100644 --- a/server/src/api/controllers/Sales/SalesInvoices.ts +++ b/server/src/api/controllers/Sales/SalesInvoices.ts @@ -264,7 +264,11 @@ export default class SaleInvoicesController extends BaseController{ } try { - const { salesInvoices, filterMeta, pagination } = await this.saleInvoiceService.salesInvoicesList( + const { + salesInvoices, + filterMeta, + pagination, + } = await this.saleInvoiceService.salesInvoicesList( tenantId, filter, ); return res.status(200).send({ diff --git a/server/src/api/controllers/Sales/SalesReceipts.ts b/server/src/api/controllers/Sales/SalesReceipts.ts index cbac69810..b3615ffed 100644 --- a/server/src/api/controllers/Sales/SalesReceipts.ts +++ b/server/src/api/controllers/Sales/SalesReceipts.ts @@ -22,6 +22,16 @@ export default class SalesReceiptsController extends BaseController{ router() { const router = Router(); + router.post( + '/:id/close', + [ + ...this.specificReceiptValidationSchema, + ], + this.validationResult, + asyncMiddleware(this.closeSaleReceipt.bind(this)), + this.handleServiceErrors, + ) + router.post( '/:id', [ ...this.specificReceiptValidationSchema, @@ -75,6 +85,7 @@ export default class SalesReceiptsController extends BaseController{ check('receipt_date').exists().isISO8601(), check('receipt_number').optional().trim().escape(), check('reference_no').optional().trim().escape(), + check('closed').default(false).isBoolean().toBoolean(), check('entries').exists().isArray({ min: 1 }), @@ -188,6 +199,31 @@ export default class SalesReceiptsController extends BaseController{ } } + /** + * Marks the given the sale receipt as closed. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async closeSaleReceipt(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { id: saleReceiptId } = req.params; + + try { + // Update the given sale receipt details. + await this.saleReceiptService.closeSaleReceipt( + tenantId, + saleReceiptId, + ); + return res.status(200).send({ + id: saleReceiptId, + message: 'Sale receipt has been closed successfully.', + }); + } catch (error) { + next(error); + } + } + /** * Listing sales receipts. * @param {Request} req @@ -296,6 +332,11 @@ export default class SalesReceiptsController extends BaseController{ errors: [{ type: 'SALE_RECEIPT_NUMBER_NOT_UNIQUE', code: 900 }], }); } + if (error.errorType === 'SALE_RECEIPT_IS_ALREADY_CLOSED') { + return res.boom.badRequest(null, { + errors: [{ type: 'SALE_RECEIPT_IS_ALREADY_CLOSED', code: 1000 }], + }); + } } next(error); } diff --git a/server/src/database/migrations/20190822214903_create_views_table.js b/server/src/database/migrations/20190822214903_create_views_table.js index 53099e854..eb3929c47 100644 --- a/server/src/database/migrations/20190822214903_create_views_table.js +++ b/server/src/database/migrations/20190822214903_create_views_table.js @@ -3,6 +3,7 @@ exports.up = function (knex) { return knex.schema.createTable('views', (table) => { table.increments(); table.string('name').index(); + table.string('slug').index(); table.boolean('predefined'); table.string('resource_model').index(); table.boolean('favourite'); diff --git a/server/src/database/migrations/20200713192127_create_sales_estimates_table.js b/server/src/database/migrations/20200713192127_create_sales_estimates_table.js index 0b385262a..43996996d 100644 --- a/server/src/database/migrations/20200713192127_create_sales_estimates_table.js +++ b/server/src/database/migrations/20200713192127_create_sales_estimates_table.js @@ -14,6 +14,9 @@ exports.up = function(knex) { table.text('send_to_email'); table.date('delivered_at').index(); + table.date('approved_at').index(); + table.date('rejected_at').index(); + table.integer('user_id').unsigned().index(); table.integer('converted_to_invoice_id').unsigned(); diff --git a/server/src/database/migrations/20200713213303_create_sales_receipt_table.js b/server/src/database/migrations/20200713213303_create_sales_receipt_table.js index 06b152596..f07d52e4c 100644 --- a/server/src/database/migrations/20200713213303_create_sales_receipt_table.js +++ b/server/src/database/migrations/20200713213303_create_sales_receipt_table.js @@ -6,11 +6,12 @@ exports.up = function(knex) { table.integer('deposit_account_id').unsigned().index().references('id').inTable('accounts'); table.integer('customer_id').unsigned().index().references('id').inTable('contacts'); table.date('receipt_date').index(); - table.string('receipt_number'); - table.string('reference_no'); + table.string('receipt_number').index(); + table.string('reference_no').index(); table.string('send_to_email'); table.text('receipt_message'); table.text('statement'); + table.date('closed_at').index(); table.timestamps(); }) }; diff --git a/server/src/database/seeds/core/20200810121807_seed_views.js b/server/src/database/seeds/core/20200810121807_seed_views.js index f2981437e..4c5495d6d 100644 --- a/server/src/database/seeds/core/20200810121807_seed_views.js +++ b/server/src/database/seeds/core/20200810121807_seed_views.js @@ -10,7 +10,7 @@ exports.up = (knex) => { .then(() => { // Inserts seed entries return knex('views').insert([ - // Accounts + // Accounts. { id: 15, name: i18n.__('Inactive'), roles_logic_expression: '1', resource_model: 'Account', predefined: true }, { id: 1, name: i18n.__('Assets'), roles_logic_expression: '1', resource_model: 'Account', predefined: true }, { id: 2, name: i18n.__('Liabilities'), roles_logic_expression: '1', resource_model: 'Account', predefined: true }, @@ -18,7 +18,7 @@ exports.up = (knex) => { { id: 4, name: i18n.__('Income'), roles_logic_expression: '1', resource_model: 'Account', predefined: true }, { id: 5, name: i18n.__('Expenses'), roles_logic_expression: '1', resource_model: 'Account', predefined: true }, - // Items + // Items. { id: 6, name: i18n.__('Services'), roles_logic_expression: '1', resource_model: 'Item', predefined: true }, { id: 7, name: i18n.__('Inventory'), roles_logic_expression: '1', resource_model: 'Item', predefined: true }, { id: 8, name: i18n.__('Non-Inventory'), roles_logic_expression: '1', resource_model: 'Item', predefined: true }, @@ -28,10 +28,34 @@ exports.up = (knex) => { { id: 10, name: i18n.__('Credit'), roles_logic_expression: '1', resource_model: 'ManualJournal', predefined: true }, { id: 11, name: i18n.__('Reconciliation'), roles_logic_expression: '1', resource_model: 'ManualJournal', predefined: true }, - // Expenses + // Expenses. { id: 12, name: i18n.__('Interest'), roles_logic_expression: '1', resource_model: 'Expense', predefined: false, }, { id: 13, name: i18n.__('Depreciation'), roles_logic_expression: '1', resource_model: 'Expense', predefined: false, }, { id: 14, name: i18n.__('Payroll'), roles_logic_expression: '1', resource_model: 'Expense', predefined: false }, + + // Sales invoices. + { id: 16, name: 'Draft', slug: 'draft', roles_logic_expression: '1', resource_model: 'SaleInvoice', predefined: true, }, + { id: 17, name: 'Delivered', slug: 'delivered', roles_logic_expression: '1', resource_model: 'SaleInvoice', predefined: true }, + { id: 18, name: 'Unpaid', slug: 'unpaid', roles_logic_expression: '1', resource_model: 'SaleInvoice', predefined: true }, + { id: 19, name: 'Overdue', slug: 'overdue', roles_logic_expression: '1', resource_model: 'SaleInvoice', predefined: true }, + { id: 20, name: 'Partially paid', slug: 'partially-paid', roles_logic_expression: '1', resource_model: 'SaleInvoice', predefined: true }, + { id: 21, name: 'Paid', slug: 'paid', roles_logic_expression: '1', resource_model: 'SaleInvoice', predefined: true }, + + // Bills. + { id: 22, name: 'Draft', slug: 'draft', roles_logic_expression: '1', resource_model: 'Bill', predefined: true, }, + { id: 23, name: 'Opened', slug: 'opened', roles_logic_expression: '1', resource_model: 'Bill', predefined: true }, + { id: 24, name: 'Unpaid', slug: 'unpaid', roles_logic_expression: '1', resource_model: 'Bill', predefined: true }, + { id: 25, name: 'Overdue', slug: 'overdue', roles_logic_expression: '1', resource_model: 'Bill', predefined: true }, + { id: 26, name: 'Partially paid', slug: 'partially-paid', roles_logic_expression: '1', resource_model: 'Bill', predefined: true }, + { id: 27, name: 'Paid', slug: 'paid', roles_logic_expression: '1', resource_model: 'Bill', predefined: true }, + + // Sale estimate. + { id: 28, name: 'Draft', slug: 'draft', roles_logic_expression: '1', resource_model: 'SaleEstimate', predefined: true }, + { id: 29, name: 'Delivered', slug: 'delivered', roles_logic_expression: '1', resource_model: 'SaleEstimate', predefined: true }, + { id: 30, name: 'Approved', slug: 'approved', roles_logic_expression: '1', resource_model: 'SaleEstimate', predefined: true }, + { id: 31, name: 'Rejected', slug: 'rejected', roles_logic_expression: '1', resource_model: 'SaleEstimate', predefined: true }, + { id: 32, name: 'Invoiced', slug: 'invoiced', roles_logic_expression: '1', resource_model: 'SaleEstimate', predefined: true }, + { id: 33, name: 'Expired', slug: 'expired', roles_logic_expression: '1', resource_model: 'SaleEstimate', predefined: true }, ]); }); }; diff --git a/server/src/database/seeds/core/20200810121808_seed_views_roles.js b/server/src/database/seeds/core/20200810121808_seed_views_roles.js index 5a9adaa6c..2983fef35 100644 --- a/server/src/database/seeds/core/20200810121808_seed_views_roles.js +++ b/server/src/database/seeds/core/20200810121808_seed_views_roles.js @@ -6,22 +6,46 @@ exports.up = (knex) => { // Inserts seed entries return knex('view_roles').insert([ // Accounts - { id: 1, field_key: 'type', index: 1, comparator: 'equals', value: 'asset', view_id: 1 }, - { id: 2, field_key: 'type', index: 1, comparator: 'equals', value: 'liability', view_id: 2 }, - { id: 3, field_key: 'type', index: 1, comparator: 'equals', value: 'equity', view_id: 3 }, - { id: 4, field_key: 'type', index: 1, comparator: 'equals', value: 'income', view_id: 4 }, - { id: 5, field_key: 'type', index: 1, comparator: 'equals', value: 'expense', view_id: 5 }, - { id: 12, field_key: 'active', index: 1, comparator: 'is', value: 1, view_id: 15 }, + { field_key: 'type', index: 1, comparator: 'equals', value: 'asset', view_id: 1 }, + { field_key: 'type', index: 1, comparator: 'equals', value: 'liability', view_id: 2 }, + { field_key: 'type', index: 1, comparator: 'equals', value: 'equity', view_id: 3 }, + { field_key: 'type', index: 1, comparator: 'equals', value: 'income', view_id: 4 }, + { field_key: 'type', index: 1, comparator: 'equals', value: 'expense', view_id: 5 }, + { field_key: 'active', index: 1, comparator: 'is', value: 1, view_id: 15 }, // Items. - { id: 6, field_key: 'type', index: 1, comparator: 'equals', value: 'service', view_id: 6 }, - { id: 7, field_key: 'type', index: 1, comparator: 'equals', value: 'inventory', view_id: 7 }, - { id: 8, field_key: 'type', index: 1, comparator: 'equals', value: 'non-inventory', view_id: 8 }, + { field_key: 'type', index: 1, comparator: 'equals', value: 'service', view_id: 6 }, + { field_key: 'type', index: 1, comparator: 'equals', value: 'inventory', view_id: 7 }, + { field_key: 'type', index: 1, comparator: 'equals', value: 'non-inventory', view_id: 8 }, // Manual Journals. - { id: 9, field_key: 'journal_type', index: 1, comparator: 'equals', value: 'Journal', view_id: 9 }, - { id: 10, field_key: 'journal_type', index: 1, comparator: 'equals', value: 'CreditNote', view_id: 10 }, - { id: 11, field_key: 'journal_type', index: 1, comparator: 'equals', value: 'Reconciliation', view_id: 11 }, + { field_key: 'journal_type', index: 1, comparator: 'equals', value: 'Journal', view_id: 9 }, + { field_key: 'journal_type', index: 1, comparator: 'equals', value: 'CreditNote', view_id: 10 }, + { field_key: 'journal_type', index: 1, comparator: 'equals', value: 'Reconciliation', view_id: 11 }, + + // Sale invoice. + { field_key: 'status', index: 1, comparator: 'is', value: 'draft', view_id: 16 }, + { field_key: 'status', index: 1, comparator: 'is', value: 'delivered', view_id: 17 }, + { field_key: 'status', index: 1, comparator: 'is', value: 'unpaid', view_id: 18 }, + { field_key: 'status', index: 1, comparator: 'is', value: 'overdue', view_id: 19 }, + { field_key: 'status', index: 1, comparator: 'is', value: 'partially-paid', view_id: 20 }, + { field_key: 'status', index: 1, comparator: 'is', value: 'paid', view_id: 21 }, + + // Bills + { field_key: 'status', index: 1, comparator: 'is', value: 'draft', view_id: 22 }, + { field_key: 'status', index: 1, comparator: 'is', value: 'opened', view_id: 23 }, + { field_key: 'status', index: 1, comparator: 'is', value: 'unpaid', view_id: 24 }, + { field_key: 'status', index: 1, comparator: 'is', value: 'overdue', view_id: 25 }, + { field_key: 'status', index: 1, comparator: 'is', value: 'partially-paid', view_id: 26 }, + { field_key: 'status', index: 1, comparator: 'is', value: 'paid', view_id: 27 }, + + // Sale estimates + { field_key: 'status', index: 1, comparator: 'is', value: 'draft', view_id: 28 }, + { field_key: 'status', index: 1, comparator: 'is', value: 'delivered', view_id: 29 }, + { field_key: 'status', index: 1, comparator: 'is', value: 'approved', view_id: 30 }, + { field_key: 'status', index: 1, comparator: 'is', value: 'rejected', view_id: 31 }, + { field_key: 'status', index: 1, comparator: 'is', value: 'invoiced', view_id: 32 }, + { field_key: 'status', index: 1, comparator: 'is', value: 'expired', view_id: 33 }, ]); }); }; diff --git a/server/src/interfaces/SaleReceipt.ts b/server/src/interfaces/SaleReceipt.ts index 3586c8055..bac286033 100644 --- a/server/src/interfaces/SaleReceipt.ts +++ b/server/src/interfaces/SaleReceipt.ts @@ -11,6 +11,7 @@ export interface ISaleReceipt { receiptMessage: string, receiptNumber: string, statement: string, + closedAt: Date|string, entries: any[], }; @@ -26,6 +27,7 @@ export interface ISaleReceiptDTO { referenceNo: string, receiptMessage: string, statement: string, + closed: boolean, entries: any[], }; diff --git a/server/src/lib/DynamicFilter/DynamicFilterViews.ts b/server/src/lib/DynamicFilter/DynamicFilterViews.ts index d8f2bfc84..392fdad61 100644 --- a/server/src/lib/DynamicFilter/DynamicFilterViews.ts +++ b/server/src/lib/DynamicFilter/DynamicFilterViews.ts @@ -49,8 +49,9 @@ export default class DynamicFilterViews extends DynamicFilterRoleAbstructor { this.responseMeta = { view: { logicExpression: this.logicExpression, - filterRoles: this.filterRoles - .map((filterRole) => ({ ...omit(filterRole, ['id', 'viewId']) })), + filterRoles: this.filterRoles.map((filterRole) => + ({ ...omit(filterRole, ['id', 'viewId']) }) + ), customViewId: this.viewId, } }; diff --git a/server/src/lib/ViewRolesBuilder/index.ts b/server/src/lib/ViewRolesBuilder/index.ts index c30f079f3..dfd997b3c 100644 --- a/server/src/lib/ViewRolesBuilder/index.ts +++ b/server/src/lib/ViewRolesBuilder/index.ts @@ -124,6 +124,12 @@ export function buildRoleQuery(model: IModel, role: IFilterRole) { const fieldRelation = getRoleFieldColumn(model, role.fieldKey); const comparatorColumn = fieldRelation.relationColumn || `${model.tableName}.${fieldRelation.column}`; + // + if (typeof fieldRelation.query !== 'undefined') { + return (builder) => { + fieldRelation.query(builder, role); + }; + } switch (fieldRelation.columnType) { case 'number': return numberRoleQueryBuilder(role, comparatorColumn); diff --git a/server/src/models/Bill.js b/server/src/models/Bill.js index f76d2b4be..40074de3c 100644 --- a/server/src/models/Bill.js +++ b/server/src/models/Bill.js @@ -2,6 +2,7 @@ import { Model, raw } from 'objection'; import moment from 'moment'; import { difference } from 'lodash'; import TenantModel from 'models/TenantModel'; +import { query } from 'winston'; export default class Bill extends TenantModel { /** @@ -17,9 +18,49 @@ export default class Bill extends TenantModel { static get modifiers() { return { + /** + * Filters the bills in draft status. + */ + draft(query) { + query.where('opened_at', null); + }, + /** + * Filters the opened bills. + */ + opened(query) { + query.whereNot('opened_at', null); + }, + /** + * Filters the unpaid bills. + */ + unpaid(query) { + query.where('payment_amount', 0); + }, + /** + * Filters the due bills. + */ dueBills(query) { query.where(raw('AMOUNT - PAYMENT_AMOUNT > 0')); - } + }, + /** + * Filters the overdue bills. + */ + overdue(query) { + query.where('due_date', '<', moment().format('YYYY-MM-DD')); + }, + /** + * Filters the partially paid bills. + */ + partiallyPaid(query) { + query.whereNot('payment_amount', 0); + query.whereNot(raw('`PAYMENT_AMOUNT` = `AMOUNT`')); + }, + /** + * Filters the paid bills. + */ + paid(query) { + query.where(raw('`PAYMENT_AMOUNT` = `AMOUNT`')); + }, } } @@ -34,7 +75,16 @@ export default class Bill extends TenantModel { * Virtual attributes. */ static get virtualAttributes() { - return ['dueAmount', 'isOpen', 'isPartiallyPaid', 'isFullyPaid', 'isPaid', 'remainingDays', 'overdueDays', 'isOverdue']; + return [ + 'dueAmount', + 'isOpen', + 'isPartiallyPaid', + 'isFullyPaid', + 'isPaid', + 'remainingDays', + 'overdueDays', + 'isOverdue', + ]; } /** @@ -181,11 +231,6 @@ export default class Bill extends TenantModel { static get fields() { return { - created_at: { - label: 'Created at', - column: 'created_at', - columnType: 'date', - }, vendor: { label: 'Vendor', column: 'vendor_id', @@ -216,7 +261,23 @@ export default class Bill extends TenantModel { }, status: { label: 'Status', - column: 'status', + options: [], + query: (query, role) => { + switch(role.value) { + case 'draft': + query.modify('draft'); break; + case 'opened': + query.modify('opened'); break; + case 'unpaid': + query.modify('unpaid'); break; + case 'overdue': + query.modify('overdue'); break; + case 'partially-paid': + query.modify('partiallyPaid'); break; + case 'paid': + query.modify('paid'); break; + } + }, }, amount: { label: 'Amount', @@ -234,6 +295,14 @@ export default class Bill extends TenantModel { label: 'Note', column: 'note', }, + user: { + + }, + created_at: { + label: 'Created at', + column: 'created_at', + columnType: 'date', + }, } } } diff --git a/server/src/models/PaymentReceive.js b/server/src/models/PaymentReceive.js index a796d5eb8..24f7d7ded 100644 --- a/server/src/models/PaymentReceive.js +++ b/server/src/models/PaymentReceive.js @@ -41,7 +41,6 @@ export default class PaymentReceive extends TenantModel { query.where('contact_service', 'customer'); } }, - depositAccount: { relation: Model.BelongsToOneRelation, modelClass: Account.default, @@ -50,7 +49,6 @@ export default class PaymentReceive extends TenantModel { to: 'accounts.id', }, }, - entries: { relation: Model.HasManyRelation, modelClass: PaymentReceiveEntry.default, @@ -59,7 +57,6 @@ export default class PaymentReceive extends TenantModel { to: 'payment_receives_entries.paymentReceiveId', }, }, - transactions: { relation: Model.HasManyRelation, modelClass: AccountTransaction.default, @@ -79,11 +76,58 @@ export default class PaymentReceive extends TenantModel { */ static get fields() { return { + customer: { + label: 'Customer', + column: 'customer_id', + fieldType: 'options', + optionsResource: 'customers', + optionsKey: 'id', + optionsLable: 'displayName', + }, + payment_date: { + label: 'Payment date', + column: 'payment_date', + columnType: 'date', + fieldType: 'date', + }, + amount: { + label: 'Amount', + column: 'amount', + columnType: 'number', + fieldType: 'number', + }, + reference_no: { + label: 'Reference No.', + column: 'reference_no', + columnType: 'string', + fieldType: 'text', + }, + deposit_acount: { + column: 'deposit_account_id', + lable: 'Deposit account', + relation: "accounts.id", + optionsResource: "account", + }, + payment_receive_no: { + label: 'Payment receive No.', + column: 'payment_receive_no', + columnType: 'string', + fieldType: 'text', + }, + description: { + label: 'description', + column: 'description', + columnType: 'string', + fieldType: 'text', + }, created_at: { label: 'Created at', column: 'created_at', columnType: 'date', }, + user: { + + }, }; } } diff --git a/server/src/models/SaleEstimate.js b/server/src/models/SaleEstimate.js index a50cfbb34..f83e016bd 100644 --- a/server/src/models/SaleEstimate.js +++ b/server/src/models/SaleEstimate.js @@ -2,6 +2,8 @@ import moment from 'moment'; import { Model } from 'objection'; import TenantModel from 'models/TenantModel'; import { defaultToTransform } from 'utils'; +import HasItemEntries from 'services/Sales/HasItemsEntries'; +import { query } from 'winston'; export default class SaleEstimate extends TenantModel { /** @@ -22,7 +24,13 @@ export default class SaleEstimate extends TenantModel { * Virtual attributes. */ static get virtualAttributes() { - return ['isDelivered', 'isExpired', 'isConvertedToInvoice']; + return [ + 'isDelivered', + 'isExpired', + 'isConvertedToInvoice', + 'isApproved', + 'isRejected' + ]; } /** @@ -53,6 +61,22 @@ export default class SaleEstimate extends TenantModel { ); } + /** + * Detarmines whether the estimate is approved. + * @return {boolean} + */ + get isApproved() { + return !!this.approvedAt; + } + + /** + * Detarmines whether the estimate is reject. + * @return {boolean} + */ + get isRejected() { + return !!this.rejectedAt; + } + /** * Allows to mark model as resourceable to viewable and filterable. */ @@ -60,6 +84,50 @@ export default class SaleEstimate extends TenantModel { return true; } + /** + * Model modifiers. + */ + static get modifiers() { + return { + /** + * Filters the drafted estimates transactions. + */ + draft(query) { + query.where('delivered_at', null); + }, + /** + * Filters the delivered estimates transactions. + */ + delivered(query) { + query.whereNot('delivered_at', null); + }, + /** + * Filters the expired estimates transactions. + */ + expired(query) { + query.where('expiration_date', '<', moment().format('YYYY-MM-DD')); + }, + /** + * Filters the rejected estimates transactions. + */ + rejected(query) { + query.whereNot('rejected_at', null); + }, + /** + * Filters the invoiced estimates transactions. + */ + invoiced(query) { + query.whereNot('converted_to_invoice_at', null); + }, + /** + * Filters the approved estimates transactions. + */ + approved(query) { + query.whereNot('approved_at', null) + }, + }; + } + /** * Relationship mapping. */ @@ -79,7 +147,6 @@ export default class SaleEstimate extends TenantModel { query.where('contact_service', 'customer'); } }, - entries: { relation: Model.HasManyRelation, modelClass: ItemEntry.default, @@ -99,6 +166,64 @@ export default class SaleEstimate extends TenantModel { */ static get fields() { return { + amount: { + label: 'Amount', + column: 'amount', + columnType: 'number', + fieldType: 'number', + }, + customer: { + label: 'Customer', + column: 'customer_id', + fieldType: 'options', + optionsResource: 'customers', + optionsKey: 'id', + optionsLable: 'displayName', + }, + estimate_date: { + label: 'Estimate date', + column: 'estimate_date', + columnType: 'date', + fieldType: 'date', + }, + expiration_date: { + label: 'Expiration date', + column: 'expiration_date', + columnType: 'date', + fieldType: 'date', + }, + note: { + label: 'Note', + column: 'note', + columnType: 'text', + fieldType: 'text', + }, + terms_conditions: { + label: 'Terms & conditions', + column: 'terms_conditions', + columnType: 'text', + fieldType: 'text', + }, + status: { + label: 'Status', + query: (query, role) => { + switch(role.value) { + case 'draft': + query.modify('draft'); break; + case 'delivered': + query.modify('delivered'); break; + case 'approved': + query.modify('approved'); break; + case 'rejected': + query.modify('rejected'); break; + case 'invoiced': + query.modify('invoiced'); + break; + case 'expired': + query.modify('expired'); break; + } + }, + }, created_at: { label: 'Created at', column: 'created_at', diff --git a/server/src/models/SaleInvoice.js b/server/src/models/SaleInvoice.js index ed63b41de..bb2beee26 100644 --- a/server/src/models/SaleInvoice.js +++ b/server/src/models/SaleInvoice.js @@ -2,6 +2,7 @@ import { Model, raw } from 'objection'; import moment from 'moment'; import TenantModel from 'models/TenantModel'; import { defaultToTransform } from 'utils'; +import { QueryBuilder } from 'knex'; export default class SaleInvoice extends TenantModel { /** @@ -29,7 +30,16 @@ export default class SaleInvoice extends TenantModel { * Virtual attributes. */ static get virtualAttributes() { - return ['dueAmount', 'isDelivered', 'isOverdue', 'isPartiallyPaid', 'isFullyPaid', 'isPaid', 'remainingDays', 'overdueDays']; + return [ + 'dueAmount', + 'isDelivered', + 'isOverdue', + 'isPartiallyPaid', + 'isFullyPaid', + 'isPaid', + 'remainingDays', + 'overdueDays', + ]; } /** @@ -118,10 +128,15 @@ export default class SaleInvoice extends TenantModel { */ static get modifiers() { return { + /** + * Filters the due invoices. + */ dueInvoices(query) { query.where(raw('BALANCE - PAYMENT_AMOUNT > 0')); }, - + /** + * Filters the invoices between the given date range. + */ filterDateRange(query, startDate, endDate, type = 'day') { const dateFormat = 'YYYY-MM-DD HH:mm:ss'; const fromDate = moment(startDate).startOf(type).format(dateFormat); @@ -134,16 +149,46 @@ export default class SaleInvoice extends TenantModel { query.where('invoice_date', '<=', toDate); } }, + /** + * Filters the invoices in draft status. + */ + draft(query) { + query.where('delivered_at', null); + }, + /** + * Filters the delivered invoices. + */ + delivered(query) { + query.whereNot('delivered_at', null); + }, + /** + * Filters the unpaid invoices. + */ + unpaid(query) { + query.where(raw('PAYMENT_AMOUNT = 0')); + }, + /** + * Filters the overdue invoices. + */ + overdue(query) { + query.where('due_date', '<', moment().format('YYYY-MM-DD')); + }, + /** + * Filters the partially invoices. + */ + partiallyPaid(query) { + query.whereNot('payment_amount', 0); + query.whereNot(raw('`PAYMENT_AMOUNT` = `BALANCE`')); + }, + /** + * Filters the paid invoices. + */ + paid(query) { + query.where(raw('PAYMENT_AMOUNT = BALANCE')); + } }; } - /** - * Due amount of the given. - */ - get dueAmount() { - return Math.max(this.balance - this.paymentAmount, 0); - } - /** * Relationship mapping. */ @@ -232,11 +277,106 @@ export default class SaleInvoice extends TenantModel { */ static get fields() { return { + customer: { + label: 'Customer', + column: 'customer_id', + fieldType: 'options', + optionsResource: 'customers', + optionsKey: 'id', + optionsLable: 'displayName', + }, + invoice_date: { + label: 'Invoice date', + column: 'invoice_date', + columnType: 'date', + fieldType: 'date', + }, + due_date: { + label: 'Due date', + column: 'due_date', + columnType: 'date', + fieldType: 'date', + }, + invoice_no: { + label: 'Invoice No.', + column: 'invoice_no', + columnType: 'number', + fieldType: 'number', + }, + refernece_no: { + label: 'Reference No.', + column: 'reference_no', + columnType: 'number', + fieldType: 'number', + }, + invoice_message: { + label: 'Invoice message', + column: 'invoice_message', + columnType: 'text', + fieldType: 'text', + }, + terms_conditions: { + label: 'Terms & conditions', + column: 'terms_conditions', + columnType: 'text', + fieldType: 'text', + }, + invoice_amount: { + label: 'Invoice amount', + column: 'invoice_amount', + columnType: 'number', + fieldType: 'number', + }, + payment_amount: { + label: 'Payment amount', + column: 'payment_amount', + columnType: 'number', + fieldType: 'number', + }, + due_amount: { + label: 'Due amount', + column: 'due_amount', + columnType: 'number', + fieldType: 'number', + }, created_at: { label: 'Created at', column: 'created_at', columnType: 'date', }, + status: { + label: 'Status', + options: [ + { key: 'draft', label: 'Draft', }, + { key: 'delivered', label: 'Delivered' }, + { key: 'unpaid', label: 'Unpaid' }, + { key: 'overdue', label: 'Overdue' }, + { key: 'partially-paid', label: 'Partially paid' }, + { key: 'paid', label: 'Paid' }, + ], + query: (query, role) => { + switch(role.value) { + case 'draft': + query.modify('draft'); + break; + case 'delivered': + query.modify('delivered'); + break; + case 'unpaid': + query.modify('unpaid'); + break; + case 'overdue': + query.modify('overdue'); + break; + case 'partially-paid': + query.modify('partiallyPaid'); + break; + case 'paid': + query.modify('paid'); + break; + } + }, + } }; } } diff --git a/server/src/models/SaleReceipt.js b/server/src/models/SaleReceipt.js index 33a2b1e8c..bd11916a6 100644 --- a/server/src/models/SaleReceipt.js +++ b/server/src/models/SaleReceipt.js @@ -16,6 +16,32 @@ export default class SaleReceipt extends TenantModel { return ['created_at', 'updated_at']; } + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return [ + 'isClosed', + 'isDraft', + ]; + } + + /** + * Detarmine whether the sale receipt closed. + * @return {boolean} + */ + get isClosed() { + return !!this.closedAt; + } + + /** + * Detarmines whether the sale receipt drafted. + * @return {boolean} + */ + get isDraft() { + return !this.closedAt; + } + /** * Relationship mapping. */ diff --git a/server/src/repositories/ViewRepository.ts b/server/src/repositories/ViewRepository.ts index 6be4f0e37..44e905010 100644 --- a/server/src/repositories/ViewRepository.ts +++ b/server/src/repositories/ViewRepository.ts @@ -14,6 +14,6 @@ export default class ViewRepository extends TenantRepository { * Retrieve all views of the given resource id. */ allByResource(resourceModel: string, withRelations?) { - return super.find({ resource_mode: resourceModel }, withRelations); + return super.find({ resource_model: resourceModel }, withRelations); } } \ No newline at end of file diff --git a/server/src/services/DynamicListing/DynamicListService.ts b/server/src/services/DynamicListing/DynamicListService.ts index 669aae5c9..27c21a4fb 100644 --- a/server/src/services/DynamicListing/DynamicListService.ts +++ b/server/src/services/DynamicListing/DynamicListService.ts @@ -39,7 +39,7 @@ export default class DynamicListService implements IDynamicListService { */ private async getCustomViewOrThrowError(tenantId: number, viewId: number, model: IModel) { const { viewRepository } = this.tenancy.repositories(tenantId); - const view = await viewRepository.findOneById(viewId); + const view = await viewRepository.findOneById(viewId, 'roles'); if (!view || view.resourceModel !== model.name) { throw new ServiceError(ERRORS.VIEW_NOT_FOUND); diff --git a/server/src/services/Purchases/BillPayments.ts b/server/src/services/Purchases/BillPayments.ts index aeeec28c4..8a3a52aed 100644 --- a/server/src/services/Purchases/BillPayments.ts +++ b/server/src/services/Purchases/BillPayments.ts @@ -25,7 +25,6 @@ import TenancyService from 'services/Tenancy/TenancyService'; import DynamicListingService from 'services/DynamicListing/DynamicListService'; import { entriesAmountDiff, formatDateFields } from 'utils'; import { ServiceError } from 'exceptions'; -import { Bill } from 'models'; const ERRORS = { BILL_VENDOR_NOT_FOUND: 'VENDOR_NOT_FOUND', diff --git a/server/src/services/Sales/SalesEstimate.ts b/server/src/services/Sales/SalesEstimate.ts index 8ae4a77ef..6ce5d7bac 100644 --- a/server/src/services/Sales/SalesEstimate.ts +++ b/server/src/services/Sales/SalesEstimate.ts @@ -21,7 +21,10 @@ const ERRORS = { SALE_ESTIMATE_NUMBER_EXISTANCE: 'SALE_ESTIMATE_NUMBER_EXISTANCE', ITEMS_IDS_NOT_EXISTS: 'ITEMS_IDS_NOT_EXISTS', SALE_ESTIMATE_ALREADY_DELIVERED: 'SALE_ESTIMATE_ALREADY_DELIVERED', - SALE_ESTIMATE_CONVERTED_TO_INVOICE: 'SALE_ESTIMATE_CONVERTED_TO_INVOICE' + SALE_ESTIMATE_CONVERTED_TO_INVOICE: 'SALE_ESTIMATE_CONVERTED_TO_INVOICE', + SALE_ESTIMATE_ALREADY_REJECTED: 'SALE_ESTIMATE_ALREADY_REJECTED', + SALE_ESTIMATE_ALREADY_APPROVED: 'SALE_ESTIMATE_ALREADY_APPROVED', + SALE_ESTIMATE_NOT_DELIVERED: 'SALE_ESTIMATE_NOT_DELIVERED' }; /** * Sale estimate service. @@ -352,4 +355,61 @@ export default class SaleEstimateService { deliveredAt: moment().toMySqlDateTime() }); } + + /** + * Mark the sale estimate as approved from the customer. + * @param {number} tenantId + * @param {number} saleEstimateId + */ + public async approveSaleEstimate( + tenantId: number, + saleEstimateId: number, + ): Promise { + const { SaleEstimate } = this.tenancy.models(tenantId); + + // Retrieve details of the given sale estimate id. + const saleEstimate = await this.getSaleEstimateOrThrowError(tenantId, saleEstimateId); + + // Throws error in case the sale estimate still not delivered to customer. + if (!saleEstimate.isDelivered) { + throw new ServiceError(ERRORS.SALE_ESTIMATE_NOT_DELIVERED); + } + // Throws error in case the sale estimate already approved. + if (saleEstimate.isApproved) { + throw new ServiceError(ERRORS.SALE_ESTIMATE_ALREADY_APPROVED); + } + await SaleEstimate.query().where('id', saleEstimateId).patch({ + approvedAt: moment().toMySqlDateTime(), + rejectedAt: null, + }); + } + + /** + * Mark the sale estimate as rejected from the customer. + * @param {number} tenantId + * @param {number} saleEstimateId + */ + public async rejectSaleEstimate( + tenantId: number, + saleEstimateId: number, + ): Promise { + const { SaleEstimate } = this.tenancy.models(tenantId); + + // Retrieve details of the given sale estimate id. + const saleEstimate = await this.getSaleEstimateOrThrowError(tenantId, saleEstimateId); + + // Throws error in case the sale estimate still not delivered to customer. + if (!saleEstimate.isDelivered) { + throw new ServiceError(ERRORS.SALE_ESTIMATE_NOT_DELIVERED); + } + // Throws error in case the sale estimate already rejected. + if (saleEstimate.isRejected) { + throw new ServiceError(ERRORS.SALE_ESTIMATE_ALREADY_REJECTED); + } + // Mark the sale estimate as reject on the storage. + await SaleEstimate.query().where('id', saleEstimateId).patch({ + rejectedAt: moment().toMySqlDateTime(), + approvedAt: null, + }); + } } \ No newline at end of file diff --git a/server/src/services/Sales/SalesInvoices.ts b/server/src/services/Sales/SalesInvoices.ts index 8c334d4d5..5b5297709 100644 --- a/server/src/services/Sales/SalesInvoices.ts +++ b/server/src/services/Sales/SalesInvoices.ts @@ -449,12 +449,19 @@ export default class SaleInvoicesService extends SalesInvoicesCost { public async salesInvoicesList( tenantId: number, salesInvoicesFilter: ISalesInvoicesFilter - ): Promise<{ salesInvoices: ISaleInvoice[], pagination: IPaginationMeta, filterMeta: IFilterMeta }> { + ): Promise<{ + salesInvoices: ISaleInvoice[], + pagination: IPaginationMeta, + filterMeta: IFilterMeta + }> { const { SaleInvoice } = this.tenancy.models(tenantId); const dynamicFilter = await this.dynamicListService.dynamicList(tenantId, SaleInvoice, salesInvoicesFilter); this.logger.info('[sale_invoice] try to get sales invoices list.', { tenantId, salesInvoicesFilter }); - const { results, pagination } = await SaleInvoice.query().onBuild((builder) => { + const { + results, + pagination, + } = await SaleInvoice.query().onBuild((builder) => { builder.withGraphFetched('entries'); builder.withGraphFetched('customer'); dynamicFilter.buildQuery()(builder); diff --git a/server/src/services/Sales/SalesReceipts.ts b/server/src/services/Sales/SalesReceipts.ts index 3041b8b86..f6ae797e4 100644 --- a/server/src/services/Sales/SalesReceipts.ts +++ b/server/src/services/Sales/SalesReceipts.ts @@ -1,11 +1,12 @@ import { omit, sumBy } from 'lodash'; import { Service, Inject } from 'typedi'; +import moment from 'moment'; import { EventDispatcher, EventDispatcherInterface, } from 'decorators/eventDispatcher'; import events from 'subscribers/events'; -import { ISaleReceipt } from 'interfaces'; +import { ISaleReceipt, ISaleReceiptDTO } from 'interfaces'; import JournalPosterService from 'services/Sales/JournalPosterService'; import TenancyService from 'services/Tenancy/TenancyService'; import { formatDateFields } from 'utils'; @@ -13,13 +14,15 @@ import { IFilterMeta, IPaginationMeta } from 'interfaces'; import DynamicListingService from 'services/DynamicListing/DynamicListService'; import { ServiceError } from 'exceptions'; import ItemsEntriesService from 'services/Items/ItemsEntriesService'; +import { ItemEntry } from 'models'; const ERRORS = { SALE_RECEIPT_NOT_FOUND: 'SALE_RECEIPT_NOT_FOUND', DEPOSIT_ACCOUNT_NOT_FOUND: 'DEPOSIT_ACCOUNT_NOT_FOUND', DEPOSIT_ACCOUNT_NOT_CURRENT_ASSET: 'DEPOSIT_ACCOUNT_NOT_CURRENT_ASSET', - SALE_RECEIPT_NUMBER_NOT_UNIQUE: 'SALE_RECEIPT_NUMBER_NOT_UNIQUE' + SALE_RECEIPT_NUMBER_NOT_UNIQUE: 'SALE_RECEIPT_NUMBER_NOT_UNIQUE', + SALE_RECEIPT_IS_ALREADY_CLOSED: 'SALE_RECEIPT_IS_ALREADY_CLOSED' }; @Service() export default class SalesReceiptService { @@ -102,6 +105,35 @@ export default class SalesReceiptService { } } + /** + * Transform DTO object to model object. + * @param {ISaleReceiptDTO} saleReceiptDTO - + * @param {ISaleReceipt} oldSaleReceipt - + * @returns {ISaleReceipt} + */ + transformObjectDTOToModel( + saleReceiptDTO: ISaleReceiptDTO, + oldSaleReceipt?: ISaleReceipt + ): ISaleReceipt { + const amount = sumBy(saleReceiptDTO.entries, e => ItemEntry.calcAmount(e)); + + return { + amount, + ...formatDateFields( + omit(saleReceiptDTO, ['closed', 'entries']), + ['receiptDate'] + ), + // Avoid rewrite the deliver date in edit mode when already published. + ...(saleReceiptDTO.closed && (!oldSaleReceipt?.closedAt)) && ({ + closedAt: moment().toMySqlDateTime(), + }), + entries: saleReceiptDTO.entries.map((entry) => ({ + reference_type: 'SaleReceipt', + ...omit(entry, ['id', 'amount']), + })), + }; + } + /** * Creates a new sale receipt with associated entries. * @async @@ -109,13 +141,10 @@ export default class SalesReceiptService { * @return {Object} */ public async createSaleReceipt(tenantId: number, saleReceiptDTO: any): Promise { - const { SaleReceipt, ItemEntry } = this.tenancy.models(tenantId); + const { SaleReceipt } = this.tenancy.models(tenantId); - const amount = sumBy(saleReceiptDTO.entries, e => ItemEntry.calcAmount(e)); - const saleReceiptObj = { - amount, - ...formatDateFields(saleReceiptDTO, ['receiptDate']) - }; + // Transform sale receipt DTO to model. + const saleReceiptObj = this.transformObjectDTOToModel(saleReceiptDTO); // Validate receipt deposit account existance and type. await this.validateReceiptDepositAccountExistance(tenantId, saleReceiptDTO.depositAccountId); @@ -131,15 +160,8 @@ export default class SalesReceiptService { await this.validateReceiptNumberUnique(tenantId, saleReceiptDTO.receiptNumber); } this.logger.info('[sale_receipt] trying to insert sale receipt graph.', { tenantId, saleReceiptDTO }); - const saleReceipt = await SaleReceipt.query() - .insertGraphAndFetch({ - ...omit(saleReceiptObj, ['entries']), + const saleReceipt = await SaleReceipt.query().insertGraphAndFetch({ ...saleReceiptObj }); - entries: saleReceiptObj.entries.map((entry) => ({ - reference_type: 'SaleReceipt', - ...omit(entry, ['id', 'amount']), - })) - }); await this.eventDispatcher.dispatch(events.saleReceipt.onCreated, { tenantId, saleReceipt }); this.logger.info('[sale_receipt] sale receipt inserted successfully.', { tenantId }); @@ -156,13 +178,11 @@ export default class SalesReceiptService { public async editSaleReceipt(tenantId: number, saleReceiptId: number, saleReceiptDTO: any) { const { SaleReceipt, ItemEntry } = this.tenancy.models(tenantId); - const amount = sumBy(saleReceiptDTO.entries, e => ItemEntry.calcAmount(e)); - const saleReceiptObj = { - amount, - ...formatDateFields(saleReceiptDTO, ['receiptDate']) - }; // Retrieve sale receipt or throw not found service error. const oldSaleReceipt = await this.getSaleReceiptOrThrowError(tenantId, saleReceiptId); + + // Transform sale receipt DTO to model. + const saleReceiptObj = this.transformObjectDTOToModel(saleReceiptDTO, oldSaleReceipt); // Validate receipt deposit account existance and type. await this.validateReceiptDepositAccountExistance(tenantId, saleReceiptDTO.depositAccountId); @@ -178,16 +198,10 @@ export default class SalesReceiptService { await this.validateReceiptNumberUnique(tenantId, saleReceiptDTO.receiptNumber, saleReceiptId); } - const saleReceipt = await SaleReceipt.query() - .upsertGraphAndFetch({ - id: saleReceiptId, - ...omit(saleReceiptObj, ['entries']), - - entries: saleReceiptObj.entries.map((entry) => ({ - reference_type: 'SaleReceipt', - ...omit(entry, ['amount']), - })) - }); + const saleReceipt = await SaleReceipt.query().upsertGraphAndFetch({ + id: saleReceiptId, + ...saleReceiptObj + }); this.logger.info('[sale_receipt] edited successfully.', { tenantId, saleReceiptId }); await this.eventDispatcher.dispatch(events.saleReceipt.onEdited, { @@ -265,4 +279,29 @@ export default class SalesReceiptService { filterMeta: dynamicFilter.getResponseMeta(), }; } + + /** + * Mark the given sale receipt as closed. + * @param {number} tenantId + * @param {number} saleReceiptId + * @return {Promise} + */ + async closeSaleReceipt( + tenantId: number, + saleReceiptId: number + ): Promise { + const { SaleReceipt } = this.tenancy.models(tenantId); + + // Retrieve sale receipt or throw not found service error. + const oldSaleReceipt = await this.getSaleReceiptOrThrowError(tenantId, saleReceiptId); + + // Throw service error if the sale receipt already closed. + if (oldSaleReceipt.isClosed) { + throw new ServiceError(ERRORS.SALE_RECEIPT_IS_ALREADY_CLOSED); + } + // Mark the sale receipt as closed on the storage. + await SaleReceipt.query().findById(saleReceiptId).patch({ + closedAt: moment().toMySqlDateTime(), + }); + } } diff --git a/server/src/services/Views/ViewsService.ts b/server/src/services/Views/ViewsService.ts index 5b882c069..334f37cd0 100644 --- a/server/src/services/Views/ViewsService.ts +++ b/server/src/services/Views/ViewsService.ts @@ -41,14 +41,17 @@ export default class ViewsService implements IViewsService { * @param {number} tenantId - * @param {string} resourceModel - */ - public async listResourceViews(tenantId: number, resourceModelName: string): Promise { + public async listResourceViews( + tenantId: number, + resourceModelName: string, + ): Promise { this.logger.info('[views] trying to retrieve resource views.', { tenantId, resourceModelName }); // Validate the resource model name is valid. const resourceModel = this.getResourceModelOrThrowError(tenantId, resourceModelName); const { viewRepository } = this.tenancy.repositories(tenantId); - return viewRepository.allByResource(resourceModel.name, ['columns', 'roles']); + return viewRepository.allByResource(resourceModel.name, 'roles'); } /** @@ -56,7 +59,10 @@ export default class ViewsService implements IViewsService { * @param {string} resourceName * @param {IViewRoleDTO[]} viewRoles */ - private validateResourceRolesFieldsExistance(ResourceModel: IModel, viewRoles: IViewRoleDTO[]) { + private validateResourceRolesFieldsExistance( + ResourceModel: IModel, + viewRoles: IViewRoleDTO[], + ) { const resourceFieldsKeys = getModelFieldsKeys(ResourceModel); const fieldsKeys = viewRoles.map(viewRole => viewRole.fieldKey); @@ -73,7 +79,10 @@ export default class ViewsService implements IViewsService { * @param {string} resourceName * @param {IViewColumnDTO[]} viewColumns */ - private validateResourceColumnsExistance(ResourceModel: IModel, viewColumns: IViewColumnDTO[]) { + private validateResourceColumnsExistance( + ResourceModel: IModel, + viewColumns: IViewColumnDTO[], + ) { const resourceFieldsKeys = getModelFieldsKeys(ResourceModel); const fieldsKeys = viewColumns.map((viewColumn: IViewColumnDTO) => viewColumn.fieldKey); @@ -118,7 +127,10 @@ export default class ViewsService implements IViewsService { * @param {number} tenantId * @param {number} resourceModel */ - private getResourceModelOrThrowError(tenantId: number, resourceModel: string): IModel { + private getResourceModelOrThrowError( + tenantId: number, + resourceModel: string, + ): IModel { return this.resourceService.getResourceModel(tenantId, resourceModel); } @@ -137,7 +149,9 @@ export default class ViewsService implements IViewsService { ): void { const { View } = this.tenancy.models(tenantId); - this.logger.info('[views] trying to validate view name uniqiness.', { tenantId, resourceModel, viewName }); + this.logger.info('[views] trying to validate view name uniqiness.', { + tenantId, resourceModel, viewName, + }); const foundViews = await View.query() .where('resource_model', resourceModel) .where('name', viewName)