feat: convert AM516 lead times to workdays

This commit is contained in:
ar51
2026-09-03 17:34:42 +08:00
parent fa8aee91b9
commit c7c5c3259c
6 changed files with 332 additions and 27 deletions

View File

@@ -12,7 +12,7 @@ import threading
import time
import unicodedata
from dataclasses import dataclass
from datetime import datetime
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any
@@ -79,6 +79,43 @@ ALLOWED_VERSIONS = {
}
CANDIDATE_BATCH_SIZE = 4
CANDIDATE_CONTINUATION_TTL_SECONDS = 6 * 60 * 60
RECEIVED_ACKNOWLEDGEMENT = "👀"
def _inclusive_date_range(start: date, end: date) -> frozenset[date]:
return frozenset(
start + timedelta(days=offset)
for offset in range((end - start).days + 1)
)
# 国办发明电20257号国务院办公厅关于2026年部分节假日安排的通知。
CHINA_WORKDAY_CALENDAR_SOURCE = (
"https://www.gov.cn/zhengce/zhengceku/202511/content_7047091.htm"
)
CHINA_OFFICIAL_HOLIDAYS = {
2026: frozenset().union(
_inclusive_date_range(date(2026, 1, 1), date(2026, 1, 3)),
_inclusive_date_range(date(2026, 2, 15), date(2026, 2, 23)),
_inclusive_date_range(date(2026, 4, 4), date(2026, 4, 6)),
_inclusive_date_range(date(2026, 5, 1), date(2026, 5, 5)),
_inclusive_date_range(date(2026, 6, 19), date(2026, 6, 21)),
_inclusive_date_range(date(2026, 9, 25), date(2026, 9, 27)),
_inclusive_date_range(date(2026, 10, 1), date(2026, 10, 7)),
)
}
CHINA_OFFICIAL_MAKEUP_WORKDAYS = {
2026: frozenset(
{
date(2026, 1, 4),
date(2026, 2, 14),
date(2026, 2, 28),
date(2026, 5, 9),
date(2026, 9, 20),
date(2026, 10, 10),
}
)
}
CANDIDATE_CONTINUATION_SHORT_PHRASES = {
"还有吗",
"还有呢",
@@ -172,6 +209,12 @@ class CandidateUnavailableError(RuntimeError):
pass
class WorkdayCalendarUnavailableError(ValueError):
def __init__(self, year: int) -> None:
self.year = year
super().__init__(f"{year}年国家法定工作日历未配置")
_candidate_continuations: dict[str, CandidateContinuation] = {}
_candidate_continuation_lock = threading.Lock()
@@ -493,13 +536,74 @@ def run_controlled_query(request: RequestInput) -> dict[str, Any]:
return payload
def lead_time_details(payload: dict[str, Any], requested_quantity: int) -> dict[str, Any]:
def natural_days_to_workdays(natural_days: int) -> int:
"""Convert natural days with AR51's fixed quotient-and-remainder rule."""
if isinstance(natural_days, bool) or not isinstance(natural_days, int):
raise TypeError("自然日交期必须是整数")
if natural_days < 0:
raise ValueError("自然日交期不能小于0")
full_weeks, remainder = divmod(natural_days, 7)
if remainder == 0:
remainder_workdays = 0
elif remainder <= 4:
remainder_workdays = max(remainder - 1, 0)
else:
remainder_workdays = remainder - 2
return full_weeks * 5 + remainder_workdays
def is_china_official_workday(day: date) -> bool:
holidays = CHINA_OFFICIAL_HOLIDAYS.get(day.year)
makeup_workdays = CHINA_OFFICIAL_MAKEUP_WORKDAYS.get(day.year)
if holidays is None or makeup_workdays is None:
raise WorkdayCalendarUnavailableError(day.year)
if day in makeup_workdays:
return True
if day in holidays:
return False
return day.weekday() < 5
def add_china_official_workdays(start: date, workdays: int) -> date:
"""Count official workdays after start; the start date itself is excluded."""
if isinstance(workdays, bool) or not isinstance(workdays, int):
raise TypeError("工作日交期必须是整数")
if workdays < 0:
raise ValueError("工作日交期不能小于0")
current = start
counted = 0
while counted < workdays:
current += timedelta(days=1)
if is_china_official_workday(current):
counted += 1
return current
def whole_natural_day_count(value: Any) -> int | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
if isinstance(value, float) and not value.is_integer():
return None
result = int(value)
return result if result >= 0 else None
def lead_time_details(
payload: dict[str, Any],
requested_quantity: int,
calculation_date: date | None = None,
) -> dict[str, Any]:
calculation_date = calculation_date or datetime.now().astimezone().date()
data = payload.get("data")
if not isinstance(data, dict):
return {
"prediction": "接口未返回可用交期汇总,需人工复核。",
"prediction_value": None,
"total_days": None,
"workdays": None,
"delivery_date": None,
"main_factor": "接口未返回,需人工复核",
"inventory": "接口未返回可用库存汇总,需人工复核。",
"available_inventory": None,
@@ -514,6 +618,8 @@ def lead_time_details(payload: dict[str, Any], requested_quantity: int) -> dict[
"prediction": "接口未返回可用交期汇总,需人工复核。",
"prediction_value": None,
"total_days": None,
"workdays": None,
"delivery_date": None,
"main_factor": "接口未返回,需人工复核",
"inventory": "接口未返回可用库存汇总,需人工复核。",
"available_inventory": None,
@@ -530,14 +636,28 @@ def lead_time_details(payload: dict[str, Any], requested_quantity: int) -> dict[
else "接口未返回,需人工复核"
)
predicted_date = summary.get("AM516预测交期")
total_days = summary.get("AM516总交期", summary.get("总交期"))
if isinstance(predicted_date, (str, int, float)):
days_suffix = f"{total_days}天)" if isinstance(total_days, (int, float)) else ""
prediction = f"AM516预测交期{predicted_date}{days_suffix}"
prediction_value = str(predicted_date)
elif isinstance(total_days, (int, float)):
prediction = f"原算法总交期参考:{total_days}"
prediction_value = f"{total_days}"
total_days = whole_natural_day_count(summary.get("AM516总交期"))
if total_days is None:
total_days = whole_natural_day_count(summary.get("总交期"))
workdays: int | None = None
delivery_date: date | None = None
if total_days is not None:
workdays = natural_days_to_workdays(total_days)
try:
delivery_date = add_china_official_workdays(calculation_date, workdays)
except WorkdayCalendarUnavailableError as exc:
prediction_value = (
f"{exc}】(原始{total_days}个自然日,折算{workdays}个工作日)"
)
else:
prediction_value = (
f"{delivery_date.isoformat()}"
f"(原始{total_days}个自然日,折算{workdays}个工作日)"
)
prediction = f"AM516预测交期{prediction_value}"
elif isinstance(predicted_date, (str, int, float)):
prediction_value = f"{predicted_date}(接口未返回自然日交期,未换算)"
prediction = f"AM516预测交期{prediction_value}"
else:
prediction = "接口未返回可用交期汇总,需人工复核。"
prediction_value = None
@@ -616,7 +736,9 @@ def lead_time_details(payload: dict[str, Any], requested_quantity: int) -> dict[
return {
"prediction": prediction,
"prediction_value": prediction_value,
"total_days": total_days if isinstance(total_days, (int, float)) else None,
"total_days": total_days,
"workdays": workdays,
"delivery_date": delivery_date.isoformat() if delivery_date else None,
"main_factor": main_factor_text,
"inventory": inventory_text,
"available_inventory": (
@@ -642,8 +764,8 @@ def overall_judgment_lines(candidates: list[dict[str, Any]]) -> list[str]:
fastest = min(
available,
key=lambda candidate: (
candidate["details"]["total_days"]
if candidate["details"]["total_days"] is not None
candidate["details"]["workdays"]
if candidate["details"]["workdays"] is not None
else float("inf")
),
)
@@ -814,6 +936,7 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
if candidate_offset < 0:
raise InputError("候选批次位置无效,请重新发起型号和数量查询。")
calculation_date = datetime.now().astimezone().date()
all_candidate_specs = generate_candidate_specs(request.model)
candidate_specs = all_candidate_specs[
candidate_offset : candidate_offset + CANDIDATE_BATCH_SIZE
@@ -826,7 +949,11 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
baseline: dict[str, Any] | None = None
if is_initial_batch:
baseline_payload = run_controlled_query(request)
baseline = lead_time_details(baseline_payload, request.quantity)
baseline = lead_time_details(
baseline_payload,
request.quantity,
calculation_date=calculation_date,
)
if not baseline["has_lead_time"]:
raise RuntimeError("原型号接口未返回可用交期汇总,未生成候选结果。")
candidates.append(
@@ -849,7 +976,11 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
payload = run_controlled_query(RequestInput(spec["model"], request.quantity))
except CandidateUnavailableError:
return spec, None
details = lead_time_details(payload, request.quantity)
details = lead_time_details(
payload,
request.quantity,
calculation_date=calculation_date,
)
return spec, details if details["has_lead_time"] else None
if candidate_specs:
@@ -1029,6 +1160,35 @@ def reply(client: lark.Client, message_id: str, text: str) -> None:
logging.info("Feishu reply sent: message_id=%s parts=%s", message_id, len(post_contents))
def reply_received_acknowledgement(client: lark.Client, message_id: str) -> None:
request = (
lark.im.v1.ReplyMessageRequest.builder()
.message_id(message_id)
.request_body(
lark.im.v1.ReplyMessageRequestBody.builder()
.msg_type("text")
.content(
json.dumps(
{"text": RECEIVED_ACKNOWLEDGEMENT},
ensure_ascii=False,
)
)
.uuid(f"{message_id[:44]}-ack")
.build()
)
.build()
)
response = client.im.v1.message.reply(request)
if not response.success():
logging.error(
"Feishu acknowledgement failed: code=%s message_id=%s",
response.code,
message_id,
)
return
logging.info("Feishu acknowledgement sent: message_id=%s", message_id)
_seen_message_ids: set[str] = set()
_seen_message_lock = threading.Lock()
@@ -1055,6 +1215,8 @@ def handle_message(client: lark.Client, data: lark.im.v1.P2ImMessageReceiveV1) -
)
return
reply_received_acknowledgement(client, message_id)
if continuation_state is None:
clear_candidate_continuation(key)