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

@@ -5,6 +5,8 @@
**日期**2026-09-02 **日期**2026-09-02
**目的**:将 AM516 交期预测输出的自然日转换为工作日,并据此得到最终交货日期。 **目的**:将 AM516 交期预测输出的自然日转换为工作日,并据此得到最终交货日期。
**当前临时处理**:因 AR836 排期较晚AM516 飞书机器人先在 API 返回后的输出层执行同一换算;不修改 AR836 API 原始数据。待 API 正式提供工作日口径后,应移除本地临时换算,避免重复转换。
## 一、需求背景 ## 一、需求背景
AM516 当前输出的是自然日交期。销售希望得到工作日口径的交期,即: AM516 当前输出的是自然日交期。销售希望得到工作日口径的交期,即:

View File

@@ -14,6 +14,7 @@ Expected:
- model parses successfully; - model parses successfully;
- quantity is accepted; - quantity is accepted;
- the bot immediately replies with `👀` after the valid trigger is accepted and before the API query starts;
- the controlled script uses the macOS system trust store and keeps TLS verification enabled; - the controlled script uses the macOS system trust store and keeps TLS verification enabled;
- no temporary TLS-bypass parameter is required for the normal happy path; - no temporary TLS-bypass parameter is required for the normal happy path;
- API script runs; - API script runs;
@@ -39,6 +40,18 @@ Expected:
- record-table columns follow this order: 序号、记录时间、型号、推荐类型、差异项、API状态、AM516预测交期、可用库存、主导因素; - record-table columns follow this order: 序号、记录时间、型号、推荐类型、差异项、API状态、AM516预测交期、可用库存、主导因素;
- unavailable candidates remain in their original rule order and are recorded as `❌ 不存在` with unavailable result fields shown as `—`; - unavailable candidates remain in their original rule order and are recorded as `❌ 不存在` with unavailable result fields shown as `—`;
## Sample 1B | Natural Days To Workdays
Rules and expected results:
- treat `AM516总交期`, or numeric `总交期` when the AM516 field is absent, as the original natural-day lead time `N`;
- convert `N` with the approved quotient-and-remainder rule, including `r = 4` as one rest day and `r = 0` as no remainder workday;
- exclude the local query date itself, then count the converted workdays using the configured China statutory workday calendar;
- statutory holidays are excluded and official makeup weekend workdays are included;
- for a query date of `2026-09-02`, `31` natural days convert to `22` workdays and produce `2026-10-09`;
- output `2026-10-09原始31个自然日折算22个工作日` instead of adding a separate National Day exception;
- when counting reaches an unconfigured calendar year, do not silently fall back to a Monday-Friday estimate.
## Sample 1A | Continue With Other Candidates ## Sample 1A | Continue With Other Candidates
Preconditions: Preconditions:
@@ -59,6 +72,7 @@ is not required.
Expected: Expected:
- the bot immediately replies with `👀` after the continuation and active-context gates pass;
- the bot reuses the active model and quantity only for that same sender and chat; - the bot reuses the active model and quantity only for that same sender and chat;
- the bot queries candidate positions 5-8 in the unchanged rule order; - the bot queries candidate positions 5-8 in the unchanged rule order;
- the original model and candidate positions 1-4 are not queried or displayed again; - the original model and candidate positions 1-4 are not queried or displayed again;
@@ -83,6 +97,7 @@ eRob110H160I-FHM-18ET[V6]
Expected: Expected:
- bot stays silent; - bot stays silent;
- no acknowledgement emoji is sent;
- no API call is made; - no API call is made;
- no record is appended. - no record is appended.

View File

