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.
@@ -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()