diff --git a/client/src/scenes/MemberPracticeSummaryReport/index.js b/client/src/scenes/MemberPracticeSummaryReport/index.js index 003aafb..fcec497 100644 --- a/client/src/scenes/MemberPracticeSummaryReport/index.js +++ b/client/src/scenes/MemberPracticeSummaryReport/index.js @@ -1,22 +1,92 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; -import { Container, Button, Loader } from 'semantic-ui-react'; +import { Container, Button, Loader, Input, Message, Grid } from 'semantic-ui-react'; import MainMenu from '../../components/MainMenu'; import { fetchMemberPracticeSummaryReport } from '../../store/actions'; class MemberPracticeSummaryReport extends Component { + + constructor(props) { + super(props); + + this.state = { + year: new Date().getFullYear(), + stateError: null, + } + } + + onGenerateReportClick = () => { + const {fetchMemberPracticeSummaryReport} = this.props; + const { year } = this.state; + + const currentYear = new Date().getFullYear(); + const parsedYear = parseInt(year); + + if (!parsedYear || isNaN(parsedYear)){ + this.setState({stateError: 'Year is not a number'}); + return; + } + + if (parsedYear > currentYear){ + this.setState({stateError: 'Selected year cannot be greater than current year'}); + return; + } + + fetchMemberPracticeSummaryReport(year); + }; + + onYearInputChange = (event, data) => { + let newYear = parseInt(data.value) + if (!newYear || isNaN(newYear)){ + newYear = new Date().getFullYear(); + } + this.setState({year: newYear, stateError: null}) + }; + render () { - const { fetchMemberPracticeSummaryReport, pendingReport } = this.props; + const { pendingReport, fetchReportError } = this.props; + const { year, stateError } = this.state; + + let error; + error = stateError ? stateError : null; + error = fetchReportError ? fetchReportError : error; + return (

Member Practice Summary Report



+ + + + + + + + + + {error && + + + + Error +
+

{error}

+
+
+
+ } +
-
); } @@ -24,10 +94,11 @@ class MemberPracticeSummaryReport extends Component { const mapStateToProps = (state) => ({ pendingReport: state.memberPracticeSummaryReport.pending, + fetchReportError: state.memberPracticeSummaryReport.error, }); const mapDispatchToProps = (dispatch) => ({ - fetchMemberPracticeSummaryReport: () => fetchMemberPracticeSummaryReport(dispatch), + fetchMemberPracticeSummaryReport: (year) => fetchMemberPracticeSummaryReport(dispatch, year), }); export default connect(mapStateToProps, mapDispatchToProps)(MemberPracticeSummaryReport); diff --git a/client/src/store/actions/integrationActions.js b/client/src/store/actions/integrationActions.js index a8bdb5d..d2b4d1f 100644 --- a/client/src/store/actions/integrationActions.js +++ b/client/src/store/actions/integrationActions.js @@ -113,15 +113,24 @@ export const checkProcessing = (dispatch) => { }); }; -export const fetchMemberPracticeSummaryReport = (dispatch) => { +export const fetchMemberPracticeSummaryReport = (dispatch, year) => { dispatch({type: FETCH_MEMBER_PRACTICE_SUMMARY_REPORT_PENDING}); - API.get('integration/report/practiceSummary', { + API.get(`integration/report/practiceSummary/${year}`, { responseType: 'blob', }) .then(response => { dispatch({type: FETCH_MEMBER_PRACTICE_SUMMARY_REPORT_SUCCESS, payload: response}); }) .catch(error => { - dispatch({type: FETCH_MEMBER_PRACTICE_SUMMARY_REPORT_FAILED, payload: error.response}); + let errorMessage = 'Error generating Member Practice Summary Report'; + switch (error.response.status) { + case 400: + errorMessage = 'Year cannot be greater than current year and it has to be a number'; + break; + case 500: + default: + break; + } + dispatch({type: FETCH_MEMBER_PRACTICE_SUMMARY_REPORT_FAILED, payload: errorMessage}); }); }; diff --git a/constants/constants.js b/constants/constants.js index 2ec13a6..1c9f12f 100644 --- a/constants/constants.js +++ b/constants/constants.js @@ -107,6 +107,18 @@ const BOOKING_CHANGE_PERCENTAGE_CHARGE = parseInt(process.env.BOOKING_CHANGE_PER const CHARGE_BOOKING_CHANGE_UNDER_TIME = parseInt(process.env.CHARGE_BOOKING_CHANGE_UNDER_TIME) || 1430; const ALLOWED_BOOKING_CANCELLATION_TIME = parseInt(process.env.ALLOWED_BOOKING_CANCELLATION_TIME) || 30; +const discounts = { + LEVEL_1:{ + hoursRequired: parseInt(process.env.DISCOUNT_LEVEL_1_HOURS) || 10, + percentage: parseInt(process.env.DISCOUNT_LEVEL_1_PERCENTAGE) || 5, + }, + LEVEL_2:{ + hoursRequired: parseInt(process.env.DISCOUNT_LEVEL_2_HOURS) || 40, + percentage: parseInt(process.env.DISCOUNT_LEVEL_2_PERCENTAGE) || 10, + } +}; +const DISCOUNT_PLANS = process.env.DISCOUNT_PLANS.split(',').map(planName => planName.trim()) || []; + module.exports = { VALID_CSV_HEADERS, USER_ENTRY_EVENT, @@ -127,4 +139,6 @@ module.exports = { BOOKING_CHANGE_PERCENTAGE_CHARGE, CHARGE_BOOKING_CHANGE_UNDER_TIME, ALLOWED_BOOKING_CANCELLATION_TIME, + discounts, + DISCOUNT_PLANS, }; diff --git a/controllers/integration.js b/controllers/integration.js index 5a8b354..c703d47 100644 --- a/controllers/integration.js +++ b/controllers/integration.js @@ -1,5 +1,7 @@ 'use strict'; +const moment = require('moment-timezone'); + const { getMappingsFromDatabase, fetchOffices, fetchResources, saveNewMappingToDatabase } = require('../services/officeRnD/resources'); const { getAllIncidents, getMemberPracticeSummaryReport } = require('../services/integration/reports'); const { getMembersFeesForDateRange } = require('../services/integration/invoiceIntegration'); @@ -7,6 +9,8 @@ const { deleteFeesFromORD, addFeesToORD } = require('../services/officeRnD/fees' const { checkBookingChanges } = require('../services/integration/checkBookingChange'); const { checkIfProcessing } = require('../services/integration/processingStatus'); +const { UI_TIMEZONE } = require('../constants/constants'); + const getKnownOfficeResourceMappings = (req, res) => { const dataToFetch = [getMappingsFromDatabase(), fetchOffices(), fetchResources() ]; @@ -127,7 +131,22 @@ const checkProcessingStatus = (req, res) => { }; const getPracticeSummaryReport = (req, res) => { - getMemberPracticeSummaryReport() + const year = req.params.year; + + const currentYear = moment.tz(UI_TIMEZONE).year(); + const parsedYear = parseInt(year); + + if (!parsedYear || isNaN(parsedYear)){ + res.status(400).send('Year is not a number'); + return; + } + + if (parsedYear > currentYear){ + res.status(400).send('Selected year cannot be greater than current year'); + return; + } + + getMemberPracticeSummaryReport(parsedYear) .then((result) => { const pathToDownloadFile = `${__dirname}/../${result.reportPath}`; res.download(pathToDownloadFile); diff --git a/environment.env b/environment.env index 2db303e..a3e73a1 100644 --- a/environment.env +++ b/environment.env @@ -26,6 +26,12 @@ ALLOWED_BOOKING_CANCELLATION_TIME=Time from creation (in minutes) in which cance SEQUELIZE_LOGGING=0 - false, 1 - true (console logging) +DISCOUNT_LEVEL_1_HOURS=Hours requred to apply DISCOUNT_LEVEL_1_PERCENTAGE discount +DISCOUNT_LEVEL_1_PERCENTAGE=Discount to apply in percentage, if DISCOUNT_LEVEL_1_HOURS of billable hours is booked +DISCOUNT_LEVEL_2_HOURS=Hours requred to apply DISCOUNT_LEVEL_2_PERCENTAGE discount +DISCOUNT_LEVEL_2_PERCENTAGE=Discount to apply in percentage, if DISCOUNT_LEVEL_2_HOURS of billable hours is booked +DISCOUNT_PLANS=Plan names for which discount is available. Comma-separated + #More about pool option : http://docs.sequelizejs.com/class/lib/sequelize.js~Sequelize.html DB_POOL_MAX_CONNECTIONS=Maximum number of connection in pool (ex. 18) DB_POOL_ACQUIRE=The maximum time, in milliseconds, that pool will try to get connection before throwing error (ex. 120000) diff --git a/routes/index.js b/routes/index.js index 47f0b46..27add39 100644 --- a/routes/index.js +++ b/routes/index.js @@ -33,7 +33,7 @@ router.post('/integration/addFees', addFees); router.get('/integration/processing', checkProcessingStatus); -router.get('/integration/report/practiceSummary', getPracticeSummaryReport); +router.get('/integration/report/practiceSummary/:year', getPracticeSummaryReport); diff --git a/services/integration/bookingChangeCharges.js b/services/integration/bookingChangeCharges.js index 9a29c28..a880225 100644 --- a/services/integration/bookingChangeCharges.js +++ b/services/integration/bookingChangeCharges.js @@ -142,7 +142,7 @@ const getChargedCanceledReservations = (reservationIds) => { incidentType: incidentType.BOOKING_CANCELED_LATE, }; - const attributes = ['memberId', 'oldBookingStart', 'oldBookingEnd']; + const attributes = ['memberId', 'oldBookingStart', 'oldBookingEnd', 'chargeFee']; return db.bookingChangeIncident.findAll({attributes, where: filters}); }; diff --git a/services/integration/bookings.js b/services/integration/bookings.js index 28ddec8..e672a73 100644 --- a/services/integration/bookings.js +++ b/services/integration/bookings.js @@ -48,9 +48,9 @@ const getActiveBookingsForMembersInDateRange = (dateRange, memberIds) => { }); }; -const getAllBookingsForYear = (year) => { - const startDate = moment.tz(year, 'YYYY', UI_TIMEZONE).startOf('year'); - const endDate = moment.tz(year, 'YYYY', UI_TIMEZONE).endOf('year'); +const getAllBookingsForMembersInDateRange = (dateRange, memberIds) => { + const startDate = moment.tz(dateRange.startDate, DEFAULT_DATE_FORMAT, UI_TIMEZONE).startOf('day'); + const endDate = moment.tz(dateRange.endDate, DEFAULT_DATE_FORMAT, UI_TIMEZONE).endOf('day'); const attributes = [ 'id', @@ -76,6 +76,12 @@ const getAllBookingsForYear = (year) => { }; } + if (memberIds && Array.isArray(memberIds) && memberIds.length > 0){ + filters.memberId = { + [Op.in]: memberIds + }; + } + return db.bookingReservation.findAll({ attributes, where: filters, @@ -84,5 +90,5 @@ const getAllBookingsForYear = (year) => { module.exports = { getActiveBookingsForMembersInDateRange, - getAllBookingsForYear, + getAllBookingsForMembersInDateRange, }; diff --git a/services/integration/invoiceIntegration.js b/services/integration/invoiceIntegration.js index a19d548..4c63322 100644 --- a/services/integration/invoiceIntegration.js +++ b/services/integration/invoiceIntegration.js @@ -3,11 +3,13 @@ const moment = require('moment-timezone'); const { getAllIncidents } = require('./reports'); -const { getActiveBookingsForMembersInDateRange } = require('./bookings'); +const { getAllBookingsForMembersInDateRange } = require('./bookings'); const { DEFAULT_DATE_FORMAT, UI_TIMEZONE, incidentTypeExplanations, incidentType, unlockedIncidentLevelsPrices } = require('../../constants/constants'); const { getResourceMappings } = require('../officeRnD/resources'); const { fetchAllMembers } = require('../officeRnD/members'); +const { fetchAllMembershipsAsMap } = require('../officeRnD/memberships'); +const { discounts, DISCOUNT_PLANS } = require('../../constants/constants'); const createFeeFromIncident = (incident) => { const { @@ -222,17 +224,81 @@ const createFeeFromBooking = (booking, resourceMappings) => { } }; +const createNegativeFeeForDiscount = (memberData, dateRange) => { + const { bookingData, member, membershipFees } = memberData; + const { totalBookedHours, totalChargedHours, totalBookingChargedFee } = bookingData; + const { memberId, officeId } = member; + + let endDate = moment.utc().endOf('day').toISOString(); + if (dateRange.endDate){ + endDate = moment.utc(dateRange.endDate, DEFAULT_DATE_FORMAT).endOf('day').toISOString(); + } + + let membershipFeeForDiscount = 0; + membershipFees.forEach((membershipFee) => { + const {name, price} = membershipFee; + if (DISCOUNT_PLANS.indexOf(name) !== -1){ + membershipFeeForDiscount = price; + } + }); + + const totalChargeFee = membershipFeeForDiscount + totalBookingChargedFee; + + let discount = 0; + let discountPercentage = 0; + + if (totalChargedHours >= discounts.LEVEL_2.hoursRequired){ + discountPercentage = discounts.LEVEL_2.percentage; + const discountRate = discountPercentage / 100; + discount = totalChargeFee * discountRate; + }else if (totalChargedHours >= discounts.LEVEL_1.hoursRequired){ + discountPercentage = discounts.LEVEL_1.percentage; + const discountRate = discountPercentage / 100; + discount = totalChargeFee * discountRate; + }else{ + return null; + } + + const formattedName = `[Discount] Total booked : ${totalBookedHours.toFixed(2)} hrs, Total charged : ${totalChargedHours.toFixed(2)} hrs, Discount : ${discountPercentage} %`; + + return { + name: formattedName, + price: -discount.toFixed(2), + quantity: 1, + date: endDate, + member: memberId, + team: null, + office: officeId, + isPersonal: false, + } +}; + const getMembersFeesForDateRange = (dateRange, memberIds) => { return new Promise((resolve, reject) => { - const collectData = [getAllIncidents(dateRange, memberIds), getActiveBookingsForMembersInDateRange(dateRange, memberIds), getResourceMappings(), fetchAllMembers()]; - + const collectData = [getAllIncidents(dateRange, memberIds), getAllBookingsForMembersInDateRange(dateRange, memberIds), getResourceMappings(), fetchAllMembers(), fetchAllMembershipsAsMap()]; Promise.all(collectData) .then((result) => { const allIncidents = result[0]; - const allActiveBookings = result[1]; + const allBookings = result[1]; const resourceMappings = result[2]; const membersList = result[3]; + const membershipsMap = result[4]; + + const membersMap = {}; + const oneMemberObject = { + totalBookedHours: 0, + totalChargedHours: 0, + totalBookingChargedFee: 0, + }; + + membersList.forEach((member) => { + membersMap[member.memberId] = { + member, + bookingData: Object.assign({}, oneMemberObject), + membershipFees: membershipsMap[member.memberId], + }; + }); const memberIdTeamMappings = {}; membersList.forEach((member) => { @@ -241,14 +307,127 @@ const getMembersFeesForDateRange = (dateRange, memberIds) => { const allFees = []; - allIncidents.forEach((incident) => allFees.push(createFeeFromIncident(incident))); - allActiveBookings.forEach((booking) => allFees.push(createFeeFromBooking(booking, resourceMappings))); + allIncidents.forEach((incident) => { + allFees.push(createFeeFromIncident(incident)); + + const incidentsValuableForDiscountCalculation = [ + incidentType.UNSCHEDULED_INCIDENT_BEFORE_RESERVATION, + incidentType.UNSCHEDULED_INCIDENT_AFTER_RESERVATION, + incidentType.UNSCHEDULED_INCIDENT_STANDALONE, + incidentType.BOOKING_SHORTENED, + incidentType.BOOKING_CANCELED_LATE + ]; + + const incidentTypeNumber = incident.incidentType; + if (incidentsValuableForDiscountCalculation.indexOf(incidentTypeNumber) === -1){ + return; + } + + const { + memberId, + oldBookingStartRaw, + oldBookingEndRaw, + newBookingStartRaw, + newBookingEndRaw, + unlockTimestampRaw, + lockTimestampRaw, + bookingStartRaw, + bookingEndRaw, + totalChargeFee + } = incident; + + let chargedBookingLength = 0; + + switch (incidentTypeNumber){ + case incidentType.UNSCHEDULED_INCIDENT_BEFORE_RESERVATION: + const unlockMoment = moment.utc(unlockTimestampRaw); + const bookingStartMoment =moment.utc(bookingStartRaw); + if (unlockMoment.isValid() && bookingStartMoment.isValid()){ + chargedBookingLength = bookingStartMoment.diff(unlockMoment, 'hours', true); + } + break; + case incidentType.UNSCHEDULED_INCIDENT_AFTER_RESERVATION: + const lockMoment = moment.utc(lockTimestampRaw); + const bookingEndMoment =moment.utc(bookingEndRaw); + if (lockMoment.isValid() && bookingEndMoment.isValid()){ + chargedBookingLength = lockMoment.diff(bookingEndMoment, 'hours', true); + } + break; + case incidentType.UNSCHEDULED_INCIDENT_STANDALONE: + const unlockMomentStandalone = moment.utc(unlockTimestampRaw); + const lockMomentStandalone = moment.utc(lockTimestampRaw); + if (unlockMomentStandalone.isValid() && lockMomentStandalone.isValid()){ + chargedBookingLength = lockMomentStandalone.diff(unlockMomentStandalone, 'hours', true); + } + break; + case incidentType.BOOKING_SHORTENED: + const oldBookingStartMoment = moment.utc(oldBookingStartRaw); + const oldBookingEndMoment = moment.utc(oldBookingEndRaw); + const newBookingStartMoment = moment.utc(newBookingStartRaw); + const newBookingEndMoment = moment.utc(newBookingEndRaw); + + if (oldBookingStartMoment.isValid() && oldBookingEndMoment.isValid() && newBookingStartMoment.isValid() && newBookingEndMoment.isValid()){ + const oldBookingLength = oldBookingEndMoment.diff(oldBookingStartMoment, 'hours', true); + const newBookingLength = newBookingEndMoment.diff(newBookingStartMoment, 'hours', true); + + chargedBookingLength = Math.abs(oldBookingLength - newBookingLength); + } + break; + case incidentType.BOOKING_CANCELED_LATE: + const startMoment = moment.utc(oldBookingStartRaw); + const endMoment = moment.utc(oldBookingEndRaw); + + if (startMoment.isValid() && endMoment.isValid()) { + chargedBookingLength = endMoment.diff(startMoment, 'hours', true); + + // membersMap[memberId].bookingData.totalBookedHours += bookingLength; + // "booked hours" is counted in canceled booking section + } + + break; + } + + membersMap[memberId].bookingData.totalChargedHours += chargedBookingLength; + membersMap[memberId].bookingData.totalBookingChargedFee += totalChargeFee; + }); + allBookings.forEach((booking) => { + const {memberId, start, end, timezone, hourlyRate, canceled } = booking.get(); + const startMoment = moment.tz(start, timezone); + const endMoment = moment.tz(end, timezone); + + if (startMoment.isValid() && endMoment.isValid()) { + const bookingLength = endMoment.diff(startMoment, 'hours', true); + + if (!membersMap[memberId] || !membersMap[memberId].bookingData) { + membersMap[memberId].bookingData = Object.assign({}, oneMemberObject); + } + + membersMap[memberId].bookingData.totalBookedHours += bookingLength; + + if (!canceled){ + membersMap[memberId].bookingData.totalChargedHours += bookingLength; + const bookingFee = bookingLength * hourlyRate; + membersMap[memberId].bookingData.totalBookingChargedFee += bookingFee; + + allFees.push(createFeeFromBooking(booking, resourceMappings)); + } + } + }); + + //add discount + memberIds.forEach((memberId) => { + const discountFee = createNegativeFeeForDiscount(membersMap[memberId], dateRange); + if (discountFee){ + allFees.push(discountFee); + } + }); allFees.forEach((fee) => { fee.team = memberIdTeamMappings[fee.member] || null; }); resolve(allFees); + }) .catch((error) => { console.log(error); diff --git a/services/integration/reports.js b/services/integration/reports.js index 4a4f054..ae79c34 100644 --- a/services/integration/reports.js +++ b/services/integration/reports.js @@ -10,7 +10,7 @@ const workbookCreator = require('excel4node'); const { checkBookingChanges } = require('./checkBookingChange'); const { incidentType, UI_TIMEZONE, DEFAULT_DATE_FORMAT, integrationServiceErrors } = require('../../constants/constants'); -const { getAllBookingsForYear } = require('./bookings'); +const { getAllBookingsForMembersInDateRange } = require('./bookings'); const { fetchAllMembers } = require('../officeRnD/members'); const { fetchOffices, fetchResources } = require('../officeRnD/resources'); const { getChargedCanceledReservations } = require('../integration/bookingChangeCharges'); @@ -318,11 +318,17 @@ const getAllIncidents = (dateRange, memberIds) => { }); }; -const getMemberPracticeSummaryReport = (res) => { +const getMemberPracticeSummaryReport = (year) => { return new Promise((resolve, reject) => { - const year = moment.tz(UI_TIMEZONE).year(); - const asyncJobs = [checkBookingChanges(), getAllBookingsForYear(year), fetchAllMembers()]; + const startDate = moment.tz(year, 'YYYY', UI_TIMEZONE).startOf('year').format(DEFAULT_DATE_FORMAT); + const endDate = moment.tz(year, 'YYYY', UI_TIMEZONE).endOf('year').format(DEFAULT_DATE_FORMAT); + const dateRange = { + startDate, + endDate, + }; + + const asyncJobs = [checkBookingChanges(), getAllBookingsForMembersInDateRange(dateRange), fetchAllMembers()]; Promise.all(asyncJobs) .then((results) => { diff --git a/services/officeRnD/members.js b/services/officeRnD/members.js index d1b9232..75945b6 100644 --- a/services/officeRnD/members.js +++ b/services/officeRnD/members.js @@ -14,6 +14,7 @@ const fetchAllMembers = () => { memberId: member['_id'], teamId: member.team, active: member.status === 'active', + officeId: member.office, }); }); cleanedResult.sort((member1, member2) => (member1.name > member2.name) ? 1 : -1 ); diff --git a/services/officeRnD/memberships.js b/services/officeRnD/memberships.js new file mode 100644 index 0000000..43774f0 --- /dev/null +++ b/services/officeRnD/memberships.js @@ -0,0 +1,35 @@ +'use strict'; + +const { API } = require('../../helpers/api'); + +const fetchAllMembershipsAsMap = () => { + return new Promise((resolve, reject) => { + API.get('/memberships') + .then((result) => { + const membershipsMap = {}; + const memberships = result.data || []; + memberships.forEach((membership) => { + const { price, name, member } = membership; + if (!membershipsMap[member]) { + membershipsMap[member] = [{ + price, + name, + }]; + }else{ + membershipsMap[member].push({ + price, + name, + }); + } + }); + resolve(membershipsMap); + }) + .catch((error) => { + reject(error); + }); + }); +}; + +module.exports = { + fetchAllMembershipsAsMap, +};