From 30a3c4dc2f34020c38a3349c2272dadfcfeeeb45 Mon Sep 17 00:00:00 2001 From: Jon Iles Date: Thu, 25 Jul 2024 11:41:53 +0100 Subject: [PATCH] P6 Timephased Data (#724) --- src/changes/changes.xml | 2 + .../java/net/sf/mpxj/ResourceAssignment.java | 29 +++- .../java/net/sf/mpxj/mspdi/MSPDIWriter.java | 5 +- .../mpxj/primavera/AbstractUnitsHelper.java | 2 - .../net/sf/mpxj/primavera/CurveHelper.java | 19 ++- .../mpxj/primavera/PrimaveraPMFileReader.java | 15 +- .../primavera/PrimaveraPMProjectWriter.java | 10 ++ .../sf/mpxj/primavera/PrimaveraReader.java | 16 +- .../primavera/PrimaveraXERFileWriter.java | 28 ++- .../sf/mpxj/primavera/TimephasedHelper.java | 159 ++++++++++++++++++ 10 files changed, 268 insertions(+), 17 deletions(-) create mode 100644 src/main/java/net/sf/mpxj/primavera/TimephasedHelper.java diff --git a/src/changes/changes.xml b/src/changes/changes.xml index f5d6eb4a49..8363df9098 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -7,6 +7,8 @@ Updated to POI 5.3.0 + Add support for reading and writing timephased data for activities in P6 schedules which have a "manual" curve. (Note: MPXJ does not currently support translating timephased data between different applications, so timephased data read from an MPP file won't be written to a P6 schedule and vice versa). + Add an attribute to the `ResourceAssignment` class to represent timephased planned work. This is read from/written to P6 as Budgeted Work. Update Phoenix schemas to ensure that cost types are represented as doubles. Updated to avoid reading apparently invalid resources from Project Commander files. Correct the `Finish` attribute for resource assignments when reading PMXML files. diff --git a/src/main/java/net/sf/mpxj/ResourceAssignment.java b/src/main/java/net/sf/mpxj/ResourceAssignment.java index cd1c356cc1..cb5d469465 100644 --- a/src/main/java/net/sf/mpxj/ResourceAssignment.java +++ b/src/main/java/net/sf/mpxj/ResourceAssignment.java @@ -655,6 +655,28 @@ public void setRateSource(RateSource source) set(AssignmentField.RATE_SOURCE, source); } + /** + * Retrieves the timephased breakdown of the planned work for this + * resource assignment. + * + * @return timephased planned work + */ + public List getTimephasedPlannedWork() + { + return m_timephasedPlannedWork == null ? null : m_timephasedPlannedWork.getData(); + } + + /** + * Sets the timephased breakdown of the planned work for this + * resource assignment. + * + * @param data timephased data + */ + public void setTimephasedPlannedWork(TimephasedWorkContainer data) + { + m_timephasedPlannedWork = data; + } + /** * Retrieves the timephased breakdown of the completed work for this * resource assignment. @@ -3210,12 +3232,15 @@ private Boolean defaultCalculateCostsFromUnits() return Boolean.TRUE; } - private TimephasedWorkContainer m_timephasedWork; - private TimephasedCostContainer m_timephasedCost; + private TimephasedWorkContainer m_timephasedPlannedWork; + private TimephasedCostContainer m_timephasedPlannedCost; private TimephasedWorkContainer m_timephasedActualWork; private TimephasedCostContainer m_timephasedActualCost; + private TimephasedWorkContainer m_timephasedWork; + private TimephasedCostContainer m_timephasedCost; + private TimephasedWorkContainer m_timephasedOvertimeWork; private TimephasedWorkContainer m_timephasedActualOvertimeWork; diff --git a/src/main/java/net/sf/mpxj/mspdi/MSPDIWriter.java b/src/main/java/net/sf/mpxj/mspdi/MSPDIWriter.java index ba257025a1..6d79b3345b 100644 --- a/src/main/java/net/sf/mpxj/mspdi/MSPDIWriter.java +++ b/src/main/java/net/sf/mpxj/mspdi/MSPDIWriter.java @@ -233,6 +233,7 @@ public SaveVersion getSaveVersion() m_extendedAttributes = getExtendedAttributesList(); m_sourceIsMicrosoftProject = MICROSOFT_PROJECT_FILES.contains(m_projectFile.getProjectProperties().getFileType()); + m_sourceIsPrimavera = "Primavera".equals(m_projectFile.getProjectProperties().getFileApplication()); m_userDefinedFieldMap = new UserDefinedFieldMap(projectFile, MAPPING_TARGET_CUSTOM_FIELDS); m_taskMapper = new MicrosoftProjectUniqueIDMapper(m_projectFile.getTasks()); @@ -2191,7 +2192,7 @@ private void writeAssignmentExtendedAttribute(List assignments) for (ResourceAssignmentType row : assignments) { Task task = m_projectFile.getTaskByUniqueID(m_activityClashMap.getID(row.getActivityObjectId())); + ProjectCalendar effectiveCalendar = task.getEffectiveCalendar(); Integer roleID = m_roleClashMap.getID(row.getRoleObjectId()); Integer resourceID = row.getResourceObjectId(); @@ -1897,7 +1899,7 @@ private void processAssignments(List assignments) assignment.setGUID(DatatypeConverter.parseUUID(row.getGUID())); assignment.setActualOvertimeCost(row.getActualOvertimeCost()); assignment.setActualOvertimeWork(getDuration(row.getActualOvertimeUnits())); - assignment.setWorkContour(m_projectFile.getWorkContours().getByUniqueID(row.getResourceCurveObjectId())); + assignment.setWorkContour(CurveHelper.getWorkContour(m_projectFile, row.getResourceCurveObjectId())); assignment.setRateIndex(RateTypeHelper.getInstanceFromXml(row.getRateType())); assignment.setRole(m_projectFile.getResourceByUniqueID(roleID)); assignment.setOverrideRate(readRate(row.getCostPerQuantity())); @@ -1913,7 +1915,7 @@ private void processAssignments(List assignments) // calculate work Duration remainingWork = assignment.getRemainingWork(); Duration actualWork = assignment.getActualWork(); - Duration totalWork = Duration.add(actualWork, remainingWork, assignment.getEffectiveCalendar()); + Duration totalWork = Duration.add(actualWork, remainingWork, effectiveCalendar); assignment.setWork(totalWork); // calculate cost @@ -1931,8 +1933,17 @@ private void processAssignments(List assignments) assignment.setUnits(Double.valueOf(NumberHelper.getDouble(row.getPlannedUnitsPerTime()) * 100)); assignment.setRemainingUnits(Double.valueOf(NumberHelper.getDouble(row.getRemainingUnitsPerTime()) * 100)); + // Add User Defined Fields populateUserDefinedFieldValues(assignment, row.getUDF()); + // Read timephased data + TimephasedWorkContainer timephasedPlannedWork = TimephasedHelper.read(effectiveCalendar, assignment.getPlannedStart(), row.getPlannedCurve()); + TimephasedWorkContainer timephasedActualWork = TimephasedHelper.read(effectiveCalendar, assignment.getActualStart(), row.getActualCurve()); + TimephasedWorkContainer timephasedRemainingWork = TimephasedHelper.read(effectiveCalendar, assignment.getRemainingEarlyStart(), row.getRemainingCurve()); + assignment.setTimephasedPlannedWork(timephasedPlannedWork); + assignment.setTimephasedActualWork(timephasedActualWork); + assignment.setTimephasedWork(timephasedRemainingWork); + m_eventManager.fireAssignmentReadEvent(assignment); } } diff --git a/src/main/java/net/sf/mpxj/primavera/PrimaveraPMProjectWriter.java b/src/main/java/net/sf/mpxj/primavera/PrimaveraPMProjectWriter.java index 432259d4d6..ecbf9ea184 100644 --- a/src/main/java/net/sf/mpxj/primavera/PrimaveraPMProjectWriter.java +++ b/src/main/java/net/sf/mpxj/primavera/PrimaveraPMProjectWriter.java @@ -155,6 +155,7 @@ private void write(boolean baseline) m_activityTypePopulated = m_projectFile.getTasks().getPopulatedFields().contains(TaskField.ACTIVITY_TYPE); m_wbsSequence = new ObjectSequence(0); m_userDefinedFields = UdfHelper.getUserDefinedFieldsSet(m_projectFile); + m_projectFromPrimavera = "Primavera".equals(m_projectFile.getProjectProperties().getFileApplication()); if (baseline) { @@ -1186,6 +1187,14 @@ private void writeAssignment(ResourceAssignment mpxj) xml.setRemainingUnits(unitsHelper.getRemainingUnits()); xml.setRemainingUnitsPerTime(unitsHelper.getRemainingUnitsPerTime()); xml.setRemainingDuration(getResourceAssignmentRemainingDuration(task, mpxj)); + + if (m_projectFromPrimavera) + { + ProjectCalendar calendar = task.getEffectiveCalendar(); + xml.setPlannedCurve(TimephasedHelper.write(calendar, mpxj.getTimephasedPlannedWork())); + xml.setActualCurve(TimephasedHelper.write(calendar, mpxj.getTimephasedActualWork())); + xml.setRemainingCurve(TimephasedHelper.write(calendar, mpxj.getTimephasedWork())); + } } private Double getResourceAssignmentRemainingDuration(Task task, ResourceAssignment mpxj) @@ -2090,4 +2099,5 @@ ProjectFile getProjectFile() private ObjectSequence m_wbsSequence; private Set m_userDefinedFields; private boolean m_activityTypePopulated; + private boolean m_projectFromPrimavera; } diff --git a/src/main/java/net/sf/mpxj/primavera/PrimaveraReader.java b/src/main/java/net/sf/mpxj/primavera/PrimaveraReader.java index 9d58e52113..d53a390caa 100644 --- a/src/main/java/net/sf/mpxj/primavera/PrimaveraReader.java +++ b/src/main/java/net/sf/mpxj/primavera/PrimaveraReader.java @@ -100,6 +100,7 @@ import net.sf.mpxj.common.NumberHelper; import net.sf.mpxj.common.ObjectSequence; import net.sf.mpxj.common.SlackHelper; +import net.sf.mpxj.TimephasedWorkContainer; /** * This class provides a generic front end to read project data from @@ -1734,6 +1735,7 @@ public void processAssignments(List rows) for (Row row : rows) { Task task = m_project.getTaskByUniqueID(m_activityClashMap.getID(row.getInteger("task_id"))); + ProjectCalendar effectiveCalendar = task.getEffectiveCalendar(); Integer roleID = m_roleClashMap.getID(row.getInteger("role_id")); Integer resourceID = row.getInteger("rsrc_id"); @@ -1751,7 +1753,7 @@ public void processAssignments(List rows) ResourceAssignment assignment = task.addResourceAssignment(resource); processFields(m_assignmentFields, row, assignment); - assignment.setWorkContour(m_project.getWorkContours().getByUniqueID(row.getInteger("curv_id"))); + assignment.setWorkContour(CurveHelper.getWorkContour(m_project, row.getInteger("curv_id"))); assignment.setRateIndex(RateTypeHelper.getInstanceFromXer(row.getString("rate_type"))); assignment.setRole(m_project.getResourceByUniqueID(roleID)); assignment.setOverrideRate(readRate(row.getDouble("cost_per_qty"))); @@ -1764,9 +1766,9 @@ public void processAssignments(List rows) Duration remainingWork = assignment.getRemainingWork(); Duration actualRegularWork = row.getDuration("act_reg_qty"); Duration actualOvertimeWork = assignment.getActualOvertimeWork(); - Duration actualWork = Duration.add(actualRegularWork, actualOvertimeWork, assignment.getEffectiveCalendar()); + Duration actualWork = Duration.add(actualRegularWork, actualOvertimeWork, effectiveCalendar); assignment.setActualWork(actualWork); - Duration totalWork = Duration.add(actualWork, remainingWork, assignment.getEffectiveCalendar()); + Duration totalWork = Duration.add(actualWork, remainingWork, effectiveCalendar); assignment.setWork(totalWork); // calculate cost @@ -1790,6 +1792,14 @@ public void processAssignments(List rows) // Add User Defined Fields populateUserDefinedFieldValues("TASKRSRC", FieldTypeClass.ASSIGNMENT, assignment, assignment.getUniqueID()); + // Read timephased data + TimephasedWorkContainer timephasedPlannedWork = TimephasedHelper.read(effectiveCalendar, assignment.getPlannedStart(), row.getString("target_crv")); + TimephasedWorkContainer timephasedActualWork = TimephasedHelper.read(effectiveCalendar, assignment.getActualStart(), row.getString("actual_crv")); + TimephasedWorkContainer timephasedRemainingWork = TimephasedHelper.read(effectiveCalendar, assignment.getRemainingEarlyStart(), row.getString("remain_crv")); + assignment.setTimephasedPlannedWork(timephasedPlannedWork); + assignment.setTimephasedActualWork(timephasedActualWork); + assignment.setTimephasedWork(timephasedRemainingWork); + m_eventManager.fireAssignmentReadEvent(assignment); } } diff --git a/src/main/java/net/sf/mpxj/primavera/PrimaveraXERFileWriter.java b/src/main/java/net/sf/mpxj/primavera/PrimaveraXERFileWriter.java index 7bb58d8959..85a7d61a39 100644 --- a/src/main/java/net/sf/mpxj/primavera/PrimaveraXERFileWriter.java +++ b/src/main/java/net/sf/mpxj/primavera/PrimaveraXERFileWriter.java @@ -121,6 +121,7 @@ public Charset getCharset() m_rateObjectID = new ObjectSequence(1); m_noteObjectID = new ObjectSequence(1); m_userDefinedFields = UdfHelper.getUserDefinedFieldsSet(projectFile); + m_projectFromPrimavera = "Primavera".equals(m_file.getProjectProperties().getFileApplication()); // We need to do this first to ensure the default topic is created if required populateWbsNotes(); @@ -330,8 +331,22 @@ private void writePredecessors() */ private void writeResourceAssignments() { + Map> columns; + if (m_projectFromPrimavera) + { + columns = RESOURCE_ASSIGNMENT_COLUMNS; + } + else + { + // Don't write timephased data if the schedule isn't from P6 + columns = new LinkedHashMap<>(RESOURCE_ASSIGNMENT_COLUMNS); + columns.put("target_crv", r -> null); + columns.put("remain_crv", r -> null); + columns.put("actual_crv", r -> null); + } + m_writer.writeTable("TASKRSRC", RESOURCE_ASSIGNMENT_COLUMNS); - m_file.getResourceAssignments().stream().filter(t -> isValidAssignment(t)).sorted(Comparator.comparing(ResourceAssignment::getUniqueID)).forEach(t -> m_writer.writeRecord(RESOURCE_ASSIGNMENT_COLUMNS, t)); + m_file.getResourceAssignments().stream().filter(t -> isValidAssignment(t)).sorted(Comparator.comparing(ResourceAssignment::getUniqueID)).forEach(t -> m_writer.writeRecord(columns, t)); } /** @@ -977,6 +992,7 @@ private static PercentCompleteType getPercentCompleteType(Task task) private Set m_userDefinedFields; private Task m_temporaryRootWbs; private Integer m_originalOutlineLevel; + private boolean m_projectFromPrimavera; private static final Integer DEFAULT_PROJECT_ID = Integer.valueOf(1); private static final String RESOURCE_ID_PREFIX = "RESOURCE-"; @@ -1306,12 +1322,12 @@ interface ExportFunction RESOURCE_ASSIGNMENT_COLUMNS.put("target_lag_drtn_hr_cnt", r -> r.getDelay()); RESOURCE_ASSIGNMENT_COLUMNS.put("target_qty_per_hr", r -> new XerUnitsHelper(r).getPlannedUnitsPerTime()); RESOURCE_ASSIGNMENT_COLUMNS.put("act_ot_qty", r -> r.getActualOvertimeWork()); - RESOURCE_ASSIGNMENT_COLUMNS.put("act_reg_qty", r -> PrimaveraXERFileWriter.getActualRegularWork(r)); + RESOURCE_ASSIGNMENT_COLUMNS.put("act_reg_qty", r -> getActualRegularWork(r)); RESOURCE_ASSIGNMENT_COLUMNS.put("relag_drtn_hr_cnt", r -> null); RESOURCE_ASSIGNMENT_COLUMNS.put("ot_factor", r -> null); RESOURCE_ASSIGNMENT_COLUMNS.put("cost_per_qty", r -> r.getOverrideRate()); RESOURCE_ASSIGNMENT_COLUMNS.put("target_cost", r -> Currency.getInstance(r.getPlannedCost())); - RESOURCE_ASSIGNMENT_COLUMNS.put("act_reg_cost", r -> Currency.getInstance(PrimaveraXERFileWriter.getActualRegularCost(r))); + RESOURCE_ASSIGNMENT_COLUMNS.put("act_reg_cost", r -> Currency.getInstance(getActualRegularCost(r))); RESOURCE_ASSIGNMENT_COLUMNS.put("act_ot_cost", r -> Currency.getInstance(r.getActualOvertimeCost())); RESOURCE_ASSIGNMENT_COLUMNS.put("remain_cost", r -> Currency.getInstance(r.getRemainingCost())); RESOURCE_ASSIGNMENT_COLUMNS.put("act_start_date", r -> r.getActualStart()); @@ -1323,9 +1339,9 @@ interface ExportFunction RESOURCE_ASSIGNMENT_COLUMNS.put("rem_late_start_date", r -> r.getRemainingLateStart()); RESOURCE_ASSIGNMENT_COLUMNS.put("rem_late_end_date", r -> r.getRemainingLateFinish()); RESOURCE_ASSIGNMENT_COLUMNS.put("rollup_dates_flag", r -> Boolean.TRUE); - RESOURCE_ASSIGNMENT_COLUMNS.put("target_crv", r -> null); - RESOURCE_ASSIGNMENT_COLUMNS.put("remain_crv", r -> null); - RESOURCE_ASSIGNMENT_COLUMNS.put("actual_crv", r -> null); + RESOURCE_ASSIGNMENT_COLUMNS.put("target_crv", r -> TimephasedHelper.write(r.getTask().getEffectiveCalendar(), r.getTimephasedPlannedWork())); + RESOURCE_ASSIGNMENT_COLUMNS.put("remain_crv", r -> TimephasedHelper.write(r.getTask().getEffectiveCalendar(), r.getTimephasedWork())); + RESOURCE_ASSIGNMENT_COLUMNS.put("actual_crv", r -> TimephasedHelper.write(r.getTask().getEffectiveCalendar(), r.getTimephasedActualWork())); RESOURCE_ASSIGNMENT_COLUMNS.put("ts_pend_act_end_flag", r -> Boolean.FALSE); RESOURCE_ASSIGNMENT_COLUMNS.put("guid", r -> r.getGUID()); RESOURCE_ASSIGNMENT_COLUMNS.put("rate_type", r -> RateTypeHelper.getXerFromInstance(r.getRateIndex())); diff --git a/src/main/java/net/sf/mpxj/primavera/TimephasedHelper.java b/src/main/java/net/sf/mpxj/primavera/TimephasedHelper.java new file mode 100644 index 0000000000..26ba6884b6 --- /dev/null +++ b/src/main/java/net/sf/mpxj/primavera/TimephasedHelper.java @@ -0,0 +1,159 @@ +/* + * file: TimephasedHelper.java + * author: Jon Iles + * date: 2024-07-25 + */ + +/* + * This library is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation; either version 2.1 of the License, or (at your + * option) any later version. + * + * This library is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. + */ + +package net.sf.mpxj.primavera; + +import java.text.DecimalFormat; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import net.sf.mpxj.Duration; +import net.sf.mpxj.ProjectCalendar; +import net.sf.mpxj.TimeUnit; +import net.sf.mpxj.TimephasedWork; +import net.sf.mpxj.TimephasedWorkContainer; + +/** + * Methods for parsing and formatting timephased data in P6 schedule files. + */ +final class TimephasedHelper +{ + /** + * Parse P6 timephased work and represent as a collection of TimephasedWork instances. + * + * @param calendar effective calendar + * @param start start date + * @param values values to parse + * @return collection of TimephasedWork instances + */ + public static TimephasedWorkContainer read(ProjectCalendar calendar, LocalDateTime start, String values) + { + if (values == null || values.isEmpty()) + { + return null; + } + + if (values.indexOf(':') == -1) + { + return null; + } + + List list = new ArrayList<>(); + LocalDateTime currentStart = calendar.getNextWorkStart(start); + + for (String value : values.split(";")) + { + String[] item = value.split(":"); + if (item.length != 2) + { + return null; + } + + Duration workHours = Duration.getInstance(Double.valueOf(item[0]), TimeUnit.HOURS); + Duration periodHours = Duration.getInstance(Double.valueOf(item[1]), TimeUnit.HOURS); + LocalDateTime currentFinish = calendar.getDate(currentStart, periodHours); + double days = calendar.getDuration(currentStart, currentFinish).getDuration(); + + TimephasedWork timephasedItem = new TimephasedWork(); + timephasedItem.setStart(currentStart); + timephasedItem.setFinish(currentFinish); + timephasedItem.setTotalAmount(workHours); + timephasedItem.setAmountPerDay(Duration.getInstance(workHours.getDuration()/days, TimeUnit.HOURS)); + list.add(timephasedItem); + + currentStart = calendar.getNextWorkStart(currentFinish); + } + + return new TimephasedWorkContainer() + { + @Override public List getData() + { + return list; + } + + @Override public boolean hasData() + { + return true; + } + + @Override public TimephasedWorkContainer applyFactor(double perDayFactor, double totalFactor) + { + throw new UnsupportedOperationException(); + } + }; + } + + /** + * Format a collection of TimephasedWork instances as P6 timephased data. + * + * @param calendar effective calendar + * @param items TimephasedWork items + * @return P6 timephased data + */ + public static String write(ProjectCalendar calendar, List items) + { + if (items == null || items.isEmpty()) + { + return null; + } + + StringBuilder result = new StringBuilder(); + LocalDateTime previousFinish = null; + + for (TimephasedWork item : items) + { + if (previousFinish != null) + { + Duration workToNextItem = calendar.getWork(previousFinish, item.getStart(), TimeUnit.HOURS); + if(workToNextItem.getDuration() != 0) + { + if (result.length() != 0) + { + result.append(";"); + } + + result.append("0:"); + result.append((int)workToNextItem.getDuration()); + } + } + + Duration workHours = item.getTotalAmount().convertUnits(TimeUnit.HOURS, calendar); + Duration periodHours = calendar.getWork(item.getStart(), item.getFinish(), TimeUnit.HOURS); + + if (result.length() != 0) + { + result.append(";"); + } + + result.append(FORMAT.format(workHours.getDuration())); + result.append(':'); + result.append(FORMAT.format(periodHours.getDuration())); + + previousFinish = item.getFinish(); + } + + return result.toString(); + } + + private static final DecimalFormat FORMAT = new DecimalFormat("#.#"); +}