302 lines
8.8 KiB
Python
302 lines
8.8 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from openpyxl import load_workbook
|
|
from sqlalchemy import delete, select
|
|
|
|
from app.extensions import db
|
|
from app.models import Asset, AssetLogEntry, AssetStateNode
|
|
|
|
|
|
EVENT_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2})\s+(.+)$")
|
|
LOG_BOOK_IGNORED_SHEETS = {"Overview", "Model"}
|
|
|
|
BODY_LOCATION_KEYWORDS = [
|
|
("earth-sun l2", "Earth-Sun L2"),
|
|
("earth-sun lagrange point 2", "Earth-Sun L2"),
|
|
("star port", "Star Port"),
|
|
("iapetus", "Iapetus"),
|
|
("callisto", "Callisto"),
|
|
("ganymede", "Ganymede"),
|
|
("europa", "Europa"),
|
|
("jupiter", "Jupiter"),
|
|
("saturn", "Saturn"),
|
|
("neptune", "Neptune"),
|
|
("mars", "Mars"),
|
|
("venus", "Venus"),
|
|
("mercury", "Mercury"),
|
|
("lunar orbit", "Lunar Orbit"),
|
|
("leo", "LEO"),
|
|
("earth orbit", "Earth"),
|
|
("earth", "Earth"),
|
|
("sun", "Sun"),
|
|
]
|
|
|
|
LOG_BOOK_ASSET_SPECS = {
|
|
"ST-01": {
|
|
"name": "万星源号 ST-01",
|
|
"asset_type": "Exploration Mothership",
|
|
"program": "Stellaria",
|
|
"home_region": "LEO",
|
|
"note": "First Class of Stellaria",
|
|
},
|
|
"XH-01": {
|
|
"name": "羲和号 XH-01",
|
|
"asset_type": "Exploration Mothership",
|
|
"program": "羲和计划",
|
|
"home_region": "LEO",
|
|
"note": "羲和计划首舰",
|
|
},
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class LogBookSheetSummary:
|
|
sheet_name: str
|
|
asset_name: str
|
|
state_entries: int = 0
|
|
event_entries: int = 0
|
|
|
|
def to_dict(self) -> dict[str, object]:
|
|
return {
|
|
"sheet_name": self.sheet_name,
|
|
"asset_name": self.asset_name,
|
|
"state_entries": self.state_entries,
|
|
"event_entries": self.event_entries,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class LogBookImportSummary:
|
|
workbook: str
|
|
imported_assets: list[LogBookSheetSummary] = field(default_factory=list)
|
|
|
|
def to_dict(self) -> dict[str, object]:
|
|
return {
|
|
"workbook": self.workbook,
|
|
"imported_assets": [item.to_dict() for item in self.imported_assets],
|
|
}
|
|
|
|
|
|
def import_log_book_data(workbook_path: str | Path) -> LogBookImportSummary:
|
|
path = Path(workbook_path)
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"Workbook not found: {path}")
|
|
|
|
workbook = load_workbook(path, data_only=True, read_only=True)
|
|
summary = LogBookImportSummary(workbook=path.name)
|
|
|
|
try:
|
|
for sheet_name in _iter_importable_sheet_names(workbook.sheetnames):
|
|
asset_spec = _resolve_asset_spec(sheet_name)
|
|
sheet_summary = _import_sheet(workbook[sheet_name], sheet_name, asset_spec)
|
|
summary.imported_assets.append(sheet_summary)
|
|
db.session.commit()
|
|
except Exception:
|
|
db.session.rollback()
|
|
raise
|
|
finally:
|
|
workbook.close()
|
|
|
|
return summary
|
|
|
|
|
|
def _iter_importable_sheet_names(sheet_names: list[str]) -> list[str]:
|
|
return [sheet_name for sheet_name in sheet_names if sheet_name not in LOG_BOOK_IGNORED_SHEETS]
|
|
|
|
|
|
def _resolve_asset_spec(sheet_name: str) -> dict[str, str | None]:
|
|
asset_spec: dict[str, str | None] = {
|
|
"name": sheet_name,
|
|
"asset_type": "Vehicle",
|
|
"program": None,
|
|
"home_region": None,
|
|
"note": f"Imported from log_book.xlsx sheet {sheet_name}.",
|
|
}
|
|
asset_spec.update(LOG_BOOK_ASSET_SPECS.get(sheet_name, {}))
|
|
return asset_spec
|
|
|
|
|
|
def _import_sheet(worksheet: object, sheet_name: str, asset_spec: dict[str, str]) -> LogBookSheetSummary:
|
|
asset = db.session.execute(select(Asset).where(Asset.name == asset_spec["name"])).scalar_one_or_none()
|
|
if asset is None:
|
|
asset = Asset(
|
|
name=asset_spec["name"],
|
|
asset_type=asset_spec["asset_type"],
|
|
program=asset_spec["program"],
|
|
home_region=asset_spec["home_region"],
|
|
note=asset_spec["note"],
|
|
)
|
|
db.session.add(asset)
|
|
db.session.flush()
|
|
|
|
asset.asset_type = asset_spec["asset_type"]
|
|
asset.program = asset_spec["program"]
|
|
asset.home_region = asset_spec["home_region"]
|
|
asset.note = asset_spec["note"]
|
|
|
|
db.session.execute(delete(AssetLogEntry).where(AssetLogEntry.asset_id == asset.id))
|
|
db.session.flush()
|
|
|
|
summary = LogBookSheetSummary(sheet_name=sheet_name, asset_name=asset.name)
|
|
for row in worksheet.iter_rows(min_row=2, values_only=True):
|
|
if not any(value not in (None, "") for value in row):
|
|
continue
|
|
|
|
log_name = _clean_text(row[0])
|
|
start_at = _coerce_datetime(row[1])
|
|
end_at = _coerce_datetime(row[2])
|
|
mission_detail = _clean_text(row[3])
|
|
sub_log = _clean_text(row[4])
|
|
explicit_location = _clean_text(row[7])
|
|
inferred_location = _infer_location(log_name, mission_detail, sub_log, explicit_location)
|
|
|
|
if log_name is None or start_at is None:
|
|
continue
|
|
|
|
entry = AssetLogEntry(
|
|
asset_id=asset.id,
|
|
entry_kind="state",
|
|
title=log_name,
|
|
mission_label=log_name,
|
|
start_at=start_at,
|
|
end_at=end_at,
|
|
summary=mission_detail,
|
|
note=sub_log,
|
|
)
|
|
entry.state_nodes.append(
|
|
AssetStateNode(
|
|
title=log_name,
|
|
detail=mission_detail,
|
|
at_time=start_at,
|
|
target_location=inferred_location,
|
|
state_label=log_name,
|
|
)
|
|
)
|
|
db.session.add(entry)
|
|
summary.state_entries += 1
|
|
|
|
events = _parse_sub_log_events(
|
|
asset.id,
|
|
log_name,
|
|
start_at,
|
|
end_at,
|
|
inferred_location,
|
|
sub_log,
|
|
)
|
|
db.session.add_all(events)
|
|
summary.event_entries += len(events)
|
|
|
|
return summary
|
|
|
|
|
|
def _clean_text(value: object) -> str | None:
|
|
if value in (None, ""):
|
|
return None
|
|
cleaned = str(value).strip()
|
|
return cleaned or None
|
|
|
|
|
|
def _coerce_datetime(value: object) -> datetime | None:
|
|
if value in (None, ""):
|
|
return None
|
|
if isinstance(value, datetime):
|
|
normalized = value
|
|
else:
|
|
normalized = datetime.fromisoformat(str(value))
|
|
if normalized.tzinfo is None:
|
|
return normalized.replace(tzinfo=timezone.utc)
|
|
return normalized.astimezone(timezone.utc)
|
|
|
|
|
|
def _infer_location(
|
|
log_name: str | None,
|
|
mission_detail: str | None,
|
|
sub_log: str | None,
|
|
explicit_location: str | None,
|
|
) -> str | None:
|
|
if explicit_location:
|
|
return explicit_location
|
|
|
|
haystacks = [
|
|
(log_name or "").casefold(),
|
|
(mission_detail or "").casefold(),
|
|
(sub_log or "").casefold(),
|
|
]
|
|
for keyword, location in BODY_LOCATION_KEYWORDS:
|
|
if any(keyword in haystack for haystack in haystacks):
|
|
return location
|
|
|
|
if mission_detail and "transfer" in mission_detail.casefold():
|
|
return "Sun"
|
|
if log_name and "maintenance" in log_name.casefold():
|
|
return "LEO"
|
|
if log_name and "construction" in log_name.casefold():
|
|
return "LEO"
|
|
return None
|
|
|
|
|
|
def _infer_event_location(description: str, default_location: str | None) -> str | None:
|
|
lowered = description.casefold()
|
|
for keyword, location in BODY_LOCATION_KEYWORDS:
|
|
if keyword in lowered:
|
|
return location
|
|
return default_location
|
|
|
|
|
|
def _parse_sub_log_events(
|
|
asset_id: object,
|
|
parent_title: str,
|
|
parent_start_at: datetime,
|
|
parent_end_at: datetime | None,
|
|
default_location: str | None,
|
|
sub_log: str | None,
|
|
) -> list[AssetLogEntry]:
|
|
if sub_log is None:
|
|
return []
|
|
|
|
events = []
|
|
for raw_line in sub_log.splitlines():
|
|
line = raw_line.strip()
|
|
if not line:
|
|
continue
|
|
match = EVENT_LINE_PATTERN.match(line)
|
|
if match is None:
|
|
continue
|
|
|
|
event_at = _coerce_datetime(match.group(1))
|
|
if event_at is None:
|
|
continue
|
|
|
|
# Skip obvious workbook typos that fall far outside the parent interval.
|
|
if event_at < parent_start_at.replace(year=parent_start_at.year - 1):
|
|
continue
|
|
if parent_end_at is not None and event_at > parent_end_at.replace(year=parent_end_at.year + 1):
|
|
continue
|
|
|
|
description = match.group(2).strip()
|
|
event = AssetLogEntry(
|
|
asset_id=asset_id,
|
|
entry_kind="event",
|
|
title=description,
|
|
mission_label=parent_title,
|
|
start_at=event_at,
|
|
end_at=None,
|
|
summary=parent_title,
|
|
note=line,
|
|
)
|
|
event.state_nodes.append(
|
|
AssetStateNode(
|
|
title=description,
|
|
at_time=event_at,
|
|
target_location=_infer_event_location(description, default_location),
|
|
)
|
|
)
|
|
events.append(event)
|
|
|
|
return events
|