@@ -8,20 +8,22 @@
4. Confirm a separate trusted AR51 approval record explicitly covers `api.zeroerr-agent.com`, the AM516 use case, and API-key use. 4. Confirm a separate trusted AR51 approval record explicitly covers `api.zeroerr-agent.com`, the AM516 use case, and API-key use.
5. If that domain-and-permission record is absent, stop before any outbound request; a general functional-test approval is insufficient by itself. 5. If that domain-and-permission record is absent, stop before any outbound request; a general functional-test approval is insufficient by itself.
6. Use model recommendation rules to generate the complete ordered internal candidate pool. 6. Use model recommendation rules to generate the complete ordered internal candidate pool.
7. Run the controlled script for the original model baseline and only the first four candidate models. 7. Immediately reply with `👀` to acknowledge the valid request.
8. Summarize the current batch, state how many candidates remain, and invite the same sender to ask naturally for more when candidates remain; exact wording is not required. 8. Run the controlled script for the original model baseline and only the first four candidate models.
9. Append a non-secret record. 9. Convert each numeric natural-day lead time to workdays with the approved quotient-and-remainder rule, then count those workdays from the local query date through the configured China statutory workday calendar. The query date itself is excluded.
10. Return Feishu-friendly text with the fixed disclaimer. 10. Summarize the current batch, including the original natural days, converted workdays, and calculated delivery date; state how many candidates remain and invite the same sender to ask naturally for more when candidates remain.
11. Append a non-secret record.
12. Return Feishu-friendly text with the fixed disclaimer.
## Candidate Continuation ## Candidate Continuation
1. Accept a clear request for more candidates only from the same sender in the same Feishu chat as the active initial query. Equivalent expressions such as `还有吗``有没有别的``再推荐几个``继续``下一批` or `more candidates` are valid; no exact phrase is required. 1. Accept a clear request for more candidates only from the same sender in the same Feishu chat as the active initial query. Equivalent expressions such as `还有吗``有没有别的``再推荐几个``继续``下一批` or `more candidates` are valid; no exact phrase is required.
2. The in-memory continuation context expires after six hours and is cleared by a bridge restart. If no matching context exists, stay silent and require a new complete model-and-quantity query. 2. The in-memory continuation context expires after six hours and is cleared by a bridge restart. If no matching context exists, stay silent and require a new complete model-and-quantity query.
3. Query the next four unqueried rule candidates; do not re-query the original baseline or any earlier candidate. 3. Immediately reply with `👀`, then query the next four unqueried rule candidates; do not re-query the original baseline or any earlier candidate.
4. Missing or unsupported candidates still consume their rule-order positions and remain under `不可用候选`. 4. Missing or unsupported candidates still consume their rule-order positions and remain under `不可用候选`.
5. Append one record block for the models queried in this continuation batch, then state whether more candidates remain. 5. Append one record block for the models queried in this continuation batch, then state whether more candidates remain.
6. A new complete model-and-quantity request replaces the earlier continuation context for that sender and chat. 6. A new complete model-and-quantity request replaces the earlier continuation context for that sender and chat.
7. Keep the dual gate of continuation intent plus valid context: unrelated text such as `more interesting` or `继续查询英语例句` must not trigger the old query. 7. Keep the dual gate of continuation intent plus valid context: unrelated text such as `more interesting` or `继续查询英语例句` must not trigger the old query or acknowledgement.
## Approved Script ## Approved Script

View File

