Coverage for app/maintenance/amp_import.py: 100%

140 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-31 10:13 +0000

1"""AMP task-list spreadsheet import (Phase 40). 

2 

3Parses the OpenHangar "AMP task list" import template — documented in 

4docs/maintenance_import.md — into structured rows ready for review. Column 

5order and leading unrelated rows/sheets don't matter: the header row is 

6located by matching known column names, not a fixed position. 

7""" 

8 

9from __future__ import annotations 

10 

11import re 

12from dataclasses import dataclass 

13from datetime import date as _date 

14from datetime import timedelta 

15from typing import TYPE_CHECKING, Any 

16 

17from models import ( # pyright: ignore[reportMissingImports] 

18 AmpCategory, 

19 ComponentType, 

20 HoursBasis, 

21) 

22 

23if TYPE_CHECKING: 

24 import openpyxl 

25 

26_MAX_HEADER_SCAN_ROWS = 30 

27 

28# Canonical column key -> normalised header text it matches. 

29_HEADER_ALIASES = { 

30 "category": "category", 

31 "task description": "task_description", 

32 "reference": "reference", 

33 "action": "action", 

34 "interval": "interval", 

35 "part number": "part_number", 

36 "serial number": "serial_number", 

37 "notes": "notes", 

38} 

39_REQUIRED_HEADER_KEYS = {"task_description", "interval"} 

40 

41 

42def _norm(raw: Any) -> str: 

43 return " ".join(str(raw or "").strip().lower().split()) 

44 

45 

46@dataclass 

47class HeaderLocation: 

48 sheet_name: str 

49 header_row_index: int # 0-based, within the sheet 

50 col_index: dict[str, int] # canonical key -> column index 

51 

52 

53def find_header(wb: openpyxl.Workbook) -> HeaderLocation: 

54 """Scan every sheet's first _MAX_HEADER_SCAN_ROWS rows for one whose 

55 cells include at least 'Task description' and 'Interval' (matched 

56 case/whitespace-insensitively); returns the first match found, sheets 

57 in workbook order.""" 

58 for ws in wb.worksheets: 

59 for row_idx, row in enumerate( 

60 ws.iter_rows(min_row=1, max_row=_MAX_HEADER_SCAN_ROWS, values_only=True) 

61 ): 

62 col_index: dict[str, int] = {} 

63 for col_idx, cell in enumerate(row): 

64 key = _HEADER_ALIASES.get(_norm(cell)) 

65 if key and key not in col_index: 

66 col_index[key] = col_idx 

67 if _REQUIRED_HEADER_KEYS <= col_index.keys(): 

68 return HeaderLocation(ws.title, row_idx, col_index) 

69 raise ValueError( 

70 "No matching header row found. Expected a sheet with at least " 

71 "'Task description' and 'Interval' columns." 

72 ) 

73 

74 

75_INTERVAL_FH_RE = re.compile(r"^(\d+(?:\.\d+)?)\s*FH$", re.IGNORECASE) 

76_INTERVAL_CAL_RE = re.compile(r"^(\d+)\s*(DY|MO|YR)$", re.IGNORECASE) 

77_CAL_UNIT_DAYS = {"DY": 1, "MO": 30, "YR": 365} 

78 

79 

80@dataclass 

81class IntervalParseResult: 

82 interval_hours: float | None = None 

83 interval_days: int | None = None 

84 needs_review: bool = False 

85 

86 

87def parse_interval(raw: Any) -> IntervalParseResult: 

88 """Parse the Interval mini-syntax: one or two '/'-separated parts, each 

89 either '<n>FH' or '<n>(DY|MO|YR)'. Both parts populate together for a 

90 combined "whichever comes first" interval. Empty, 'PENDING', or any 

91 unparseable part makes the whole interval needs_review — never a 

92 partial/silent result.""" 

93 text = str(raw or "").strip() 

94 if not text or text.upper() == "PENDING": 

95 return IntervalParseResult(needs_review=True) 

96 

97 interval_hours: float | None = None 

98 interval_days: int | None = None 

99 for part in text.split("/"): 

100 part = part.strip() 

101 m = _INTERVAL_FH_RE.match(part) 

102 if m: 

103 interval_hours = float(m.group(1)) 

104 continue 

105 m = _INTERVAL_CAL_RE.match(part) 

106 if m: 

107 n = int(m.group(1)) 

108 interval_days = n * _CAL_UNIT_DAYS[m.group(2).upper()] 

109 continue 

110 return IntervalParseResult(needs_review=True) 

111 

112 # Every part matched (or we'd have returned above), so at least one of 

113 # interval_hours/interval_days is set here — text.split("/") can never 

114 # yield zero parts. 

115 return IntervalParseResult( 

116 interval_hours=interval_hours, interval_days=interval_days 

117 ) 

118 

119 

120def match_category(raw: Any) -> str | None: 

121 """Case/whitespace-tolerant exact match against the 9 canonical 

122 AmpCategory values. No match (blank, admin/routine text, typo) -> None, 

123 the row's category is simply left unset — not rejected.""" 

124 norm = _norm(raw) 

125 if not norm: 

126 return None 

127 for canonical in AmpCategory.ALL: 

128 if _norm(canonical) == norm: 

129 return canonical 

130 return None 

131 

132 

133def suggest_component_id( 

134 category_raw: Any, task_description_raw: Any, components: list[Any] 

135) -> int | None: 

136 """Heuristic: a mention of 'propeller' or 'engine' in the category or 

137 task description text suggests the aircraft's matching installed 

138 component. No mention, or no installed component of that type, leaves 

139 the row unscoped — corrected per-row on the review screen.""" 

140 text = f"{category_raw or ''} {task_description_raw or ''}".lower() 

141 if "propeller" in text: 

