feat: convert AM516 lead times to workdays
This commit is contained in:
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
# 国办发明电〔2025〕7号:国务院办公厅关于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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user