Files
wyndham-ARR/arr_ingestion/repository.py
2026-08-06 22:40:18 +08:00

676 lines
28 KiB
Python

"""Repository contracts and an offline reference implementation for ARR ingestion."""
from __future__ import annotations
import copy
import hashlib
import json
import re
import threading
from dataclasses import dataclass, replace
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, Mapping, Optional, Protocol, Tuple
from arr_ingestion.contracts import ArtifactRef, IngestionError
from arr_ingestion.validation import VerifiedDelivery
MANUAL_PRICE_RE = re.compile(r"^(?:0|[1-9][0-9]{0,15})$")
@dataclass(frozen=True)
class JobRegistration:
job_id: str
source: ArtifactRef
processor_version: str
rule_set_sha256: str
attempt_no: int
idempotency_key: str
uploaded_filename: Optional[str] = None
@dataclass(frozen=True)
class IngestionOutcome:
status: str
job_id: str
business_date: Optional[date]
daily_version_id: Optional[int]
version_no: Optional[int]
review_case_id: Optional[str] = None
review_revision: Optional[int] = None
review_completed_items: Optional[int] = None
review_total_items: Optional[int] = None
@dataclass(frozen=True)
class ReviewGeneration:
status: str
job_id: str
review_case_id: str
attempt_no: Optional[int]
business_date: date
manual_override_bytes: Optional[bytes]
manual_override_sha256: Optional[str]
daily_version_id: Optional[int] = None
version_no: Optional[int] = None
source: Optional[ArtifactRef] = None
class IngestionRepository(Protocol):
def register_job(self, registration: JobRegistration) -> None:
...
def mark_running(self, job_id: str, attempt_no: int) -> None:
...
def record_failure(self, job_id: str, attempt_no: int, failure_code: str) -> None:
...
def commit_delivery(self, delivery: VerifiedDelivery) -> IngestionOutcome:
...
def get_price_review(self, job_id: str, limit: int, offset: int) -> Dict[str, Any]:
...
def update_price_review_item(
self,
job_id: str,
item_id: int,
case_id: str,
revision: int,
real_price: str,
actor_username: str,
) -> Dict[str, Any]:
...
def begin_price_review_generation(
self,
job_id: str,
case_id: str,
revision: int,
actor_username: str,
processor_version: str,
rule_set_sha256: str,
idempotency_key: str,
) -> ReviewGeneration:
...
def record_price_review_generation_failure(
self,
job_id: str,
case_id: str,
attempt_no: int,
failure_code: str,
) -> None:
...
def cancel_price_review(
self,
job_id: str,
case_id: str,
revision: int,
actor_username: str,
) -> Dict[str, Any]:
...
@dataclass
class _MemoryJob:
registration: JobRegistration
status: str = "queued"
failure_code: Optional[str] = None
business_date: Optional[date] = None
@dataclass
class _MemoryReviewCase:
case_id: str
business_date: date
processor_version: str
rule_set_sha256: str
source_sha256: str
status: str
revision: int
items: list[Dict[str, Any]]
manifest_bytes: Optional[bytes] = None
manifest_sha256: Optional[str] = None
@dataclass
class _MemoryDailyVersion:
id: int
business_date: date
version_no: int
identity: Tuple[str, date, str, str, Optional[str]]
status: str
records: Tuple[Mapping[str, Any], ...]
channels: Tuple[Mapping[str, Any], ...]
class InMemoryIngestionRepository:
"""Thread-safe vertical-slice repository; never used as the production fact store."""
def __init__(self) -> None:
self._lock = threading.RLock()
self._jobs: Dict[str, _MemoryJob] = {}
self._callbacks: Dict[str, Tuple[str, IngestionOutcome]] = {}
self._versions: Dict[int, _MemoryDailyVersion] = {}
self._identity_versions: Dict[Tuple[str, date, str, str, Optional[str]], int] = {}
self._current: Dict[date, int] = {}
self._reviews: Dict[str, _MemoryReviewCase] = {}
self._next_review_item_id = 1
self._next_version_id = 1
def register_job(self, registration: JobRegistration) -> None:
if registration.source.role != "source_xml" or registration.attempt_no < 1:
raise IngestionError("JOB_INVALID", "processing job registration is invalid")
with self._lock:
existing = self._jobs.get(registration.job_id)
if existing is not None:
if (
existing.registration.source != registration.source
or existing.registration.processor_version != registration.processor_version
or existing.registration.rule_set_sha256 != registration.rule_set_sha256
or existing.registration.uploaded_filename != registration.uploaded_filename
):
raise IngestionError("JOB_CONFLICT", "processing job identity conflicts")
if registration.attempt_no == existing.registration.attempt_no:
if existing.registration != registration:
raise IngestionError("JOB_CONFLICT", "processing attempt identity conflicts")
return
if (
existing.status != "awaiting_review"
or registration.attempt_no != existing.registration.attempt_no + 1
):
raise IngestionError("JOB_CONFLICT", "processing attempt sequence conflicts")
existing.registration = registration
existing.status = "queued"
return
self._jobs[registration.job_id] = _MemoryJob(registration=registration)
def mark_running(self, job_id: str, attempt_no: int) -> None:
with self._lock:
job = self._jobs.get(job_id)
if job is None:
raise IngestionError("JOB_NOT_FOUND", "processing job was not registered")
if attempt_no != job.registration.attempt_no:
raise IngestionError("JOB_NOT_FOUND", "processing attempt was not registered")
if job.status in {"succeeded", "failed", "cancelled"}:
raise IngestionError("JOB_TERMINAL", "processing job is already terminal")
job.status = "running"
def record_failure(self, job_id: str, attempt_no: int, failure_code: str) -> None:
with self._lock:
job = self._jobs.get(job_id)
if job is None:
raise IngestionError("JOB_NOT_FOUND", "processing job was not registered")
if attempt_no != job.registration.attempt_no:
raise IngestionError("JOB_NOT_FOUND", "processing attempt was not registered")
if job.status == "succeeded":
raise IngestionError("JOB_TERMINAL", "processing job is already terminal")
job.status = "failed"
job.failure_code = failure_code
review_case = self._reviews.get(job_id)
if review_case is not None and review_case.status == "processing":
review_case.status = "failed"
def commit_delivery(self, delivery: VerifiedDelivery) -> IngestionOutcome:
envelope = delivery.envelope
with self._lock:
existing_callback = self._callbacks.get(envelope.delivery_id)
if existing_callback is not None:
existing_hash, outcome = existing_callback
if existing_hash != delivery.envelope_sha256:
raise IngestionError("CALLBACK_CONFLICT", "delivery identifier conflicts")
return outcome
job = self._jobs.get(envelope.job_id)
if job is None:
raise IngestionError("JOB_NOT_FOUND", "processing job was not registered")
registration = job.registration
source = envelope.artifacts["source_xml"]
if (
source is None
or source.sha256 != registration.source.sha256
or source.byte_size != registration.source.byte_size
or source.object_key != registration.source.object_key
or envelope.attempt_no != registration.attempt_no
or envelope.processor_version != registration.processor_version
or envelope.rule_set_sha256 != registration.rule_set_sha256
):
raise IngestionError("JOB_DELIVERY_MISMATCH", "delivery does not match its job")
if envelope.status == "failed":
job.status = "failed"
errors = delivery.structured_payload.get("errors")
first_error = errors[0] if isinstance(errors, list) and errors else {}
code = first_error.get("code") if isinstance(first_error, Mapping) else None
job.failure_code = str(code or "PROCESSING_FAILED")
review_case = self._reviews.get(envelope.job_id)
if review_case is not None and review_case.status == "processing":
review_case.status = "failed"
outcome = IngestionOutcome(
status="recorded_failure",
job_id=envelope.job_id,
business_date=envelope.business_date,
daily_version_id=None,
version_no=None,
)
self._callbacks[envelope.delivery_id] = (
delivery.envelope_sha256,
outcome,
)
return outcome
if envelope.status == "review_required":
if envelope.business_date is None:
raise IngestionError("RESULT_CONTRACT_INVALID", "review delivery has no date")
issues = delivery.structured_payload.get("review_issues")
if not isinstance(issues, list) or not issues:
raise IngestionError("RESULT_CONTRACT_INVALID", "review issues are invalid")
case_id = "dailyreview-" + hashlib.sha256(
f"{envelope.job_id}\x00{envelope.delivery_id}".encode("utf-8")
).hexdigest()[:32]
if envelope.job_id in self._reviews:
case = self._reviews[envelope.job_id]
else:
items: list[Dict[str, Any]] = []
for issue in issues:
if not isinstance(issue, Mapping):
raise IngestionError("RESULT_CONTRACT_INVALID", "review issue is invalid")
item = copy.deepcopy(dict(issue))
item["item_id"] = self._next_review_item_id
self._next_review_item_id += 1
item["real_price"] = None
item["revision"] = 0
items.append(item)
case = _MemoryReviewCase(
case_id=case_id,
business_date=envelope.business_date,
processor_version=envelope.processor_version,
rule_set_sha256=envelope.rule_set_sha256,
source_sha256=registration.source.sha256,
status="open",
revision=0,
items=items,
)
self._reviews[envelope.job_id] = case
job.status = "awaiting_review"
job.business_date = envelope.business_date
completed = sum(1 for item in case.items if item["real_price"] is not None)
outcome = IngestionOutcome(
status="recorded_review",
job_id=envelope.job_id,
business_date=envelope.business_date,
daily_version_id=None,
version_no=None,
review_case_id=case.case_id,
review_revision=case.revision,
review_completed_items=completed,
review_total_items=len(case.items),
)
self._callbacks[envelope.delivery_id] = (delivery.envelope_sha256, outcome)
return outcome
review_case_id = delivery.structured_payload.get("review_case_id")
review_case: Optional[_MemoryReviewCase] = None
if review_case_id is not None:
review_case = self._reviews.get(envelope.job_id)
manual_ref = envelope.artifacts.get("manual_override_json")
if (
review_case is None
or review_case.case_id != review_case_id
or review_case.status != "processing"
or manual_ref is None
or review_case.manifest_sha256 != manual_ref.sha256
):
raise IngestionError("REVIEW_STATE_INVALID", "人工复核确认状态无效")
if envelope.business_date is None:
raise IngestionError("RESULT_CONTRACT_INVALID", "successful delivery has no date")
manual_override_sha256 = delivery.structured_payload.get("manual_override_sha256")
if manual_override_sha256 is not None and not isinstance(manual_override_sha256, str):
raise IngestionError("RESULT_CONTRACT_INVALID", "人工复核清单哈希无效")
identity = (
registration.source.sha256,
envelope.business_date,
envelope.processor_version,
envelope.rule_set_sha256,
manual_override_sha256,
)
existing_version_id = self._identity_versions.get(identity)
if existing_version_id is not None:
version = self._versions[existing_version_id]
job.status = "succeeded"
job.business_date = envelope.business_date
if review_case is not None:
review_case.status = "completed"
outcome = IngestionOutcome(
status="already_committed",
job_id=envelope.job_id,
business_date=envelope.business_date,
daily_version_id=version.id,
version_no=version.version_no,
)
self._callbacks[envelope.delivery_id] = (
delivery.envelope_sha256,
outcome,
)
return outcome
version_no = 1 + max(
(
version.version_no
for version in self._versions.values()
if version.business_date == envelope.business_date
),
default=0,
)
version_id = self._next_version_id
self._next_version_id += 1
previous_id = self._current.get(envelope.business_date)
if previous_id is not None:
self._versions[previous_id].status = "superseded"
records = delivery.structured_payload.get("records")
channels = delivery.structured_payload.get("channels")
version = _MemoryDailyVersion(
id=version_id,
business_date=envelope.business_date,
version_no=version_no,
identity=identity,
status="active",
records=tuple(copy.deepcopy(records if isinstance(records, list) else [])),
channels=tuple(copy.deepcopy(channels if isinstance(channels, list) else [])),
)
self._versions[version_id] = version
self._identity_versions[identity] = version_id
self._current[envelope.business_date] = version_id
job.status = "succeeded"
job.business_date = envelope.business_date
if review_case is not None:
review_case.status = "completed"
outcome = IngestionOutcome(
status="committed",
job_id=envelope.job_id,
business_date=envelope.business_date,
daily_version_id=version_id,
version_no=version_no,
)
self._callbacks[envelope.delivery_id] = (
delivery.envelope_sha256,
outcome,
)
return outcome
def current_version_id(self, business_date: date) -> Optional[int]:
with self._lock:
return self._current.get(business_date)
def version_status(self, version_id: int) -> Optional[str]:
with self._lock:
version = self._versions.get(version_id)
return version.status if version else None
def version_records(self, version_id: int) -> Tuple[Mapping[str, Any], ...]:
with self._lock:
version = self._versions.get(version_id)
return tuple(copy.deepcopy(version.records)) if version else tuple()
@staticmethod
def _money(value: object, *, two_places: bool = False) -> Decimal:
try:
amount = Decimal(str(value))
except (InvalidOperation, ValueError):
raise IngestionError("REVIEW_PRICE_INVALID", "人工价格格式无效") from None
if not amount.is_finite() or amount < 0:
raise IngestionError("REVIEW_PRICE_INVALID", "人工价格必须为非负金额")
if two_places:
if not re.fullmatch(r"(?:0|[1-9][0-9]{0,15})\.[0-9]{2}", str(value)):
raise IngestionError("REVIEW_PRICE_INVALID", "人工价格必须使用两位小数字符串")
return amount.quantize(Decimal("0.01"))
return amount.normalize()
@staticmethod
def _decimal_text(value: Decimal) -> str:
return format(value.normalize(), "f")
@classmethod
def _manual_price(cls, value: object) -> Decimal:
if not isinstance(value, str) or MANUAL_PRICE_RE.fullmatch(value) is None:
raise IngestionError("REVIEW_PRICE_INVALID", "人工价格必须是非负整数字符串")
return cls._money(value).quantize(Decimal("0.01"))
@classmethod
def _review_price_text(cls, value: object) -> str:
amount = cls._money(value)
if amount != amount.to_integral_value():
raise IngestionError("DATABASE_STATE_INVALID", "人工价格不是整数")
return format(amount.quantize(Decimal("1")), "f")
@classmethod
def _manifest_for(cls, job_id: str, case: _MemoryReviewCase) -> bytes:
keys = sorted(
(
str(item["company_key"]),
str(item["rate_code"]),
cls._money(item["effective_rate_amount"]),
)
for item in case.items
)
issues = [
{
"company_key": company_key,
"rate_code": rate_code,
"effective_rate_amount": cls._decimal_text(amount),
}
for company_key, rate_code, amount in keys
]
by_key = {
(
str(item["company_key"]),
str(item["rate_code"]),
cls._money(item["effective_rate_amount"]),
): item
for item in case.items
}
overrides = []
for company_key, rate_code, amount in keys:
price = cls._money(by_key[(company_key, rate_code, amount)]["real_price"], two_places=True)
overrides.append(
{
"company_key": company_key,
"rate_code": rate_code,
"effective_rate_amount": cls._decimal_text(amount),
"real_price": format(price, ".2f"),
}
)
payload = {
"review_version": "1.0",
"review_case_id": case.case_id,
"job_id": job_id,
"source_sha256": case.source_sha256,
"business_date": case.business_date.isoformat(),
"processor_version": case.processor_version,
"rule_set_sha256": case.rule_set_sha256,
"issues": issues,
"overrides": overrides,
}
return json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
).encode("utf-8")
@classmethod
def _review_response(cls, case: _MemoryReviewCase, limit: int, offset: int) -> Dict[str, Any]:
if limit < 1 or offset < 0:
raise IngestionError("REVIEW_PAGE_INVALID", "复核分页参数无效")
total = len(case.items)
sliced = case.items[offset : offset + limit]
completed = sum(1 for item in case.items if item["real_price"] is not None)
safe_items = []
for item in sliced:
safe_items.append(
{
"item_id": item["item_id"],
"company_key": item["company_key"],
"rate_code": item["rate_code"],
"effective_rate_amount": item["effective_rate_amount"],
"candidate_prices": copy.deepcopy(item["candidate_prices"]),
"affected_records": item["affected_records"],
"affected_rooms": item["affected_rooms"],
"affected_room_nights": item["affected_room_nights"],
"real_price": (
cls._review_price_text(item["real_price"])
if item["real_price"] is not None
else None
),
"revision": item["revision"],
}
)
return {
"case_id": case.case_id,
"case_status": case.status,
"revision": case.revision,
"business_date": case.business_date.isoformat(),
"completed_items": completed,
"total_items": total,
"items": safe_items,
"pagination": {
"limit": limit,
"offset": offset,
"total": total,
"has_next": offset + len(safe_items) < total,
},
}
def get_price_review(self, job_id: str, limit: int, offset: int) -> Dict[str, Any]:
with self._lock:
case = self._reviews.get(job_id)
if case is None:
raise IngestionError("REVIEW_NOT_FOUND", "待人工处理任务不存在")
return self._review_response(case, limit, offset)
def update_price_review_item(
self,
job_id: str,
item_id: int,
case_id: str,
revision: int,
real_price: str,
actor_username: str,
) -> Dict[str, Any]:
del actor_username
with self._lock:
case = self._reviews.get(job_id)
if case is None or case.case_id != case_id:
raise IngestionError("REVIEW_NOT_FOUND", "待人工处理任务不存在")
if case.status != "open":
raise IngestionError("REVIEW_IMMUTABLE", "复核清单已冻结,不能修改")
if revision != case.revision:
raise IngestionError("REVIEW_REVISION_CONFLICT", "复核清单已被其他会话更新")
item = next((value for value in case.items if value["item_id"] == item_id), None)
if item is None:
raise IngestionError("REVIEW_ITEM_NOT_FOUND", "待人工处理项不存在")
price = self._manual_price(real_price)
item["real_price"] = format(price, ".2f")
case.revision += 1
item["revision"] = case.revision
return self._review_response(case, len(case.items), 0)
def begin_price_review_generation(
self,
job_id: str,
case_id: str,
revision: int,
actor_username: str,
processor_version: str,
rule_set_sha256: str,
idempotency_key: str,
) -> ReviewGeneration:
del actor_username
with self._lock:
job = self._jobs.get(job_id)
case = self._reviews.get(job_id)
if job is None or case is None or case.case_id != case_id:
raise IngestionError("REVIEW_NOT_FOUND", "待人工处理任务不存在")
if case.processor_version != processor_version or case.rule_set_sha256 != rule_set_sha256:
raise IngestionError("REVIEW_RULESET_CHANGED", "处理器或规则已变更,请取消后重新上传")
if case.status == "completed":
version_id = self._current.get(case.business_date)
version = self._versions.get(version_id) if version_id is not None else None
return ReviewGeneration(
"completed", job_id, case.case_id, None, case.business_date, None, case.manifest_sha256,
version.id if version else None, version.version_no if version else None
)
if case.status == "processing":
return ReviewGeneration(
"processing", job_id, case.case_id, job.registration.attempt_no, case.business_date,
None, case.manifest_sha256
)
if case.status not in {"open", "generation_failed"}:
raise IngestionError("REVIEW_NOT_OPEN", "复核任务不能确认生成")
if revision != case.revision:
raise IngestionError("REVIEW_REVISION_CONFLICT", "复核清单已被其他会话更新")
if any(item["real_price"] is None for item in case.items):
raise IngestionError("REVIEW_INCOMPLETE", "请先填写全部缺价项")
if case.manifest_bytes is None:
case.manifest_bytes = self._manifest_for(job_id, case)
case.manifest_sha256 = hashlib.sha256(case.manifest_bytes).hexdigest()
next_attempt = job.registration.attempt_no + 1
job.registration = replace(
job.registration,
attempt_no=next_attempt,
idempotency_key=idempotency_key,
)
job.status = "queued"
case.status = "processing"
return ReviewGeneration(
"ready", job_id, case.case_id, next_attempt, case.business_date,
case.manifest_bytes, case.manifest_sha256,
source=job.registration.source,
)
def record_price_review_generation_failure(
self,
job_id: str,
case_id: str,
attempt_no: int,
failure_code: str,
) -> None:
del attempt_no, failure_code
with self._lock:
job = self._jobs.get(job_id)
case = self._reviews.get(job_id)
if job is None or case is None or case.case_id != case_id:
raise IngestionError("REVIEW_NOT_FOUND", "待人工处理任务不存在")
if case.status == "completed":
return
case.status = "generation_failed"
job.status = "awaiting_review"
def cancel_price_review(
self,
job_id: str,
case_id: str,
revision: int,
actor_username: str,
) -> Dict[str, Any]:
del actor_username
with self._lock:
job = self._jobs.get(job_id)
case = self._reviews.get(job_id)
if job is None or case is None or case.case_id != case_id:
raise IngestionError("REVIEW_NOT_FOUND", "待人工处理任务不存在")
if case.status not in {"open", "generation_failed"}:
raise IngestionError("REVIEW_NOT_CANCELLABLE", "复核任务当前不能取消")
if revision != case.revision:
raise IngestionError("REVIEW_REVISION_CONFLICT", "复核清单已被其他会话更新")
case.status = "cancelled"
case.revision += 1
job.status = "cancelled"
return self._review_response(case, len(case.items), 0)