cpp-xer-parser 0.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cpp_xer_parser/__init__.py +38 -0
- cpp_xer_parser/config_profiles.py +131 -0
- cpp_xer_parser/validation.py +164 -0
- cpp_xer_parser/xer_parser.py +3468 -0
- cpp_xer_parser-0.6.1.dist-info/METADATA +287 -0
- cpp_xer_parser-0.6.1.dist-info/RECORD +10 -0
- cpp_xer_parser-0.6.1.dist-info/WHEEL +5 -0
- cpp_xer_parser-0.6.1.dist-info/entry_points.txt +2 -0
- cpp_xer_parser-0.6.1.dist-info/licenses/LICENSE +21 -0
- cpp_xer_parser-0.6.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,3468 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
XER Parser & Generator — Primavera P6 XER reader and writer
|
|
4
|
+
===========================================================
|
|
5
|
+
Standalone parsing and generation core: no third-party runtime dependencies.
|
|
6
|
+
See the repository README for what this package covers, and for the validation
|
|
7
|
+
that Critical Path Partners runs on top of it and does not publish here.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
PARSING:
|
|
11
|
+
from xer_parser import parse_xer, print_summary
|
|
12
|
+
data = parse_xer('/path/to/file.xer')
|
|
13
|
+
print_summary(data)
|
|
14
|
+
|
|
15
|
+
GENERATION:
|
|
16
|
+
from xer_parser import generate_xer
|
|
17
|
+
generate_xer(data, '/path/to/output.xer')
|
|
18
|
+
|
|
19
|
+
CLI:
|
|
20
|
+
python xer_parser.py parse input.xer [--summary] [--json output.json]
|
|
21
|
+
python xer_parser.py generate input.json output.xer
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import sys
|
|
25
|
+
import json
|
|
26
|
+
import math
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import warnings
|
|
30
|
+
from datetime import date, datetime, timedelta
|
|
31
|
+
from collections import defaultdict, OrderedDict
|
|
32
|
+
|
|
33
|
+
# ─────────────────────────────────────────────
|
|
34
|
+
# SKILL VERSION
|
|
35
|
+
# ─────────────────────────────────────────────
|
|
36
|
+
# v2.0.0 — nuclear-grade upgrade (VP teardown §1):
|
|
37
|
+
# * BOM-aware encoding detection (UTF-8 BOM / UTF-16 LE-BE BOM)
|
|
38
|
+
# * Schedule integrity manifest (generate_xer_manifest)
|
|
39
|
+
# * Baseline identification (get_baselines / contract_baseline_id)
|
|
40
|
+
# * UDF type classification (get_udf_types)
|
|
41
|
+
# * Schema drift detection (schema_diff)
|
|
42
|
+
# * Calendar exception classification (special_workdays vs holidays)
|
|
43
|
+
# * Unified validation report (validate_schedule)
|
|
44
|
+
# * Schedule structure scoring: aace_31r_compliance (legacy name; findings cite 29R-03 / 38R-06 / DCMA)
|
|
45
|
+
_SKILL_VERSION = '2.0.0'
|
|
46
|
+
|
|
47
|
+
# ─────────────────────────────────────────────
|
|
48
|
+
# CPP COMMON MODULE IMPORT STANZA
|
|
49
|
+
# ─────────────────────────────────────────────
|
|
50
|
+
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
51
|
+
_CPP_COMMON = os.path.normpath(os.path.join(_SCRIPT_DIR, '..', '..', '_cpp_common', 'scripts'))
|
|
52
|
+
# The package this module was imported from: 'cpp_xer_parser' when installed,
|
|
53
|
+
# '' from a clone or run as a script. __spec__.parent, not __package__, which
|
|
54
|
+
# Python 3.15 schedules for removal.
|
|
55
|
+
_PKG = __spec__.parent if __spec__ is not None else ''
|
|
56
|
+
# Only from a clone: an installed copy has no sibling _cpp_common.
|
|
57
|
+
if not _PKG and os.path.isdir(_CPP_COMMON) and _CPP_COMMON not in sys.path:
|
|
58
|
+
sys.path.insert(0, _CPP_COMMON)
|
|
59
|
+
|
|
60
|
+
# Optional imports, guarded so xer_parser stays useful in callers that do not
|
|
61
|
+
# have the wider CPP common module available (e.g. embedded packaging, or this
|
|
62
|
+
# repository on its own).
|
|
63
|
+
#
|
|
64
|
+
# The two groups are imported SEPARATELY on purpose. `validation.py` and
|
|
65
|
+
# `config_profiles.py` ship in this repository's own scripts/ directory, so a
|
|
66
|
+
# plain clone can bind them; `audit_trail` does not ship here. Importing all
|
|
67
|
+
# three in one try block meant a missing `audit_trail` discarded the two
|
|
68
|
+
# modules that were present, and `validate_schedule` / `aace_31r_compliance`
|
|
69
|
+
# raised RuntimeError on a clean clone even though their dependencies were
|
|
70
|
+
# sitting next to them. Keep these blocks separate.
|
|
71
|
+
try:
|
|
72
|
+
if _PKG:
|
|
73
|
+
# Installed with pip, the modules are the package cpp_xer_parser.
|
|
74
|
+
from .validation import Finding, ValidationReport, BLOCK, WARN, INFO, PASS
|
|
75
|
+
from .config_profiles import get_profile
|
|
76
|
+
else:
|
|
77
|
+
# A clone with scripts/ on sys.path: they are top-level modules.
|
|
78
|
+
from validation import Finding, ValidationReport, BLOCK, WARN, INFO, PASS
|
|
79
|
+
from config_profiles import get_profile
|
|
80
|
+
_VALIDATION_AVAILABLE = True
|
|
81
|
+
except ImportError:
|
|
82
|
+
_VALIDATION_AVAILABLE = False
|
|
83
|
+
# Stub placeholders so the names are bound. The severity constants keep
|
|
84
|
+
# their real values so callers can compare against them either way.
|
|
85
|
+
Finding = None
|
|
86
|
+
ValidationReport = None
|
|
87
|
+
BLOCK = 'BLOCK'
|
|
88
|
+
WARN = 'WARN'
|
|
89
|
+
INFO = 'INFO'
|
|
90
|
+
PASS = 'PASS'
|
|
91
|
+
get_profile = None
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
from audit_trail import generate_manifest, render_manifest_block # noqa: F401
|
|
95
|
+
_AUDIT_TRAIL_AVAILABLE = True
|
|
96
|
+
except ImportError:
|
|
97
|
+
_AUDIT_TRAIL_AVAILABLE = False
|
|
98
|
+
generate_manifest = None
|
|
99
|
+
render_manifest_block = None
|
|
100
|
+
|
|
101
|
+
# Retained for callers that check the old flag: true only when BOTH groups
|
|
102
|
+
# resolved, which is what it meant before the split.
|
|
103
|
+
_CPP_COMMON_AVAILABLE = _VALIDATION_AVAILABLE and _AUDIT_TRAIL_AVAILABLE
|
|
104
|
+
|
|
105
|
+
# ─────────────────────────────────────────────
|
|
106
|
+
# PUBLIC API
|
|
107
|
+
# ─────────────────────────────────────────────
|
|
108
|
+
__all__ = [
|
|
109
|
+
# Version
|
|
110
|
+
'_SKILL_VERSION',
|
|
111
|
+
# Parsing
|
|
112
|
+
'parse_xer', 'get_table', 'get_fields',
|
|
113
|
+
# Calendar
|
|
114
|
+
'parse_calendar_data', 'get_calendar_map',
|
|
115
|
+
'get_work_days_between', 'duration_hours_to_days',
|
|
116
|
+
'resolve_task_calendars', 'with_resolved_calendars', 'calendar_resolution_block',
|
|
117
|
+
'calendar_shift_hours', 'CalendarShiftHoursWarning',
|
|
118
|
+
'calendar_corrupt_fallbacks', 'CalendarCorruptRecordWarning',
|
|
119
|
+
# Cross-reference
|
|
120
|
+
'build_wbs_map', 'build_resource_map',
|
|
121
|
+
'build_predecessor_map', 'build_activity_code_map', 'build_udf_map',
|
|
122
|
+
# Summary
|
|
123
|
+
'generate_summary', 'print_summary',
|
|
124
|
+
# V2.0 additions
|
|
125
|
+
'generate_xer_manifest', 'get_baselines', 'contract_baseline_id',
|
|
126
|
+
'get_udf_types', 'schema_diff', 'validate_schedule', 'aace_31r_compliance',
|
|
127
|
+
# Generation
|
|
128
|
+
'generate_xer',
|
|
129
|
+
# Half-step XER (vendor-equivalent: SmartPM/Plannex)
|
|
130
|
+
'compute_half_step_xer',
|
|
131
|
+
# Constants
|
|
132
|
+
'TABLE_ORDER', 'TABLE_FIELD_COUNTS', 'TABLE_FIELD_COUNTS_BY_VERSION',
|
|
133
|
+
'P6_DATE_FORMAT', 'STATUS_CODES', 'NOT_STARTED_STATUS', 'ACTIVE_STATUS',
|
|
134
|
+
'COMPLETE_STATUS', 'TASK_TYPES', 'MILESTONE_TASK_TYPES', 'EXCLUDED_TASK_TYPES',
|
|
135
|
+
'RELATIONSHIP_TYPES', 'CONSTRAINT_TYPES',
|
|
136
|
+
# Severity re-exports from _cpp_common
|
|
137
|
+
'BLOCK', 'WARN', 'INFO', 'PASS',
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
# ─────────────────────────────────────────────
|
|
141
|
+
# CONSTANTS
|
|
142
|
+
# ─────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
# Generation order for writing an XER. This is the sequence `generate_xer` emits
|
|
145
|
+
# tables in, and P6 imports it cleanly. It is NOT a canonical export order: there
|
|
146
|
+
# is no such thing. The 166 genuine exports measured for
|
|
147
|
+
# references/table-reference.md produced 38 distinct %T sequences, and the first
|
|
148
|
+
# nine names below are only the most common of them, appearing in 28 of 166.
|
|
149
|
+
# Definition tables such as UDFTYPE, MEMOTYPE and PCATTYPE routinely appear before
|
|
150
|
+
# PROJECT in real files. Never parse on the assumption of any order: read every
|
|
151
|
+
# %T block into a table map first, then resolve cross-references.
|
|
152
|
+
#
|
|
153
|
+
# Names below are %T headers. 29 of the 39 appear in the measured set; the other
|
|
154
|
+
# 10 (TASKFIN, TRSRCFIN, TASKDOC, PROJDOCS, SHIFT, SHIFTPER, ACCOUNT, WBSMEMO,
|
|
155
|
+
# PROJMEMO, RISK) are P6 catalogue names that appear in none of the 166 exports,
|
|
156
|
+
# kept so a file carrying one lands in a sensible slot. Three tables that DO
|
|
157
|
+
# appear in the measured set are absent from this list on purpose — NONWORK,
|
|
158
|
+
# RSRCCURVDATA and WBSSTEP — and are written in the trailing "everything else"
|
|
159
|
+
# pass, which is harmless because P6 imports on content, not order.
|
|
160
|
+
#
|
|
161
|
+
# The resource-code assignment table is RSRCRCAT. No measured export writes a
|
|
162
|
+
# `RSRCCAT` header, so code keyed on that spelling never fires (corrected in
|
|
163
|
+
# v0.2.0, along with the removal of a phantom `RISKTYPES` entry that
|
|
164
|
+
# duplicated RISKTYPE and matches no P6 table).
|
|
165
|
+
TABLE_ORDER = [
|
|
166
|
+
'CURRTYPE', 'FINTMPL', 'OBS', 'PROJECT', 'CALENDAR',
|
|
167
|
+
'SCHEDOPTIONS', 'PROJWBS', 'TASK', 'TASKPRED', 'TASKRSRC',
|
|
168
|
+
'RSRC', 'RSRCRATE', 'ACTVTYPE', 'ACTVCODE', 'TASKACTV',
|
|
169
|
+
'UDFTYPE', 'UDFVALUE', 'PROJPCAT', 'PCATTYPE', 'PCATVAL',
|
|
170
|
+
'TASKFIN', 'TRSRCFIN', 'TASKDOC', 'PROJDOCS', 'ROLERATE',
|
|
171
|
+
'ROLES', 'RSRCROLE', 'SHIFT', 'SHIFTPER', 'ACCOUNT',
|
|
172
|
+
'RCATTYPE', 'RCATVAL', 'RSRCRCAT', 'MEMOTYPE', 'TASKMEMO',
|
|
173
|
+
'WBSMEMO', 'PROJMEMO', 'RISKTYPE', 'RISK'
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
# Known field counts per table, keyed by P6 major-version family.
|
|
177
|
+
# P6 added and removed fields across major versions, so validation must be
|
|
178
|
+
# version-aware or warnings fire on every valid XER.
|
|
179
|
+
#
|
|
180
|
+
# MEASURED against the 166 genuine P6 exports behind
|
|
181
|
+
# references/table-reference.md: ERMHDR 19.12 (3 files), 23.10 (4), 23.12 (109),
|
|
182
|
+
# 24.12 (50). The 163 exports at 23.10 / 23.12 / 24.12 all read PROJECT=71,
|
|
183
|
+
# PROJWBS=26, TASK=61, TASKPRED=11, SCHEDOPTIONS=25. No export gave a different
|
|
184
|
+
# count for a table it carried. Not every export carries every table: PROJECT,
|
|
185
|
+
# PROJWBS and TASK appear in 163 of 163, TASKPRED in 159, SCHEDOPTIONS in 140. A
|
|
186
|
+
# missing SCHEDOPTIONS or TASKPRED is normal, not malformed. The 3 exports at
|
|
187
|
+
# 19.12 read PROJECT=82, PROJWBS=27, TASK=66, TASKPRED=10, SCHEDOPTIONS=25,
|
|
188
|
+
# matching the '19' entry on the three keys it carries.
|
|
189
|
+
#
|
|
190
|
+
# `crt_path_num` is field 61 of 61 in every one of those 163 exports, including
|
|
191
|
+
# all 50 genuine 24.12 files. It is a standard member of the 61-field TASK layout,
|
|
192
|
+
# not a 24.12 addition that takes TASK to 62. Do not version-gate it, and do not
|
|
193
|
+
# raise the '24' entry to 62.
|
|
194
|
+
#
|
|
195
|
+
# NOT measured: no 22.x or 20.x export is in the measured set. The '22' and '20'
|
|
196
|
+
# entries are extrapolations from their neighbours, kept only so a file at those
|
|
197
|
+
# versions is checked rather than silently skipped. Do not quote them as measured,
|
|
198
|
+
# and do not describe these counts as covering a "22.x / 23.x / 24.x family" —
|
|
199
|
+
# the measured versions are 19.12, 23.10, 23.12 and 24.12.
|
|
200
|
+
#
|
|
201
|
+
# Counts are a sanity check, not a parsing contract. Field count and column order
|
|
202
|
+
# vary inside a single version and between adjacent versions: RSRC appears with
|
|
203
|
+
# both 28 and 31 fields at 23.12, 24.12 moves `rsrc_type` and `location_id` to the
|
|
204
|
+
# end of the RSRC row, and TASKRSRC swaps `has_rsrchours` for `update_user` and
|
|
205
|
+
# `update_date` at 24.12. Always index by name from the %F line, never by position.
|
|
206
|
+
TABLE_FIELD_COUNTS_BY_VERSION = {
|
|
207
|
+
# Measured identical across 23.10, 23.12 and 24.12 (163 genuine exports).
|
|
208
|
+
'24': {'PROJECT': 71, 'SCHEDOPTIONS': 25, 'PROJWBS': 26, 'TASK': 61, 'TASKPRED': 11},
|
|
209
|
+
'23': {'PROJECT': 71, 'SCHEDOPTIONS': 25, 'PROJWBS': 26, 'TASK': 61, 'TASKPRED': 11},
|
|
210
|
+
# Unmeasured extrapolation — no 22.x export has been seen.
|
|
211
|
+
'22': {'PROJECT': 71, 'SCHEDOPTIONS': 25, 'PROJWBS': 26, 'TASK': 61, 'TASKPRED': 11},
|
|
212
|
+
# 19.12 is a different layout (more fields on PROJECT/PROJWBS/TASK, fewer on
|
|
213
|
+
# TASKPRED). '20' is an unmeasured extrapolation from it. Neither entry carries
|
|
214
|
+
# a PROJWBS or SCHEDOPTIONS key, so those two tables are not field-count-checked
|
|
215
|
+
# at these versions even though 19.12 measures PROJWBS=27 and SCHEDOPTIONS=25.
|
|
216
|
+
'20': {'PROJECT': 82, 'TASK': 66, 'TASKPRED': 10},
|
|
217
|
+
'19': {'PROJECT': 82, 'TASK': 66, 'TASKPRED': 10},
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
# Back-compat alias — points at the current/default schema family so older
|
|
221
|
+
# callers that reference TABLE_FIELD_COUNTS directly keep working.
|
|
222
|
+
TABLE_FIELD_COUNTS = TABLE_FIELD_COUNTS_BY_VERSION['24']
|
|
223
|
+
|
|
224
|
+
# P6 date format
|
|
225
|
+
P6_DATE_FORMAT = '%Y-%m-%d %H:%M'
|
|
226
|
+
|
|
227
|
+
# Status code mappings
|
|
228
|
+
STATUS_CODES = {
|
|
229
|
+
'TK_NotStart': 'Not Started',
|
|
230
|
+
'TK_Active': 'In Progress',
|
|
231
|
+
'TK_Complete': 'Complete',
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
# Scalar status codes (for downstream skills — avoids redefining these everywhere).
|
|
235
|
+
NOT_STARTED_STATUS = 'TK_NotStart'
|
|
236
|
+
ACTIVE_STATUS = 'TK_Active'
|
|
237
|
+
COMPLETE_STATUS = 'TK_Complete'
|
|
238
|
+
|
|
239
|
+
# Task type mappings
|
|
240
|
+
TASK_TYPES = {
|
|
241
|
+
'TT_Task': 'Task Dependent',
|
|
242
|
+
'TT_Rsrc': 'Resource Dependent',
|
|
243
|
+
'TT_Mile': 'Start Milestone',
|
|
244
|
+
'TT_FinMile': 'Finish Milestone',
|
|
245
|
+
'TT_LOE': 'Level of Effort',
|
|
246
|
+
'TT_WBS': 'WBS Summary',
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# Commonly-needed task_type SETS — exported to eliminate drift across skills.
|
|
250
|
+
# Use these instead of redefining `{'TT_Mile', 'TT_FinMile'}` locally.
|
|
251
|
+
MILESTONE_TASK_TYPES = frozenset({'TT_Mile', 'TT_FinMile'})
|
|
252
|
+
EXCLUDED_TASK_TYPES = frozenset({'TT_WBS', 'TT_LOE'}) # Excluded from CP / duration analyses
|
|
253
|
+
|
|
254
|
+
# Relationship type mappings
|
|
255
|
+
RELATIONSHIP_TYPES = {
|
|
256
|
+
'PR_FS': 'Finish-to-Start',
|
|
257
|
+
'PR_FF': 'Finish-to-Finish',
|
|
258
|
+
'PR_SS': 'Start-to-Start',
|
|
259
|
+
'PR_SF': 'Start-to-Finish',
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
# Constraint type mappings (P6 24.12 canonical codes)
|
|
263
|
+
CONSTRAINT_TYPES = {
|
|
264
|
+
'CS_MSO': 'Start On',
|
|
265
|
+
'CS_MEO': 'Finish On',
|
|
266
|
+
'CS_MSOA': 'Start On or After',
|
|
267
|
+
'CS_MSOB': 'Start On or Before',
|
|
268
|
+
'CS_MEOA': 'Finish On or After',
|
|
269
|
+
'CS_MEOB': 'Finish On or Before',
|
|
270
|
+
'CS_ALAP': 'As Late As Possible',
|
|
271
|
+
'CS_MANDSTART': 'Mandatory Start',
|
|
272
|
+
'CS_MANDFIN': 'Mandatory Finish',
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ─────────────────────────────────────────────
|
|
277
|
+
# PARSING ENGINE
|
|
278
|
+
# ─────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
def _detect_bom_encoding(filepath):
|
|
281
|
+
"""Inspect the first 4 bytes and return an encoding string if a BOM is present.
|
|
282
|
+
|
|
283
|
+
Returns one of: 'utf-8-sig', 'utf-16', 'utf-16-le', 'utf-16-be', 'utf-32',
|
|
284
|
+
or None if no BOM is detected. Python's 'utf-8-sig' and 'utf-16' codecs
|
|
285
|
+
strip the BOM automatically when decoding.
|
|
286
|
+
"""
|
|
287
|
+
try:
|
|
288
|
+
with open(filepath, 'rb') as f:
|
|
289
|
+
head = f.read(4)
|
|
290
|
+
except OSError:
|
|
291
|
+
return None
|
|
292
|
+
if head.startswith(b'\xef\xbb\xbf'):
|
|
293
|
+
return 'utf-8-sig'
|
|
294
|
+
if head.startswith(b'\xff\xfe\x00\x00'):
|
|
295
|
+
return 'utf-32-le'
|
|
296
|
+
if head.startswith(b'\x00\x00\xfe\xff'):
|
|
297
|
+
return 'utf-32-be'
|
|
298
|
+
if head.startswith(b'\xff\xfe'):
|
|
299
|
+
return 'utf-16-le'
|
|
300
|
+
if head.startswith(b'\xfe\xff'):
|
|
301
|
+
return 'utf-16-be'
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def parse_xer(filepath, encoding=None):
|
|
306
|
+
"""
|
|
307
|
+
Parse a Primavera P6 XER file into a structured dictionary.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
{
|
|
311
|
+
'ermhdr': { ... header metadata ... },
|
|
312
|
+
'tables': {
|
|
313
|
+
'TABLE_NAME': {
|
|
314
|
+
'fields': ['field1', 'field2', ...],
|
|
315
|
+
'records': [ {field1: val1, field2: val2, ...}, ... ]
|
|
316
|
+
},
|
|
317
|
+
...
|
|
318
|
+
},
|
|
319
|
+
'filepath': str,
|
|
320
|
+
'parse_timestamp': str,
|
|
321
|
+
'encoding_used': str # e.g. 'utf-8', 'utf-8-sig', 'utf-16-le', 'cp1252'
|
|
322
|
+
}
|
|
323
|
+
"""
|
|
324
|
+
# BOM detection takes priority over the fallback list — a correctly
|
|
325
|
+
# BOM-tagged file should decode first-attempt and never fall through to
|
|
326
|
+
# latin-1 (which never fails but produces garbled Unicode).
|
|
327
|
+
bom_encoding = _detect_bom_encoding(filepath)
|
|
328
|
+
|
|
329
|
+
if encoding:
|
|
330
|
+
encodings_to_try = [encoding]
|
|
331
|
+
else:
|
|
332
|
+
encodings_to_try = []
|
|
333
|
+
if bom_encoding:
|
|
334
|
+
encodings_to_try.append(bom_encoding)
|
|
335
|
+
encodings_to_try.extend(['utf-8', 'cp1252', 'utf-16', 'latin-1'])
|
|
336
|
+
|
|
337
|
+
raw_text = None
|
|
338
|
+
encoding_used = None
|
|
339
|
+
|
|
340
|
+
for enc in encodings_to_try:
|
|
341
|
+
if enc is None:
|
|
342
|
+
continue
|
|
343
|
+
try:
|
|
344
|
+
with open(filepath, encoding=enc) as f:
|
|
345
|
+
raw_text = f.read()
|
|
346
|
+
encoding_used = enc
|
|
347
|
+
break
|
|
348
|
+
except (UnicodeDecodeError, LookupError):
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
if raw_text is None:
|
|
352
|
+
raise ValueError(f"Could not decode {filepath} with any encoding: {encodings_to_try}")
|
|
353
|
+
|
|
354
|
+
# Defensive strip — if a BOM somehow survived (e.g. caller passed a plain
|
|
355
|
+
# 'utf-16' codec that leaves the BOM character), drop the leading U+FEFF.
|
|
356
|
+
if raw_text and raw_text[0] == '\ufeff':
|
|
357
|
+
raw_text = raw_text[1:]
|
|
358
|
+
|
|
359
|
+
lines = raw_text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
|
|
360
|
+
|
|
361
|
+
result = {
|
|
362
|
+
'ermhdr': {},
|
|
363
|
+
'tables': OrderedDict(),
|
|
364
|
+
'filepath': os.path.abspath(filepath),
|
|
365
|
+
'filename': os.path.basename(filepath),
|
|
366
|
+
'parse_timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
|
367
|
+
'encoding_used': encoding_used or '',
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
current_table = None
|
|
371
|
+
current_fields = []
|
|
372
|
+
|
|
373
|
+
for line in lines:
|
|
374
|
+
line = line.rstrip('\n').rstrip('\r')
|
|
375
|
+
|
|
376
|
+
if not line.strip():
|
|
377
|
+
continue
|
|
378
|
+
|
|
379
|
+
# ERMHDR — file header
|
|
380
|
+
if line.startswith('ERMHDR'):
|
|
381
|
+
parts = line.split('\t')
|
|
382
|
+
# Canonical 9-field P6 ERMHDR layout (positional):
|
|
383
|
+
# parts[0] ERMHDR
|
|
384
|
+
# parts[1] version
|
|
385
|
+
# parts[2] export_date
|
|
386
|
+
# parts[3] export/project flag (e.g. 'Project')
|
|
387
|
+
# parts[4] user_login (the export user — chain of custody)
|
|
388
|
+
# parts[5] user_full_name
|
|
389
|
+
# parts[6] database
|
|
390
|
+
# parts[7] module
|
|
391
|
+
# parts[8] currency (the file's currency unit)
|
|
392
|
+
# Only set a field when its index exists so shorter legacy
|
|
393
|
+
# headers do not get mislabeled values from the wrong slot.
|
|
394
|
+
result['ermhdr'] = {
|
|
395
|
+
'raw': parts,
|
|
396
|
+
'version': parts[1] if len(parts) > 1 else '',
|
|
397
|
+
'export_date': parts[2] if len(parts) > 2 else '',
|
|
398
|
+
'export_flag': parts[3] if len(parts) > 3 else '',
|
|
399
|
+
'user': parts[4] if len(parts) > 4 else '',
|
|
400
|
+
'user_full_name': parts[5] if len(parts) > 5 else '',
|
|
401
|
+
'database': parts[6] if len(parts) > 6 else '',
|
|
402
|
+
'module': parts[7] if len(parts) > 7 else '',
|
|
403
|
+
'currency': parts[8] if len(parts) > 8 else '',
|
|
404
|
+
}
|
|
405
|
+
continue
|
|
406
|
+
|
|
407
|
+
# %T — Table name marker
|
|
408
|
+
if line.startswith('%T'):
|
|
409
|
+
parts = line.split('\t')
|
|
410
|
+
current_table = parts[1].strip() if len(parts) > 1 else None
|
|
411
|
+
current_fields = []
|
|
412
|
+
if current_table and current_table not in result['tables']:
|
|
413
|
+
result['tables'][current_table] = {
|
|
414
|
+
'fields': [],
|
|
415
|
+
'records': []
|
|
416
|
+
}
|
|
417
|
+
continue
|
|
418
|
+
|
|
419
|
+
# %F — Field names
|
|
420
|
+
if line.startswith('%F'):
|
|
421
|
+
parts = line.split('\t')
|
|
422
|
+
current_fields = [f.strip() for f in parts[1:]]
|
|
423
|
+
if current_table:
|
|
424
|
+
result['tables'][current_table]['fields'] = current_fields
|
|
425
|
+
continue
|
|
426
|
+
|
|
427
|
+
# %R — Data row
|
|
428
|
+
if line.startswith('%R'):
|
|
429
|
+
if not current_table or not current_fields:
|
|
430
|
+
continue
|
|
431
|
+
parts = line.split('\t')
|
|
432
|
+
values = parts[1:] # Skip the %R marker
|
|
433
|
+
|
|
434
|
+
# Pad values if fewer than fields (empty trailing fields)
|
|
435
|
+
while len(values) < len(current_fields):
|
|
436
|
+
values.append('')
|
|
437
|
+
|
|
438
|
+
record = {}
|
|
439
|
+
for i, field in enumerate(current_fields):
|
|
440
|
+
record[field] = values[i]
|
|
441
|
+
# Preserve cells beyond the declared fields under synthetic string
|
|
442
|
+
# keys rather than silently dropping them (never-truncate). Only
|
|
443
|
+
# fires on a malformed/hand-edited row with extra tabs; values stay
|
|
444
|
+
# strings (the passthrough contract).
|
|
445
|
+
for i in range(len(current_fields), len(values)):
|
|
446
|
+
record[f'__extra_{i}'] = values[i]
|
|
447
|
+
|
|
448
|
+
result['tables'][current_table]['records'].append(record)
|
|
449
|
+
continue
|
|
450
|
+
|
|
451
|
+
result['data_date_filled'] = _fill_blank_data_dates(result)
|
|
452
|
+
return result
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _fill_blank_data_dates(result):
|
|
456
|
+
"""Read a blank data date as the project's planned start, as P6 does.
|
|
457
|
+
|
|
458
|
+
P6 schedules "from the project data date, using the project start date as
|
|
459
|
+
the initial data date" (P6 Professional 23.12 help). Measured 12-Oct-2026
|
|
460
|
+
on the two P6-scheduled projects in the local P6 database whose
|
|
461
|
+
PROJECT.last_recalc_date is blank: every unconstrained not-started activity
|
|
462
|
+
with no predecessor starts on PROJECT.plan_start_date, and handed that date
|
|
463
|
+
the engine matches P6 on all 437 open activities of the first. Left blank,
|
|
464
|
+
every consumer that hands the engine PROJECT.last_recalc_date scheduled
|
|
465
|
+
from the engine's epoch instead.
|
|
466
|
+
|
|
467
|
+
The blank is filled in place so every consumer reads the data date P6
|
|
468
|
+
used. Returns one entry per filled project ({proj_id, last_recalc_date,
|
|
469
|
+
filled_from}) for disclosure: the file's own value was blank. A blank with
|
|
470
|
+
no planned start stays blank.
|
|
471
|
+
|
|
472
|
+
A filed data date EARLIER than the planned start is lifted to it the same
|
|
473
|
+
way (13-Oct-2026): P6 lifts the data date to a later planned start when it
|
|
474
|
+
opens the project and schedules from there, every completed activity's
|
|
475
|
+
retained-logic date included (P6 probe ZZ-CPP-XZ: data date 28-Aug, planned
|
|
476
|
+
start 01-Sep, P6 shows and schedules 01-Sep; the 2026-09-23 probes had to
|
|
477
|
+
move their planned start back for that reason). The entry then carries the
|
|
478
|
+
filed date as filed_data_date. Only a planned start on a LATER DAY lifts a
|
|
479
|
+
filed data date: the measured case; a planned start later the same day (a
|
|
480
|
+
00:00 data date beside an 08:00 start) is the same working instant on any
|
|
481
|
+
calendar that opens at or after it, and is left as filed.
|
|
482
|
+
"""
|
|
483
|
+
filled = []
|
|
484
|
+
for rec in result.get('tables', {}).get('PROJECT', {}).get('records', []):
|
|
485
|
+
dd = (rec.get('last_recalc_date') or '').strip()
|
|
486
|
+
start = (rec.get('plan_start_date') or '').strip()
|
|
487
|
+
if not start:
|
|
488
|
+
continue
|
|
489
|
+
if dd:
|
|
490
|
+
# 'YYYY-MM-DD ...' strings, so the string order is the date order
|
|
491
|
+
if start[:10] > dd[:10]:
|
|
492
|
+
rec['last_recalc_date'] = start
|
|
493
|
+
filled.append({'proj_id': rec.get('proj_id', ''), 'last_recalc_date': start,
|
|
494
|
+
'filled_from': 'plan_start_date', 'filed_data_date': dd})
|
|
495
|
+
continue
|
|
496
|
+
rec['last_recalc_date'] = start
|
|
497
|
+
filled.append({'proj_id': rec.get('proj_id', ''), 'last_recalc_date': start,
|
|
498
|
+
'filled_from': 'plan_start_date'})
|
|
499
|
+
return filled
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# XER date columns conventionally end in '_date'. A non-empty cell that does
|
|
503
|
+
# not parse as a P6 date (YYYY-MM-DD prefix) is silently mishandled downstream
|
|
504
|
+
# (truncated by `[:10]`, then dropped/coerced by the CPM) — producing a
|
|
505
|
+
# wrong-but-valid finish and a silently-wrong forensic number. Validate at the
|
|
506
|
+
# source so every parse_xer consumer can surface it.
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def get_table(data, table_name):
|
|
510
|
+
"""Get records for a specific table. Returns list of dicts, or empty list."""
|
|
511
|
+
table = data.get('tables', {}).get(table_name, {})
|
|
512
|
+
return table.get('records', [])
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def get_fields(data, table_name):
|
|
516
|
+
"""Get field names for a specific table. Returns list of strings."""
|
|
517
|
+
table = data.get('tables', {}).get(table_name, {})
|
|
518
|
+
return table.get('fields', [])
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# ─────────────────────────────────────────────
|
|
522
|
+
# CALENDAR PARSING
|
|
523
|
+
# ─────────────────────────────────────────────
|
|
524
|
+
|
|
525
|
+
# P6 writes a calendar time slot in either field order, and the order is a
|
|
526
|
+
# property of the individual calendar, not of the export or the P6 version:
|
|
527
|
+
# (s|08:00|f|16:00) start-first
|
|
528
|
+
# (f|12:00|s|08:00) finish-first
|
|
529
|
+
# Both orders occur inside one genuine P6 24.12 export (a start-first five-day
|
|
530
|
+
# calendar beside a finish-first six-day calendar), so the order cannot be
|
|
531
|
+
# decided from the export header. Hours may be 1 or 2 digits ('8:00').
|
|
532
|
+
#
|
|
533
|
+
# Accepting only start-first made every finish-first calendar decode to zero
|
|
534
|
+
# working days, which the working-day helpers then replaced with a Mon-Fri
|
|
535
|
+
# week, and filed every finish-first exception body as a holiday. Calendars
|
|
536
|
+
# that hit include a genuine Mon-Sat six-day calendar and a seven-day
|
|
537
|
+
# continuous calendar.
|
|
538
|
+
_TIME_SLOT_RE = re.compile(
|
|
539
|
+
r'\((?:s\|\d{1,2}:\d{2}\|f|f\|\d{1,2}:\d{2}\|s)\|\d{1,2}:\d{2}\)'
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def parse_calendar_data(clndr_data_str):
|
|
544
|
+
"""
|
|
545
|
+
Parse the encoded clndr_data field from the CALENDAR table.
|
|
546
|
+
Returns structured calendar info including work days, holidays, and exceptions.
|
|
547
|
+
|
|
548
|
+
The clndr_data string uses a proprietary P6 encoding:
|
|
549
|
+
- Parenthesized sections define day patterns
|
|
550
|
+
- Work/non-work patterns for each day of the week
|
|
551
|
+
- Exception dates for holidays and special days
|
|
552
|
+
"""
|
|
553
|
+
result = {
|
|
554
|
+
'work_days': [], # list of day indices (0=Sun, 1=Mon, ..., 6=Sat)
|
|
555
|
+
'work_day_names': [], # human-readable day names
|
|
556
|
+
'holidays': [], # exception dates that are NON-working (day off)
|
|
557
|
+
'special_workdays': [], # exception dates that ARE working (day on despite being normally off)
|
|
558
|
+
'exceptions': [], # other exception entries
|
|
559
|
+
'hours_per_day': None,
|
|
560
|
+
'raw': clndr_data_str,
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if not clndr_data_str or not clndr_data_str.strip():
|
|
564
|
+
return result
|
|
565
|
+
|
|
566
|
+
day_names = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
|
567
|
+
|
|
568
|
+
# Extract standard work week pattern (P6 19.x / 23.x / 24.x format).
|
|
569
|
+
# Actual P6 encoding is:
|
|
570
|
+
# (0||<dayN>()((0||<slot>(s|HH:MM|f|HH:MM)())(...))) <- work day (1+ time slots)
|
|
571
|
+
# (0||<dayN>()()) <- non-work day (empty)
|
|
572
|
+
# Day numbers: 1=Sunday ... 7=Saturday.
|
|
573
|
+
#
|
|
574
|
+
# Strategy: find the DaysOfWeek block by balanced-paren walking (the earlier
|
|
575
|
+
# non-greedy regex stopped at the first `))` which is inside a day's time-slot
|
|
576
|
+
# list — only ever captured 1 day). Then walk the block and classify each
|
|
577
|
+
# `(0||N()...)` segment by whether it contains a time slot.
|
|
578
|
+
def _balanced_block_after(text, anchor):
|
|
579
|
+
"""Return the contents inside the () that follow `anchor` in `text`."""
|
|
580
|
+
idx = text.find(anchor)
|
|
581
|
+
if idx < 0:
|
|
582
|
+
return None
|
|
583
|
+
# Find the `(` that starts the block we want (the one AFTER the anchor's own "()")
|
|
584
|
+
# DaysOfWeek pattern is `DaysOfWeek()(...)` — we want the second `(`.
|
|
585
|
+
open_idx = text.find('(', idx + len(anchor))
|
|
586
|
+
# Skip over a possible empty-paren pair `()` that comes immediately
|
|
587
|
+
if text[open_idx:open_idx+2] == '()':
|
|
588
|
+
open_idx = text.find('(', open_idx + 2)
|
|
589
|
+
if open_idx < 0:
|
|
590
|
+
return None
|
|
591
|
+
depth = 0
|
|
592
|
+
for k in range(open_idx, len(text)):
|
|
593
|
+
c = text[k]
|
|
594
|
+
if c == '(':
|
|
595
|
+
depth += 1
|
|
596
|
+
elif c == ')':
|
|
597
|
+
depth -= 1
|
|
598
|
+
if depth == 0:
|
|
599
|
+
return text[open_idx + 1:k]
|
|
600
|
+
return None
|
|
601
|
+
|
|
602
|
+
dow_block = _balanced_block_after(clndr_data_str, 'DaysOfWeek')
|
|
603
|
+
if dow_block is not None:
|
|
604
|
+
# Walk the block to find each (0||N()...) day segment.
|
|
605
|
+
i = 0
|
|
606
|
+
while i < len(dow_block):
|
|
607
|
+
m = re.match(r'\(0\|\|(\d)\(\)', dow_block[i:])
|
|
608
|
+
if not m:
|
|
609
|
+
i += 1
|
|
610
|
+
continue
|
|
611
|
+
day_num = int(m.group(1))
|
|
612
|
+
start = i
|
|
613
|
+
# Walk to matching close of this day segment
|
|
614
|
+
depth = 0
|
|
615
|
+
j = i
|
|
616
|
+
while j < len(dow_block):
|
|
617
|
+
ch = dow_block[j]
|
|
618
|
+
if ch == '(':
|
|
619
|
+
depth += 1
|
|
620
|
+
elif ch == ')':
|
|
621
|
+
depth -= 1
|
|
622
|
+
if depth == 0:
|
|
623
|
+
j += 1
|
|
624
|
+
break
|
|
625
|
+
j += 1
|
|
626
|
+
day_body = dow_block[start:j]
|
|
627
|
+
# A day is a work day iff it contains at least one time slot, in
|
|
628
|
+
# either P6 field order — see _TIME_SLOT_RE.
|
|
629
|
+
if _TIME_SLOT_RE.search(day_body):
|
|
630
|
+
day_idx = day_num - 1
|
|
631
|
+
if 0 <= day_idx <= 6:
|
|
632
|
+
result['work_days'].append(day_idx)
|
|
633
|
+
result['work_day_names'].append(day_names[day_idx])
|
|
634
|
+
i = j
|
|
635
|
+
|
|
636
|
+
# Fallback: older P6 versions compact work days as bare `(d|N)` patterns.
|
|
637
|
+
if not result['work_days']:
|
|
638
|
+
bare_days = set(int(m) for m in re.findall(r'\(d\|(\d)\)', clndr_data_str))
|
|
639
|
+
for day_idx in range(7):
|
|
640
|
+
day_num = day_idx + 1
|
|
641
|
+
if day_num in bare_days:
|
|
642
|
+
result['work_days'].append(day_idx)
|
|
643
|
+
result['work_day_names'].append(day_names[day_idx])
|
|
644
|
+
|
|
645
|
+
# Second fallback: legacy `d|N(s|...` inline pattern (keeps compatibility
|
|
646
|
+
# with any P6 variant that used the old encoding we originally targeted).
|
|
647
|
+
if not result['work_days']:
|
|
648
|
+
for day_idx in range(7):
|
|
649
|
+
day_num = day_idx + 1
|
|
650
|
+
if f'd|{day_num}(s|' in clndr_data_str:
|
|
651
|
+
result['work_days'].append(day_idx)
|
|
652
|
+
result['work_day_names'].append(day_names[day_idx])
|
|
653
|
+
|
|
654
|
+
# Extract exception dates, classifying each as:
|
|
655
|
+
# - holiday (day off: body after d|<serial> is empty → `()( )`)
|
|
656
|
+
# - special workday (day forced on: body after d|<serial> contains a time slot `(s|HH:MM|f|HH:MM)`)
|
|
657
|
+
#
|
|
658
|
+
# P6 exception encoding:
|
|
659
|
+
# (0||0(d|<serial>)(<body>))
|
|
660
|
+
# where:
|
|
661
|
+
# - <serial> is either an integer Excel serial date or a YYYY-MM-DD string
|
|
662
|
+
# - <body> is empty `()` for a non-working exception (holiday), or
|
|
663
|
+
# contains `(0||0(s|08:00|f|16:00)())` time-slot segments for a
|
|
664
|
+
# working exception (special workday).
|
|
665
|
+
exc_block = _balanced_block_after(clndr_data_str, 'Exceptions')
|
|
666
|
+
if exc_block:
|
|
667
|
+
# Walk the exceptions block segment-by-segment via balanced parens so
|
|
668
|
+
# we can classify each `(0||0(d|<serial>)(<body>))` as working or not.
|
|
669
|
+
i = 0
|
|
670
|
+
while i < len(exc_block):
|
|
671
|
+
# Look for start of an exception segment: (0||0(d|<serial>)(<body>))
|
|
672
|
+
m = re.match(r'\(0\|\|0\(d\|([^)]+)\)', exc_block[i:])
|
|
673
|
+
if not m:
|
|
674
|
+
i += 1
|
|
675
|
+
continue
|
|
676
|
+
serial_raw = m.group(1)
|
|
677
|
+
# Move past the `(d|...)` portion to where <body> begins
|
|
678
|
+
j = i + m.end()
|
|
679
|
+
# Skip whitespace if any
|
|
680
|
+
while j < len(exc_block) and exc_block[j] in ' \t\r\n':
|
|
681
|
+
j += 1
|
|
682
|
+
# Expect `(` — the body's outer open paren
|
|
683
|
+
body_start = j
|
|
684
|
+
body_end = body_start
|
|
685
|
+
if j < len(exc_block) and exc_block[j] == '(':
|
|
686
|
+
depth = 0
|
|
687
|
+
k = j
|
|
688
|
+
while k < len(exc_block):
|
|
689
|
+
ch = exc_block[k]
|
|
690
|
+
if ch == '(':
|
|
691
|
+
depth += 1
|
|
692
|
+
elif ch == ')':
|
|
693
|
+
depth -= 1
|
|
694
|
+
if depth == 0:
|
|
695
|
+
body_end = k + 1
|
|
696
|
+
break
|
|
697
|
+
k += 1
|
|
698
|
+
body_text = exc_block[body_start:body_end] if body_end > body_start else ''
|
|
699
|
+
# Classify: any time slot → working exception; else → holiday.
|
|
700
|
+
# Both P6 field orders count — see _TIME_SLOT_RE. A seven-day
|
|
701
|
+
# continuous calendar can carry dozens of finish-first exception
|
|
702
|
+
# bodies; matching only start-first turned every one of them into
|
|
703
|
+
# an invented day off.
|
|
704
|
+
is_special_workday = bool(_TIME_SLOT_RE.search(body_text))
|
|
705
|
+
# Parse the serial into an ISO date
|
|
706
|
+
iso_date = _xer_exception_serial_to_iso(serial_raw)
|
|
707
|
+
if iso_date:
|
|
708
|
+
if is_special_workday:
|
|
709
|
+
result['special_workdays'].append(iso_date)
|
|
710
|
+
else:
|
|
711
|
+
result['holidays'].append(iso_date)
|
|
712
|
+
# Advance past this segment; find the closing paren of the outer
|
|
713
|
+
# (0||0(d|...)(<body>)) tuple. body_end already ended on the
|
|
714
|
+
# body's closing paren. One more `)` should close the outer tuple.
|
|
715
|
+
seg_end = body_end
|
|
716
|
+
# Advance at least one past body_end to avoid infinite loop if
|
|
717
|
+
# body_end didn't advance.
|
|
718
|
+
i = max(seg_end, i + 1)
|
|
719
|
+
|
|
720
|
+
# Always apply the proven v1.0 regex-based capture across the entire
|
|
721
|
+
# exceptions block. Some P6 exports nest exceptions in formats the
|
|
722
|
+
# balanced-paren walker above misses (certain large-program XER exports
|
|
723
|
+
# use a different opening marker on the full holiday set). Union with
|
|
724
|
+
# the walker output; dedupe below. Special_workdays already captured
|
|
725
|
+
# above — this fallback preserves holidays without double-classifying.
|
|
726
|
+
if exc_block:
|
|
727
|
+
walker_working = set(result['special_workdays'])
|
|
728
|
+
# Classify EACH integer-serial exception by whether ITS OWN segment
|
|
729
|
+
# (from this serial up to the next serial) contains a work time-slot.
|
|
730
|
+
# A segment with a time-slot is a special workday (modified working
|
|
731
|
+
# day), NOT a holiday; an empty body is a genuine non-working holiday.
|
|
732
|
+
#
|
|
733
|
+
# The earlier pre-scan regex required the serial and its time-slot body
|
|
734
|
+
# to be adjacent. P6 separates them with line markers (\x7f\x7f) +
|
|
735
|
+
# whitespace, so on continuous calendars (7x24 / 7-Day) every working
|
|
736
|
+
# exception fell through to `holidays`: a continuous calendar decoded
|
|
737
|
+
# to hundreds of phantom days off and CPM finish dates moved by months.
|
|
738
|
+
# Per-segment scanning is separator-tolerant and preserves real
|
|
739
|
+
# statutory holidays (empty body) on work calendars.
|
|
740
|
+
_time_slot = _TIME_SLOT_RE
|
|
741
|
+
_int_serials = list(re.finditer(r'd\|(\d+)\b', exc_block))
|
|
742
|
+
for _idx, _m in enumerate(_int_serials):
|
|
743
|
+
iso = _xer_exception_serial_to_iso(_m.group(1))
|
|
744
|
+
if not iso or iso in walker_working:
|
|
745
|
+
continue
|
|
746
|
+
_seg_end = (_int_serials[_idx + 1].start()
|
|
747
|
+
if _idx + 1 < len(_int_serials) else len(exc_block))
|
|
748
|
+
if _time_slot.search(exc_block[_m.end():_seg_end]):
|
|
749
|
+
if iso not in result['special_workdays']:
|
|
750
|
+
result['special_workdays'].append(iso)
|
|
751
|
+
else:
|
|
752
|
+
result['holidays'].append(iso)
|
|
753
|
+
# Legacy string exceptions: d|YYYY-MM-DD (no inline body observed in
|
|
754
|
+
# this layout; treat as non-working holidays unless already working).
|
|
755
|
+
for iso in re.findall(r'd\|(\d{4}-\d{2}-\d{2})', exc_block):
|
|
756
|
+
try:
|
|
757
|
+
y = int(iso[:4])
|
|
758
|
+
if 1970 <= y <= 2099 and iso not in walker_working:
|
|
759
|
+
result['holidays'].append(iso)
|
|
760
|
+
except ValueError:
|
|
761
|
+
continue
|
|
762
|
+
|
|
763
|
+
# Remove duplicates and sort. A day carrying explicit work hours is a
|
|
764
|
+
# working day, never a holiday: if two parse paths disagree on a serial,
|
|
765
|
+
# special_workday wins (insurance against double-classification).
|
|
766
|
+
result['special_workdays'] = sorted(set(result['special_workdays']))
|
|
767
|
+
result['holidays'] = sorted(set(result['holidays']) - set(result['special_workdays']))
|
|
768
|
+
|
|
769
|
+
return result
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _xer_exception_serial_to_iso(serial_raw):
|
|
773
|
+
"""Convert a P6 exception date serial (int or YYYY-MM-DD) to an ISO date string.
|
|
774
|
+
|
|
775
|
+
Returns '' if the value is malformed or out of the 1970–2099 sanity range.
|
|
776
|
+
(The window was 1990–2050 before v0.2.0, which dropped genuine holidays
|
|
777
|
+
entered for later years.)
|
|
778
|
+
"""
|
|
779
|
+
s = serial_raw.strip()
|
|
780
|
+
# Integer Excel-serial date
|
|
781
|
+
if re.fullmatch(r'\d{5,6}', s):
|
|
782
|
+
try:
|
|
783
|
+
serial = int(s)
|
|
784
|
+
# Excel/P6 epoch = 1899-12-30 (accounts for the 1900 leap-year bug)
|
|
785
|
+
dt = datetime(1899, 12, 30) + timedelta(days=serial)
|
|
786
|
+
if 1970 <= dt.year <= 2099:
|
|
787
|
+
return dt.strftime('%Y-%m-%d')
|
|
788
|
+
except (ValueError, OverflowError):
|
|
789
|
+
pass
|
|
790
|
+
# String date (legacy format)
|
|
791
|
+
m = re.match(r'(\d{4}-\d{2}-\d{2})', s)
|
|
792
|
+
if m:
|
|
793
|
+
try:
|
|
794
|
+
dt = datetime.strptime(m.group(1), '%Y-%m-%d')
|
|
795
|
+
if 1970 <= dt.year <= 2099:
|
|
796
|
+
return m.group(1)
|
|
797
|
+
except ValueError:
|
|
798
|
+
pass
|
|
799
|
+
return ''
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
# A time slot with its times captured, in either P6 field order (see
|
|
803
|
+
# _TIME_SLOT_RE): groups 1-4 are start-first, 5-8 finish-first.
|
|
804
|
+
_TIME_SLOT_TIMES_RE = re.compile(
|
|
805
|
+
r'\((?:s\|(\d{1,2}):(\d{2})\|f\|(\d{1,2}):(\d{2})'
|
|
806
|
+
r'|f\|(\d{1,2}):(\d{2})\|s\|(\d{1,2}):(\d{2}))\)'
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
class CalendarShiftHoursWarning(UserWarning):
|
|
811
|
+
"""A CALENDAR row's shifts work a different number of hours a day from its
|
|
812
|
+
day_hr_cnt ("Hours per Day"), the same on every working day of its week.
|
|
813
|
+
|
|
814
|
+
P6 schedules durations against the shifts; day_hr_cnt only converts hours
|
|
815
|
+
to days for display. ``get_calendar_map`` takes hours_per_day from the
|
|
816
|
+
shifts for such a calendar and keeps the file's figure as
|
|
817
|
+
``declared_hours_per_day``; ``calendar_shift_hours(cal_map)`` lists them.
|
|
818
|
+
Measured 10-Oct-2026 on Primavera P6 Professional 23.12.1's own F9: a
|
|
819
|
+
Monday-Saturday 07:00-17:00 calendar declaring 8 ran a 40 h activity over
|
|
820
|
+
four days.
|
|
821
|
+
"""
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _shift_hours_by_weekday(clndr_data_str):
|
|
825
|
+
"""Hours each working weekday's shifts work, from a clndr_data blob's
|
|
826
|
+
DaysOfWeek block: {weekday 0=Sun..6=Sat: hours}, working days only.
|
|
827
|
+
|
|
828
|
+
Slots count in either P6 field order; a finish of 00:00 is midnight at the
|
|
829
|
+
end of the day. Empty when the blob has no DaysOfWeek block or no day in it
|
|
830
|
+
carries a time slot (the older bare-day encodings carry no hours)."""
|
|
831
|
+
text = clndr_data_str or ''
|
|
832
|
+
idx = text.find('DaysOfWeek')
|
|
833
|
+
if idx < 0:
|
|
834
|
+
return {}
|
|
835
|
+
open_idx = text.find('(', idx + len('DaysOfWeek'))
|
|
836
|
+
if text[open_idx:open_idx + 2] == '()':
|
|
837
|
+
open_idx = text.find('(', open_idx + 2)
|
|
838
|
+
if open_idx < 0:
|
|
839
|
+
return {}
|
|
840
|
+
depth, block = 0, None
|
|
841
|
+
for k in range(open_idx, len(text)):
|
|
842
|
+
if text[k] == '(':
|
|
843
|
+
depth += 1
|
|
844
|
+
elif text[k] == ')':
|
|
845
|
+
depth -= 1
|
|
846
|
+
if depth == 0:
|
|
847
|
+
block = text[open_idx + 1:k]
|
|
848
|
+
break
|
|
849
|
+
if block is None:
|
|
850
|
+
return {}
|
|
851
|
+
out = {}
|
|
852
|
+
i = 0
|
|
853
|
+
while i < len(block):
|
|
854
|
+
m = re.match(r'\(0\|\|(\d)\(\)', block[i:])
|
|
855
|
+
if not m:
|
|
856
|
+
i += 1
|
|
857
|
+
continue
|
|
858
|
+
day_idx = int(m.group(1)) - 1
|
|
859
|
+
depth, j = 0, i
|
|
860
|
+
while j < len(block):
|
|
861
|
+
if block[j] == '(':
|
|
862
|
+
depth += 1
|
|
863
|
+
elif block[j] == ')':
|
|
864
|
+
depth -= 1
|
|
865
|
+
if depth == 0:
|
|
866
|
+
j += 1
|
|
867
|
+
break
|
|
868
|
+
j += 1
|
|
869
|
+
minutes = 0
|
|
870
|
+
for slot in _TIME_SLOT_TIMES_RE.finditer(block[i:j]):
|
|
871
|
+
g = slot.groups()
|
|
872
|
+
if g[0] is not None:
|
|
873
|
+
start = int(g[0]) * 60 + int(g[1])
|
|
874
|
+
finish = int(g[2]) * 60 + int(g[3])
|
|
875
|
+
else:
|
|
876
|
+
finish = int(g[4]) * 60 + int(g[5])
|
|
877
|
+
start = int(g[6]) * 60 + int(g[7])
|
|
878
|
+
if finish == 0:
|
|
879
|
+
finish = 24 * 60
|
|
880
|
+
if finish > start:
|
|
881
|
+
minutes += finish - start
|
|
882
|
+
if minutes and 0 <= day_idx <= 6:
|
|
883
|
+
out[day_idx] = minutes / 60.0
|
|
884
|
+
i = j
|
|
885
|
+
return out
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def calendar_shift_hours(cal_map):
|
|
889
|
+
"""Return the calendars whose hours_per_day was taken from their shifts.
|
|
890
|
+
|
|
891
|
+
Each entry: {'clndr_id', 'clndr_name', 'hours_per_day',
|
|
892
|
+
'declared_hours_per_day'}. Empty when every calendar's shifts work the
|
|
893
|
+
hours its day_hr_cnt declares (or its week's working days differ). An
|
|
894
|
+
empty record also keeps its declared figure, but it has no shifts: P6
|
|
895
|
+
schedules it on its internal Standard, so it is not listed here."""
|
|
896
|
+
return [
|
|
897
|
+
{
|
|
898
|
+
'clndr_id': c.get('clndr_id', ''),
|
|
899
|
+
'clndr_name': c.get('clndr_name', ''),
|
|
900
|
+
'hours_per_day': c.get('hours_per_day'),
|
|
901
|
+
'declared_hours_per_day': c.get('declared_hours_per_day'),
|
|
902
|
+
}
|
|
903
|
+
for c in cal_map.values()
|
|
904
|
+
if isinstance(c, dict) and c.get('declared_hours_per_day') is not None
|
|
905
|
+
and not c.get('empty_record')
|
|
906
|
+
]
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
# ─────────────────────────────────────────────
|
|
910
|
+
# CORRUPT CALENDAR RECORDS
|
|
911
|
+
# ─────────────────────────────────────────────
|
|
912
|
+
# The clndr_data decoder below is cpp-cpm-engine's (python_reference/cpm.py,
|
|
913
|
+
# decode_clndr_data and decode_calendar_record, the Python port of
|
|
914
|
+
# cpm-engine.js decodeClndrData), copied unchanged so that this parser,
|
|
915
|
+
# standalone, screens a calendar record exactly as the engine's parseXER
|
|
916
|
+
# does. Keep it in lockstep with the engine.
|
|
917
|
+
#
|
|
918
|
+
# The screen it carries: a record is CORRUPT when (a) its DaysOfWeek block
|
|
919
|
+
# holds at least one finish-first slot pair, (f|HH:MM|s|HH:MM), AND (b) its
|
|
920
|
+
# CALENDAR.clndr_type is not a legal P6 token (CA_Base / CA_Rsrc /
|
|
921
|
+
# CA_Project). The corrupt record seen in real exports carries 'CT_Project'
|
|
922
|
+
# where every legitimate project calendar says 'CA_Project'. P6 cannot bind
|
|
923
|
+
# such a record and schedules the activities assigned to it on its internal
|
|
924
|
+
# Standard calendar: Monday to Friday, eight hours a day, no exceptions.
|
|
925
|
+
# Measured on P6's own stored dates in real exports carrying the record:
|
|
926
|
+
# every one of 130 unambiguous not-started spans on it walks Monday to
|
|
927
|
+
# Friday, no forecast date lands on the Saturday it declares working,
|
|
928
|
+
# statutory holidays are worked, and every finish closes at the default
|
|
929
|
+
# calendar's closing time.
|
|
930
|
+
#
|
|
931
|
+
# Finish-first slots ALONE are a legitimate export variant: finish-first
|
|
932
|
+
# records with a legal clndr_type were shown genuine by P6's own stored dates
|
|
933
|
+
# (their declared week walked, their own holidays kept), and they decode as
|
|
934
|
+
# declared. A record failing only one conjunct decodes as declared. A blank
|
|
935
|
+
# or missing clndr_type fails (b), so finish-first slots with no type take
|
|
936
|
+
# the fallback; a genuine P6 export always writes the type.
|
|
937
|
+
#
|
|
938
|
+
# Three grammar cases the decoder handles beyond the common form: slots
|
|
939
|
+
# serialized finish-first; f|00:00, the midnight END of a shift (1440
|
|
940
|
+
# minutes), not 0; and s|00:00|f|00:00, a full 24-hour working day.
|
|
941
|
+
|
|
942
|
+
_D7_SLOT_ANY = re.compile(
|
|
943
|
+
r'(?:s\|(\d{1,2}:\d{2})\|f\|(\d{1,2}:\d{2}))'
|
|
944
|
+
r'|(?:f\|(\d{1,2}:\d{2})\|s\|(\d{1,2}:\d{2}))')
|
|
945
|
+
_D7_FINISH_FIRST = re.compile(r'f\|\d{1,2}:\d{2}\|s\|\d{1,2}:\d{2}')
|
|
946
|
+
_D7_DAY_SEG = re.compile(r'^\(0\|\|([1-7])\(\)')
|
|
947
|
+
_D7_EXC_DATE = re.compile(r'd\|(\d{3,7}|\d{4}-\d{2}-\d{2})\b')
|
|
948
|
+
_D7_ISO_DATE = re.compile(r'^\d{4}-\d{2}-\d{2}$')
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _d7_block_after(text, anchor):
|
|
952
|
+
"""Contents of the parenthesised block that follows `anchor`."""
|
|
953
|
+
i = text.find(anchor)
|
|
954
|
+
if i < 0:
|
|
955
|
+
return None
|
|
956
|
+
j = text.find('(', i + len(anchor))
|
|
957
|
+
if j < 0:
|
|
958
|
+
return None
|
|
959
|
+
if text[j:j + 2] == '()':
|
|
960
|
+
j = text.find('(', j + 2)
|
|
961
|
+
if j < 0:
|
|
962
|
+
return None
|
|
963
|
+
depth = 0
|
|
964
|
+
for k in range(j, len(text)):
|
|
965
|
+
ch = text[k]
|
|
966
|
+
if ch == '(':
|
|
967
|
+
depth += 1
|
|
968
|
+
elif ch == ')':
|
|
969
|
+
depth -= 1
|
|
970
|
+
if depth == 0:
|
|
971
|
+
return text[j + 1:k]
|
|
972
|
+
return None
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _d7_segments_of(block):
|
|
976
|
+
"""Split a block into its top-level (...) segments."""
|
|
977
|
+
segs = []
|
|
978
|
+
depth = 0
|
|
979
|
+
start = -1
|
|
980
|
+
for k, ch in enumerate(block):
|
|
981
|
+
if ch == '(':
|
|
982
|
+
if depth == 0:
|
|
983
|
+
start = k
|
|
984
|
+
depth += 1
|
|
985
|
+
elif ch == ')':
|
|
986
|
+
depth -= 1
|
|
987
|
+
if depth == 0 and start >= 0:
|
|
988
|
+
segs.append(block[start:k + 1])
|
|
989
|
+
start = -1
|
|
990
|
+
return segs
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _d7_hhmm(s):
|
|
994
|
+
p = s.split(':')
|
|
995
|
+
return int(p[0]) * 60 + int(p[1])
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _d7_slots_of(seg):
|
|
999
|
+
slots = []
|
|
1000
|
+
for m in _D7_SLOT_ANY.finditer(seg):
|
|
1001
|
+
if m.group(1) is not None:
|
|
1002
|
+
s = _d7_hhmm(m.group(1))
|
|
1003
|
+
f = _d7_hhmm(m.group(2))
|
|
1004
|
+
else:
|
|
1005
|
+
f = _d7_hhmm(m.group(3))
|
|
1006
|
+
s = _d7_hhmm(m.group(4))
|
|
1007
|
+
if f == 0:
|
|
1008
|
+
f = 1440 # midnight END of the shift (with s=0, a full 24h day)
|
|
1009
|
+
if f > s:
|
|
1010
|
+
slots.append((s, f))
|
|
1011
|
+
return slots
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _d7_serial_to_date_string(raw_val):
|
|
1015
|
+
v = str(raw_val).strip()
|
|
1016
|
+
if _D7_ISO_DATE.match(v):
|
|
1017
|
+
try:
|
|
1018
|
+
date(int(v[0:4]), int(v[5:7]), int(v[8:10]))
|
|
1019
|
+
except ValueError:
|
|
1020
|
+
return ''
|
|
1021
|
+
return v
|
|
1022
|
+
try:
|
|
1023
|
+
serial = int(v, 10)
|
|
1024
|
+
except ValueError:
|
|
1025
|
+
return ''
|
|
1026
|
+
dt = date(1899, 12, 30) + timedelta(days=serial)
|
|
1027
|
+
if dt.year < 1970 or dt.year > 2099:
|
|
1028
|
+
return ''
|
|
1029
|
+
return dt.strftime('%Y-%m-%d')
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
_D7_LEGAL_CLNDR_TYPES = ('CA_Base', 'CA_Rsrc', 'CA_Project')
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def decode_clndr_data(clndr_data, clndr_type=None):
|
|
1036
|
+
"""Decode a P6 CALENDAR.clndr_data blob: cpp-cpm-engine's decoder,
|
|
1037
|
+
unchanged (see CORRUPT CALENDAR RECORDS above)."""
|
|
1038
|
+
out = {
|
|
1039
|
+
'decode_ok': False,
|
|
1040
|
+
'corrupt_fallback': False,
|
|
1041
|
+
'work_days': [],
|
|
1042
|
+
'holidays': [],
|
|
1043
|
+
'special_workdays': [],
|
|
1044
|
+
'week_hours': [0, 0, 0, 0, 0, 0, 0], # hours/day, index 0=Sun..6=Sat
|
|
1045
|
+
'hours_per_day': None,
|
|
1046
|
+
'declared': None,
|
|
1047
|
+
}
|
|
1048
|
+
raw = '' if clndr_data is None else str(clndr_data)
|
|
1049
|
+
if not raw or '(' not in raw:
|
|
1050
|
+
return out
|
|
1051
|
+
|
|
1052
|
+
week_minutes = [0, 0, 0, 0, 0, 0, 0]
|
|
1053
|
+
finish_first_in_week = False
|
|
1054
|
+
dow_block = _d7_block_after(raw, 'DaysOfWeek')
|
|
1055
|
+
if dow_block is not None:
|
|
1056
|
+
for seg in _d7_segments_of(dow_block):
|
|
1057
|
+
m = _D7_DAY_SEG.match(seg)
|
|
1058
|
+
if not m:
|
|
1059
|
+
continue
|
|
1060
|
+
js_dow = (int(m.group(1)) - 1) % 7 # P6 1=Sun..7=Sat -> 0=Sun..6=Sat
|
|
1061
|
+
if _D7_FINISH_FIRST.search(seg):
|
|
1062
|
+
finish_first_in_week = True
|
|
1063
|
+
mins = 0
|
|
1064
|
+
for sl in _d7_slots_of(seg):
|
|
1065
|
+
mins += sl[1] - sl[0]
|
|
1066
|
+
week_minutes[js_dow] = mins
|
|
1067
|
+
holidays = []
|
|
1068
|
+
specials = []
|
|
1069
|
+
exc_block = _d7_block_after(raw, 'Exceptions')
|
|
1070
|
+
if exc_block is not None:
|
|
1071
|
+
for seg in _d7_segments_of(exc_block):
|
|
1072
|
+
dm = _D7_EXC_DATE.search(seg)
|
|
1073
|
+
if not dm:
|
|
1074
|
+
continue
|
|
1075
|
+
ds = _d7_serial_to_date_string(dm.group(1))
|
|
1076
|
+
if not ds:
|
|
1077
|
+
continue
|
|
1078
|
+
if len(_d7_slots_of(seg)) > 0:
|
|
1079
|
+
specials.append(ds)
|
|
1080
|
+
else:
|
|
1081
|
+
holidays.append(ds)
|
|
1082
|
+
work_days = [i for i in range(7) if week_minutes[i] > 0]
|
|
1083
|
+
week_hours = [mn / 60 for mn in week_minutes]
|
|
1084
|
+
|
|
1085
|
+
_legal_clndr_type = (
|
|
1086
|
+
str('' if clndr_type is None else clndr_type).strip()
|
|
1087
|
+
in _D7_LEGAL_CLNDR_TYPES)
|
|
1088
|
+
if finish_first_in_week and not _legal_clndr_type:
|
|
1089
|
+
# The corrupt record: P6's internal Standard, Mon-Fri, 8 h/day, NO
|
|
1090
|
+
# exceptions. A corrupt record declaring other hours still falls
|
|
1091
|
+
# back to 8 (inferred for that case: the measured records declare 8).
|
|
1092
|
+
out['corrupt_fallback'] = True
|
|
1093
|
+
out['decode_ok'] = False
|
|
1094
|
+
out['work_days'] = [1, 2, 3, 4, 5]
|
|
1095
|
+
out['week_hours'] = [0, 8, 8, 8, 8, 8, 0]
|
|
1096
|
+
out['hours_per_day'] = 8
|
|
1097
|
+
out['declared'] = {
|
|
1098
|
+
'work_days': work_days,
|
|
1099
|
+
'week_hours': week_hours,
|
|
1100
|
+
'holidays': holidays,
|
|
1101
|
+
'special_workdays': specials,
|
|
1102
|
+
}
|
|
1103
|
+
return out
|
|
1104
|
+
out['work_days'] = work_days
|
|
1105
|
+
out['week_hours'] = week_hours
|
|
1106
|
+
out['holidays'] = holidays
|
|
1107
|
+
out['special_workdays'] = specials
|
|
1108
|
+
out['decode_ok'] = len(work_days) > 0
|
|
1109
|
+
if work_days:
|
|
1110
|
+
out['hours_per_day'] = max(week_hours[d] for d in work_days)
|
|
1111
|
+
return out
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def decode_calendar_record(clndr_id, clndr_name, clndr_data, alerts=None,
|
|
1115
|
+
clndr_type=None):
|
|
1116
|
+
"""Decode one CALENDAR row the way cpp-cpm-engine's parseXER does
|
|
1117
|
+
(the engine's function, unchanged).
|
|
1118
|
+
|
|
1119
|
+
Returns {work_days, holidays, special_workdays, hours_per_day,
|
|
1120
|
+
p6_fallback?}, or None when clndr_data is empty or does not decode. A
|
|
1121
|
+
corrupt record (see CORRUPT CALENDAR RECORDS above; pass the row's
|
|
1122
|
+
clndr_type) gets the P6 Standard fallback, and the
|
|
1123
|
+
``calendar-corrupt-p6-fallback`` ALERT is appended to ``alerts``.
|
|
1124
|
+
"""
|
|
1125
|
+
if not clndr_data:
|
|
1126
|
+
return None
|
|
1127
|
+
dec = decode_clndr_data(clndr_data, clndr_type)
|
|
1128
|
+
if dec['corrupt_fallback']:
|
|
1129
|
+
cal = {
|
|
1130
|
+
'work_days': list(dec['work_days']),
|
|
1131
|
+
'holidays': [],
|
|
1132
|
+
'special_workdays': [],
|
|
1133
|
+
'hours_per_day': 8,
|
|
1134
|
+
'p6_fallback': True,
|
|
1135
|
+
}
|
|
1136
|
+
if isinstance(alerts, list):
|
|
1137
|
+
decl = dec.get('declared') or {}
|
|
1138
|
+
decl_wd = decl.get('work_days') or []
|
|
1139
|
+
decl_hrs = decl.get('week_hours') or []
|
|
1140
|
+
decl_max = 0
|
|
1141
|
+
for d in decl_wd:
|
|
1142
|
+
if d < len(decl_hrs) and decl_hrs[d] > decl_max:
|
|
1143
|
+
decl_max = decl_hrs[d]
|
|
1144
|
+
alerts.append({
|
|
1145
|
+
'severity': 'ALERT',
|
|
1146
|
+
'context': 'calendar-corrupt-p6-fallback',
|
|
1147
|
+
'message': 'FORENSIC FINDING: calendar ' + str(clndr_id) +
|
|
1148
|
+
' (' + (clndr_name or 'unnamed') + ') carries a ' +
|
|
1149
|
+
"corrupt CALENDAR record — its clndr_type field holds '" +
|
|
1150
|
+
('' if clndr_type is None else str(clndr_type)) +
|
|
1151
|
+
"', not a legal P6 calendar-type " +
|
|
1152
|
+
'token (CA_Base / CA_Rsrc / CA_Project), and its weekly ' +
|
|
1153
|
+
'shift slots are serialized finish-first ' +
|
|
1154
|
+
'((f|HH:MM|s|HH:MM)). P6 cannot bind such a record and ' +
|
|
1155
|
+
'demonstrably scheduled the activities assigned to it on ' +
|
|
1156
|
+
'its internal default Standard calendar (Mon-Fri, 8 h/day, ' +
|
|
1157
|
+
'NO exceptions), not on the declared pattern (' +
|
|
1158
|
+
str(len(decl_wd)) + ' working day(s)/week at ' +
|
|
1159
|
+
('%g' % decl_max) +
|
|
1160
|
+
' h/day). The engine emulates that fallback so its dates ' +
|
|
1161
|
+
'match what P6 actually computed. The source record ' +
|
|
1162
|
+
'should be repaired in the P6 database; dates computed ' +
|
|
1163
|
+
'on the declared pattern would diverge from every P6 ' +
|
|
1164
|
+
'reschedule of this file.',
|
|
1165
|
+
})
|
|
1166
|
+
return cal
|
|
1167
|
+
if dec['decode_ok']:
|
|
1168
|
+
return {
|
|
1169
|
+
'work_days': list(dec['work_days']),
|
|
1170
|
+
'holidays': list(dec['holidays']),
|
|
1171
|
+
'special_workdays': list(dec['special_workdays']),
|
|
1172
|
+
'hours_per_day': dec['hours_per_day'],
|
|
1173
|
+
}
|
|
1174
|
+
return None
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
class CalendarCorruptRecordWarning(UserWarning):
|
|
1178
|
+
"""A CALENDAR row is a corrupt record: its week's shift slots are
|
|
1179
|
+
serialized finish-first ((f|HH:MM|s|HH:MM)) AND its ``clndr_type`` is not
|
|
1180
|
+
a legal P6 token (CA_Base / CA_Rsrc / CA_Project).
|
|
1181
|
+
|
|
1182
|
+
P6 cannot bind such a record and schedules the activities assigned to it
|
|
1183
|
+
on its internal Standard calendar (Monday to Friday, 8 h/day, no
|
|
1184
|
+
exceptions); measured on P6's own stored dates in real exports.
|
|
1185
|
+
``get_calendar_map`` substitutes exactly that calendar, which is a
|
|
1186
|
+
finding to disclose, not a repair, and carries the
|
|
1187
|
+
``calendar-corrupt-p6-fallback`` ALERT on the entry; see
|
|
1188
|
+
``calendar_corrupt_fallbacks(cal_map)``.
|
|
1189
|
+
|
|
1190
|
+
Finish-first slots alone are a legitimate export variant: a record with a
|
|
1191
|
+
legal ``clndr_type`` decodes as declared and never raises this warning.
|
|
1192
|
+
"""
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def calendar_corrupt_fallbacks(cal_map):
|
|
1196
|
+
"""Return the ``calendar-corrupt-p6-fallback`` ALERT dicts carried by
|
|
1197
|
+
``cal_map``: {'severity', 'context', 'message'}.
|
|
1198
|
+
|
|
1199
|
+
One entry per calendar that took the P6 Standard fallback because its
|
|
1200
|
+
record is corrupt (see ``CalendarCorruptRecordWarning``). A report
|
|
1201
|
+
computed on the fallback must disclose it. Empty when no calendar in the
|
|
1202
|
+
file is a corrupt record.
|
|
1203
|
+
"""
|
|
1204
|
+
out = []
|
|
1205
|
+
for c in cal_map.values():
|
|
1206
|
+
if isinstance(c, dict) and c.get('p6_fallback') \
|
|
1207
|
+
and c.get('corrupt_record_alert'):
|
|
1208
|
+
out.append(dict(c['corrupt_record_alert']))
|
|
1209
|
+
return out
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def get_calendar_map(data):
|
|
1213
|
+
"""
|
|
1214
|
+
Build a calendar lookup: clndr_id → parsed calendar info.
|
|
1215
|
+
Includes hours_per_day from the CALENDAR table's day_hr_cnt field, and
|
|
1216
|
+
the row's default_flag as the file writes it. An empty record is P6's
|
|
1217
|
+
internal Standard (Monday to Friday, 08:00-16:00, eight hours, no
|
|
1218
|
+
holidays) whatever day_hr_cnt says, flagged empty_record, with the
|
|
1219
|
+
declared figure in declared_hours_per_day. When every working day of
|
|
1220
|
+
the calendar's week works the same hours and day_hr_cnt says otherwise,
|
|
1221
|
+
hours_per_day is the hours the shifts work, as P6 schedules them; the
|
|
1222
|
+
file's figure is kept as declared_hours_per_day, and a
|
|
1223
|
+
CalendarShiftHoursWarning names the calendar.
|
|
1224
|
+
|
|
1225
|
+
A corrupt record (finish-first slot pairs in its week AND a clndr_type
|
|
1226
|
+
that is not CA_Base, CA_Rsrc or CA_Project) is P6's internal Standard
|
|
1227
|
+
too, as P6 schedules it: Monday to Friday, hours_per_day 8 whatever
|
|
1228
|
+
day_hr_cnt says, no holidays, ``p6_fallback`` True, with the
|
|
1229
|
+
``calendar-corrupt-p6-fallback`` ALERT under ``corrupt_record_alert``
|
|
1230
|
+
(``calendar_corrupt_fallbacks(cal_map)`` reads them back) and a
|
|
1231
|
+
CalendarCorruptRecordWarning. Every other record, a finish-first one with
|
|
1232
|
+
a legal clndr_type included, decodes as declared.
|
|
1233
|
+
"""
|
|
1234
|
+
calendars = get_table(data, 'CALENDAR')
|
|
1235
|
+
cal_map = {}
|
|
1236
|
+
|
|
1237
|
+
for cal in calendars:
|
|
1238
|
+
clndr_id = cal.get('clndr_id', '')
|
|
1239
|
+
# An EMPTY record: P6 schedules on its internal Standard, Monday to
|
|
1240
|
+
# Friday 08:00-16:00 with no exceptions, the base calendar's holidays
|
|
1241
|
+
# NOT applied, whatever hours a day the record declares. Measured on
|
|
1242
|
+
# Primavera P6 Professional 23.12.1's own F9 of synthetic probes
|
|
1243
|
+
# (13-Oct-2026): a task 40 h from a Friday works the Labour Day
|
|
1244
|
+
# holiday, and records declaring 10, 6 and 24 h ran the same
|
|
1245
|
+
# 08:00-16:00 days. The declared figure is kept in
|
|
1246
|
+
# declared_hours_per_day; calendar_shift_hours does not list the
|
|
1247
|
+
# record, which has no shifts.
|
|
1248
|
+
if not (cal.get('clndr_data') or '').strip():
|
|
1249
|
+
try:
|
|
1250
|
+
_hpd_decl = float(cal.get('day_hr_cnt') or 8)
|
|
1251
|
+
except (TypeError, ValueError):
|
|
1252
|
+
_hpd_decl = 8.0
|
|
1253
|
+
cal_map[clndr_id] = {
|
|
1254
|
+
'work_days': [1, 2, 3, 4, 5],
|
|
1255
|
+
'work_day_names': ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
|
|
1256
|
+
'holidays': [], 'special_workdays': [], 'exceptions': [],
|
|
1257
|
+
'hours_per_day': 8.0, 'raw': '',
|
|
1258
|
+
'clndr_id': clndr_id, 'clndr_name': cal.get('clndr_name', ''),
|
|
1259
|
+
'default_flag': cal.get('default_flag', '') or '',
|
|
1260
|
+
'hours_per_week': 40.0,
|
|
1261
|
+
'p6_fallback': True, 'empty_record': True,
|
|
1262
|
+
'declared_hours_per_day': _hpd_decl,
|
|
1263
|
+
}
|
|
1264
|
+
continue
|
|
1265
|
+
|
|
1266
|
+
# A CORRUPT record (see CORRUPT CALENDAR RECORDS above): P6 schedules
|
|
1267
|
+
# it on its internal Standard, so it gets that calendar here, with
|
|
1268
|
+
# the ALERT on the entry. Every other record falls through to the
|
|
1269
|
+
# unchanged decode below.
|
|
1270
|
+
clndr_name = cal.get('clndr_name', '')
|
|
1271
|
+
clndr_data = cal.get('clndr_data', '')
|
|
1272
|
+
_alerts = []
|
|
1273
|
+
try:
|
|
1274
|
+
_dec = decode_calendar_record(
|
|
1275
|
+
clndr_id, clndr_name, clndr_data, _alerts,
|
|
1276
|
+
clndr_type=(cal.get('clndr_type') or '').strip())
|
|
1277
|
+
except Exception: # pragma: no cover — defensive
|
|
1278
|
+
_dec, _alerts = None, []
|
|
1279
|
+
if isinstance(_dec, dict) and _dec.get('p6_fallback'):
|
|
1280
|
+
_day_names = ['Sunday', 'Monday', 'Tuesday', 'Wednesday',
|
|
1281
|
+
'Thursday', 'Friday', 'Saturday']
|
|
1282
|
+
cal_map[clndr_id] = {
|
|
1283
|
+
'work_days': list(_dec.get('work_days') or [1, 2, 3, 4, 5]),
|
|
1284
|
+
'work_day_names': [_day_names[d] for d in
|
|
1285
|
+
(_dec.get('work_days') or [1, 2, 3, 4, 5])],
|
|
1286
|
+
'holidays': [],
|
|
1287
|
+
'special_workdays': [],
|
|
1288
|
+
'exceptions': [],
|
|
1289
|
+
# Eight hours whatever the row's day_hr_cnt says, as the
|
|
1290
|
+
# engine schedules it, so hours-to-days matches the walk.
|
|
1291
|
+
'hours_per_day': 8.0,
|
|
1292
|
+
'hours_per_week': 40.0,
|
|
1293
|
+
'raw': clndr_data,
|
|
1294
|
+
'clndr_id': clndr_id,
|
|
1295
|
+
'clndr_name': clndr_name,
|
|
1296
|
+
'default_flag': cal.get('default_flag', '') or '',
|
|
1297
|
+
'p6_fallback': True,
|
|
1298
|
+
'corrupt_record_alert': (
|
|
1299
|
+
dict(_alerts[0]) if _alerts else None),
|
|
1300
|
+
}
|
|
1301
|
+
warnings.warn(
|
|
1302
|
+
'CALENDAR {id} {name!r} carries a corrupt clndr_data '
|
|
1303
|
+
'record (finish-first slot pairs + illegal clndr_type '
|
|
1304
|
+
'{typ!r}). P6 demonstrably schedules such records on its '
|
|
1305
|
+
'internal default Standard calendar; the P6 Standard '
|
|
1306
|
+
'fallback (Mon-Fri, 8 h/day, NO exceptions) is '
|
|
1307
|
+
'substituted here to match, and this is a FORENSIC '
|
|
1308
|
+
'FINDING that must be disclosed on any deliverable '
|
|
1309
|
+
'computed from this map — see '
|
|
1310
|
+
'calendar_corrupt_fallbacks(cal_map).'.format(
|
|
1311
|
+
id=clndr_id, name=clndr_name,
|
|
1312
|
+
typ=(cal.get('clndr_type') or '').strip()),
|
|
1313
|
+
CalendarCorruptRecordWarning,
|
|
1314
|
+
stacklevel=2,
|
|
1315
|
+
)
|
|
1316
|
+
continue
|
|
1317
|
+
|
|
1318
|
+
parsed = parse_calendar_data(cal.get('clndr_data', ''))
|
|
1319
|
+
parsed['clndr_id'] = clndr_id
|
|
1320
|
+
parsed['clndr_name'] = cal.get('clndr_name', '')
|
|
1321
|
+
# As the file writes it. A consumer that needs the default calendar
|
|
1322
|
+
# reads it off this entry; "the first calendar in the file" is not a
|
|
1323
|
+
# substitute, since table order says nothing about which is default.
|
|
1324
|
+
parsed['default_flag'] = cal.get('default_flag', '') or ''
|
|
1325
|
+
|
|
1326
|
+
# Hours per day
|
|
1327
|
+
day_hr = cal.get('day_hr_cnt', '8')
|
|
1328
|
+
try:
|
|
1329
|
+
parsed['hours_per_day'] = float(day_hr)
|
|
1330
|
+
except (ValueError, TypeError):
|
|
1331
|
+
parsed['hours_per_day'] = 8.0
|
|
1332
|
+
# The hours the shifts work, when every working day of the week works
|
|
1333
|
+
# the same and day_hr_cnt says otherwise: P6 schedules on the shifts
|
|
1334
|
+
# (a Monday-Saturday 07:00-17:00 calendar declaring 8 ran 40 h over
|
|
1335
|
+
# four days on its own F9), and every hours-to-days conversion reads
|
|
1336
|
+
# this field. A week of unequal days keeps day_hr_cnt.
|
|
1337
|
+
_hrs = {round(h, 6) for h in
|
|
1338
|
+
_shift_hours_by_weekday(cal.get('clndr_data', '')).values()}
|
|
1339
|
+
if len(_hrs) == 1:
|
|
1340
|
+
_h = _hrs.pop()
|
|
1341
|
+
if _h > 0 and abs(_h - parsed['hours_per_day']) > 1e-6:
|
|
1342
|
+
parsed['declared_hours_per_day'] = parsed['hours_per_day']
|
|
1343
|
+
parsed['hours_per_day'] = float(_h)
|
|
1344
|
+
warnings.warn(
|
|
1345
|
+
'CALENDAR {id} {name!r} works {h:g} h on every working '
|
|
1346
|
+
'day but declares day_hr_cnt {d:g}; hours are '
|
|
1347
|
+
'converted to days on the {h:g} h its shifts work, '
|
|
1348
|
+
'as P6 schedules them.'.format(
|
|
1349
|
+
id=clndr_id, name=parsed['clndr_name'], h=_h,
|
|
1350
|
+
d=parsed['declared_hours_per_day']),
|
|
1351
|
+
CalendarShiftHoursWarning, stacklevel=2)
|
|
1352
|
+
|
|
1353
|
+
# Hours per week
|
|
1354
|
+
week_hr = cal.get('week_hr_cnt', '40')
|
|
1355
|
+
try:
|
|
1356
|
+
parsed['hours_per_week'] = float(week_hr)
|
|
1357
|
+
except (ValueError, TypeError):
|
|
1358
|
+
parsed['hours_per_week'] = 40.0
|
|
1359
|
+
|
|
1360
|
+
cal_map[clndr_id] = parsed
|
|
1361
|
+
|
|
1362
|
+
return cal_map
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def resolve_task_calendars(data):
|
|
1366
|
+
"""The calendar every TASK row is scheduled on, blank clndr_id included.
|
|
1367
|
+
|
|
1368
|
+
A blank TASK.clndr_id is not "no calendar": it means the project calendar.
|
|
1369
|
+
MPXJ writes it that way for every MS Project task that has no task-level
|
|
1370
|
+
calendar, and MS Project schedules such a task on the project calendar.
|
|
1371
|
+
Looking the blank id up in ``get_calendar_map`` returns None, and a
|
|
1372
|
+
consumer then falls through to its own last resort: 8 h/day in
|
|
1373
|
+
``duration_hours_to_days``, without a word, and in cpp-cpm-engine a
|
|
1374
|
+
continuous seven-day week, with an ALERT, unless the caller hands it the
|
|
1375
|
+
project calendar.
|
|
1376
|
+
|
|
1377
|
+
The chain, per TASK row:
|
|
1378
|
+
|
|
1379
|
+
1. TASK.clndr_id, when the row carries one;
|
|
1380
|
+
2. PROJECT.clndr_id of the row's own proj_id, when the CALENDAR table
|
|
1381
|
+
declares it (tier ``'project'``);
|
|
1382
|
+
3. the first CALENDAR row flagged default_flag = 'Y' (tier ``'default'``);
|
|
1383
|
+
4. unresolved - reported under ``unresolved``, reason ``'blank'``.
|
|
1384
|
+
|
|
1385
|
+
A row NAMING a calendar the CALENDAR table does not declare is reported
|
|
1386
|
+
too (reason ``'undeclared'``) and keeps its id: handing it the project
|
|
1387
|
+
calendar would swap in a week the activity was never assigned.
|
|
1388
|
+
|
|
1389
|
+
Ids are matched EXACTLY as the file writes them, because that is how
|
|
1390
|
+
``get_calendar_map`` keys them and the parser keeps a padded id padded: an
|
|
1391
|
+
id tidied here would be reported resolved while every lookup on it missed.
|
|
1392
|
+
Only "blank" is judged after stripping.
|
|
1393
|
+
|
|
1394
|
+
Returns a dict:
|
|
1395
|
+
``by_task`` {task_id: clndr_id} for every TASK row; '' when
|
|
1396
|
+
nothing resolved. Keyed by P6's primary key, so
|
|
1397
|
+
rows sharing a task_id share a slot - fill rows
|
|
1398
|
+
with ``with_resolved_calendars``, which does not
|
|
1399
|
+
go through it.
|
|
1400
|
+
``project_calendar`` {proj_id: clndr_id} - each project's declared
|
|
1401
|
+
calendar, '' when it names none the file has.
|
|
1402
|
+
``default_clndr_id`` the default_flag = 'Y' calendar, or ''.
|
|
1403
|
+
``resolved_by_fallback`` rows resolved by tier 2 or 3: task_id,
|
|
1404
|
+
task_code, proj_id, clndr_id, clndr_name, tier.
|
|
1405
|
+
``unresolved`` rows with no usable calendar: task_id,
|
|
1406
|
+
task_code, task_name, proj_id, clndr_id, reason.
|
|
1407
|
+
Both lists are complete and in file order.
|
|
1408
|
+
"""
|
|
1409
|
+
declared = {}
|
|
1410
|
+
default_id = ''
|
|
1411
|
+
for c in get_table(data, 'CALENDAR'):
|
|
1412
|
+
cid = c.get('clndr_id', '') or ''
|
|
1413
|
+
if not cid.strip():
|
|
1414
|
+
continue
|
|
1415
|
+
declared.setdefault(cid, (c.get('clndr_name') or '').strip())
|
|
1416
|
+
if not default_id and (c.get('default_flag') or '').strip().upper() == 'Y':
|
|
1417
|
+
default_id = cid
|
|
1418
|
+
|
|
1419
|
+
project_calendar = {}
|
|
1420
|
+
for p in get_table(data, 'PROJECT'):
|
|
1421
|
+
pcid = p.get('clndr_id', '') or ''
|
|
1422
|
+
project_calendar[p.get('proj_id', '')] = pcid if pcid in declared else ''
|
|
1423
|
+
|
|
1424
|
+
by_task = {}
|
|
1425
|
+
resolved_by_fallback = []
|
|
1426
|
+
unresolved = []
|
|
1427
|
+
for t in get_table(data, 'TASK'):
|
|
1428
|
+
tid = t.get('task_id', '')
|
|
1429
|
+
proj_id = t.get('proj_id', '')
|
|
1430
|
+
cid = t.get('clndr_id', '') or ''
|
|
1431
|
+
tier = ''
|
|
1432
|
+
if not cid.strip():
|
|
1433
|
+
cid = project_calendar.get(proj_id, '')
|
|
1434
|
+
tier = 'project'
|
|
1435
|
+
if not cid:
|
|
1436
|
+
cid, tier = default_id, 'default'
|
|
1437
|
+
by_task[tid] = cid
|
|
1438
|
+
if cid in declared:
|
|
1439
|
+
if tier:
|
|
1440
|
+
resolved_by_fallback.append({
|
|
1441
|
+
'task_id': tid,
|
|
1442
|
+
'task_code': t.get('task_code', ''),
|
|
1443
|
+
'proj_id': proj_id,
|
|
1444
|
+
'clndr_id': cid,
|
|
1445
|
+
'clndr_name': declared[cid],
|
|
1446
|
+
'tier': tier,
|
|
1447
|
+
})
|
|
1448
|
+
continue
|
|
1449
|
+
unresolved.append({
|
|
1450
|
+
'task_id': tid,
|
|
1451
|
+
'task_code': t.get('task_code', ''),
|
|
1452
|
+
'task_name': (t.get('task_name') or '').strip(),
|
|
1453
|
+
'proj_id': proj_id,
|
|
1454
|
+
'clndr_id': cid,
|
|
1455
|
+
'reason': 'undeclared' if cid else 'blank',
|
|
1456
|
+
})
|
|
1457
|
+
return {
|
|
1458
|
+
'by_task': by_task,
|
|
1459
|
+
'project_calendar': project_calendar,
|
|
1460
|
+
'default_clndr_id': default_id,
|
|
1461
|
+
'resolved_by_fallback': resolved_by_fallback,
|
|
1462
|
+
'unresolved': unresolved,
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def with_resolved_calendars(tasks, resolution):
|
|
1467
|
+
"""TASK rows carrying the clndr_id ``resolve_task_calendars`` gave them.
|
|
1468
|
+
|
|
1469
|
+
For a consumer that looks calendars up as ``cal_map.get(row['clndr_id'])``:
|
|
1470
|
+
feed it these rows and every lookup it makes sees the resolved calendar.
|
|
1471
|
+
Only a row whose own clndr_id is BLANK is ever filled, so a calendar the
|
|
1472
|
+
file names is never overridden, and each row is filled from its OWN
|
|
1473
|
+
proj_id, so malformed rows sharing a task_id cannot take each other's
|
|
1474
|
+
calendar. A filled row is a COPY - the parsed data keeps the file as
|
|
1475
|
+
received - and every other row is passed through as the same object.
|
|
1476
|
+
"""
|
|
1477
|
+
resolution = resolution or {}
|
|
1478
|
+
project_calendar = resolution.get('project_calendar') or {}
|
|
1479
|
+
default_id = resolution.get('default_clndr_id') or ''
|
|
1480
|
+
out = []
|
|
1481
|
+
for t in tasks:
|
|
1482
|
+
if not (t.get('clndr_id') or '').strip():
|
|
1483
|
+
cid = project_calendar.get(t.get('proj_id', ''), '') or default_id
|
|
1484
|
+
if cid:
|
|
1485
|
+
t = dict(t, clndr_id=cid)
|
|
1486
|
+
out.append(t)
|
|
1487
|
+
return out
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def calendar_resolution_block(resolution, keep=None):
|
|
1491
|
+
"""``resolve_task_calendars`` output as the block a consumer publishes.
|
|
1492
|
+
|
|
1493
|
+
``resolve_task_calendars`` reports on every TASK row in the file; a
|
|
1494
|
+
consumer schedules only some of them (one project, no LOE / WBS summary,
|
|
1495
|
+
...) and must disclose what happened to THOSE. ``keep`` is a predicate
|
|
1496
|
+
over a resolution row (task_id, task_code, proj_id, clndr_id, ...) that
|
|
1497
|
+
selects them; None keeps every row. A consumer that builds the block
|
|
1498
|
+
here, rather than keeping its own copy, cannot drift from the shape
|
|
1499
|
+
cpp-critical-path-validator publishes as ``calendar_resolution``:
|
|
1500
|
+
|
|
1501
|
+
``resolved_by_fallback_count`` blank ids resolved by tier 2 or 3
|
|
1502
|
+
``fallback_calendars`` [{clndr_id, clndr_name, tier, task_count}]
|
|
1503
|
+
``unresolved_count`` activities with no usable calendar
|
|
1504
|
+
``unresolved`` [{task_code, task_name, clndr_id, reason}],
|
|
1505
|
+
task_code falling back to the task_id
|
|
1506
|
+
|
|
1507
|
+
Every row is listed, in file order; the block is never partial.
|
|
1508
|
+
"""
|
|
1509
|
+
resolution = resolution or {}
|
|
1510
|
+
|
|
1511
|
+
def _kept(rows):
|
|
1512
|
+
return [r for r in (rows or []) if keep is None or keep(r)]
|
|
1513
|
+
|
|
1514
|
+
fell_back = _kept(resolution.get('resolved_by_fallback'))
|
|
1515
|
+
unresolved = _kept(resolution.get('unresolved'))
|
|
1516
|
+
by_cal = {}
|
|
1517
|
+
for r in fell_back:
|
|
1518
|
+
key = (r['clndr_id'], r['clndr_name'], r['tier'])
|
|
1519
|
+
by_cal[key] = by_cal.get(key, 0) + 1
|
|
1520
|
+
return {
|
|
1521
|
+
'resolved_by_fallback_count': len(fell_back),
|
|
1522
|
+
'fallback_calendars': [
|
|
1523
|
+
{'clndr_id': k[0], 'clndr_name': k[1], 'tier': k[2], 'task_count': v}
|
|
1524
|
+
for k, v in by_cal.items()],
|
|
1525
|
+
'unresolved_count': len(unresolved),
|
|
1526
|
+
'unresolved': [
|
|
1527
|
+
{'task_code': u['task_code'] or u['task_id'],
|
|
1528
|
+
'task_name': u['task_name'],
|
|
1529
|
+
'clndr_id': u['clndr_id'],
|
|
1530
|
+
'reason': u['reason']}
|
|
1531
|
+
for u in unresolved],
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def _calendar_day(d):
|
|
1536
|
+
"""The calendar day of a date, datetime or 'YYYY-MM-DD[ HH:MM]' string, as
|
|
1537
|
+
a datetime at midnight; None when it cannot be read.
|
|
1538
|
+
|
|
1539
|
+
Every working-day count in this module is a count of DAYS, and a P6 date
|
|
1540
|
+
carries a clock time. Before v0.2.0 the time was stripped from string
|
|
1541
|
+
inputs only: a datetime whose clock time was earlier than the start's was
|
|
1542
|
+
never reached by a walk in whole days from the start instant, so Friday
|
|
1543
|
+
17:00 to Monday 08:00 counted Friday alone, and a date could not be
|
|
1544
|
+
compared with a datetime at all.
|
|
1545
|
+
"""
|
|
1546
|
+
if isinstance(d, str):
|
|
1547
|
+
try:
|
|
1548
|
+
return datetime.strptime(d[:10], '%Y-%m-%d')
|
|
1549
|
+
except (ValueError, TypeError):
|
|
1550
|
+
return None
|
|
1551
|
+
if isinstance(d, datetime):
|
|
1552
|
+
return d.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
1553
|
+
if hasattr(d, 'year') and hasattr(d, 'month') and hasattr(d, 'day'):
|
|
1554
|
+
return datetime(d.year, d.month, d.day)
|
|
1555
|
+
return None
|
|
1556
|
+
|
|
1557
|
+
|
|
1558
|
+
def get_work_days_between(start_date, end_date, calendar_info=None):
|
|
1559
|
+
"""
|
|
1560
|
+
Calculate work days between two dates using calendar info.
|
|
1561
|
+
Accounts for work week pattern and holiday exceptions.
|
|
1562
|
+
If calendar_info is None or empty, defaults to Mon-Fri with no holidays.
|
|
1563
|
+
Both dates are read on their calendar day, so the count includes both
|
|
1564
|
+
endpoint days whatever their clock times.
|
|
1565
|
+
"""
|
|
1566
|
+
if not start_date or not end_date:
|
|
1567
|
+
return None
|
|
1568
|
+
|
|
1569
|
+
start = _calendar_day(start_date)
|
|
1570
|
+
end = _calendar_day(end_date)
|
|
1571
|
+
if start is None or end is None:
|
|
1572
|
+
return None
|
|
1573
|
+
|
|
1574
|
+
# Default to Mon-Fri in P6 weekday scheme (0=Sun, 1=Mon...6=Sat)
|
|
1575
|
+
if calendar_info is None:
|
|
1576
|
+
work_days = [1, 2, 3, 4, 5]
|
|
1577
|
+
holidays = set()
|
|
1578
|
+
special_workdays = set()
|
|
1579
|
+
else:
|
|
1580
|
+
work_days = calendar_info.get('work_days') or [1, 2, 3, 4, 5]
|
|
1581
|
+
holidays = set(calendar_info.get('holidays') or [])
|
|
1582
|
+
special_workdays = set(calendar_info.get('special_workdays') or [])
|
|
1583
|
+
|
|
1584
|
+
# A span beyond ~100 years is not a real schedule window — a garbled or
|
|
1585
|
+
# sentinel date (e.g. a 9999 finish) reached this function. Refuse to compute
|
|
1586
|
+
# and return None (like missing/unparsable dates) rather than loop for
|
|
1587
|
+
# millions of days or return a plausible-but-wrong count.
|
|
1588
|
+
MAX_SPAN_DAYS = 366 * 100
|
|
1589
|
+
if (end - start).days > MAX_SPAN_DAYS:
|
|
1590
|
+
return None
|
|
1591
|
+
|
|
1592
|
+
count = 0
|
|
1593
|
+
current = start
|
|
1594
|
+
while current <= end:
|
|
1595
|
+
# Single source of truth for working-day status, so forced-ON
|
|
1596
|
+
# (special_workdays) and forced-OFF (holidays) exceptions are honored.
|
|
1597
|
+
if _is_work_day(current, work_days, holidays, special_workdays):
|
|
1598
|
+
count += 1
|
|
1599
|
+
current += timedelta(days=1)
|
|
1600
|
+
|
|
1601
|
+
return count
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
def _is_work_day(dt, work_days, holidays, special_workdays=None):
|
|
1605
|
+
"""True if dt (date/datetime) is a working day on the given calendar.
|
|
1606
|
+
|
|
1607
|
+
work_days: list of P6 weekday indices (0=Sun, 1=Mon, ..., 6=Sat).
|
|
1608
|
+
holidays: iterable of 'YYYY-MM-DD' exception date strings (forced OFF —
|
|
1609
|
+
non-working even when the weekday is normally worked, e.g. a stat day).
|
|
1610
|
+
special_workdays: iterable of 'YYYY-MM-DD' exception date strings (forced
|
|
1611
|
+
ON — worked even when the weekday is normally non-working, e.g. a worked
|
|
1612
|
+
Saturday). Optional for backward compatibility; default = none.
|
|
1613
|
+
|
|
1614
|
+
Exception precedence: an explicit holiday wins (the day is off), then an
|
|
1615
|
+
explicit special workday (the day is on), otherwise the weekly pattern.
|
|
1616
|
+
"""
|
|
1617
|
+
date_str = dt.strftime('%Y-%m-%d')
|
|
1618
|
+
if date_str in holidays:
|
|
1619
|
+
return False
|
|
1620
|
+
if special_workdays and date_str in special_workdays:
|
|
1621
|
+
return True
|
|
1622
|
+
day_of_week = dt.weekday() # Python: Mon=0..Sun=6
|
|
1623
|
+
p6_day = (day_of_week + 1) % 7 # Python Mon=0 → P6 1 ; Python Sun=6 → P6 0
|
|
1624
|
+
return p6_day in work_days
|
|
1625
|
+
|
|
1626
|
+
|
|
1627
|
+
def _round_half_up(x):
|
|
1628
|
+
"""Return floor(x + 0.5) as int — HALF-UP, not banker's rounding.
|
|
1629
|
+
|
|
1630
|
+
Mirrors ``_round_half_up`` in cpp-cpm-engine's Python reference
|
|
1631
|
+
(python_reference/cpm.py) and ``_roundHalfUp`` in cpm-engine.js, so the
|
|
1632
|
+
same fractional duration resolves to the same whole workday count here and
|
|
1633
|
+
in the engine.
|
|
1634
|
+
|
|
1635
|
+
Before v0.2.0 this module used ``int(round(x))``, which is Python's
|
|
1636
|
+
round-half-to-even: round(0.5) = 0 and round(2.5) = 2, while the engine
|
|
1637
|
+
returns 1 and 3. On a Mon-Fri calendar from Mon 2026-08-17,
|
|
1638
|
+
add_work_days(..., 0.5) answered 2026-08-17 here and 2026-08-18 in the
|
|
1639
|
+
engine. Durations and lags of exactly half a day are common in real
|
|
1640
|
+
exports, and every one of them resolved a working day differently
|
|
1641
|
+
depending on which module handled it.
|
|
1642
|
+
"""
|
|
1643
|
+
if x is None:
|
|
1644
|
+
return 0
|
|
1645
|
+
try:
|
|
1646
|
+
return int(math.floor(float(x) + 0.5))
|
|
1647
|
+
except (TypeError, ValueError):
|
|
1648
|
+
return 0
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
def add_work_days(start_date, n_workdays, calendar_info=None):
|
|
1652
|
+
"""Advance ``start_date`` by ``n_workdays`` working days on the given calendar.
|
|
1653
|
+
|
|
1654
|
+
Walks the calendar day-by-day from ``start_date``, skipping non-work weekdays
|
|
1655
|
+
(per ``calendar_info['work_days']`` in P6 indexing — 0=Sun, 1=Mon, ..., 6=Sat)
|
|
1656
|
+
and exception holidays (``calendar_info['holidays']``), counting only actual
|
|
1657
|
+
work days. Returns when ``n_workdays`` working days have been consumed.
|
|
1658
|
+
|
|
1659
|
+
GAP SEMANTICS, not occupancy. The result is the date N working days AFTER
|
|
1660
|
+
the anchor; the anchor day itself is not one of the N. On a Mon-Fri
|
|
1661
|
+
calendar, ``add_work_days(Mon, 5)`` is the FOLLOWING Monday, and
|
|
1662
|
+
``add_work_days(Mon, 4)`` is Friday. So a task of D working days starting
|
|
1663
|
+
on ES finishes on::
|
|
1664
|
+
|
|
1665
|
+
EF = add_work_days(ES, D - 1)
|
|
1666
|
+
|
|
1667
|
+
(Before v0.2.0 this docstring claimed the opposite — 'a 5-workday task on a
|
|
1668
|
+
Mon-Fri calendar starting on Monday finishes Friday' — which is what
|
|
1669
|
+
``add_work_days(Mon, 4)`` returns, not ``add_work_days(Mon, 5)``:
|
|
1670
|
+
add_work_days('2026-08-17' Mon, 5) = 2026-08-24 Mon;
|
|
1671
|
+
add_work_days('2026-08-17', 4) = 2026-08-21 Fri. A caller that followed
|
|
1672
|
+
the old docstring and wrote ``finish = add_work_days(start, duration)``
|
|
1673
|
+
landed one working day late on every activity.)
|
|
1674
|
+
|
|
1675
|
+
Args:
|
|
1676
|
+
start_date: 'YYYY-MM-DD' string OR datetime/date object.
|
|
1677
|
+
n_workdays: float or int — fractional workdays are rounded to a whole
|
|
1678
|
+
workday count internally, HALF-UP (0.5 → 1, 2.5 → 3), matching
|
|
1679
|
+
cpp-cpm-engine. P6 CPM works on whole-day nodes.
|
|
1680
|
+
calendar_info: dict from get_calendar_map(). None → Mon-Fri, no holidays.
|
|
1681
|
+
|
|
1682
|
+
Returns:
|
|
1683
|
+
A ``date`` object N working days after start_date. ``n_workdays == 0``
|
|
1684
|
+
SNAPS a non-working anchor FORWARD to the next working day (and leaves
|
|
1685
|
+
a working anchor alone); ``subtract_work_days(d, 0)`` snaps backward,
|
|
1686
|
+
so add-0 followed by subtract-0 is not the identity. Negative
|
|
1687
|
+
``n_workdays`` delegates to ``subtract_work_days`` for symmetry.
|
|
1688
|
+
|
|
1689
|
+
Raises:
|
|
1690
|
+
ValueError if start_date cannot be parsed.
|
|
1691
|
+
"""
|
|
1692
|
+
if n_workdays is None:
|
|
1693
|
+
n_workdays = 0
|
|
1694
|
+
try:
|
|
1695
|
+
n = _round_half_up(float(n_workdays))
|
|
1696
|
+
except (TypeError, ValueError):
|
|
1697
|
+
n = 0
|
|
1698
|
+
if n < 0:
|
|
1699
|
+
return subtract_work_days(start_date, -n, calendar_info)
|
|
1700
|
+
|
|
1701
|
+
# Normalize start_date → date object.
|
|
1702
|
+
if isinstance(start_date, str):
|
|
1703
|
+
try:
|
|
1704
|
+
current = datetime.strptime(start_date[:10], '%Y-%m-%d').date()
|
|
1705
|
+
except (ValueError, TypeError) as e:
|
|
1706
|
+
raise ValueError(f'add_work_days: cannot parse start_date={start_date!r}') from e
|
|
1707
|
+
elif isinstance(start_date, datetime):
|
|
1708
|
+
current = start_date.date()
|
|
1709
|
+
else:
|
|
1710
|
+
current = start_date # assume date-like
|
|
1711
|
+
|
|
1712
|
+
if calendar_info is None:
|
|
1713
|
+
work_days = [1, 2, 3, 4, 5]
|
|
1714
|
+
holidays = set()
|
|
1715
|
+
special_workdays = set()
|
|
1716
|
+
else:
|
|
1717
|
+
work_days = calendar_info.get('work_days') or [1, 2, 3, 4, 5]
|
|
1718
|
+
holidays = set(calendar_info.get('holidays') or [])
|
|
1719
|
+
special_workdays = set(calendar_info.get('special_workdays') or [])
|
|
1720
|
+
|
|
1721
|
+
# Guard: a calendar with zero working days would loop forever.
|
|
1722
|
+
if not work_days:
|
|
1723
|
+
return current
|
|
1724
|
+
|
|
1725
|
+
if n == 0:
|
|
1726
|
+
# Zero-day snap, matching cpp-cpm-engine (add_work_days in its Python
|
|
1727
|
+
# reference, addWorkDays in cpm-engine.js): a non-working anchor moves
|
|
1728
|
+
# FORWARD to the next working day. This module used to return the
|
|
1729
|
+
# anchor unchanged, so the identical call on the identical calendar
|
|
1730
|
+
# answered two different dates depending on which module the caller
|
|
1731
|
+
# reached; on a calendar carrying a long run of exception days the two
|
|
1732
|
+
# answers can be months apart.
|
|
1733
|
+
while not _is_work_day(current, work_days, holidays, special_workdays):
|
|
1734
|
+
current += timedelta(days=1)
|
|
1735
|
+
return current
|
|
1736
|
+
|
|
1737
|
+
# Walks N GAPS: `remaining` decrements once per working day LANDED ON after
|
|
1738
|
+
# the anchor, so the anchor day is not counted. EF for a D-day task is
|
|
1739
|
+
# therefore add_work_days(ES, D - 1). (This comment used to assert the
|
|
1740
|
+
# opposite — 'the task occupies N work days (NOT N gaps)' — which the code
|
|
1741
|
+
# has never done; see the docstring.)
|
|
1742
|
+
remaining = n
|
|
1743
|
+
while remaining > 0:
|
|
1744
|
+
current += timedelta(days=1)
|
|
1745
|
+
if _is_work_day(current, work_days, holidays, special_workdays):
|
|
1746
|
+
remaining -= 1
|
|
1747
|
+
return current
|
|
1748
|
+
|
|
1749
|
+
|
|
1750
|
+
def subtract_work_days(end_date, n_workdays, calendar_info=None):
|
|
1751
|
+
"""Walk backwards N working days from ``end_date`` on the given calendar.
|
|
1752
|
+
|
|
1753
|
+
Used by the CPM backward pass: LS = LF - duration on the activity's calendar.
|
|
1754
|
+
|
|
1755
|
+
GAP SEMANTICS, mirroring ``add_work_days``: the result is the date N
|
|
1756
|
+
working days BEFORE the anchor, which is not itself counted. On a Mon-Fri
|
|
1757
|
+
calendar a task of D working days finishing on LF starts on
|
|
1758
|
+
``subtract_work_days(LF, D - 1)`` — a 5-day task finishing Friday starts
|
|
1759
|
+
Monday, which is ``subtract_work_days(Fri, 4)``.
|
|
1760
|
+
|
|
1761
|
+
Same arg conventions as ``add_work_days`` (half-up rounding of fractional
|
|
1762
|
+
workdays). ``n_workdays == 0`` snaps a non-working anchor BACKWARD to the
|
|
1763
|
+
prior working day. Negative ``n_workdays`` delegates forward (inverse
|
|
1764
|
+
symmetry).
|
|
1765
|
+
"""
|
|
1766
|
+
if n_workdays is None:
|
|
1767
|
+
n_workdays = 0
|
|
1768
|
+
try:
|
|
1769
|
+
n = _round_half_up(float(n_workdays))
|
|
1770
|
+
except (TypeError, ValueError):
|
|
1771
|
+
n = 0
|
|
1772
|
+
if n < 0:
|
|
1773
|
+
return add_work_days(end_date, -n, calendar_info)
|
|
1774
|
+
|
|
1775
|
+
if isinstance(end_date, str):
|
|
1776
|
+
try:
|
|
1777
|
+
current = datetime.strptime(end_date[:10], '%Y-%m-%d').date()
|
|
1778
|
+
except (ValueError, TypeError) as e:
|
|
1779
|
+
raise ValueError(f'subtract_work_days: cannot parse end_date={end_date!r}') from e
|
|
1780
|
+
elif isinstance(end_date, datetime):
|
|
1781
|
+
current = end_date.date()
|
|
1782
|
+
else:
|
|
1783
|
+
current = end_date
|
|
1784
|
+
|
|
1785
|
+
if calendar_info is None:
|
|
1786
|
+
work_days = [1, 2, 3, 4, 5]
|
|
1787
|
+
holidays = set()
|
|
1788
|
+
special_workdays = set()
|
|
1789
|
+
else:
|
|
1790
|
+
work_days = calendar_info.get('work_days') or [1, 2, 3, 4, 5]
|
|
1791
|
+
holidays = set(calendar_info.get('holidays') or [])
|
|
1792
|
+
special_workdays = set(calendar_info.get('special_workdays') or [])
|
|
1793
|
+
|
|
1794
|
+
if not work_days:
|
|
1795
|
+
return current
|
|
1796
|
+
|
|
1797
|
+
if n == 0:
|
|
1798
|
+
# Symmetric to add_work_days: matches cpp-cpm-engine's
|
|
1799
|
+
# subtract_work_days / subtractWorkDays, which snap a non-working
|
|
1800
|
+
# anchor BACKWARD to the prior working day.
|
|
1801
|
+
while not _is_work_day(current, work_days, holidays, special_workdays):
|
|
1802
|
+
current -= timedelta(days=1)
|
|
1803
|
+
return current
|
|
1804
|
+
|
|
1805
|
+
remaining = n
|
|
1806
|
+
while remaining > 0:
|
|
1807
|
+
current -= timedelta(days=1)
|
|
1808
|
+
if _is_work_day(current, work_days, holidays, special_workdays):
|
|
1809
|
+
remaining -= 1
|
|
1810
|
+
return current
|
|
1811
|
+
|
|
1812
|
+
|
|
1813
|
+
def duration_hours_to_days(hours, calendar_info=None, default_hrs_per_day=8.0, ndigits=None):
|
|
1814
|
+
"""Convert P6 duration in hours to workdays. Set ndigits to round; default returns raw float."""
|
|
1815
|
+
if not hours:
|
|
1816
|
+
return 0
|
|
1817
|
+
try:
|
|
1818
|
+
hrs = float(hours)
|
|
1819
|
+
except (ValueError, TypeError):
|
|
1820
|
+
return 0
|
|
1821
|
+
|
|
1822
|
+
hrs_per_day = default_hrs_per_day
|
|
1823
|
+
if calendar_info and calendar_info.get('hours_per_day'):
|
|
1824
|
+
hrs_per_day = calendar_info['hours_per_day']
|
|
1825
|
+
|
|
1826
|
+
if hrs_per_day <= 0:
|
|
1827
|
+
return 0
|
|
1828
|
+
result = hrs / hrs_per_day
|
|
1829
|
+
return round(result, ndigits) if ndigits is not None else result
|
|
1830
|
+
|
|
1831
|
+
|
|
1832
|
+
# ─────────────────────────────────────────────
|
|
1833
|
+
# CROSS-REFERENCE BUILDERS
|
|
1834
|
+
# ─────────────────────────────────────────────
|
|
1835
|
+
|
|
1836
|
+
def build_wbs_map(data):
|
|
1837
|
+
"""Build WBS lookup: wbs_id → wbs record with hierarchy path."""
|
|
1838
|
+
wbs_records = get_table(data, 'PROJWBS')
|
|
1839
|
+
wbs_map = {}
|
|
1840
|
+
|
|
1841
|
+
for wbs in wbs_records:
|
|
1842
|
+
wbs_id = wbs.get('wbs_id', '')
|
|
1843
|
+
wbs_map[wbs_id] = wbs
|
|
1844
|
+
|
|
1845
|
+
# Build full paths
|
|
1846
|
+
# Each top-level call to get_path() starts with a fresh `visited` set, and
|
|
1847
|
+
# the recursion only walks the parent chain (each WBS has at most ONE parent).
|
|
1848
|
+
# That means a repeated wbs_id within a single chain walk is a genuine cycle
|
|
1849
|
+
# (A→B→A) and not a legitimate revisit — so raise loudly instead of silently
|
|
1850
|
+
# truncating the path with an empty string.
|
|
1851
|
+
def get_path(wbs_id, visited=None):
|
|
1852
|
+
if visited is None:
|
|
1853
|
+
visited = set()
|
|
1854
|
+
if wbs_id not in wbs_map:
|
|
1855
|
+
return ''
|
|
1856
|
+
if wbs_id in visited:
|
|
1857
|
+
raise ValueError(
|
|
1858
|
+
f"Circular WBS hierarchy detected at wbs_id={wbs_id!r}; "
|
|
1859
|
+
f"visited={sorted(visited)}"
|
|
1860
|
+
)
|
|
1861
|
+
visited.add(wbs_id)
|
|
1862
|
+
wbs = wbs_map[wbs_id]
|
|
1863
|
+
parent_id = wbs.get('parent_wbs_id', '')
|
|
1864
|
+
parent_path = get_path(parent_id, visited)
|
|
1865
|
+
name = wbs.get('wbs_name', wbs.get('wbs_short_name', ''))
|
|
1866
|
+
return f"{parent_path} > {name}" if parent_path else name
|
|
1867
|
+
|
|
1868
|
+
for wbs_id in wbs_map:
|
|
1869
|
+
wbs_map[wbs_id]['_full_path'] = get_path(wbs_id)
|
|
1870
|
+
|
|
1871
|
+
return wbs_map
|
|
1872
|
+
|
|
1873
|
+
|
|
1874
|
+
def build_resource_map(data):
|
|
1875
|
+
"""Build resource assignment lookup: task_id → list of resource records."""
|
|
1876
|
+
taskrsrc = get_table(data, 'TASKRSRC')
|
|
1877
|
+
rsrc = get_table(data, 'RSRC')
|
|
1878
|
+
|
|
1879
|
+
# RSRC lookup by rsrc_id
|
|
1880
|
+
rsrc_lookup = {r.get('rsrc_id', ''): r for r in rsrc}
|
|
1881
|
+
|
|
1882
|
+
# Group by task_id
|
|
1883
|
+
task_resources = defaultdict(list)
|
|
1884
|
+
for tr in taskrsrc:
|
|
1885
|
+
task_id = tr.get('task_id', '')
|
|
1886
|
+
rsrc_id = tr.get('rsrc_id', '')
|
|
1887
|
+
rsrc_record = rsrc_lookup.get(rsrc_id, {})
|
|
1888
|
+
combined = {**tr, '_rsrc_name': rsrc_record.get('rsrc_name', ''),
|
|
1889
|
+
'_rsrc_short_name': rsrc_record.get('rsrc_short_name', '')}
|
|
1890
|
+
task_resources[task_id].append(combined)
|
|
1891
|
+
|
|
1892
|
+
return dict(task_resources)
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
def resource_dependent_assignments(data):
|
|
1896
|
+
"""``{task_id: [assignment, ...]}``: every TASKRSRC row as the engine schedules it on a resource dependent
|
|
1897
|
+
activity (cpp-cpm-engine v2.10.1; P6 probes ZZ-CPP-RSRC 1-3, 13-Oct-2026).
|
|
1898
|
+
|
|
1899
|
+
Each assignment: ``rsrc_id``; ``clndr_id``, the resource's calendar (RSRC.clndr_id; None for a role or a
|
|
1900
|
+
resource the file does not carry, which then works the activity's calendar); ``remaining_hours``, remaining
|
|
1901
|
+
units / remaining units per hour, the duration P6 schedules it for; ``lag_hours``, the remaining lag (the
|
|
1902
|
+
planned lag when that is blank); ``driving``, False only where Drive Activity Dates (rollup_dates_flag) is N.
|
|
1903
|
+
"""
|
|
1904
|
+
def num(v):
|
|
1905
|
+
try:
|
|
1906
|
+
return float(v)
|
|
1907
|
+
except (TypeError, ValueError):
|
|
1908
|
+
return None
|
|
1909
|
+
|
|
1910
|
+
rsrc_cal = {(r.get('rsrc_id') or '').strip(): (r.get('clndr_id') or '').strip() or None
|
|
1911
|
+
for r in get_table(data, 'RSRC')}
|
|
1912
|
+
out = {}
|
|
1913
|
+
for r in get_table(data, 'TASKRSRC'):
|
|
1914
|
+
tid = (r.get('task_id') or '').strip()
|
|
1915
|
+
if not tid:
|
|
1916
|
+
continue
|
|
1917
|
+
rid = (r.get('rsrc_id') or '').strip()
|
|
1918
|
+
qty = num(r.get('remain_qty')) or 0.0
|
|
1919
|
+
rate = num(r.get('remain_qty_per_hr')) or 0.0
|
|
1920
|
+
lag = num(r.get('relag_drtn_hr_cnt'))
|
|
1921
|
+
if lag is None:
|
|
1922
|
+
lag = num(r.get('target_lag_drtn_hr_cnt')) or 0.0
|
|
1923
|
+
out.setdefault(tid, []).append({
|
|
1924
|
+
'rsrc_id': rid, 'clndr_id': rsrc_cal.get(rid) if rid else None,
|
|
1925
|
+
'remaining_hours': (qty / rate) if rate > 0 else 0.0,
|
|
1926
|
+
'lag_hours': lag,
|
|
1927
|
+
'driving': (r.get('rollup_dates_flag') or 'Y').strip().upper() != 'N',
|
|
1928
|
+
})
|
|
1929
|
+
return out
|
|
1930
|
+
|
|
1931
|
+
|
|
1932
|
+
def build_predecessor_map(data):
|
|
1933
|
+
"""Build predecessor/successor lookups from TASKPRED table."""
|
|
1934
|
+
preds = get_table(data, 'TASKPRED')
|
|
1935
|
+
|
|
1936
|
+
predecessors = defaultdict(list) # task_id → list of predecessor records
|
|
1937
|
+
successors = defaultdict(list) # pred_task_id → list of successor records
|
|
1938
|
+
|
|
1939
|
+
for p in preds:
|
|
1940
|
+
task_id = p.get('task_id', '')
|
|
1941
|
+
pred_task_id = p.get('pred_task_id', '')
|
|
1942
|
+
predecessors[task_id].append(p)
|
|
1943
|
+
successors[pred_task_id].append(p)
|
|
1944
|
+
|
|
1945
|
+
return dict(predecessors), dict(successors)
|
|
1946
|
+
|
|
1947
|
+
|
|
1948
|
+
def build_activity_code_map(data):
|
|
1949
|
+
"""Build activity code lookups from ACTVTYPE, ACTVCODE, TASKACTV."""
|
|
1950
|
+
actvtype = get_table(data, 'ACTVTYPE')
|
|
1951
|
+
actvcode = get_table(data, 'ACTVCODE')
|
|
1952
|
+
taskactv = get_table(data, 'TASKACTV')
|
|
1953
|
+
|
|
1954
|
+
# Type lookup
|
|
1955
|
+
type_map = {t.get('actv_code_type_id', ''): t for t in actvtype}
|
|
1956
|
+
|
|
1957
|
+
# Code lookup
|
|
1958
|
+
code_map = {c.get('actv_code_id', ''): c for c in actvcode}
|
|
1959
|
+
|
|
1960
|
+
# Task → codes
|
|
1961
|
+
task_codes = defaultdict(list)
|
|
1962
|
+
for ta in taskactv:
|
|
1963
|
+
task_id = ta.get('task_id', '')
|
|
1964
|
+
code_id = ta.get('actv_code_id', '')
|
|
1965
|
+
code_rec = code_map.get(code_id, {})
|
|
1966
|
+
type_id = code_rec.get('actv_code_type_id', '')
|
|
1967
|
+
type_rec = type_map.get(type_id, {})
|
|
1968
|
+
task_codes[task_id].append({
|
|
1969
|
+
'type_name': type_rec.get('actv_code_type', ''),
|
|
1970
|
+
'code_name': code_rec.get('short_name', ''),
|
|
1971
|
+
'code_desc': code_rec.get('actv_code_name', ''),
|
|
1972
|
+
})
|
|
1973
|
+
|
|
1974
|
+
return dict(task_codes)
|
|
1975
|
+
|
|
1976
|
+
|
|
1977
|
+
def build_udf_map(data):
|
|
1978
|
+
"""Build UDF value lookup: (table_name, fk_id) → list of UDF values.
|
|
1979
|
+
|
|
1980
|
+
Keyed on (table_name, fk_id) because fk_id is only unique within a table.
|
|
1981
|
+
TASK, PROJECT, PROJWBS, and RSRC can all have overlapping fk_id values.
|
|
1982
|
+
"""
|
|
1983
|
+
udftype = get_table(data, 'UDFTYPE')
|
|
1984
|
+
udfvalue = get_table(data, 'UDFVALUE')
|
|
1985
|
+
|
|
1986
|
+
type_map = {u.get('udf_type_id', ''): u for u in udftype}
|
|
1987
|
+
|
|
1988
|
+
udf_lookup = defaultdict(list)
|
|
1989
|
+
for uv in udfvalue:
|
|
1990
|
+
fk_id = uv.get('fk_id', '')
|
|
1991
|
+
udf_type_id = uv.get('udf_type_id', '')
|
|
1992
|
+
type_rec = type_map.get(udf_type_id, {})
|
|
1993
|
+
table_name = type_rec.get('table_name', '')
|
|
1994
|
+
udf_lookup[(table_name, fk_id)].append({
|
|
1995
|
+
'label': type_rec.get('udf_type_label', ''),
|
|
1996
|
+
'name': type_rec.get('udf_type_name', ''),
|
|
1997
|
+
'value': uv.get('udf_text', '') or uv.get('udf_number', '') or uv.get('udf_date', ''),
|
|
1998
|
+
})
|
|
1999
|
+
|
|
2000
|
+
return dict(udf_lookup)
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
# ─────────────────────────────────────────────
|
|
2004
|
+
# SUMMARY REPORT
|
|
2005
|
+
# ─────────────────────────────────────────────
|
|
2006
|
+
|
|
2007
|
+
def generate_summary(data):
|
|
2008
|
+
"""
|
|
2009
|
+
Generate a comprehensive summary report from parsed XER data.
|
|
2010
|
+
Returns a dictionary with all key metrics.
|
|
2011
|
+
"""
|
|
2012
|
+
summary = {
|
|
2013
|
+
'file_info': {},
|
|
2014
|
+
'project': {},
|
|
2015
|
+
'schedule_metrics': {},
|
|
2016
|
+
'activity_breakdown': {},
|
|
2017
|
+
'critical_path': {},
|
|
2018
|
+
'relationships': {},
|
|
2019
|
+
'calendars': [],
|
|
2020
|
+
'wbs_structure': [],
|
|
2021
|
+
'resources': {},
|
|
2022
|
+
'data_quality': {},
|
|
2023
|
+
'tables_found': [],
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
# ── File Info ──
|
|
2027
|
+
summary['file_info'] = {
|
|
2028
|
+
'filename': data.get('filename', ''),
|
|
2029
|
+
'filepath': data.get('filepath', ''),
|
|
2030
|
+
'parsed_at': data.get('parse_timestamp', ''),
|
|
2031
|
+
'p6_version': data.get('ermhdr', {}).get('version', ''),
|
|
2032
|
+
'export_date': data.get('ermhdr', {}).get('export_date', ''),
|
|
2033
|
+
'exported_by': data.get('ermhdr', {}).get('user', ''),
|
|
2034
|
+
'currency': data.get('ermhdr', {}).get('currency', ''),
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
# ── Tables Found ──
|
|
2038
|
+
for table_name, table_data in data.get('tables', {}).items():
|
|
2039
|
+
record_count = len(table_data.get('records', []))
|
|
2040
|
+
field_count = len(table_data.get('fields', []))
|
|
2041
|
+
summary['tables_found'].append({
|
|
2042
|
+
'table': table_name,
|
|
2043
|
+
'fields': field_count,
|
|
2044
|
+
'records': record_count,
|
|
2045
|
+
})
|
|
2046
|
+
|
|
2047
|
+
# ── Project Info ──
|
|
2048
|
+
projects = get_table(data, 'PROJECT')
|
|
2049
|
+
if projects:
|
|
2050
|
+
proj = projects[0] # Primary project
|
|
2051
|
+
summary['project'] = {
|
|
2052
|
+
'proj_id': proj.get('proj_id', ''),
|
|
2053
|
+
'project_name': proj.get('proj_short_name', ''),
|
|
2054
|
+
'project_long_name': proj.get('proj_long_name', '') or proj.get('proj_short_name', ''),
|
|
2055
|
+
'data_date': proj.get('last_recalc_date', ''),
|
|
2056
|
+
'plan_start': proj.get('plan_start_date', ''),
|
|
2057
|
+
'plan_finish': proj.get('plan_end_date', ''),
|
|
2058
|
+
'scheduled_finish': proj.get('scd_end_date', ''),
|
|
2059
|
+
'must_finish_by': proj.get('plan_end_date', ''),
|
|
2060
|
+
'project_count': len(projects),
|
|
2061
|
+
}
|
|
2062
|
+
if len(projects) > 1:
|
|
2063
|
+
summary['project']['all_projects'] = [
|
|
2064
|
+
{'proj_id': p.get('proj_id', ''), 'name': p.get('proj_short_name', '')}
|
|
2065
|
+
for p in projects
|
|
2066
|
+
]
|
|
2067
|
+
|
|
2068
|
+
# ── Activity Metrics ──
|
|
2069
|
+
# Rows carry their RESOLVED calendar (blank clndr_id = project calendar),
|
|
2070
|
+
# so the hours-to-days conversions below divide by the right day length.
|
|
2071
|
+
tasks = with_resolved_calendars(get_table(data, 'TASK'),
|
|
2072
|
+
resolve_task_calendars(data))
|
|
2073
|
+
cal_map = get_calendar_map(data)
|
|
2074
|
+
|
|
2075
|
+
# Filter out LOE and WBS Summary
|
|
2076
|
+
real_tasks = [t for t in tasks if t.get('task_type', '') not in ('TT_LOE', 'TT_WBS')]
|
|
2077
|
+
milestones = [t for t in real_tasks if t.get('task_type', '') in ('TT_Mile', 'TT_FinMile')]
|
|
2078
|
+
|
|
2079
|
+
# Work activities = real tasks minus zero-duration milestone markers. A
|
|
2080
|
+
# count-based percent_complete that includes milestones overstates progress
|
|
2081
|
+
# on milestone-heavy schedules, so we also report a work-only completion.
|
|
2082
|
+
work_tasks = [t for t in real_tasks if t.get('task_type', '') not in MILESTONE_TASK_TYPES]
|
|
2083
|
+
work_total = len(work_tasks)
|
|
2084
|
+
complete_work = len([t for t in work_tasks if t.get('status_code', '') == 'TK_Complete'])
|
|
2085
|
+
|
|
2086
|
+
total = len(real_tasks)
|
|
2087
|
+
complete = len([t for t in real_tasks if t.get('status_code', '') == 'TK_Complete'])
|
|
2088
|
+
in_progress = len([t for t in real_tasks if t.get('status_code', '') == 'TK_Active'])
|
|
2089
|
+
not_started = len([t for t in real_tasks if t.get('status_code', '') == 'TK_NotStart'])
|
|
2090
|
+
|
|
2091
|
+
summary['schedule_metrics'] = {
|
|
2092
|
+
'total_activities': total,
|
|
2093
|
+
'total_including_loe': len(tasks),
|
|
2094
|
+
'loe_count': len([t for t in tasks if t.get('task_type', '') == 'TT_LOE']),
|
|
2095
|
+
'wbs_count': len([t for t in tasks if t.get('task_type', '') == 'TT_WBS']),
|
|
2096
|
+
'complete': complete,
|
|
2097
|
+
'in_progress': in_progress,
|
|
2098
|
+
'not_started': not_started,
|
|
2099
|
+
'percent_complete': round((complete / total * 100), 1) if total > 0 else 0,
|
|
2100
|
+
'work_task_count': work_total,
|
|
2101
|
+
'complete_work': complete_work,
|
|
2102
|
+
'percent_complete_work': round((complete_work / work_total * 100), 1) if work_total > 0 else 0,
|
|
2103
|
+
'milestone_count': len(milestones),
|
|
2104
|
+
'milestones_complete': len([m for m in milestones if m.get('status_code') == 'TK_Complete']),
|
|
2105
|
+
'milestones_remaining': len([m for m in milestones if m.get('status_code') != 'TK_Complete']),
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
# ── Activity Type Breakdown ──
|
|
2109
|
+
type_counts = defaultdict(int)
|
|
2110
|
+
for t in tasks:
|
|
2111
|
+
tt = t.get('task_type', 'Unknown')
|
|
2112
|
+
type_counts[TASK_TYPES.get(tt, tt)] += 1
|
|
2113
|
+
summary['activity_breakdown']['by_type'] = dict(type_counts)
|
|
2114
|
+
|
|
2115
|
+
status_counts = defaultdict(int)
|
|
2116
|
+
for t in real_tasks:
|
|
2117
|
+
sc = t.get('status_code', 'Unknown')
|
|
2118
|
+
status_counts[STATUS_CODES.get(sc, sc)] += 1
|
|
2119
|
+
summary['activity_breakdown']['by_status'] = dict(status_counts)
|
|
2120
|
+
|
|
2121
|
+
# ── Critical Path ──
|
|
2122
|
+
# Method 1: total_float_hr_cnt <= 0
|
|
2123
|
+
critical_by_float = [t for t in real_tasks if _is_critical_by_float(t)]
|
|
2124
|
+
# Method 2: driving_path_flag
|
|
2125
|
+
critical_by_flag = [t for t in real_tasks if t.get('driving_path_flag', '') == 'Y']
|
|
2126
|
+
# Method 3: crt_path_num (longest path) — nonzero integer means on the longest path
|
|
2127
|
+
critical_by_lp = [t for t in real_tasks if _crt_path_num_active(t)]
|
|
2128
|
+
|
|
2129
|
+
summary['critical_path'] = {
|
|
2130
|
+
'critical_by_float_zero': len(critical_by_float),
|
|
2131
|
+
'critical_by_driving_flag': len(critical_by_flag),
|
|
2132
|
+
'critical_by_longest_path': len(critical_by_lp),
|
|
2133
|
+
'critical_activities': [
|
|
2134
|
+
{
|
|
2135
|
+
'task_code': t.get('task_code', ''),
|
|
2136
|
+
'task_name': t.get('task_name', ''),
|
|
2137
|
+
'status': STATUS_CODES.get(t.get('status_code', ''), t.get('status_code', '')),
|
|
2138
|
+
'total_float_days': duration_hours_to_days(
|
|
2139
|
+
t.get('total_float_hr_cnt', '0'),
|
|
2140
|
+
cal_map.get(t.get('clndr_id', ''))
|
|
2141
|
+
),
|
|
2142
|
+
'early_start': t.get('early_start_date', ''),
|
|
2143
|
+
'early_finish': t.get('early_end_date', ''),
|
|
2144
|
+
}
|
|
2145
|
+
for t in critical_by_float
|
|
2146
|
+
],
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
# Float distribution — uses per-activity calendar hours, not hardcoded 8hr
|
|
2150
|
+
float_dist = {'negative': 0, 'zero': 0, '1-5': 0, '6-10': 0, '11-20': 0, '21-40': 0, '41+': 0, 'null': 0}
|
|
2151
|
+
for t in real_tasks:
|
|
2152
|
+
tf = t.get('total_float_hr_cnt', '')
|
|
2153
|
+
if not tf or tf == '':
|
|
2154
|
+
float_dist['null'] += 1
|
|
2155
|
+
continue
|
|
2156
|
+
try:
|
|
2157
|
+
# Use the activity's own calendar for the hr→day conversion.
|
|
2158
|
+
# Fall back to 8hr default only when no calendar is resolvable.
|
|
2159
|
+
cal = cal_map.get(t.get('clndr_id', ''))
|
|
2160
|
+
tf_days = duration_hours_to_days(tf, cal)
|
|
2161
|
+
except (ValueError, TypeError):
|
|
2162
|
+
float_dist['null'] += 1
|
|
2163
|
+
continue
|
|
2164
|
+
if tf_days < 0:
|
|
2165
|
+
float_dist['negative'] += 1
|
|
2166
|
+
elif tf_days == 0:
|
|
2167
|
+
float_dist['zero'] += 1
|
|
2168
|
+
elif tf_days <= 5:
|
|
2169
|
+
float_dist['1-5'] += 1
|
|
2170
|
+
elif tf_days <= 10:
|
|
2171
|
+
float_dist['6-10'] += 1
|
|
2172
|
+
elif tf_days <= 20:
|
|
2173
|
+
float_dist['11-20'] += 1
|
|
2174
|
+
elif tf_days <= 40:
|
|
2175
|
+
float_dist['21-40'] += 1
|
|
2176
|
+
else:
|
|
2177
|
+
float_dist['41+'] += 1
|
|
2178
|
+
summary['critical_path']['float_distribution'] = float_dist
|
|
2179
|
+
|
|
2180
|
+
# ── Relationships ──
|
|
2181
|
+
preds = get_table(data, 'TASKPRED')
|
|
2182
|
+
rel_types = defaultdict(int)
|
|
2183
|
+
lags = []
|
|
2184
|
+
for p in preds:
|
|
2185
|
+
pt = p.get('pred_type', 'Unknown')
|
|
2186
|
+
rel_types[RELATIONSHIP_TYPES.get(pt, pt)] += 1
|
|
2187
|
+
lag = p.get('lag_hr_cnt', '0')
|
|
2188
|
+
try:
|
|
2189
|
+
lag_val = float(lag)
|
|
2190
|
+
if lag_val != 0:
|
|
2191
|
+
lags.append(lag_val)
|
|
2192
|
+
except (ValueError, TypeError):
|
|
2193
|
+
pass
|
|
2194
|
+
|
|
2195
|
+
# Open ends
|
|
2196
|
+
pred_map, succ_map = build_predecessor_map(data)
|
|
2197
|
+
no_predecessors = [t for t in real_tasks
|
|
2198
|
+
if t.get('task_id', '') not in pred_map
|
|
2199
|
+
and t.get('task_type', '') not in ('TT_Mile',)]
|
|
2200
|
+
no_successors = [t for t in real_tasks
|
|
2201
|
+
if t.get('task_id', '') not in succ_map
|
|
2202
|
+
and t.get('task_type', '') not in ('TT_FinMile',)]
|
|
2203
|
+
|
|
2204
|
+
summary['relationships'] = {
|
|
2205
|
+
'total_relationships': len(preds),
|
|
2206
|
+
'by_type': dict(rel_types),
|
|
2207
|
+
'relationships_with_lag': len(lags),
|
|
2208
|
+
'open_ends': {
|
|
2209
|
+
'no_predecessors': len(no_predecessors),
|
|
2210
|
+
'no_successors': len(no_successors),
|
|
2211
|
+
'activities_without_pred': [
|
|
2212
|
+
{'task_code': t.get('task_code', ''), 'task_name': t.get('task_name', '')}
|
|
2213
|
+
for t in no_predecessors
|
|
2214
|
+
],
|
|
2215
|
+
'activities_without_succ': [
|
|
2216
|
+
{'task_code': t.get('task_code', ''), 'task_name': t.get('task_name', '')}
|
|
2217
|
+
for t in no_successors
|
|
2218
|
+
],
|
|
2219
|
+
},
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
# ── Calendars ──
|
|
2223
|
+
for clndr_id, cal_info in cal_map.items():
|
|
2224
|
+
summary['calendars'].append({
|
|
2225
|
+
'clndr_id': clndr_id,
|
|
2226
|
+
'name': cal_info.get('clndr_name', ''),
|
|
2227
|
+
'hours_per_day': cal_info.get('hours_per_day', 8.0),
|
|
2228
|
+
'hours_per_week': cal_info.get('hours_per_week', 40.0),
|
|
2229
|
+
'work_days': cal_info.get('work_day_names', []),
|
|
2230
|
+
'holiday_count': len(cal_info.get('holidays', [])),
|
|
2231
|
+
'holidays': cal_info.get('holidays', []),
|
|
2232
|
+
})
|
|
2233
|
+
|
|
2234
|
+
# ── WBS Structure ──
|
|
2235
|
+
wbs_map = build_wbs_map(data)
|
|
2236
|
+
wbs_task_counts = defaultdict(int)
|
|
2237
|
+
for t in real_tasks:
|
|
2238
|
+
wbs_id = t.get('wbs_id', '')
|
|
2239
|
+
wbs_task_counts[wbs_id] += 1
|
|
2240
|
+
|
|
2241
|
+
for wbs_id, wbs_rec in wbs_map.items():
|
|
2242
|
+
summary['wbs_structure'].append({
|
|
2243
|
+
'wbs_id': wbs_id,
|
|
2244
|
+
'wbs_code': wbs_rec.get('wbs_short_name', ''),
|
|
2245
|
+
'wbs_name': wbs_rec.get('wbs_name', ''),
|
|
2246
|
+
'full_path': wbs_rec.get('_full_path', ''),
|
|
2247
|
+
'activity_count': wbs_task_counts.get(wbs_id, 0),
|
|
2248
|
+
})
|
|
2249
|
+
|
|
2250
|
+
# ── Resources ──
|
|
2251
|
+
taskrsrc = get_table(data, 'TASKRSRC')
|
|
2252
|
+
rsrc = get_table(data, 'RSRC')
|
|
2253
|
+
summary['resources'] = {
|
|
2254
|
+
'resource_assignments': len(taskrsrc),
|
|
2255
|
+
'unique_resources': len(rsrc),
|
|
2256
|
+
'resource_names': [r.get('rsrc_name', '') for r in rsrc],
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
# ── Data Quality Flags ──
|
|
2260
|
+
constraints = [t for t in real_tasks if t.get('cstr_type', '').strip()]
|
|
2261
|
+
constraint_counts = defaultdict(int)
|
|
2262
|
+
for t in constraints:
|
|
2263
|
+
code = t.get('cstr_type', '').strip()
|
|
2264
|
+
label = CONSTRAINT_TYPES.get(code, code)
|
|
2265
|
+
constraint_counts[label] += 1
|
|
2266
|
+
summary['data_quality'] = {
|
|
2267
|
+
'activities_with_constraints': len(constraints),
|
|
2268
|
+
'constraint_types': dict(constraint_counts),
|
|
2269
|
+
'field_count_validation': _validate_field_counts(data),
|
|
2270
|
+
'missing_logic': {
|
|
2271
|
+
'no_predecessor_count': len(no_predecessors),
|
|
2272
|
+
'no_successor_count': len(no_successors),
|
|
2273
|
+
},
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
return summary
|
|
2277
|
+
|
|
2278
|
+
|
|
2279
|
+
def _is_critical_by_float(task):
|
|
2280
|
+
"""Check if a task is critical based on total float."""
|
|
2281
|
+
tf = task.get('total_float_hr_cnt', '')
|
|
2282
|
+
if not tf or tf == '':
|
|
2283
|
+
return False
|
|
2284
|
+
try:
|
|
2285
|
+
return float(tf) <= 0
|
|
2286
|
+
except ValueError:
|
|
2287
|
+
return False
|
|
2288
|
+
|
|
2289
|
+
|
|
2290
|
+
def _crt_path_num_active(task):
|
|
2291
|
+
"""True when crt_path_num identifies this task as on a longest path (nonzero)."""
|
|
2292
|
+
v = task.get('crt_path_num', '')
|
|
2293
|
+
if not v:
|
|
2294
|
+
return False
|
|
2295
|
+
try:
|
|
2296
|
+
return int(v) > 0
|
|
2297
|
+
except ValueError:
|
|
2298
|
+
return False
|
|
2299
|
+
|
|
2300
|
+
|
|
2301
|
+
def _validate_field_counts(data):
|
|
2302
|
+
"""Check table %F field count against known values for the XER's P6 version.
|
|
2303
|
+
|
|
2304
|
+
P6 field counts vary by major version — so pick the version family from the
|
|
2305
|
+
ERMHDR and validate against that. Unknown version → no warnings (we can't say).
|
|
2306
|
+
"""
|
|
2307
|
+
version = (data.get('ermhdr', {}) or {}).get('version', '')
|
|
2308
|
+
major = version.split('.')[0] if version else ''
|
|
2309
|
+
expected_schema = TABLE_FIELD_COUNTS_BY_VERSION.get(major)
|
|
2310
|
+
|
|
2311
|
+
if not expected_schema:
|
|
2312
|
+
return 'P6 version not in schema map — field-count validation skipped'
|
|
2313
|
+
|
|
2314
|
+
issues = []
|
|
2315
|
+
for table_name, table_data in data.get('tables', {}).items():
|
|
2316
|
+
field_count = len(table_data.get('fields', []))
|
|
2317
|
+
if table_name in expected_schema:
|
|
2318
|
+
expected = expected_schema[table_name]
|
|
2319
|
+
if field_count != expected:
|
|
2320
|
+
issues.append({
|
|
2321
|
+
'table': table_name,
|
|
2322
|
+
'note': f'Expected {expected} fields for P6 {version}, found {field_count}',
|
|
2323
|
+
'severity': 'warning',
|
|
2324
|
+
})
|
|
2325
|
+
return issues if issues else 'All field counts valid'
|
|
2326
|
+
|
|
2327
|
+
|
|
2328
|
+
def print_summary(data, output_file=None):
|
|
2329
|
+
"""Print a formatted summary report to console or file."""
|
|
2330
|
+
summary = generate_summary(data)
|
|
2331
|
+
|
|
2332
|
+
lines = []
|
|
2333
|
+
lines.append('=' * 70)
|
|
2334
|
+
lines.append('XER FILE SUMMARY REPORT')
|
|
2335
|
+
lines.append('=' * 70)
|
|
2336
|
+
lines.append('')
|
|
2337
|
+
|
|
2338
|
+
# File Info
|
|
2339
|
+
fi = summary['file_info']
|
|
2340
|
+
lines.append(f"File: {fi['filename']}")
|
|
2341
|
+
lines.append(f"P6 Version: {fi['p6_version']}")
|
|
2342
|
+
lines.append(f"Exported: {fi['export_date']}")
|
|
2343
|
+
lines.append(f"Exported By: {fi['exported_by']}")
|
|
2344
|
+
lines.append(f"Parsed At: {fi['parsed_at']}")
|
|
2345
|
+
lines.append('')
|
|
2346
|
+
|
|
2347
|
+
# Project
|
|
2348
|
+
proj = summary['project']
|
|
2349
|
+
lines.append(f"Project: {proj.get('project_name', 'N/A')}")
|
|
2350
|
+
lines.append(f"Data Date: {proj.get('data_date', 'N/A')}")
|
|
2351
|
+
lines.append(f"Plan Start: {proj.get('plan_start', 'N/A')}")
|
|
2352
|
+
lines.append(f"Plan Finish: {proj.get('plan_finish', 'N/A')}")
|
|
2353
|
+
lines.append(f"Sched Finish: {proj.get('scheduled_finish', 'N/A')}")
|
|
2354
|
+
if proj.get('project_count', 0) > 1:
|
|
2355
|
+
lines.append(f"** Multi-project XER: {proj['project_count']} projects found **")
|
|
2356
|
+
lines.append('')
|
|
2357
|
+
|
|
2358
|
+
# Tables
|
|
2359
|
+
lines.append('─' * 40)
|
|
2360
|
+
lines.append('TABLES IN FILE')
|
|
2361
|
+
lines.append('─' * 40)
|
|
2362
|
+
for t in summary['tables_found']:
|
|
2363
|
+
lines.append(f" {t['table']:<20} {t['fields']:>3} fields {t['records']:>6} records")
|
|
2364
|
+
lines.append('')
|
|
2365
|
+
|
|
2366
|
+
# Schedule Metrics
|
|
2367
|
+
sm = summary['schedule_metrics']
|
|
2368
|
+
lines.append('─' * 40)
|
|
2369
|
+
lines.append('SCHEDULE METRICS')
|
|
2370
|
+
lines.append('─' * 40)
|
|
2371
|
+
lines.append(f" Total Activities: {sm['total_activities']} (excl. {sm['loe_count']} LOE, {sm['wbs_count']} WBS summary)")
|
|
2372
|
+
lines.append(f" Complete: {sm['complete']} ({sm['percent_complete']}% of all activities)")
|
|
2373
|
+
lines.append(f" Work-task completion: {sm['complete_work']}/{sm['work_task_count']} ({sm['percent_complete_work']}%, excl. milestones)")
|
|
2374
|
+
lines.append(f" In Progress: {sm['in_progress']}")
|
|
2375
|
+
lines.append(f" Not Started: {sm['not_started']}")
|
|
2376
|
+
lines.append(f" Milestones: {sm['milestone_count']} ({sm['milestones_complete']} hit, {sm['milestones_remaining']} remaining)")
|
|
2377
|
+
lines.append('')
|
|
2378
|
+
|
|
2379
|
+
# Critical Path
|
|
2380
|
+
cp = summary['critical_path']
|
|
2381
|
+
lines.append('─' * 40)
|
|
2382
|
+
lines.append('CRITICAL PATH')
|
|
2383
|
+
lines.append('─' * 40)
|
|
2384
|
+
lines.append(f" Critical (TF ≤ 0): {cp['critical_by_float_zero']}")
|
|
2385
|
+
lines.append(f" Driving Path Flag: {cp['critical_by_driving_flag']}")
|
|
2386
|
+
lines.append(f" Longest Path: {cp['critical_by_longest_path']}")
|
|
2387
|
+
lines.append('')
|
|
2388
|
+
fd = cp['float_distribution']
|
|
2389
|
+
lines.append(' Float Distribution:')
|
|
2390
|
+
lines.append(f" Negative: {fd['negative']}")
|
|
2391
|
+
lines.append(f" Zero: {fd['zero']}")
|
|
2392
|
+
lines.append(f" 1-5 days: {fd['1-5']}")
|
|
2393
|
+
lines.append(f" 6-10 days: {fd['6-10']}")
|
|
2394
|
+
lines.append(f" 11-20: {fd['11-20']}")
|
|
2395
|
+
lines.append(f" 21-40: {fd['21-40']}")
|
|
2396
|
+
lines.append(f" 41+: {fd['41+']}")
|
|
2397
|
+
lines.append(f" Null: {fd['null']}")
|
|
2398
|
+
lines.append('')
|
|
2399
|
+
|
|
2400
|
+
# Relationships
|
|
2401
|
+
rel = summary['relationships']
|
|
2402
|
+
lines.append('─' * 40)
|
|
2403
|
+
lines.append('RELATIONSHIPS')
|
|
2404
|
+
lines.append('─' * 40)
|
|
2405
|
+
lines.append(f" Total: {rel['total_relationships']}")
|
|
2406
|
+
for rtype, count in rel['by_type'].items():
|
|
2407
|
+
lines.append(f" {rtype:<20} {count}")
|
|
2408
|
+
lines.append(f" With Lag: {rel['relationships_with_lag']}")
|
|
2409
|
+
oe = rel['open_ends']
|
|
2410
|
+
lines.append(f" No Predecessors: {oe['no_predecessors']}")
|
|
2411
|
+
lines.append(f" No Successors: {oe['no_successors']}")
|
|
2412
|
+
lines.append('')
|
|
2413
|
+
|
|
2414
|
+
# Calendars
|
|
2415
|
+
lines.append('─' * 40)
|
|
2416
|
+
lines.append('CALENDARS')
|
|
2417
|
+
lines.append('─' * 40)
|
|
2418
|
+
for cal in summary['calendars']:
|
|
2419
|
+
lines.append(f" [{cal['clndr_id']}] {cal['name']}")
|
|
2420
|
+
lines.append(f" {cal['hours_per_day']}h/day, {cal['hours_per_week']}h/week")
|
|
2421
|
+
lines.append(f" Work days: {', '.join(cal['work_days']) if cal['work_days'] else 'Not parsed'}")
|
|
2422
|
+
lines.append(f" Holidays: {cal['holiday_count']}")
|
|
2423
|
+
lines.append('')
|
|
2424
|
+
|
|
2425
|
+
# Resources
|
|
2426
|
+
res = summary['resources']
|
|
2427
|
+
lines.append('─' * 40)
|
|
2428
|
+
lines.append('RESOURCES')
|
|
2429
|
+
lines.append('─' * 40)
|
|
2430
|
+
lines.append(f" Unique Resources: {res['unique_resources']}")
|
|
2431
|
+
lines.append(f" Total Assignments: {res['resource_assignments']}")
|
|
2432
|
+
lines.append('')
|
|
2433
|
+
|
|
2434
|
+
# Data Quality
|
|
2435
|
+
dq = summary['data_quality']
|
|
2436
|
+
lines.append('─' * 40)
|
|
2437
|
+
lines.append('DATA QUALITY FLAGS')
|
|
2438
|
+
lines.append('─' * 40)
|
|
2439
|
+
lines.append(f" Constrained Activities: {dq['activities_with_constraints']}")
|
|
2440
|
+
lines.append(f" Missing Predecessors: {dq['missing_logic']['no_predecessor_count']}")
|
|
2441
|
+
lines.append(f" Missing Successors: {dq['missing_logic']['no_successor_count']}")
|
|
2442
|
+
fv = dq['field_count_validation']
|
|
2443
|
+
if isinstance(fv, str):
|
|
2444
|
+
lines.append(f" Field Validation: {fv}")
|
|
2445
|
+
else:
|
|
2446
|
+
lines.append(f" Field Validation: {len(fv)} issues found")
|
|
2447
|
+
for issue in fv:
|
|
2448
|
+
lines.append(f" - {issue}")
|
|
2449
|
+
lines.append('')
|
|
2450
|
+
lines.append('=' * 70)
|
|
2451
|
+
|
|
2452
|
+
report_text = '\n'.join(lines)
|
|
2453
|
+
|
|
2454
|
+
if output_file:
|
|
2455
|
+
with open(output_file, 'w') as f:
|
|
2456
|
+
f.write(report_text)
|
|
2457
|
+
print(f"Summary report saved to: {output_file}")
|
|
2458
|
+
else:
|
|
2459
|
+
print(report_text)
|
|
2460
|
+
|
|
2461
|
+
return summary
|
|
2462
|
+
|
|
2463
|
+
|
|
2464
|
+
# ─────────────────────────────────────────────
|
|
2465
|
+
# V2.0 — MANIFEST / BASELINES / UDF TYPES / SCHEMA DIFF / VALIDATION
|
|
2466
|
+
# ─────────────────────────────────────────────
|
|
2467
|
+
|
|
2468
|
+
def generate_xer_manifest(data, xer_path=None, **manifest_kwargs):
|
|
2469
|
+
"""Generate a nuclear-grade manifest for a parsed XER.
|
|
2470
|
+
|
|
2471
|
+
Wraps the common `generate_manifest()` with XER-specific fields:
|
|
2472
|
+
- P6 version (from ERMHDR)
|
|
2473
|
+
- ERMHDR raw line (tab-joined) as 'extra.ermhdr'
|
|
2474
|
+
- Table counts (in 'extra.table_counts')
|
|
2475
|
+
- Source file SHA-256 + size (from audit_trail)
|
|
2476
|
+
- xer-parser skill version
|
|
2477
|
+
|
|
2478
|
+
Args:
|
|
2479
|
+
data: parsed XER dict from parse_xer()
|
|
2480
|
+
xer_path: optional path to the source XER file (used for input sha256)
|
|
2481
|
+
**manifest_kwargs: additional kwargs forwarded to generate_manifest()
|
|
2482
|
+
(skill_name, operator, parameters, extra, ...)
|
|
2483
|
+
|
|
2484
|
+
Returns:
|
|
2485
|
+
dict — manifest in the common schema, with XER-specific extras merged.
|
|
2486
|
+
|
|
2487
|
+
Raises:
|
|
2488
|
+
RuntimeError: if _cpp_common is not importable
|
|
2489
|
+
"""
|
|
2490
|
+
if generate_manifest is None:
|
|
2491
|
+
raise RuntimeError('_cpp_common is not on sys.path; cannot build manifest')
|
|
2492
|
+
|
|
2493
|
+
ermhdr = data.get('ermhdr', {}) or {}
|
|
2494
|
+
raw = ermhdr.get('raw') or []
|
|
2495
|
+
ermhdr_raw_str = '\t'.join(raw) if isinstance(raw, list) else str(raw or '')
|
|
2496
|
+
|
|
2497
|
+
# Table-level counts (record and field counts per table)
|
|
2498
|
+
table_counts = {}
|
|
2499
|
+
for table_name, table_data in (data.get('tables', {}) or {}).items():
|
|
2500
|
+
table_counts[table_name] = {
|
|
2501
|
+
'records': len(table_data.get('records', [])),
|
|
2502
|
+
'fields': len(table_data.get('fields', [])),
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
# Build the XER-specific extra block
|
|
2506
|
+
xer_extra = {
|
|
2507
|
+
'p6_version': ermhdr.get('version', ''),
|
|
2508
|
+
'p6_export_date': ermhdr.get('export_date', ''),
|
|
2509
|
+
'p6_exported_by': ermhdr.get('user', ''),
|
|
2510
|
+
'p6_currency': ermhdr.get('currency', ''),
|
|
2511
|
+
'ermhdr_raw': ermhdr_raw_str,
|
|
2512
|
+
'ermhdr_field_count': str(len(raw) if isinstance(raw, list) else 0),
|
|
2513
|
+
'table_count': str(len(data.get('tables', {}) or {})),
|
|
2514
|
+
'table_counts': table_counts,
|
|
2515
|
+
'encoding_used': data.get('encoding_used', ''),
|
|
2516
|
+
'xer_parser_version': _SKILL_VERSION,
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
# Merge caller-supplied extras
|
|
2520
|
+
merged_extra = dict(manifest_kwargs.pop('extra', {}) or {})
|
|
2521
|
+
merged_extra.update(xer_extra)
|
|
2522
|
+
|
|
2523
|
+
# Default skill_name and input_files if caller didn't provide them
|
|
2524
|
+
skill_name = manifest_kwargs.pop('skill_name', 'xer-parser')
|
|
2525
|
+
input_files = manifest_kwargs.pop('input_files', None)
|
|
2526
|
+
if input_files is None and xer_path:
|
|
2527
|
+
input_files = [('xer', xer_path)]
|
|
2528
|
+
elif input_files is None and data.get('filepath'):
|
|
2529
|
+
input_files = [('xer', data['filepath'])]
|
|
2530
|
+
else:
|
|
2531
|
+
input_files = input_files or []
|
|
2532
|
+
|
|
2533
|
+
skill_dir = manifest_kwargs.pop(
|
|
2534
|
+
'skill_dir',
|
|
2535
|
+
os.path.normpath(os.path.join(_SCRIPT_DIR, '..'))
|
|
2536
|
+
)
|
|
2537
|
+
|
|
2538
|
+
manifest = generate_manifest(
|
|
2539
|
+
skill_name=skill_name,
|
|
2540
|
+
skill_dir=skill_dir,
|
|
2541
|
+
input_files=input_files,
|
|
2542
|
+
extra=merged_extra,
|
|
2543
|
+
**manifest_kwargs,
|
|
2544
|
+
)
|
|
2545
|
+
return manifest
|
|
2546
|
+
|
|
2547
|
+
|
|
2548
|
+
def get_baselines(data):
|
|
2549
|
+
"""Identify baseline schedules in the XER.
|
|
2550
|
+
|
|
2551
|
+
P6 stores baselines either in a dedicated BASELINE table (newer versions)
|
|
2552
|
+
or as separate PROJECT records with bl_type/sum_base_proj_id flags. When
|
|
2553
|
+
neither exists, the single PROJECT record itself is the (only) baseline.
|
|
2554
|
+
|
|
2555
|
+
Returns:
|
|
2556
|
+
list of dicts, each with:
|
|
2557
|
+
proj_id: P6 project id
|
|
2558
|
+
short_name: proj_short_name (or equivalent)
|
|
2559
|
+
bl_type: bl_type code if present ('', 'BL_Project', 'BL_Primary', ...)
|
|
2560
|
+
data_date: last_recalc_date / fin_dt
|
|
2561
|
+
source: 'BASELINE' | 'PROJECT' | 'PROJECT_SINGLE'
|
|
2562
|
+
"""
|
|
2563
|
+
baselines = []
|
|
2564
|
+
|
|
2565
|
+
# Path 1 — dedicated BASELINE table
|
|
2566
|
+
bl_records = get_table(data, 'BASELINE')
|
|
2567
|
+
for bl in bl_records:
|
|
2568
|
+
baselines.append({
|
|
2569
|
+
'proj_id': bl.get('base_proj_id', '') or bl.get('proj_id', ''),
|
|
2570
|
+
'short_name': bl.get('bl_name', '') or bl.get('proj_short_name', ''),
|
|
2571
|
+
'bl_type': bl.get('bl_type', '') or '',
|
|
2572
|
+
'data_date': bl.get('fin_dt', '') or bl.get('last_recalc_date', ''),
|
|
2573
|
+
'source': 'BASELINE',
|
|
2574
|
+
})
|
|
2575
|
+
|
|
2576
|
+
# Path 2 — baseline projects tagged in PROJECT table (bl_type / sum_base_proj_id / base_type_id)
|
|
2577
|
+
projects = get_table(data, 'PROJECT')
|
|
2578
|
+
tagged = [
|
|
2579
|
+
p for p in projects
|
|
2580
|
+
if (p.get('bl_type', '') or p.get('base_type_id', '') or p.get('sum_base_proj_id', '')).strip()
|
|
2581
|
+
]
|
|
2582
|
+
for p in tagged:
|
|
2583
|
+
# Skip duplicates already added via BASELINE table
|
|
2584
|
+
pid = p.get('proj_id', '')
|
|
2585
|
+
if any(b['proj_id'] == pid and b['source'] == 'BASELINE' for b in baselines):
|
|
2586
|
+
continue
|
|
2587
|
+
baselines.append({
|
|
2588
|
+
'proj_id': pid,
|
|
2589
|
+
'short_name': p.get('proj_short_name', ''),
|
|
2590
|
+
'bl_type': p.get('bl_type', '') or p.get('base_type_id', '') or '',
|
|
2591
|
+
'data_date': p.get('last_recalc_date', ''),
|
|
2592
|
+
'source': 'PROJECT',
|
|
2593
|
+
})
|
|
2594
|
+
|
|
2595
|
+
# Path 3 — no baseline markers anywhere: the single PROJECT record IS the baseline
|
|
2596
|
+
if not baselines and projects:
|
|
2597
|
+
p = projects[0]
|
|
2598
|
+
baselines.append({
|
|
2599
|
+
'proj_id': p.get('proj_id', ''),
|
|
2600
|
+
'short_name': p.get('proj_short_name', ''),
|
|
2601
|
+
'bl_type': '',
|
|
2602
|
+
'data_date': p.get('last_recalc_date', ''),
|
|
2603
|
+
'source': 'PROJECT_SINGLE',
|
|
2604
|
+
})
|
|
2605
|
+
|
|
2606
|
+
return baselines
|
|
2607
|
+
|
|
2608
|
+
|
|
2609
|
+
def contract_baseline_id(data):
|
|
2610
|
+
"""Return the proj_id of the contract baseline, or '' if none identifiable.
|
|
2611
|
+
|
|
2612
|
+
The "contract baseline" is, in order of preference:
|
|
2613
|
+
1. The baseline tagged bl_type == 'BL_Project' (Primavera's canonical tag)
|
|
2614
|
+
2. The baseline tagged bl_type == 'BL_Primary'
|
|
2615
|
+
3. The earliest baseline by data_date
|
|
2616
|
+
4. The first baseline in get_baselines()
|
|
2617
|
+
5. '' if there are no baselines / no projects
|
|
2618
|
+
"""
|
|
2619
|
+
baselines = get_baselines(data)
|
|
2620
|
+
if not baselines:
|
|
2621
|
+
return ''
|
|
2622
|
+
|
|
2623
|
+
# Preferred tag wins
|
|
2624
|
+
for preferred in ('BL_Project', 'BL_Primary'):
|
|
2625
|
+
for b in baselines:
|
|
2626
|
+
if b.get('bl_type') == preferred:
|
|
2627
|
+
return b.get('proj_id', '')
|
|
2628
|
+
|
|
2629
|
+
# Earliest by data_date (empty strings sort first — use a sentinel)
|
|
2630
|
+
def _sort_key(b):
|
|
2631
|
+
dd = (b.get('data_date') or '').strip()
|
|
2632
|
+
return (dd == '', dd) # non-empty dates first, then lexical order
|
|
2633
|
+
|
|
2634
|
+
sorted_bls = sorted(baselines, key=_sort_key)
|
|
2635
|
+
return sorted_bls[0].get('proj_id', '') if sorted_bls else ''
|
|
2636
|
+
|
|
2637
|
+
|
|
2638
|
+
def get_udf_types(data):
|
|
2639
|
+
"""Return full UDFTYPE records with a lookup-friendly shape.
|
|
2640
|
+
|
|
2641
|
+
Existing `build_udf_map()` returns UDF *values* keyed by (table, fk_id) —
|
|
2642
|
+
this function exposes the UDF *type metadata* so callers can ask
|
|
2643
|
+
"what UDFs are defined, on which tables, of what data type?"
|
|
2644
|
+
|
|
2645
|
+
Returns:
|
|
2646
|
+
list of dicts, each with:
|
|
2647
|
+
udf_type_id
|
|
2648
|
+
table_name (TASK / PROJECT / PROJWBS / RSRC / ...)
|
|
2649
|
+
udf_type_name (internal name, e.g. 'task_note')
|
|
2650
|
+
udf_type_label (human label, e.g. 'Task Note')
|
|
2651
|
+
logical_data_type (FT_TEXT / FT_INT / FT_STATICTYPE / FT_DOUBLE / FT_DATE / ...)
|
|
2652
|
+
super_flag (indicator field flag if present)
|
|
2653
|
+
"""
|
|
2654
|
+
udftypes = get_table(data, 'UDFTYPE')
|
|
2655
|
+
out = []
|
|
2656
|
+
for u in udftypes:
|
|
2657
|
+
out.append({
|
|
2658
|
+
'udf_type_id': u.get('udf_type_id', ''),
|
|
2659
|
+
'table_name': u.get('table_name', ''),
|
|
2660
|
+
'udf_type_name': u.get('udf_type_name', ''),
|
|
2661
|
+
'udf_type_label': u.get('udf_type_label', ''),
|
|
2662
|
+
'logical_data_type': u.get('logical_data_type', '')
|
|
2663
|
+
or u.get('udf_type_logical_type', '')
|
|
2664
|
+
or u.get('udf_type', ''),
|
|
2665
|
+
'super_flag': u.get('super_flag', ''),
|
|
2666
|
+
})
|
|
2667
|
+
return out
|
|
2668
|
+
|
|
2669
|
+
|
|
2670
|
+
def schema_diff(data_a, data_b):
|
|
2671
|
+
"""Compare parsed-XER schemas across two XERs.
|
|
2672
|
+
|
|
2673
|
+
Useful for spotting schema drift between P6 versions or baseline vs update.
|
|
2674
|
+
|
|
2675
|
+
Args:
|
|
2676
|
+
data_a: parsed XER (the 'before' / baseline)
|
|
2677
|
+
data_b: parsed XER (the 'after' / current)
|
|
2678
|
+
|
|
2679
|
+
Returns:
|
|
2680
|
+
{
|
|
2681
|
+
'tables_added': [table names in B but not A],
|
|
2682
|
+
'tables_removed': [table names in A but not B],
|
|
2683
|
+
'fields_added': {table_name: [fields in B but not A]},
|
|
2684
|
+
'fields_removed': {table_name: [fields in A but not B]},
|
|
2685
|
+
'record_count_delta': {table_name: int_delta_b_minus_a},
|
|
2686
|
+
}
|
|
2687
|
+
"""
|
|
2688
|
+
tables_a = (data_a.get('tables', {}) or {})
|
|
2689
|
+
tables_b = (data_b.get('tables', {}) or {})
|
|
2690
|
+
|
|
2691
|
+
names_a = set(tables_a.keys())
|
|
2692
|
+
names_b = set(tables_b.keys())
|
|
2693
|
+
|
|
2694
|
+
added = sorted(names_b - names_a)
|
|
2695
|
+
removed = sorted(names_a - names_b)
|
|
2696
|
+
|
|
2697
|
+
fields_added = {}
|
|
2698
|
+
fields_removed = {}
|
|
2699
|
+
record_delta = {}
|
|
2700
|
+
|
|
2701
|
+
for name in names_a | names_b:
|
|
2702
|
+
fa = set((tables_a.get(name) or {}).get('fields', []) or [])
|
|
2703
|
+
fb = set((tables_b.get(name) or {}).get('fields', []) or [])
|
|
2704
|
+
added_fields = sorted(fb - fa)
|
|
2705
|
+
removed_fields = sorted(fa - fb)
|
|
2706
|
+
if added_fields:
|
|
2707
|
+
fields_added[name] = added_fields
|
|
2708
|
+
if removed_fields:
|
|
2709
|
+
fields_removed[name] = removed_fields
|
|
2710
|
+
# Record delta
|
|
2711
|
+
ra = len((tables_a.get(name) or {}).get('records', []) or [])
|
|
2712
|
+
rb = len((tables_b.get(name) or {}).get('records', []) or [])
|
|
2713
|
+
if ra != rb:
|
|
2714
|
+
record_delta[name] = rb - ra
|
|
2715
|
+
|
|
2716
|
+
return {
|
|
2717
|
+
'tables_added': added,
|
|
2718
|
+
'tables_removed': removed,
|
|
2719
|
+
'fields_added': fields_added,
|
|
2720
|
+
'fields_removed': fields_removed,
|
|
2721
|
+
'record_count_delta': record_delta,
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
|
|
2725
|
+
def validate_schedule(data, profile='commercial', subject=None):
|
|
2726
|
+
"""Unified XER validation using the Finding/ValidationReport framework.
|
|
2727
|
+
|
|
2728
|
+
Replaces the old `_validate_field_counts` style of ad-hoc issue lists
|
|
2729
|
+
with severity-graded findings in a single report.
|
|
2730
|
+
|
|
2731
|
+
Checks performed:
|
|
2732
|
+
- File field-count validation (WARN on version-specific mismatch)
|
|
2733
|
+
- AACE 29R-03 §2.1: at least one PROJECT record (BLOCK if zero)
|
|
2734
|
+
- AACE 29R-03 §2.1.B.10: at least one CALENDAR record (BLOCK if zero)
|
|
2735
|
+
- AACE 29R-03 §2.1.B.10: every TASK row has a usable calendar, a blank
|
|
2736
|
+
clndr_id resolving to the project or default calendar (BLOCK otherwise)
|
|
2737
|
+
- INFO: blank TASK.clndr_id values resolved onto the project calendar
|
|
2738
|
+
- CPP profile / AACE 38R-06 §3.5: WBS depth within profile range (BLOCK if below min, WARN if above max)
|
|
2739
|
+
- CPP profile / AACE 38R-06 §3.5: activity count within profile range (WARN outside)
|
|
2740
|
+
- AACE 29R-03 §2.1.B.5 / DCMA 14-Point #1: TASKPRED ties on real work tasks (BLOCK otherwise)
|
|
2741
|
+
- AACE 29R-03 §2.1.B.10: holiday list empty on calendar named "...with holidays..." (WARN)
|
|
2742
|
+
- INFO: multi-project XER
|
|
2743
|
+
- INFO: unusual encoding used for decoding
|
|
2744
|
+
|
|
2745
|
+
Citation note (2026-08-19): earlier revisions pinned these checks to
|
|
2746
|
+
AACE 31R-03 (a cost-estimate RP, wrong document for schedule
|
|
2747
|
+
propositions) and to 53R-06 pinpoints (the RP has no numbered sections).
|
|
2748
|
+
References now point to AACE 29R-03 §2.1 (baseline schedule validation),
|
|
2749
|
+
AACE 38R-06 §3.5 Planning Basis (Rev. June 18, 2009, Ed. Rev.
|
|
2750
|
+
August 6, 2025), DCMA 14-Point, and the P6 XER schema itself.
|
|
2751
|
+
|
|
2752
|
+
Args:
|
|
2753
|
+
data: parsed XER dict
|
|
2754
|
+
profile: 'commercial' | 'nuclear' | 'mining' (from config_profiles)
|
|
2755
|
+
subject: optional subject string for the report (defaults to filename)
|
|
2756
|
+
|
|
2757
|
+
Returns:
|
|
2758
|
+
ValidationReport
|
|
2759
|
+
"""
|
|
2760
|
+
if ValidationReport is None or Finding is None or get_profile is None:
|
|
2761
|
+
raise RuntimeError('_cpp_common is not on sys.path; cannot build ValidationReport')
|
|
2762
|
+
|
|
2763
|
+
prof = get_profile(profile)
|
|
2764
|
+
subject = subject or data.get('filename') or data.get('filepath') or '<in-memory XER>'
|
|
2765
|
+
report = ValidationReport(subject=subject, context={'profile': profile})
|
|
2766
|
+
|
|
2767
|
+
# ── File field-count validation ────────────────────────────────
|
|
2768
|
+
version = (data.get('ermhdr', {}) or {}).get('version', '')
|
|
2769
|
+
major = version.split('.')[0] if version else ''
|
|
2770
|
+
expected_schema = TABLE_FIELD_COUNTS_BY_VERSION.get(major)
|
|
2771
|
+
if expected_schema:
|
|
2772
|
+
for table_name, table_data in (data.get('tables', {}) or {}).items():
|
|
2773
|
+
field_count = len(table_data.get('fields', []) or [])
|
|
2774
|
+
if table_name in expected_schema:
|
|
2775
|
+
expected = expected_schema[table_name]
|
|
2776
|
+
if field_count != expected:
|
|
2777
|
+
report.add(Finding(
|
|
2778
|
+
severity=WARN,
|
|
2779
|
+
check_id='XER-FIELD-COUNT',
|
|
2780
|
+
message=f'{table_name} has {field_count} fields; expected {expected} for P6 {version}',
|
|
2781
|
+
evidence={'table': table_name, 'expected': expected, 'found': field_count, 'p6_version': version},
|
|
2782
|
+
reference='P6 XER schema (field counts by version)',
|
|
2783
|
+
))
|
|
2784
|
+
else:
|
|
2785
|
+
report.add(Finding(
|
|
2786
|
+
severity=INFO,
|
|
2787
|
+
check_id='XER-FIELD-COUNT-SKIPPED',
|
|
2788
|
+
message=f'P6 version {version!r} not in schema map. Field-count check skipped.',
|
|
2789
|
+
evidence={'p6_version': version},
|
|
2790
|
+
reference='P6 XER schema (field counts by version)',
|
|
2791
|
+
))
|
|
2792
|
+
|
|
2793
|
+
# ── PROJECT record count ────────────────────────────────
|
|
2794
|
+
projects = get_table(data, 'PROJECT')
|
|
2795
|
+
if len(projects) == 0:
|
|
2796
|
+
report.add(Finding(
|
|
2797
|
+
severity=BLOCK,
|
|
2798
|
+
check_id='XER-PROJECT-MISSING',
|
|
2799
|
+
message='No PROJECT records found in the XER. File is not a valid schedule.',
|
|
2800
|
+
evidence={'project_count': 0},
|
|
2801
|
+
reference='AACE 29R-03 §2.1 (baseline schedule validation)',
|
|
2802
|
+
))
|
|
2803
|
+
elif len(projects) > 1:
|
|
2804
|
+
report.add(Finding(
|
|
2805
|
+
severity=INFO,
|
|
2806
|
+
check_id='XER-MULTI-PROJECT',
|
|
2807
|
+
message=f'Multi-project XER: {len(projects)} PROJECT records found',
|
|
2808
|
+
evidence={'project_count': len(projects),
|
|
2809
|
+
'project_ids': [p.get('proj_id', '') for p in projects]},
|
|
2810
|
+
reference='P6 XER structure (multi-project export)',
|
|
2811
|
+
))
|
|
2812
|
+
|
|
2813
|
+
# ── CALENDAR record count ────────────────────────────────
|
|
2814
|
+
calendars = get_table(data, 'CALENDAR')
|
|
2815
|
+
if len(calendars) == 0:
|
|
2816
|
+
report.add(Finding(
|
|
2817
|
+
severity=BLOCK,
|
|
2818
|
+
check_id='XER-CALENDAR-MISSING',
|
|
2819
|
+
message='No CALENDAR records found in the XER. Activity durations cannot be computed.',
|
|
2820
|
+
evidence={'calendar_count': 0},
|
|
2821
|
+
reference='AACE 29R-03 §2.1.B.10 (calendar validation)',
|
|
2822
|
+
))
|
|
2823
|
+
|
|
2824
|
+
# ── Activities with no usable calendar ────────────────────────────────
|
|
2825
|
+
# The row gives the arithmetic no calendar at all: duration_hours_to_days
|
|
2826
|
+
# falls to 8 h/day, and cpp-cpm-engine, handed no project calendar, to a
|
|
2827
|
+
# continuous seven-day week. A blank id that resolves to the project
|
|
2828
|
+
# calendar is the file's own meaning and only disclosed.
|
|
2829
|
+
# Skipped when the file has no CALENDAR table - XER-CALENDAR-MISSING
|
|
2830
|
+
# already says that.
|
|
2831
|
+
if calendars:
|
|
2832
|
+
_task_cals = resolve_task_calendars(data)
|
|
2833
|
+
_unres = _task_cals['unresolved']
|
|
2834
|
+
if _unres:
|
|
2835
|
+
_blank = sum(1 for u in _unres if u['reason'] == 'blank')
|
|
2836
|
+
report.add(Finding(
|
|
2837
|
+
severity=BLOCK,
|
|
2838
|
+
check_id='XER-TASK-CALENDAR-UNRESOLVED',
|
|
2839
|
+
message=(
|
|
2840
|
+
'{n} TASK row(s) have no usable calendar: {b} with a blank '
|
|
2841
|
+
'clndr_id and no PROJECT.clndr_id or default_flag=Y '
|
|
2842
|
+
'calendar to fall back on, {u} naming a clndr_id the '
|
|
2843
|
+
'CALENDAR table does not declare. Working-day arithmetic '
|
|
2844
|
+
'has no week this file declares to run them on: '
|
|
2845
|
+
'duration_hours_to_days reads them at 8 h/day, and '
|
|
2846
|
+
'cpp-cpm-engine, handed no project calendar, schedules '
|
|
2847
|
+
'them on a continuous seven-day week.'.format(
|
|
2848
|
+
n=len(_unres), b=_blank, u=len(_unres) - _blank)
|
|
2849
|
+
),
|
|
2850
|
+
evidence={'unresolved_count': len(_unres),
|
|
2851
|
+
'blank_count': _blank,
|
|
2852
|
+
'undeclared_count': len(_unres) - _blank,
|
|
2853
|
+
'task_codes': [u['task_code'] for u in _unres]},
|
|
2854
|
+
reference='AACE 29R-03 §2.1.B.10 (calendar validation)',
|
|
2855
|
+
))
|
|
2856
|
+
_fell_back = _task_cals['resolved_by_fallback']
|
|
2857
|
+
if _fell_back:
|
|
2858
|
+
_by_cal = {}
|
|
2859
|
+
for r in _fell_back:
|
|
2860
|
+
_k = (r['clndr_id'], r['clndr_name'], r['tier'])
|
|
2861
|
+
_by_cal[_k] = _by_cal.get(_k, 0) + 1
|
|
2862
|
+
report.add(Finding(
|
|
2863
|
+
severity=INFO,
|
|
2864
|
+
check_id='XER-TASK-CALENDAR-FALLBACK',
|
|
2865
|
+
message=(
|
|
2866
|
+
'{n} TASK row(s) carry a blank clndr_id and are scheduled '
|
|
2867
|
+
'on the project calendar (PROJECT.clndr_id, else the '
|
|
2868
|
+
'default_flag=Y calendar).'.format(n=len(_fell_back))
|
|
2869
|
+
),
|
|
2870
|
+
evidence={'resolved_count': len(_fell_back),
|
|
2871
|
+
'calendars': [
|
|
2872
|
+
{'clndr_id': k[0], 'clndr_name': k[1],
|
|
2873
|
+
'tier': k[2], 'task_count': v}
|
|
2874
|
+
for k, v in _by_cal.items()]},
|
|
2875
|
+
reference='P6 XER schema (TASK.clndr_id, PROJECT.clndr_id)',
|
|
2876
|
+
))
|
|
2877
|
+
|
|
2878
|
+
# ── WBS depth ────────────────────────────────
|
|
2879
|
+
wbs_map = build_wbs_map(data)
|
|
2880
|
+
wbs_depths = []
|
|
2881
|
+
for wbs_id, wbs in wbs_map.items():
|
|
2882
|
+
path = wbs.get('_full_path', '') or ''
|
|
2883
|
+
depth = len([seg for seg in path.split(' > ') if seg])
|
|
2884
|
+
wbs_depths.append(depth)
|
|
2885
|
+
max_wbs_depth = max(wbs_depths) if wbs_depths else 0
|
|
2886
|
+
wbs_min = prof.get('wbs_depth_min', 3)
|
|
2887
|
+
wbs_max = prof.get('wbs_depth_max', 6)
|
|
2888
|
+
if wbs_map:
|
|
2889
|
+
if max_wbs_depth < wbs_min:
|
|
2890
|
+
report.add(Finding(
|
|
2891
|
+
severity=BLOCK,
|
|
2892
|
+
check_id='XER-WBS-DEPTH-LOW',
|
|
2893
|
+
message=f'WBS is too shallow: max depth {max_wbs_depth} < {wbs_min} ({profile} profile minimum)',
|
|
2894
|
+
evidence={'max_wbs_depth': max_wbs_depth, 'min_required': wbs_min, 'profile': profile},
|
|
2895
|
+
reference='CPP profile heuristic / AACE 38R-06 §3.5 (Planning Basis)',
|
|
2896
|
+
))
|
|
2897
|
+
elif max_wbs_depth > wbs_max:
|
|
2898
|
+
report.add(Finding(
|
|
2899
|
+
severity=WARN,
|
|
2900
|
+
check_id='XER-WBS-DEPTH-HIGH',
|
|
2901
|
+
message=f'WBS is unusually deep: max depth {max_wbs_depth} > {wbs_max} ({profile} profile maximum)',
|
|
2902
|
+
evidence={'max_wbs_depth': max_wbs_depth, 'max_allowed': wbs_max, 'profile': profile},
|
|
2903
|
+
reference='CPP profile heuristic / AACE 38R-06 §3.5 (Planning Basis)',
|
|
2904
|
+
))
|
|
2905
|
+
|
|
2906
|
+
# ── Activity count ────────────────────────────────
|
|
2907
|
+
tasks = get_table(data, 'TASK')
|
|
2908
|
+
real_tasks = [t for t in tasks if t.get('task_type', '') not in ('TT_LOE', 'TT_WBS')]
|
|
2909
|
+
act_count = len(real_tasks)
|
|
2910
|
+
act_min = prof.get('activity_count_min', 50)
|
|
2911
|
+
act_max = prof.get('activity_count_max', 10000)
|
|
2912
|
+
if act_count < act_min:
|
|
2913
|
+
report.add(Finding(
|
|
2914
|
+
severity=WARN,
|
|
2915
|
+
check_id='XER-ACTIVITY-COUNT-LOW',
|
|
2916
|
+
message=f'Activity count {act_count} is below {profile} profile minimum of {act_min}',
|
|
2917
|
+
evidence={'activity_count': act_count, 'min': act_min, 'profile': profile},
|
|
2918
|
+
reference='CPP profile heuristic / AACE 38R-06 §3.5 (Planning Basis)',
|
|
2919
|
+
))
|
|
2920
|
+
elif act_count > act_max:
|
|
2921
|
+
report.add(Finding(
|
|
2922
|
+
severity=WARN,
|
|
2923
|
+
check_id='XER-ACTIVITY-COUNT-HIGH',
|
|
2924
|
+
message=f'Activity count {act_count} exceeds {profile} profile maximum of {act_max}',
|
|
2925
|
+
evidence={'activity_count': act_count, 'max': act_max, 'profile': profile},
|
|
2926
|
+
reference='CPP profile heuristic / AACE 38R-06 §3.5 (Planning Basis)',
|
|
2927
|
+
))
|
|
2928
|
+
|
|
2929
|
+
# ── TASKPRED presence when tasks exist ────────────────────────────────
|
|
2930
|
+
# AACE 29R-03 §2.1.B.5 / DCMA 14-Point #1: every non-summary, non-milestone
|
|
2931
|
+
# activity must participate in at least one logic tie. Milestones and
|
|
2932
|
+
# LOE/WBS rows are excluded because:
|
|
2933
|
+
# - LOE / WBS-summary rows are roll-ups, not work items;
|
|
2934
|
+
# - milestones are zero-duration markers and a milestone-only schedule
|
|
2935
|
+
# can be valid without ties.
|
|
2936
|
+
# The previous logic globbed TASKPRED count instead of checking whether
|
|
2937
|
+
# the *real work* tasks were actually wired in — meaning a 1000-LOE/WBS
|
|
2938
|
+
# network with zero TASKPRED slipped through, and a real-tasks network
|
|
2939
|
+
# could pass when the only TASKPRED rows tied milestones, never the work.
|
|
2940
|
+
taskpred = get_table(data, 'TASKPRED')
|
|
2941
|
+
non_summary_tasks = [
|
|
2942
|
+
t for t in tasks
|
|
2943
|
+
if t.get('task_type', '') not in ('TT_LOE', 'TT_WBS', 'TT_Mile', 'TT_FinMile')
|
|
2944
|
+
]
|
|
2945
|
+
non_summary_task_ids = {t.get('task_id', '') for t in non_summary_tasks}
|
|
2946
|
+
# A TASKPRED row counts iff either endpoint is a real work task.
|
|
2947
|
+
real_taskpred_rows = [
|
|
2948
|
+
p for p in taskpred
|
|
2949
|
+
if p.get('task_id', '') in non_summary_task_ids
|
|
2950
|
+
or p.get('pred_task_id', '') in non_summary_task_ids
|
|
2951
|
+
]
|
|
2952
|
+
if non_summary_tasks and not real_taskpred_rows:
|
|
2953
|
+
report.add(Finding(
|
|
2954
|
+
severity=BLOCK,
|
|
2955
|
+
check_id='XER-NO-TASKPRED',
|
|
2956
|
+
message=(
|
|
2957
|
+
f'Schedule has {len(non_summary_tasks)} non-summary, non-milestone '
|
|
2958
|
+
f'activities but zero TASKPRED relationships tie any of them. No work logic exists.'
|
|
2959
|
+
),
|
|
2960
|
+
evidence={
|
|
2961
|
+
'non_summary_task_count': len(non_summary_tasks),
|
|
2962
|
+
'total_taskpred_count': len(taskpred),
|
|
2963
|
+
'real_work_taskpred_count': 0,
|
|
2964
|
+
},
|
|
2965
|
+
reference='AACE 29R-03 §2.1.B.5 / DCMA 14-Point #1',
|
|
2966
|
+
))
|
|
2967
|
+
|
|
2968
|
+
# ── Calendar-with-holidays sanity ────────────────────────────────
|
|
2969
|
+
cal_map = get_calendar_map(data)
|
|
2970
|
+
for cid, cal in cal_map.items():
|
|
2971
|
+
name = (cal.get('clndr_name') or '').lower()
|
|
2972
|
+
if 'holiday' in name and 'no holiday' not in name:
|
|
2973
|
+
if not cal.get('holidays'):
|
|
2974
|
+
report.add(Finding(
|
|
2975
|
+
severity=WARN,
|
|
2976
|
+
check_id='XER-CALENDAR-HOLIDAYS-EMPTY',
|
|
2977
|
+
message=f"Calendar {cal.get('clndr_name')!r} is named 'with holidays' but has zero holidays parsed",
|
|
2978
|
+
evidence={'clndr_id': cid, 'clndr_name': cal.get('clndr_name', '')},
|
|
2979
|
+
reference='AACE 29R-03 §2.1.B.10 (calendar validation)',
|
|
2980
|
+
))
|
|
2981
|
+
|
|
2982
|
+
# ── Encoding info ────────────────────────────────
|
|
2983
|
+
enc = (data.get('encoding_used') or '').lower()
|
|
2984
|
+
if enc and enc not in ('utf-8', 'utf-8-sig', 'cp1252'):
|
|
2985
|
+
report.add(Finding(
|
|
2986
|
+
severity=INFO,
|
|
2987
|
+
check_id='XER-ENCODING-UNUSUAL',
|
|
2988
|
+
message=f'XER was decoded with unusual encoding {enc!r}',
|
|
2989
|
+
evidence={'encoding_used': enc},
|
|
2990
|
+
reference='P6 XER file encoding',
|
|
2991
|
+
))
|
|
2992
|
+
|
|
2993
|
+
return report
|
|
2994
|
+
|
|
2995
|
+
|
|
2996
|
+
def aace_31r_compliance(data, profile='commercial'):
|
|
2997
|
+
"""Schedule structure score (0–100) for an XER schedule.
|
|
2998
|
+
|
|
2999
|
+
The function name is legacy: AACE 31R-03 is a cost-estimate RP
|
|
3000
|
+
("Reviewing, Validating, and Documenting the Estimate") and does not
|
|
3001
|
+
govern these checks. The name is kept for API compatibility; the
|
|
3002
|
+
underlying findings cite AACE 29R-03 §2.1, AACE 38R-06 §3.5, DCMA
|
|
3003
|
+
14-Point, and the P6 XER schema (see validate_schedule).
|
|
3004
|
+
|
|
3005
|
+
Scoring:
|
|
3006
|
+
- Start at 100
|
|
3007
|
+
- Subtract 20 per BLOCK finding (serious structural defect)
|
|
3008
|
+
- Subtract 5 per WARN finding
|
|
3009
|
+
- INFO and PASS findings do not subtract
|
|
3010
|
+
Grade bands: A=90+, B=80+, C=70+, D=60+, F<60
|
|
3011
|
+
Score clamped to [0, 100].
|
|
3012
|
+
|
|
3013
|
+
Returns:
|
|
3014
|
+
{
|
|
3015
|
+
'score_100': int,
|
|
3016
|
+
'grade': 'A' | 'B' | 'C' | 'D' | 'F',
|
|
3017
|
+
'findings': ValidationReport,
|
|
3018
|
+
}
|
|
3019
|
+
"""
|
|
3020
|
+
if ValidationReport is None:
|
|
3021
|
+
raise RuntimeError('_cpp_common is not on sys.path; cannot build ValidationReport')
|
|
3022
|
+
|
|
3023
|
+
report = validate_schedule(data, profile=profile)
|
|
3024
|
+
|
|
3025
|
+
score = 100
|
|
3026
|
+
score -= 20 * report.count(BLOCK)
|
|
3027
|
+
score -= 5 * report.count(WARN)
|
|
3028
|
+
if score < 0:
|
|
3029
|
+
score = 0
|
|
3030
|
+
if score > 100:
|
|
3031
|
+
score = 100
|
|
3032
|
+
|
|
3033
|
+
if score >= 90:
|
|
3034
|
+
grade = 'A'
|
|
3035
|
+
elif score >= 80:
|
|
3036
|
+
grade = 'B'
|
|
3037
|
+
elif score >= 70:
|
|
3038
|
+
grade = 'C'
|
|
3039
|
+
elif score >= 60:
|
|
3040
|
+
grade = 'D'
|
|
3041
|
+
else:
|
|
3042
|
+
grade = 'F'
|
|
3043
|
+
|
|
3044
|
+
return {
|
|
3045
|
+
'score_100': score,
|
|
3046
|
+
'grade': grade,
|
|
3047
|
+
'findings': report,
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
|
|
3051
|
+
# ─────────────────────────────────────────────
|
|
3052
|
+
# XER GENERATION ENGINE
|
|
3053
|
+
# ─────────────────────────────────────────────
|
|
3054
|
+
|
|
3055
|
+
def generate_xer(data, output_path, p6_version='24.12', currency='CAD',
|
|
3056
|
+
module='Project Management', start_time='08:00',
|
|
3057
|
+
end_time='16:00', hrs_per_day=8, hrs_per_week=40,
|
|
3058
|
+
user='admin', user_full_name='Claude Agent',
|
|
3059
|
+
database='dbxDatabaseNoName', export_scope='Project',
|
|
3060
|
+
encoding='utf-8'):
|
|
3061
|
+
"""
|
|
3062
|
+
Generate a valid Primavera P6 XER file from structured data.
|
|
3063
|
+
|
|
3064
|
+
data format:
|
|
3065
|
+
Same structure as parse_xer output, or:
|
|
3066
|
+
{
|
|
3067
|
+
'tables': {
|
|
3068
|
+
'TABLE_NAME': {
|
|
3069
|
+
'fields': [...],
|
|
3070
|
+
'records': [{...}, ...]
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
When an 'ermhdr' block is present in `data` it is preserved verbatim.
|
|
3076
|
+
Otherwise the 9-field P6 header is synthesised from the kwargs.
|
|
3077
|
+
|
|
3078
|
+
Tables are written in TABLE_ORDER, then any table not named there, with CRLF
|
|
3079
|
+
line endings. TABLE_ORDER is a generation order that P6 imports cleanly, not
|
|
3080
|
+
a canonical export order: real exports use no single order (38 distinct %T
|
|
3081
|
+
sequences across the 166 measured). See the TABLE_ORDER comment.
|
|
3082
|
+
Encoding defaults to utf-8 (pass 'cp1252' for strict legacy P6 compatibility).
|
|
3083
|
+
"""
|
|
3084
|
+
lines = []
|
|
3085
|
+
|
|
3086
|
+
# ERMHDR — prefer the raw header from the source if available.
|
|
3087
|
+
# Minimum 5 fields is the smallest form seen in the wild (ERMHDR, version,
|
|
3088
|
+
# date, + 2 metadata). Real P6 headers range from 5 to 9 fields depending
|
|
3089
|
+
# on version. Preserve whatever the source had.
|
|
3090
|
+
raw_ermhdr = data.get('ermhdr', {}).get('raw')
|
|
3091
|
+
if raw_ermhdr and isinstance(raw_ermhdr, list) and len(raw_ermhdr) >= 5:
|
|
3092
|
+
lines.append('\t'.join(_sanitize_cell(p) for p in raw_ermhdr))
|
|
3093
|
+
else:
|
|
3094
|
+
export_date = datetime.now().strftime(P6_DATE_FORMAT)
|
|
3095
|
+
ermhdr_parts = [
|
|
3096
|
+
'ERMHDR', p6_version, export_date, export_scope,
|
|
3097
|
+
user, user_full_name, database, module, currency,
|
|
3098
|
+
]
|
|
3099
|
+
lines.append('\t'.join(_sanitize_cell(p) for p in ermhdr_parts))
|
|
3100
|
+
|
|
3101
|
+
# Write tables in TABLE_ORDER (a generation order, not a canonical one)
|
|
3102
|
+
tables = data.get('tables', {})
|
|
3103
|
+
|
|
3104
|
+
# First the tables TABLE_ORDER names, then any remaining
|
|
3105
|
+
written = set()
|
|
3106
|
+
for table_name in TABLE_ORDER:
|
|
3107
|
+
if table_name in tables:
|
|
3108
|
+
_write_table(lines, table_name, tables[table_name])
|
|
3109
|
+
written.add(table_name)
|
|
3110
|
+
|
|
3111
|
+
# Any remaining tables TABLE_ORDER does not name
|
|
3112
|
+
for table_name in tables:
|
|
3113
|
+
if table_name not in written:
|
|
3114
|
+
_write_table(lines, table_name, tables[table_name])
|
|
3115
|
+
|
|
3116
|
+
# %E - end-of-file marker. Every genuine Oracle export terminates with
|
|
3117
|
+
# it. Measured across a 224-file corpus of real exports: 150 carry it,
|
|
3118
|
+
# 74 do not, and all 74 of those were written by this function. P6
|
|
3119
|
+
# itself tolerates the omission - files this writer produced have been
|
|
3120
|
+
# imported successfully - so this is
|
|
3121
|
+
# format fidelity rather than a known import failure. The parser skips
|
|
3122
|
+
# any line that is not %T/%F/%R, so the marker round-trips without
|
|
3123
|
+
# ever becoming a table.
|
|
3124
|
+
lines.append('%E')
|
|
3125
|
+
|
|
3126
|
+
# Write with CRLF line endings
|
|
3127
|
+
output_text = '\r\n'.join(lines) + '\r\n'
|
|
3128
|
+
|
|
3129
|
+
with open(output_path, 'w', encoding=encoding, newline='') as f:
|
|
3130
|
+
f.write(output_text)
|
|
3131
|
+
|
|
3132
|
+
return output_path
|
|
3133
|
+
|
|
3134
|
+
|
|
3135
|
+
def _sanitize_cell(value):
|
|
3136
|
+
"""Collapse embedded TSV delimiters (tab / CR / LF) in a cell value.
|
|
3137
|
+
|
|
3138
|
+
XER is tab-delimited and newline-terminated. A field value that itself
|
|
3139
|
+
contains a raw tab injects a phantom column; one containing a newline
|
|
3140
|
+
splits the row — silently corrupting the file and scrambling later columns
|
|
3141
|
+
(e.g. status_code reading as the tail of a task_name). The field-COUNT
|
|
3142
|
+
assertion in _write_table cannot catch this: the values list length is
|
|
3143
|
+
still correct before the tab-join. We collapse any \\t / \\r / \\n run
|
|
3144
|
+
to a single space.
|
|
3145
|
+
|
|
3146
|
+
Real P6 exports never carry raw delimiters in values, so this is a no-op on
|
|
3147
|
+
genuine schedules; it only neutralizes programmatically-built data —
|
|
3148
|
+
half-step generation, hand-built models, multi-line memo text — that would
|
|
3149
|
+
otherwise emit a malformed XER.
|
|
3150
|
+
"""
|
|
3151
|
+
if value is None:
|
|
3152
|
+
return ''
|
|
3153
|
+
s = value if isinstance(value, str) else str(value)
|
|
3154
|
+
if '\t' in s or '\n' in s or '\r' in s:
|
|
3155
|
+
s = re.sub(r'[\t\r\n]+', ' ', s)
|
|
3156
|
+
return s
|
|
3157
|
+
|
|
3158
|
+
|
|
3159
|
+
def _write_table(lines, table_name, table_data):
|
|
3160
|
+
"""Write a single table's %T, %F, and %R lines.
|
|
3161
|
+
|
|
3162
|
+
Asserts row/field-count parity per row before emitting %R — a mismatch
|
|
3163
|
+
causes a blank import grid in P6 (see the README, "XER generation rules").
|
|
3164
|
+
Because each value is sourced by .get(field, '') against the canonical
|
|
3165
|
+
fields list, the two counts can only diverge if the fields list itself is
|
|
3166
|
+
empty/None or mutated mid-loop, but the explicit assertion makes the
|
|
3167
|
+
contract loud and fails fast instead of producing a silently broken XER.
|
|
3168
|
+
|
|
3169
|
+
Every emitted token (table name, field names, cell values) is run through
|
|
3170
|
+
_sanitize_cell so an embedded tab/newline in any value cannot break the
|
|
3171
|
+
row/column framing.
|
|
3172
|
+
"""
|
|
3173
|
+
fields = table_data.get('fields', [])
|
|
3174
|
+
records = table_data.get('records', [])
|
|
3175
|
+
|
|
3176
|
+
if not fields:
|
|
3177
|
+
return
|
|
3178
|
+
|
|
3179
|
+
# %T line
|
|
3180
|
+
lines.append(f'%T\t{_sanitize_cell(table_name)}')
|
|
3181
|
+
|
|
3182
|
+
# %F line
|
|
3183
|
+
lines.append('%F\t' + '\t'.join(_sanitize_cell(f) for f in fields))
|
|
3184
|
+
|
|
3185
|
+
# %R lines
|
|
3186
|
+
for record in records:
|
|
3187
|
+
values = []
|
|
3188
|
+
for field in fields:
|
|
3189
|
+
values.append(_sanitize_cell(record.get(field, '')))
|
|
3190
|
+
if len(values) != len(fields):
|
|
3191
|
+
raise ValueError(
|
|
3192
|
+
f"Field count mismatch in table {table_name!r}: "
|
|
3193
|
+
f"row has {len(values)} values but %F declared {len(fields)} fields. "
|
|
3194
|
+
f"This would produce a blank grid in P6 on import."
|
|
3195
|
+
)
|
|
3196
|
+
lines.append('%R\t' + '\t'.join(values))
|
|
3197
|
+
|
|
3198
|
+
|
|
3199
|
+
# ─────────────────────────────────────────────
|
|
3200
|
+
# HALF-STEP XER GENERATOR (vendor-equivalent: SmartPM/Plannex)
|
|
3201
|
+
# ─────────────────────────────────────────────
|
|
3202
|
+
|
|
3203
|
+
#: Progress-only fields copied from updated → base in the half-step.
|
|
3204
|
+
#: These capture what actually happened without carrying forward any logic
|
|
3205
|
+
#: revisions the contractor made between updates.
|
|
3206
|
+
_HALF_STEP_PROGRESS_FIELDS = [
|
|
3207
|
+
'act_start_date', # actual start (recorded when work began)
|
|
3208
|
+
'act_end_date', # actual finish (recorded when work completed)
|
|
3209
|
+
'remain_drtn_hr_cnt', # remaining duration in hours
|
|
3210
|
+
'phys_complete_pct', # physical percent complete
|
|
3211
|
+
'status_code', # TK_NotStart / TK_Active / TK_Complete
|
|
3212
|
+
]
|
|
3213
|
+
|
|
3214
|
+
#: Early-date fields that are ONLY copied when updated is TK_Complete and
|
|
3215
|
+
#: base is not — anchors completed activities to their actual positions so the
|
|
3216
|
+
#: forward pass of a CPM re-run lands the completion correctly.
|
|
3217
|
+
_HALF_STEP_COMPLETION_ANCHOR_FIELDS = [
|
|
3218
|
+
'early_start_date',
|
|
3219
|
+
'early_end_date',
|
|
3220
|
+
]
|
|
3221
|
+
|
|
3222
|
+
#: Fields that MUST NOT be copied even if they appear in updated. These carry
|
|
3223
|
+
#: the contractor's logic-revision intent, which the half-step purposely strips.
|
|
3224
|
+
_HALF_STEP_FORBIDDEN_FIELDS = frozenset({
|
|
3225
|
+
'target_drtn_hr_cnt', # original planned duration — preserve base intent
|
|
3226
|
+
'task_name',
|
|
3227
|
+
'task_type',
|
|
3228
|
+
'wbs_id',
|
|
3229
|
+
'clndr_id',
|
|
3230
|
+
})
|
|
3231
|
+
|
|
3232
|
+
|
|
3233
|
+
def compute_half_step_xer(base_xer_path, updated_xer_path, output_xer_path):
|
|
3234
|
+
"""Generate a half-step XER from two sequential schedule updates (vendor-equivalent: SmartPM/Plannex).
|
|
3235
|
+
|
|
3236
|
+
Implements the bifurcation procedure of AACE 29R-03 §2.3.D.2
|
|
3237
|
+
("Bifurcation: Creating a Progress-Only Half-Step Update"), used by
|
|
3238
|
+
MIP 3.4 (Observational / Dynamic / Contemporaneous Split):
|
|
3239
|
+
start with the period-START schedule (base), apply ONLY the progress
|
|
3240
|
+
fields (actual dates, remaining duration, percent complete, status) from the
|
|
3241
|
+
next update, and output a "half-step" schedule. The result isolates the
|
|
3242
|
+
*progress impact* from the *logic-revision impact*: anything that moves in
|
|
3243
|
+
the half-step moved because work didn't happen as planned; anything that
|
|
3244
|
+
moves between the half-step and the full update moved because the contractor
|
|
3245
|
+
revised logic or scope mid-period.
|
|
3246
|
+
|
|
3247
|
+
Method classification: AACE 29R-03 names this procedure bifurcation,
|
|
3248
|
+
"aka half-stepping or two-stepping" (§2.3.D.2, "Bifurcation: Creating
|
|
3249
|
+
a Progress-Only Half-Step Update"); MIP 3.4 (Observational / Dynamic /
|
|
3250
|
+
Contemporaneous Split) applies it each window, and 3.4.K.4 carries the
|
|
3251
|
+
step-by-step procedure. The SmartPM/Plannex half-step generators are
|
|
3252
|
+
vendor implementations of this canonical procedure; CPP cpm-engine v2.2
|
|
3253
|
+
closes the vendor-tooling gap.
|
|
3254
|
+
|
|
3255
|
+
Methodology disclosures
|
|
3256
|
+
-----------------------
|
|
3257
|
+
* Progress fields are matched by ``task_code`` — the user-visible activity ID.
|
|
3258
|
+
This is the stable cross-update key in P6; ``task_id`` is a surrogate that
|
|
3259
|
+
changes if the contractor re-exports from a rebuilt project.
|
|
3260
|
+
* Logic (TASKPRED rows) is preserved verbatim from the BASE schedule.
|
|
3261
|
+
Any logic changes the contractor made in the update are NOT reflected.
|
|
3262
|
+
* Resource assignments (TASKRSRC, RSRC, RSRCRATE) are out of scope for v1
|
|
3263
|
+
and are preserved from the base unchanged.
|
|
3264
|
+
* Activities present in the updated XER but absent from the base are NOT
|
|
3265
|
+
added to the half-step — the half-step shows base-logic +
|
|
3266
|
+
actual-progress only. Their task codes are logged in
|
|
3267
|
+
``unmatched_in_updated`` for the analyst's awareness; these are typically
|
|
3268
|
+
activities the contractor added in the update (scope adds, splits, etc.).
|
|
3269
|
+
* ``early_start_date`` / ``early_end_date`` are copied ONLY when the updated
|
|
3270
|
+
task is TK_Complete and the base task is not, to anchor completed activities
|
|
3271
|
+
so a CPM re-run places them correctly. For in-progress and not-started
|
|
3272
|
+
activities the scheduler's forward-pass dates are left for the CPM engine
|
|
3273
|
+
to recompute from base logic + applied progress.
|
|
3274
|
+
* This function does NOT copy ``target_drtn_hr_cnt`` (original planned
|
|
3275
|
+
duration — preserving base intent), nor any structural fields
|
|
3276
|
+
(task_name, task_type, wbs_id, clndr_id).
|
|
3277
|
+
|
|
3278
|
+
Args:
|
|
3279
|
+
base_xer_path (str): Path to the period-START schedule XER.
|
|
3280
|
+
updated_xer_path (str): Path to the next-period update XER.
|
|
3281
|
+
output_xer_path (str): Destination path for the half-step XER.
|
|
3282
|
+
|
|
3283
|
+
Returns:
|
|
3284
|
+
dict: Summary of what was done::
|
|
3285
|
+
|
|
3286
|
+
{
|
|
3287
|
+
'matched_count': int, # tasks matched by task_code
|
|
3288
|
+
'unmatched_in_base': [...], # task_codes in base but not updated (kept as-is)
|
|
3289
|
+
'unmatched_in_updated': [...], # task_codes in updated but not base (NOT added)
|
|
3290
|
+
'progressed_count': int, # tasks that received non-empty progress fields
|
|
3291
|
+
'output_path': str,
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
``unmatched_in_updated`` is forensically important: these are activities
|
|
3295
|
+
the contractor added in the revision period. They belong to the
|
|
3296
|
+
logic-revision layer, not the progress layer.
|
|
3297
|
+
|
|
3298
|
+
Attribution:
|
|
3299
|
+
AACE 29R-03 §2.3.D.2 bifurcation (the RP names it half-stepping
|
|
3300
|
+
or two-stepping); MIP 3.4.K.4 carries the step-by-step procedure.
|
|
3301
|
+
Vendor equivalents: SmartPM / Plannex half-step generators.
|
|
3302
|
+
CPP cpm-engine v2.2 half-step generator.
|
|
3303
|
+
"""
|
|
3304
|
+
import copy
|
|
3305
|
+
|
|
3306
|
+
# ── 1. Parse both XERs ──────────────────────────────────────
|
|
3307
|
+
base_data = parse_xer(base_xer_path)
|
|
3308
|
+
updated_data = parse_xer(updated_xer_path)
|
|
3309
|
+
|
|
3310
|
+
# ── 2. Build task_code → record maps ────────────────────────
|
|
3311
|
+
base_tasks = get_table(base_data, 'TASK')
|
|
3312
|
+
updated_tasks = get_table(updated_data, 'TASK')
|
|
3313
|
+
|
|
3314
|
+
updated_by_code = {t.get('task_code', ''): t for t in updated_tasks
|
|
3315
|
+
if t.get('task_code', '')}
|
|
3316
|
+
|
|
3317
|
+
base_codes = {t.get('task_code', '') for t in base_tasks if t.get('task_code', '')}
|
|
3318
|
+
updated_codes = set(updated_by_code.keys())
|
|
3319
|
+
|
|
3320
|
+
unmatched_in_base = sorted(base_codes - updated_codes)
|
|
3321
|
+
unmatched_in_updated = sorted(updated_codes - base_codes)
|
|
3322
|
+
|
|
3323
|
+
# ── 3. Deep-copy base data (all tables preserved intact) ─────
|
|
3324
|
+
half_step_data = copy.deepcopy(base_data)
|
|
3325
|
+
|
|
3326
|
+
# ── 4. Apply progress fields to matched TASK records ─────────
|
|
3327
|
+
matched_count = 0
|
|
3328
|
+
progressed_count = 0
|
|
3329
|
+
|
|
3330
|
+
half_step_tasks = get_table(half_step_data, 'TASK')
|
|
3331
|
+
for task in half_step_tasks:
|
|
3332
|
+
code = task.get('task_code', '')
|
|
3333
|
+
if not code or code not in updated_by_code:
|
|
3334
|
+
# Not matched — keep as-is (base state: not-started, base duration)
|
|
3335
|
+
continue
|
|
3336
|
+
|
|
3337
|
+
matched_count += 1
|
|
3338
|
+
upd = updated_by_code[code]
|
|
3339
|
+
|
|
3340
|
+
# Determine completion state of both sides for the anchor-field logic
|
|
3341
|
+
upd_status = upd.get('status_code', '')
|
|
3342
|
+
base_status = task.get('status_code', '')
|
|
3343
|
+
upd_complete = (upd_status == COMPLETE_STATUS)
|
|
3344
|
+
base_not_complete = (base_status != COMPLETE_STATUS)
|
|
3345
|
+
|
|
3346
|
+
# Track whether any progress was applied — defined as: at least one
|
|
3347
|
+
# field value from updated differs from the base value AND is non-empty.
|
|
3348
|
+
# Copying TK_NotStart onto a TK_NotStart base task is not progress.
|
|
3349
|
+
applied_any = False
|
|
3350
|
+
|
|
3351
|
+
# Apply core progress fields.
|
|
3352
|
+
# A field counts as "progress applied" when the updated value is
|
|
3353
|
+
# non-empty AND different from the base value. Copying an identical
|
|
3354
|
+
# value (e.g. TK_NotStart → TK_NotStart) is not real progress and
|
|
3355
|
+
# must not increment progressed_count.
|
|
3356
|
+
for field in _HALF_STEP_PROGRESS_FIELDS:
|
|
3357
|
+
val = upd.get(field, '')
|
|
3358
|
+
base_val = task.get(field, '')
|
|
3359
|
+
if val and val != base_val:
|
|
3360
|
+
task[field] = val
|
|
3361
|
+
applied_any = True
|
|
3362
|
+
|
|
3363
|
+
# Apply completion-anchor early dates only when updated is TK_Complete
|
|
3364
|
+
# and base is not — so a CPM re-run can anchor completed work.
|
|
3365
|
+
if upd_complete and base_not_complete:
|
|
3366
|
+
for field in _HALF_STEP_COMPLETION_ANCHOR_FIELDS:
|
|
3367
|
+
val = upd.get(field, '')
|
|
3368
|
+
if val:
|
|
3369
|
+
task[field] = val
|
|
3370
|
+
# Don't set applied_any for anchor fields alone — the status
|
|
3371
|
+
# copy above already sets applied_any when TK_Complete is copied.
|
|
3372
|
+
|
|
3373
|
+
if applied_any:
|
|
3374
|
+
progressed_count += 1
|
|
3375
|
+
|
|
3376
|
+
# ── 5. Inject attribution note into PROJECT row ───────────
|
|
3377
|
+
# Write a note into the proj_url or web_site field (commonly unused in
|
|
3378
|
+
# construction schedules, round-trips cleanly through P6 import).
|
|
3379
|
+
attribution_text = (
|
|
3380
|
+
'Half-step XER produced by CPP cpm-engine v2.2. AACE 29R-03 '
|
|
3381
|
+
'§2.3.D.2 bifurcation (half-stepping), per MIP 3.4 '
|
|
3382
|
+
'(Observational / Dynamic / Contemporaneous Split). '
|
|
3383
|
+
'Vendor equivalents: SmartPM/Plannex half-step.'
|
|
3384
|
+
)
|
|
3385
|
+
project_records = get_table(half_step_data, 'PROJECT')
|
|
3386
|
+
for proj in project_records:
|
|
3387
|
+
# Use proj_url if the field exists in this XER's PROJECT fields; fall
|
|
3388
|
+
# back to web_site. If neither is present, skip (don't corrupt the row).
|
|
3389
|
+
proj_fields = half_step_data['tables'].get('PROJECT', {}).get('fields', [])
|
|
3390
|
+
if 'proj_url' in proj_fields:
|
|
3391
|
+
proj['proj_url'] = attribution_text
|
|
3392
|
+
elif 'web_site' in proj_fields:
|
|
3393
|
+
proj['web_site'] = attribution_text
|
|
3394
|
+
# If neither field is present the attribution lives only in the docstring.
|
|
3395
|
+
|
|
3396
|
+
# ── 6. Write the half-step XER ───────────────────────────────
|
|
3397
|
+
generate_xer(half_step_data, output_xer_path)
|
|
3398
|
+
|
|
3399
|
+
return {
|
|
3400
|
+
'matched_count': matched_count,
|
|
3401
|
+
'unmatched_in_base': unmatched_in_base,
|
|
3402
|
+
'unmatched_in_updated': unmatched_in_updated,
|
|
3403
|
+
'progressed_count': progressed_count,
|
|
3404
|
+
'output_path': str(output_xer_path),
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
|
|
3408
|
+
# ─────────────────────────────────────────────
|
|
3409
|
+
# CLI INTERFACE
|
|
3410
|
+
# ─────────────────────────────────────────────
|
|
3411
|
+
|
|
3412
|
+
def main():
|
|
3413
|
+
if len(sys.argv) < 2:
|
|
3414
|
+
print(__doc__)
|
|
3415
|
+
sys.exit(1)
|
|
3416
|
+
|
|
3417
|
+
command = sys.argv[1]
|
|
3418
|
+
|
|
3419
|
+
if command == 'parse':
|
|
3420
|
+
if len(sys.argv) < 3:
|
|
3421
|
+
print("Usage: xer_parser.py parse <file.xer> [--summary] [--json output.json]")
|
|
3422
|
+
sys.exit(1)
|
|
3423
|
+
|
|
3424
|
+
filepath = sys.argv[2]
|
|
3425
|
+
data = parse_xer(filepath)
|
|
3426
|
+
|
|
3427
|
+
if '--summary' in sys.argv:
|
|
3428
|
+
print_summary(data)
|
|
3429
|
+
|
|
3430
|
+
if '--json' in sys.argv:
|
|
3431
|
+
json_idx = sys.argv.index('--json')
|
|
3432
|
+
if json_idx + 1 < len(sys.argv):
|
|
3433
|
+
json_path = sys.argv[json_idx + 1]
|
|
3434
|
+
# Custom serializer for summary
|
|
3435
|
+
summary = generate_summary(data)
|
|
3436
|
+
with open(json_path, 'w') as f:
|
|
3437
|
+
json.dump(summary, f, indent=2, default=str)
|
|
3438
|
+
print(f"JSON summary saved to: {json_path}")
|
|
3439
|
+
|
|
3440
|
+
if '--summary' not in sys.argv and '--json' not in sys.argv:
|
|
3441
|
+
# Default: print basic info
|
|
3442
|
+
print(f"Parsed: {data['filename']}")
|
|
3443
|
+
print(f"Tables: {list(data['tables'].keys())}")
|
|
3444
|
+
for tname, tdata in data['tables'].items():
|
|
3445
|
+
print(f" {tname}: {len(tdata['records'])} records")
|
|
3446
|
+
|
|
3447
|
+
elif command == 'generate':
|
|
3448
|
+
if len(sys.argv) < 4:
|
|
3449
|
+
print("Usage: xer_parser.py generate <input.json> <output.xer>")
|
|
3450
|
+
sys.exit(1)
|
|
3451
|
+
|
|
3452
|
+
json_path = sys.argv[2]
|
|
3453
|
+
output_path = sys.argv[3]
|
|
3454
|
+
|
|
3455
|
+
with open(json_path) as f:
|
|
3456
|
+
data = json.load(f)
|
|
3457
|
+
|
|
3458
|
+
generate_xer(data, output_path)
|
|
3459
|
+
print(f"XER file generated: {output_path}")
|
|
3460
|
+
|
|
3461
|
+
else:
|
|
3462
|
+
print(f"Unknown command: {command}")
|
|
3463
|
+
print("Commands: parse, generate")
|
|
3464
|
+
sys.exit(1)
|
|
3465
|
+
|
|
3466
|
+
|
|
3467
|
+
if __name__ == '__main__':
|
|
3468
|
+
main()
|