import { transaction } from 'objection'; import * as Excel from 'exceljs'; import * as path from 'path'; import * as uuid from 'node-uuid'; import { RenovationProject, RenovationProjectTpl, ProjectStage, } from 'iworks-db-model'; import { getOr } from 'lodash/fp'; import { ICreateProject, ISelectProject, IProject, IJob, ProjectStatus, ProjectPhaseStatus, IProjectPhase, IUpdateProject, } from '../model/renovationProject'; import { ProjectStageStatus, IProjectStage, IJobInstance, IJobInstanceMaterial, } from '../model/projectStage'; import { ProjectTplStatus, } from '../model/renovationProjectTpl'; import { JobPriceType, } from '../model/jobTpl'; import { evenRound, jobPriceCalcStrategies, } from '../helpers/price'; // Create a new project based on Create parameters and Project template const getProjectData = ( { stateProperty, customer, jobs, managerId, ownerId, customerId, props, }: ICreateProject, renovationProjectTpl: any) : Partial => { if (!jobs || jobs.length === 0) { throw new Error('You should provide at least one job for the project'); } const date = (new Date()).toISOString(); const created = date; const updated = date; const statePropertyWithDates = Object.assign( { created, updated, id: uuid.v4(), }, stateProperty, ); const newOrExistCutomer = customerId && !customer ? { customerId } : { customer: Object.assign( { created, updated, id: uuid.v4(), }, customer, ), }; const jobMap: Map = jobs.reduce( (curMap, job) => { curMap.set(job.id, job); return curMap; }, new Map(), ); const projectId = uuid.v4(); return { ...newOrExistCutomer, created, updated, managerId, ownerId, props, id: projectId, stateProperty: statePropertyWithDates, status: ProjectStatus.created, renovationProjectTplId: renovationProjectTpl.id, projectPhases: renovationProjectTpl.phases .map((phase: any) => { const projectPhase = { created, updated, phaseId: phase.id, status: ProjectPhaseStatus.created, projectStages: phase.stages .map((stage: any) => { const projStage = { created, updated, stageId: stage.id, ownerId: managerId, status: ProjectStageStatus.created, jobInstances: stage.jobTplStages .filter((jobTplStage: any) => jobTplStage.jobTpl && jobMap.has(jobTplStage.jobTpl.id)) .map((jobTplStage: any) => { const jobTpl = jobTplStage.jobTpl; const job = jobMap.get(jobTpl.id); if (!job) { return { jobTplId: jobTpl.id, vol: 0, }; } const price = evenRound(jobPriceCalcStrategies.has(jobTpl.jobPriceType) ? jobPriceCalcStrategies.get(jobTpl.jobPriceType)(job.vol, jobTpl) : 0.0, 2); return { price, jobTplId: jobTpl.id, vol: job.vol, providerLinked: job.providerLinked, jobInstanceMaterials: jobTpl.jobTplMaterials.map((jobTplMaterial: any) => ({ consumption: jobTplMaterial.consumption, price: jobTplMaterial.material.price, materialId: jobTplMaterial.material.id, // unitValue: jobTplMaterial.material.unitValue, })), }; }), }; if (projStage.jobInstances.length === 0) { return null; } return projStage; }) .filter((projStage: any) => !!projStage), }; if (projectPhase.projectStages.length === 0) { return null; } return projectPhase; }) .filter((projPhase: any) => !!projPhase), }; }; export function createRenovationProject(createProjectDto: ICreateProject) : Promise> { const knex = RenovationProjectTpl.knex(); return transaction(knex, async (trx) => { const renovationProjectTpls = await RenovationProjectTpl .query(trx) .where('id', createProjectDto.renovationProjectTplId) .eager('phases.stages.jobTplStages.jobTpl.jobTplMaterials(onlyDefault).material', { onlyDefault: (builder: any) => { builder.where('default', true); }, }); if (renovationProjectTpls.length === 0) { throw new Error(`Can't find project template with id ${createProjectDto.renovationProjectTplId}`); } const renovationProjectTpl = renovationProjectTpls[0]; if (renovationProjectTpl.status === ProjectTplStatus.notactive) { throw new Error('Can`t create project for a not active template'); } const projectData = getProjectData(createProjectDto, renovationProjectTpl); const projWithPrice: Partial = await recalculateProjectPrice(projectData, trx); return RenovationProject .query(trx) .insertGraph({ ...projectData, ...projWithPrice, }); }); } export function updateRenovationProject(updateProjectDto: IUpdateProject) : Promise> { const knex = RenovationProject.knex(); const date = (new Date()).toISOString(); return transaction(knex, async (trx) => { const project = await RenovationProject .query(trx) .eager('projectPhases.projectStages.jobInstances.jobInstanceMaterials') .findById(updateProjectDto.id); if (!project) { throw new Error(`Project with id ${updateProjectDto.id} does not exist`); } // Change project stage if (updateProjectDto.managerId !== project.managerId && typeof updateProjectDto.managerId !== 'undefined') { const dataToPatch = { ownerId: updateProjectDto.managerId }; const stageIds = project.projectPhases.reduce( (aggr: number[], projPhase: IProjectPhase) => { return aggr.concat(projPhase.projectStages.map(projStage => projStage.id)); }, [], ); if (stageIds.length) { await ProjectStage .query(trx) .patch(dataToPatch) .whereIn('id', stageIds); } } const projWithPrice: Partial = await recalculateProjectPrice( { ...project, ...updateProjectDto, }, trx, ); return RenovationProject .query(trx) .upsertGraph({ ...updateProjectDto, ...projWithPrice, updated: date, }); }); } export function exportRenovationProject(selProjectDto: ISelectProject) { const knex = RenovationProject.knex(); return transaction(knex, async (trx) => { const currProject = await RenovationProject .query(trx) .eager('[customer, stateProperty, projectPhases.' + '[phase, projectStages.[stage, jobInstances.[jobInstanceMaterials.material, jobTpl]]]]') .findById(selProjectDto.id); if (!currProject || !currProject.customer || !currProject.stateProperty) { return false; } const customer = currProject.customer; const stateProperty = currProject.stateProperty; const workbook = new Excel.Workbook(); workbook.creator = 'Vmassive'; workbook.created = new Date(); const worksheet = workbook.addWorksheet('Калькуляция', { pageSetup: { paperSize: 9, orientation:'landscape' }, }); const headerFill: Excel.FillPattern = { fgColor: { argb: 'FFDFC5AD' }, type: 'pattern', pattern: 'solid', }; const phaseFill: Excel.FillPattern = { fgColor: { argb: 'FFE2CFBE' }, type: 'pattern', pattern: 'solid', }; const stageFill: Excel.FillPattern = { fgColor: { argb: 'FFEDDBCB' }, type: 'pattern', pattern: 'solid', }; const jobFill: Excel.FillPattern = { fgColor: { argb: 'FFF4E7DC' }, type: 'pattern', pattern: 'solid', }; const makeAll = (project: any) => { let sumJobsTotal = 0; let sumMaterialsTotal = 0; makeHeader(); project.projectPhases.map((projectPhase:any, phaseIndex:number) => { const phaseResult = makePhase(projectPhase, `${phaseIndex + 1}`); sumJobsTotal += phaseResult.sumJobsInPhase; sumMaterialsTotal += phaseResult.sumMaterialsInPhase; }); let agentFee = 0; if (project.fees) { agentFee = sumJobsTotal * (parseFloat(project.fees.tnFee) + parseFloat(project.fees.tnFee) + parseFloat(project.fees.tnFee)); } makeFooter(sumJobsTotal, sumMaterialsTotal, agentFee); }; const makeHeader = () => { worksheet.addRow([]); worksheet.addRow([ `Для клиента ${customer.firstName} ${customer.lastName}. ${stateProperty.address}`, ]).font = { bold: true }; worksheet.addRow(['Приложение № 1 Калькуляция к договору']).font = { bold: true }; worksheet.columns = [ { width: 10, style: { alignment: { vertical: 'bottom', horizontal: 'left', wrapText: true } } }, { width: 50, style: { alignment: { vertical: 'bottom', horizontal: 'left', wrapText: true } } }, { width: 10, style: { alignment: { vertical: 'bottom', horizontal: 'left', wrapText: true } } }, { width: 10, style: { alignment: { vertical: 'bottom', horizontal: 'left', wrapText: true } } }, { width: 15, style: { alignment: { vertical: 'bottom', horizontal: 'left', wrapText: true } } }, { width: 15, style: { alignment: { vertical: 'bottom', horizontal: 'left', wrapText: true } } }, { width: 15, style: { alignment: { vertical: 'bottom', horizontal: 'left', wrapText: true } } }, { width: 15, style: { alignment: { vertical: 'bottom', horizontal: 'left', wrapText: true } } }, { width: 15, style: { alignment: { vertical: 'bottom', horizontal: 'left', wrapText: true } } }, ]; const headerRow = worksheet.addRow([ 'Номер', 'Наименование работ и затрат', 'Ед. изм.', 'Кол-во', 'Стоимость еденицы работ, руб', 'Стоимость еденицы материала, руб', 'Сумма работ, руб', 'Сумма материалов, руб', 'Сумма всего, руб', ]); headerRow.height = 30; headerRow.fill = headerFill; headerRow.font = { bold: true, size: 10 }; }; const makePhase = (projectPhase: any, phaseNumber: string) => { const phaseRow = worksheet.addRow([ phaseNumber, projectPhase.phase.name, ]); phaseRow.font = { bold: true, size: 10 }; phaseRow.fill = phaseFill; let sumJobsInPhase = 0; let sumMaterialsInPhase = 0; projectPhase.projectStages.map((prStage:any, stageIndex:number) => { const stageNumber = `${phaseNumber}.${stageIndex + 1}`; const stageResult = makeStage(prStage, stageNumber); sumJobsInPhase += stageResult.sumJobsInStage; sumMaterialsInPhase += stageResult.sumMaterialsInStage; }); return { sumJobsInPhase, sumMaterialsInPhase, }; }; const makeStage = (projectStage: any, stageNumber:string) => { const stageRow = worksheet.addRow([ stageNumber, projectStage.stage.name, ]); stageRow.font = { bold: true, size: 9 }; stageRow.fill = stageFill; let sumJobsInStage = 0; let sumMaterialsInStage = 0; projectStage.jobInstances.map((jobInstance:any, jobIndex:number) => { const jobNumber = `${stageNumber}.${jobIndex + 1}`; sumJobsInStage += jobInstance.price * jobInstance.vol; const jobResult = makeJob(jobInstance, jobNumber); sumMaterialsInStage += jobResult.sumMaterials; }); stageRow.getCell(7).value = sumJobsInStage; stageRow.getCell(8).value = sumMaterialsInStage; stageRow.getCell(9).value = sumJobsInStage + sumMaterialsInStage; return { sumJobsInStage, sumMaterialsInStage, }; }; const makeJob = (jobInstance:any, jobNumber:string) => { const jobRow = worksheet.addRow([ jobNumber, jobInstance.jobTpl.name, jobInstance.jobTpl.unit, jobInstance.vol, jobInstance.price, '-', jobInstance.price * jobInstance.vol, '-', '-', ]); jobRow.font = { size: 9 }; jobRow.fill = jobFill; let sumMaterials = 0; jobInstance.jobInstanceMaterials.map((jobMaterial:any, materialIndex:number) => { // Materials const materialNumber = `${jobNumber}.${materialIndex + 1}`; makeMaterial(jobMaterial, jobInstance, materialNumber); sumMaterials += jobMaterial.price * jobMaterial.consumption * jobInstance.vol; }); jobRow.getCell(8).value = sumMaterials; jobRow.getCell(9).value = sumMaterials + parseFloat(jobRow.getCell(7).value.toString()); return { sumMaterials }; }; const makeMaterial = (jobMaterial:any, jobInstance:any, materialNumber:string) => { const materialRow = worksheet.addRow([ materialNumber, jobMaterial.material.name, '-', jobInstance.vol * jobMaterial.consumption, '-', jobMaterial.price, '-', jobMaterial.price * jobMaterial.consumption * jobInstance.vol, '-', ]); materialRow.font = { size: 9 }; }; const makeFooter = (sumJobsTotal:number, sumMaterialsTotal:number, agentFee:number) => { const totalRow = worksheet.addRow([ '', 'ИТОГО ЗА РАБОТЫ И МАТЕРИАЛЫ:', '', '', '', '', sumJobsTotal, sumMaterialsTotal, sumJobsTotal + sumMaterialsTotal, ]); totalRow.font = { size: 12, bold: true }; totalRow.fill = headerFill; const agentFeeRow = worksheet.addRow([ '', 'АГЕНТСКОЕ ВОЗНАГРАЖДЕНИЕ:', '', '', '', '', '', '', agentFee, ]); agentFeeRow.font = { size: 12, bold: true }; agentFeeRow.fill = headerFill; const overallRow = worksheet.addRow([ '', 'ОБЩАЯ СТОИМОСТЬ ПО ДОГОВОРУ:', '', '', '', '', '', '', sumJobsTotal + sumMaterialsTotal + agentFee, ]); overallRow.font = { size: 12, bold: true }; overallRow.fill = headerFill; }; makeAll(currProject); const filePath = path.normalize(`${__dirname}/../../excel/export${Date.now()}.xlsx`); await workbook.xlsx.writeFile(filePath); return filePath; }); } export function deleteRenovationProject(selProjectDto: ISelectProject) { const knex = RenovationProject.knex(); return transaction(knex, async (trx) => { const currProject = await RenovationProject .query(trx) .findById(selProjectDto.id); if (!currProject) { return false; } if (currProject.status !== ProjectStatus.created) { throw new Error(`Can't delete project with id ${selProjectDto.id}, it is in status ${currProject.status}`); } const numDeleted = await RenovationProject .query(trx) .where('id', selProjectDto.id) .delete(); return numDeleted > 0; }); } const jobInstanceMaterialPriceReducer = ( curStagePrice: number, jobInstance: Partial, ) => { return jobInstance.jobInstanceMaterials .reduce( (curMaterialPrice: number, jobInstanceMaterial: Partial) => { const unitValue = jobInstanceMaterial.unitValue || 1; return evenRound( curMaterialPrice + jobInstanceMaterial.price * jobInstanceMaterial.consumption * unitValue * jobInstance.vol, 2, ); }, 0.0, ); }; const jobInstanceWorkPriceReducer = ( curStagePrice: number, jobInstance: Partial, ) => { return evenRound( curStagePrice + jobInstance.price, 2, ); }; const calculateSummaryPrice = ( project: Partial, jobInstancePriceReducer: (currPrice: number, jobInstance: Partial) => number, ) => { return project.projectPhases .reduce( (curProjPrice: number, projPhase: Partial) => { // Add current phase price const nextProjPrice: number = evenRound( curProjPrice + projPhase.projectStages .reduce( (curPhasePrice: number, projStage: Partial) => { // Add current stage price const nextPhasePrice: number = evenRound( curPhasePrice + projStage.jobInstances .reduce( (curStagePrice: number, jobInstance: Partial) => { return jobInstancePriceReducer(curStagePrice, jobInstance); }, 0.0, ), 2, ); return nextPhasePrice; }, 0.0, ), 2, ); return nextProjPrice; }, 0.0, ); }; export async function recalculateProjectPrice(project: string | Partial, trx: any): Promise> { const projectId = typeof project === 'string' ? project : project.id; let currProject: Partial; if (typeof project === 'string') { currProject = await RenovationProject .query(trx) .eager('projectPhases.projectStages.jobInstances.jobInstanceMaterials') .findById(projectId); } else { currProject = project; } const materialsDiscount: number = getOr(0, ['props', 'fees', 'materialsDiscount'], currProject); const jobsMulty: number = getOr(1, ['props', 'fees', 'jobPriceMultiplicator'], currProject); const materialsPrice: number = evenRound( calculateSummaryPrice(currProject, jobInstanceMaterialPriceReducer) * (1 - materialsDiscount / 100), 2, ); const workPrice: number = evenRound( calculateSummaryPrice(currProject, jobInstanceWorkPriceReducer) * jobsMulty, 2, ); const agentFee: number = getOr(0, ['props', 'fees', 'agentFee'], currProject); const tnFee: number = getOr(0, ['props', 'fees', 'tnFee'], currProject); const pmFee: number = getOr(0, ['props', 'fees', 'pmFee'], currProject); const price = evenRound(materialsPrice + workPrice, 2); const totalPrice: number = evenRound( workPrice * (1 + (agentFee + tnFee + pmFee) / 100) + materialsPrice * (1 + pmFee / 100), 2, ); return { price, totalPrice, }; }