@@ -44,8 +44,12 @@ Stop if:
- Enter the query workflow only when one message contains exactly one complete, - Enter the query workflow only when one message contains exactly one complete,
rule-valid eRob model and exactly one positive quantity. rule-valid eRob model and exactly one positive quantity.
- After a valid initial query or valid same-conversation candidate continuation
passes this gate, immediately reply with `👀` before starting the API query so
the sender knows the request was received.
- If the model or quantity is missing, invalid, multiple, or ambiguous, stay - If the model or quantity is missing, invalid, multiple, or ambiguous, stay
silent: send no Feishu reply, make no API request, and append no record. silent: send no acknowledgement or other Feishu reply, make no API request,
and append no record.
- An @mention by itself does not bypass this content gate. - An @mention by itself does not bypass this content gate.
- After a valid message passes the gate, API or runtime failures may still return - After a valid message passes the gate, API or runtime failures may still return
the bounded internal error response defined by this capability. the bounded internal error response defined by this capability.
@@ -89,9 +93,12 @@ Do not use temporary curl, WebFetch, or any API method outside the controlled sc
## Output Selection ## Output Selection
- Prefer `data.交期汇总.AM516预测交期` when the API returns it and AR51 confirms it is usable. - Prefer numeric `data.交期汇总.AM516交期`; if it is absent, fall back to numeric `data.交期汇总.总交期`. Treat this value as the original natural-day lead time `N`.
- If AM516 fields are absent, use original API prediction and mark it as original algorithm reference. - Convert `N` with `(q, r) = divmod(N, 7)`: `r = 0` gives `q × 5`; `1 ≤ r ≤ 4` gives `q × 5 + MAX(r - 1, 0)`; `5 ≤ r ≤ 6` gives `q × 5 + r - 2`. The result is workday lead time `W`.
- If only total days are available, state the total days and ask for human review. - Starting from the local query date, exclude the query date itself and count forward `W` workdays using the configured China statutory calendar. Statutory holidays are not workdays, ordinary Saturdays and Sundays are not workdays, and official makeup Saturdays or Sundays are workdays.
- The configured 2026 calendar follows `国办发明电20257号`. If counting reaches a year whose official calendar is not configured, do not silently estimate a delivery date; mark that calendar year as pending.
- Display the calculated delivery date together with both `N` and `W`, for example `2026-10-09原始31个自然日折算22个工作日`.
- Use `data.交期汇总.AM516预测交期` only as a fallback when the API does not return a usable whole-number natural-day lead time, and explicitly mark that no conversion was performed.
## Candidate Batch Rule ## Candidate Batch Rule
@@ -156,13 +163,13 @@ Use this field order for a successful Feishu report:
总体判断: 总体判断:
- 现货候选:{fully covered candidate or none} - 现货候选:{fully covered candidate or none}
- 最快候选型号:{fastest candidate} - 最快候选型号:{fastest candidate}
- 最快候选交期:{predicted date and total days} - 最快候选交期:{calculated delivery date, original natural days, and converted workdays}
- 是否完全覆盖数量:{yes / no / unavailable} - 是否完全覆盖数量:{yes / no / unavailable}
- 主导因素:{main factor} - 主导因素:{main factor}
1. {model}{candidate type and reducer brand} 1. {model}{candidate type and reducer brand}
- 数量:{quantity}台 - 数量:{quantity}台
- AM516预测交期{date and total days} - AM516预测交期{calculated delivery date}(原始{N}个自然日,折算{W}个工作日)
- 可用库存/覆盖情况:{summary-level inventory and coverage only} - 可用库存/覆盖情况:{summary-level inventory and coverage only}
- 主导因素:{main factor} - 主导因素:{main factor}
- 推荐理由:{rule-based recommendation reason} - 推荐理由:{rule-based recommendation reason}

View File