142 comp_type = ComponentType.PROPELLER 

143 elif "engine" in text: 

144 comp_type = ComponentType.ENGINE 

145 else: 

146 return None 

147 for c in components: 

148 if c.removed_at is None and c.type == comp_type: 

149 return c.id # type: ignore[no-any-return] 

150 return None 

151 

152 

153def hours_basis_for_component(component: Any) -> str: 

154 """Engine/propeller components track TBO-style intervals in engine 

155 hours; everything else (including unscoped) is flight-hours based — 

156 mirrors the same default used by the trigger form's component picker.""" 

157 if component is not None and component.type in ( 

158 ComponentType.ENGINE, 

159 ComponentType.PROPELLER, 

160 ): 

161 return HoursBasis.ENGINE 

162 return HoursBasis.FLIGHT 

163 

164 

165@dataclass 

166class ParsedAmpRow: 

167 row_number: int # 1-based spreadsheet row, for display only 

168 name: str 

169 category_raw: str | None 

170 category: str | None 

171 reference: str | None 

172 action: str | None 

173 part_number: str | None 

174 serial_number: str | None 

175 notes: str | None 

176 interval_raw: str | None 

177 interval_hours: float | None 

178 interval_days: int | None 

179 needs_review: bool 

180 suggested_component_id: int | None 

181 

182 

183def _cell(row: tuple[Any, ...], col_index: dict[str, int], key: str) -> Any: 

184 idx = col_index.get(key) 

185 if idx is None or idx >= len(row): 

186 return None 

187 return row[idx] 

188 

189 

190def _text_or_none(v: Any) -> str | None: 

191 if v is None: 

192 return None 

193 s = str(v).strip() 

194 return s or None 

195 

196 

197def parse_amp_rows(wb: openpyxl.Workbook, components: list[Any]) -> list[ParsedAmpRow]: 

198 """Parse every data row of the located header's sheet. A row with no 

199 task description is a blank/trailing row and is silently skipped (not 

200 counted, not an error) — everything else always produces a row, even 

201 when its interval is unparseable (see parse_interval).""" 

202 header = find_header(wb) 

203 ws = wb[header.sheet_name] 

204 rows: list[ParsedAmpRow] = [] 

205 for row_idx, row in enumerate( 

206 ws.iter_rows(min_row=header.header_row_index + 2, values_only=True), 

207 start=header.header_row_index + 2, 

208 ): 

209 name = _text_or_none(_cell(row, header.col_index, "task_description")) 

210 if not name: 

211 continue 

212 category_raw = _text_or_none(_cell(row, header.col_index, "category")) 

213 interval_raw = _text_or_none(_cell(row, header.col_index, "interval")) 

214 interval = parse_interval(interval_raw) 

215 rows.append( 

216 ParsedAmpRow( 

217 row_number=row_idx, 

218 name=name, 

219 category_raw=category_raw, 

220 category=match_category(category_raw), 

221 reference=_text_or_none(_cell(row, header.col_index, "reference")), 

222 action=_text_or_none(_cell(row, header.col_index, "action")), 

223 part_number=_text_or_none(_cell(row, header.col_index, "part_number")), 

224 serial_number=_text_or_none( 

225 _cell(row, header.col_index, "serial_number") 

226 ), 

227 notes=_text_or_none(_cell(row, header.col_index, "notes")), 

228 interval_raw=interval_raw, 

229 interval_hours=interval.interval_hours, 

230 interval_days=interval.interval_days, 

231 needs_review=interval.needs_review, 

232 suggested_component_id=suggest_component_id( 

233 category_raw, name, components 

234 ), 

235 ) 

236 ) 

237 return rows 

238 

239 

240def format_interval(interval_hours: float | None, interval_days: int | None) -> str: 

241 """Inverse of parse_interval: reconstruct the "<n>FH / <n>MO"-style 

242 display text from stored interval_hours/interval_days, for Appendix B 

243 export. Days are expressed in whichever of YR/MO/DY the value divides 

244 evenly by (365/30/1 days respectively — the same units parse_interval 

245 converts from), preferring the largest, so a value imported as "12MO" 

246 round-trips to "12MO" rather than some equivalent-but-different text.""" 

247 parts = [] 

248 if interval_hours is not None: 

249 h = float(interval_hours) 

250 parts.append(f"{h:.0f}FH" if h == int(h) else f"{h}FH") 

251 if interval_days is not None: 

252 d = int(interval_days) 

253 if d % 365 == 0: 

254 parts.append(f"{d // 365}YR") 

255 elif d % 30 == 0: 

256 parts.append(f"{d // 30}MO") 

257 else: 

258 parts.append(f"{d}DY") 

259 return " / ".join(parts) 

260 

261 

262def compute_due_fields( 

263 interval_hours: float | None, 

264 interval_days: int | None, 

265 hours_basis: str, 

266 current_engine_hours: float | None, 

267 current_flight_hours: float | None, 

268 today: _date | None = None, 

269) -> tuple[float | None, _date | None]: 

270 """Initial due_engine_hours/due_date for a freshly-imported trigger: 

271 the current reading plus the interval (hours side needs a current 

272 reading to anchor to; with none available yet, that side is simply 

273 left unset — the same as a manually-entered trigger with no due value 

274 yet, correctable once flight history exists).""" 

275 due_engine_hours = None 

276 if interval_hours is not None: 

277 current = ( 

278 current_flight_hours 

279 if hours_basis == HoursBasis.FLIGHT 

280 else current_engine_hours 

281 ) 

282 if current is not None: 

283 due_engine_hours = current + interval_hours 

284 due_date = None 

285 if interval_days is not None: 

286 due_date = (today or _date.today()) + timedelta(days=interval_days) 

287 return due_engine_hours, due_date