@@ -12,7 +12,7 @@ import threading
import time import time
import unicodedata import unicodedata
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import date, datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -79,6 +79,43 @@ ALLOWED_VERSIONS = {
} }
CANDIDATE_BATCH_SIZE = 4 CANDIDATE_BATCH_SIZE = 4
CANDIDATE_CONTINUATION_TTL_SECONDS = 6 * 60 * 60 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 = { CANDIDATE_CONTINUATION_SHORT_PHRASES = {
"还有吗", "还有吗",
"还有呢", "还有呢",
@@ -172,6 +209,12 @@ class CandidateUnavailableError(RuntimeError):
pass pass
class WorkdayCalendarUnavailableError(ValueError):
def __init__(self, year: int) -> None:
self.year = year
super().__init__(f"{year}年国家法定工作日历未配置")
_candidate_continuations: dict[str, CandidateContinuation] = {} _candidate_continuations: dict[str, CandidateContinuation] = {}
_candidate_continuation_lock = threading.Lock() _candidate_continuation_lock = threading.Lock()
@@ -493,13 +536,74 @@ def run_controlled_query(request: RequestInput) -> dict[str, Any]:
return payload 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") data = payload.get("data")
if not isinstance(data, dict): if not isinstance(data, dict):
return { return {
"prediction": "接口未返回可用交期汇总,需人工复核。", "prediction": "接口未返回可用交期汇总,需人工复核。",
"prediction_value": None, "prediction_value": None,
"total_days": None, "total_days": None,
"workdays": None,
"delivery_date": None,
"main_factor": "接口未返回,需人工复核", "main_factor": "接口未返回,需人工复核",
"inventory": "接口未返回可用库存汇总,需人工复核。", "inventory": "接口未返回可用库存汇总,需人工复核。",
"available_inventory": None, "available_inventory": None,
@@ -514,6 +618,8 @@ def lead_time_details(payload: dict[str, Any], requested_quantity: int) -> dict[
"prediction": "接口未返回可用交期汇总,需人工复核。", "prediction": "接口未返回可用交期汇总,需人工复核。",
"prediction_value": None, "prediction_value": None,
"total_days": None, "total_days": None,
"workdays": None,
"delivery_date": None,
"main_factor": "接口未返回,需人工复核", "main_factor": "接口未返回,需人工复核",
"inventory": "接口未返回可用库存汇总,需人工复核。", "inventory": "接口未返回可用库存汇总,需人工复核。",
"available_inventory": None, "available_inventory": None,
@@ -530,14 +636,28 @@ def lead_time_details(payload: dict[str, Any], requested_quantity: int) -> dict[
else "接口未返回,需人工复核" else "接口未返回,需人工复核"
) )
predicted_date = summary.get("AM516预测交期") predicted_date = summary.get("AM516预测交期")
total_days = summary.get("AM516总交期", summary.get("总交期")) total_days = whole_natural_day_count(summary.get("AM516总交期"))
if isinstance(predicted_date, (str, int, float)): if total_days is None:
days_suffix = f"{total_days}天)" if isinstance(total_days, (int, float)) else "" total_days = whole_natural_day_count(summary.get("总交期"))
prediction = f"AM516预测交期{predicted_date}{days_suffix}" workdays: int | None = None
prediction_value = str(predicted_date) delivery_date: date | None = None
elif isinstance(total_days, (int, float)): if total_days is not None:
prediction = f"原算法总交期参考:{total_days}" workdays = natural_days_to_workdays(total_days)
prediction_value = f"{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: else:
prediction = "接口未返回可用交期汇总,需人工复核。" prediction = "接口未返回可用交期汇总,需人工复核。"
prediction_value = None prediction_value = None
@@ -616,7 +736,9 @@ def lead_time_details(payload: dict[str, Any], requested_quantity: int) -> dict[
return { return {
"prediction": prediction, "prediction": prediction,
"prediction_value": prediction_value, "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, "main_factor": main_factor_text,
"inventory": inventory_text, "inventory": inventory_text,
"available_inventory": ( "available_inventory": (
@@ -642,8 +764,8 @@ def overall_judgment_lines(candidates: list[dict[str, Any]]) -> list[str]:
fastest = min( fastest = min(
available, available,
key=lambda candidate: ( key=lambda candidate: (
candidate["details"]["total_days"] candidate["details"]["workdays"]
if candidate["details"]["total_days"] is not None if candidate["details"]["workdays"] is not None
else float("inf") else float("inf")
), ),
) )
@@ -814,6 +936,7 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
if candidate_offset < 0: if candidate_offset < 0:
raise InputError("候选批次位置无效,请重新发起型号和数量查询。") raise InputError("候选批次位置无效,请重新发起型号和数量查询。")
calculation_date = datetime.now().astimezone().date()
all_candidate_specs = generate_candidate_specs(request.model) all_candidate_specs = generate_candidate_specs(request.model)
candidate_specs = all_candidate_specs[ candidate_specs = all_candidate_specs[
candidate_offset : candidate_offset + CANDIDATE_BATCH_SIZE 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 baseline: dict[str, Any] | None = None
if is_initial_batch: if is_initial_batch:
baseline_payload = run_controlled_query(request) 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"]: if not baseline["has_lead_time"]:
raise RuntimeError("原型号接口未返回可用交期汇总,未生成候选结果。") raise RuntimeError("原型号接口未返回可用交期汇总,未生成候选结果。")
candidates.append( 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)) payload = run_controlled_query(RequestInput(spec["model"], request.quantity))
except CandidateUnavailableError: except CandidateUnavailableError:
return spec, None 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 return spec, details if details["has_lead_time"] else None
if candidate_specs: 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)) 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_ids: set[str] = set()
_seen_message_lock = threading.Lock() _seen_message_lock = threading.Lock()
@@ -1055,6 +1215,8 @@ def handle_message(client: lark.Client, data: lark.im.v1.P2ImMessageReceiveV1) -
) )
return return
reply_received_acknowledgement(client, message_id)
if continuation_state is None: if continuation_state is None:
clear_candidate_continuation(key) clear_candidate_continuation(key)

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import sys import sys
import types import types
import unittest import unittest
from datetime import date
from types import SimpleNamespace from types import SimpleNamespace
from unittest import mock from unittest import mock
@@ -103,11 +104,16 @@ class TriggerGateTests(unittest.TestCase):
with ( with (
mock.patch.object(bridge, "message_text", return_value="他怎么什么消息都回复啊"), mock.patch.object(bridge, "message_text", return_value="他怎么什么消息都回复啊"),
mock.patch.object(bridge, "build_report") as build_report, mock.patch.object(bridge, "build_report") as build_report,
mock.patch.object(
bridge,
"reply_received_acknowledgement",
) as acknowledge,
mock.patch.object(bridge, "reply") as reply, mock.patch.object(bridge, "reply") as reply,
): ):
bridge.handle_message(object(), data) bridge.handle_message(object(), data)
build_report.assert_not_called() build_report.assert_not_called()
acknowledge.assert_not_called()
reply.assert_not_called() reply.assert_not_called()
def test_valid_message_builds_and_replies(self) -> None: def test_valid_message_builds_and_replies(self) -> None:
@@ -121,11 +127,16 @@ class TriggerGateTests(unittest.TestCase):
"message_text", "message_text",
return_value="eRob70H50I-BHM-18CTC[V5] 10台", return_value="eRob70H50I-BHM-18CTC[V5] 10台",
), ),
mock.patch.object(
bridge,
"reply_received_acknowledgement",
) as acknowledge,
mock.patch.object(bridge, "build_report", return_value="report") as build_report, mock.patch.object(bridge, "build_report", return_value="report") as build_report,
mock.patch.object(bridge, "reply") as reply, mock.patch.object(bridge, "reply") as reply,
): ):
bridge.handle_message("client", data) bridge.handle_message("client", data)
acknowledge.assert_called_once_with("client", "om_valid")
build_report.assert_called_once_with( build_report.assert_called_once_with(
bridge.RequestInput(model="eRob70H50I-BHM-18CTC[V5]", quantity=10) bridge.RequestInput(model="eRob70H50I-BHM-18CTC[V5]", quantity=10)
) )
@@ -177,11 +188,16 @@ class TriggerGateTests(unittest.TestCase):
return_value="还有没有其他候选型号?", return_value="还有没有其他候选型号?",
), ),
mock.patch.object(bridge, "build_report") as build_report, mock.patch.object(bridge, "build_report") as build_report,
mock.patch.object(
bridge,
"reply_received_acknowledgement",
) as acknowledge,
mock.patch.object(bridge, "reply") as reply, mock.patch.object(bridge, "reply") as reply,
): ):
bridge.handle_message("client", data) bridge.handle_message("client", data)
build_report.assert_not_called() build_report.assert_not_called()
acknowledge.assert_not_called()
reply.assert_not_called() reply.assert_not_called()
def test_follow_up_continues_from_fifth_candidate_in_same_conversation(self) -> None: def test_follow_up_continues_from_fifth_candidate_in_same_conversation(self) -> None:
@@ -201,6 +217,10 @@ class TriggerGateTests(unittest.TestCase):
"build_report", "build_report",
side_effect=["initial report", "continued report"], side_effect=["initial report", "continued report"],
) as build_report, ) as build_report,
mock.patch.object(
bridge,
"reply_received_acknowledgement",
) as acknowledge,
mock.patch.object(bridge, "reply") as reply, mock.patch.object(bridge, "reply") as reply,
): ):
bridge.handle_message("client", initial_data) bridge.handle_message("client", initial_data)
@@ -210,6 +230,13 @@ class TriggerGateTests(unittest.TestCase):
build_report.call_args_list, build_report.call_args_list,
[mock.call(request), mock.call(request, candidate_offset=4)], [mock.call(request), mock.call(request, candidate_offset=4)],
) )
self.assertEqual(
acknowledge.call_args_list,
[
mock.call("client", "om_initial"),
mock.call("client", "om_follow_up"),
],
)
self.assertEqual( self.assertEqual(
reply.call_args_list, reply.call_args_list,
[ [
@@ -229,6 +256,10 @@ class TriggerGateTests(unittest.TestCase):
"message_text", "message_text",
side_effect=[request_text, "还有没有其他候选型号?"], side_effect=[request_text, "还有没有其他候选型号?"],
), ),
mock.patch.object(
bridge,
"reply_received_acknowledgement",
) as acknowledge,
mock.patch.object(bridge, "build_report", return_value="report") as build_report, mock.patch.object(bridge, "build_report", return_value="report") as build_report,
mock.patch.object(bridge, "reply") as reply, mock.patch.object(bridge, "reply") as reply,
): ):
@@ -238,6 +269,7 @@ class TriggerGateTests(unittest.TestCase):
build_report.assert_called_once_with( build_report.assert_called_once_with(
bridge.RequestInput(model="eRob70H50I-BHM-18CTC[V5]", quantity=10) bridge.RequestInput(model="eRob70H50I-BHM-18CTC[V5]", quantity=10)
) )
acknowledge.assert_called_once_with("client", "om_small")
reply.assert_called_once_with("client", "om_small", "report") reply.assert_called_once_with("client", "om_small", "report")
def test_expired_candidate_context_is_removed(self) -> None: def test_expired_candidate_context_is_removed(self) -> None:
@@ -313,5 +345,90 @@ class CandidateBatchTests(unittest.TestCase):
self.assertNotIn(model, report) self.assertNotIn(model, report)
class WorkdayConversionTests(unittest.TestCase):
def test_natural_day_conversion_rule(self) -> None:
expected = {
0: 0,
1: 0,
2: 1,
3: 2,
4: 3,
5: 3,
6: 4,
7: 5,
18: 13,
23: 16,
26: 18,
31: 22,
33: 23,
36: 25,
43: 30,
48: 34,
}
for natural_days, workdays in expected.items():
with self.subTest(natural_days=natural_days):
self.assertEqual(
bridge.natural_days_to_workdays(natural_days),
workdays,
)
def test_2026_official_holiday_and_makeup_workdays(self) -> None:
self.assertTrue(bridge.is_china_official_workday(date(2026, 9, 20)))
self.assertFalse(bridge.is_china_official_workday(date(2026, 9, 25)))
self.assertFalse(bridge.is_china_official_workday(date(2026, 10, 1)))
self.assertTrue(bridge.is_china_official_workday(date(2026, 10, 10)))
self.assertFalse(bridge.is_china_official_workday(date(2026, 10, 11)))
def test_workday_dates_match_validation_samples(self) -> None:
start = date(2026, 9, 2)
samples = {
18: date(2026, 9, 20),
23: date(2026, 9, 23),
26: date(2026, 9, 28),
16: date(2026, 9, 17),
31: date(2026, 10, 9),
33: date(2026, 10, 10),
36: date(2026, 10, 13),
43: date(2026, 10, 20),
48: date(2026, 10, 26),
}
for natural_days, expected_date in samples.items():
with self.subTest(natural_days=natural_days):
workdays = bridge.natural_days_to_workdays(natural_days)
self.assertEqual(
bridge.add_china_official_workdays(start, workdays),
expected_date,
)
def test_api_natural_days_drive_displayed_delivery_date(self) -> None:
payload = successful_payload()
summary = payload["data"]["交期汇总"]
summary["AM516预测交期"] = "2026-10-03"
summary["AM516总交期"] = 31
details = bridge.lead_time_details(
payload,
requested_quantity=10,
calculation_date=date(2026, 9, 2),
)
self.assertEqual(details["total_days"], 31)
self.assertEqual(details["workdays"], 22)
self.assertEqual(details["delivery_date"], "2026-10-09")
self.assertEqual(
details["prediction"],
"AM516预测交期2026-10-09原始31个自然日折算22个工作日",
)
def test_unconfigured_calendar_year_is_not_silently_estimated(self) -> None:
with self.assertRaisesRegex(
bridge.WorkdayCalendarUnavailableError,
"2027年国家法定工作日历未配置",
):
bridge.add_china_official_workdays(date(2026, 12, 31), 1)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()