feat: convert AM516 lead times to workdays
This commit is contained in:
@@ -5,6 +5,8 @@
|
||||
**日期**:2026-09-02
|
||||
**目的**:将 AM516 交期预测输出的自然日转换为工作日,并据此得到最终交货日期。
|
||||
|
||||
**当前临时处理**:因 AR836 排期较晚,AM516 飞书机器人先在 API 返回后的输出层执行同一换算;不修改 AR836 API 原始数据。待 API 正式提供工作日口径后,应移除本地临时换算,避免重复转换。
|
||||
|
||||
## 一、需求背景
|
||||
|
||||
AM516 当前输出的是自然日交期。销售希望得到工作日口径的交期,即:
|
||||
|
||||
@@ -14,6 +14,7 @@ Expected:
|
||||
|
||||
- model parses successfully;
|
||||
- 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;
|
||||
- no temporary TLS-bypass parameter is required for the normal happy path;
|
||||
- API script runs;
|
||||
@@ -39,6 +40,18 @@ Expected:
|
||||
- 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 `—`;
|
||||
|
||||
## 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
|
||||
|
||||
Preconditions:
|
||||
@@ -59,6 +72,7 @@ is not required.
|
||||
|
||||
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 queries candidate positions 5-8 in the unchanged rule order;
|
||||
- the original model and candidate positions 1-4 are not queried or displayed again;
|
||||
@@ -83,6 +97,7 @@ eRob110H160I-FHM-18ET[V6]
|
||||
Expected:
|
||||
|
||||
- bot stays silent;
|
||||
- no acknowledgement emoji is sent;
|
||||
- no API call is made;
|
||||
- no record is appended.
|
||||
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
7. Run the controlled script for the original model baseline and only the first four candidate models.
|
||||
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.
|
||||
9. Append a non-secret record.
|
||||
10. Return Feishu-friendly text with the fixed disclaimer.
|
||||
7. Immediately reply with `👀` to acknowledge the valid request.
|
||||
8. Run the controlled script for the original model baseline and only the first four candidate models.
|
||||
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. 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
|
||||
|
||||
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.
|
||||
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 `不可用候选`.
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
@@ -44,8 +44,12 @@ Stop if:
|
||||
|
||||
- Enter the query workflow only when one message contains exactly one complete,
|
||||
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
|
||||
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.
|
||||
- After a valid message passes the gate, API or runtime failures may still return
|
||||
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
|
||||
|
||||
- Prefer `data.交期汇总.AM516预测交期` when the API returns it and AR51 confirms it is usable.
|
||||
- If AM516 fields are absent, use original API prediction and mark it as original algorithm reference.
|
||||
- If only total days are available, state the total days and ask for human review.
|
||||
- Prefer numeric `data.交期汇总.AM516总交期`; if it is absent, fall back to numeric `data.交期汇总.总交期`. Treat this value as the original natural-day lead time `N`.
|
||||
- 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`.
|
||||
- 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 `国办发明电〔2025〕7号`. 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
|
||||
|
||||
@@ -156,13 +163,13 @@ Use this field order for a successful Feishu report:
|
||||
总体判断:
|
||||
- 现货候选:{fully covered candidate or none}
|
||||
- 最快候选型号:{fastest candidate}
|
||||
- 最快候选交期:{predicted date and total days}
|
||||
- 最快候选交期:{calculated delivery date, original natural days, and converted workdays}
|
||||
- 是否完全覆盖数量:{yes / no / unavailable}
|
||||
- 主导因素:{main factor}
|
||||
|
||||
1. {model}({candidate type and reducer brand})
|
||||
- 数量:{quantity}台
|
||||
- AM516预测交期:{date and total days}
|
||||
- AM516预测交期:{calculated delivery date}(原始{N}个自然日,折算{W}个工作日)
|
||||
- 可用库存/覆盖情况:{summary-level inventory and coverage only}
|
||||
- 主导因素:{main factor}
|
||||
- 推荐理由:{rule-based recommendation reason}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from datetime import date
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
@@ -103,11 +104,16 @@ class TriggerGateTests(unittest.TestCase):
|
||||
with (
|
||||
mock.patch.object(bridge, "message_text", return_value="他怎么什么消息都回复啊"),
|
||||
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,
|
||||
):
|
||||
bridge.handle_message(object(), data)
|
||||
|
||||
build_report.assert_not_called()
|
||||
acknowledge.assert_not_called()
|
||||
reply.assert_not_called()
|
||||
|
||||
def test_valid_message_builds_and_replies(self) -> None:
|
||||
@@ -121,11 +127,16 @@ class TriggerGateTests(unittest.TestCase):
|
||||
"message_text",
|
||||
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, "reply") as reply,
|
||||
):
|
||||
bridge.handle_message("client", data)
|
||||
|
||||
acknowledge.assert_called_once_with("client", "om_valid")
|
||||
build_report.assert_called_once_with(
|
||||
bridge.RequestInput(model="eRob70H50I-BHM-18CTC[V5]", quantity=10)
|
||||
)
|
||||
@@ -177,11 +188,16 @@ class TriggerGateTests(unittest.TestCase):
|
||||
return_value="还有没有其他候选型号?",
|
||||
),
|
||||
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,
|
||||
):
|
||||
bridge.handle_message("client", data)
|
||||
|
||||
build_report.assert_not_called()
|
||||
acknowledge.assert_not_called()
|
||||
reply.assert_not_called()
|
||||
|
||||
def test_follow_up_continues_from_fifth_candidate_in_same_conversation(self) -> None:
|
||||
@@ -201,6 +217,10 @@ class TriggerGateTests(unittest.TestCase):
|
||||
"build_report",
|
||||
side_effect=["initial report", "continued report"],
|
||||
) as build_report,
|
||||
mock.patch.object(
|
||||
bridge,
|
||||
"reply_received_acknowledgement",
|
||||
) as acknowledge,
|
||||
mock.patch.object(bridge, "reply") as reply,
|
||||
):
|
||||
bridge.handle_message("client", initial_data)
|
||||
@@ -210,6 +230,13 @@ class TriggerGateTests(unittest.TestCase):
|
||||
build_report.call_args_list,
|
||||
[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(
|
||||
reply.call_args_list,
|
||||
[
|
||||
@@ -229,6 +256,10 @@ class TriggerGateTests(unittest.TestCase):
|
||||
"message_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, "reply") as reply,
|
||||
):
|
||||
@@ -238,6 +269,7 @@ class TriggerGateTests(unittest.TestCase):
|
||||
build_report.assert_called_once_with(
|
||||
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")
|
||||
|
||||
def test_expired_candidate_context_is_removed(self) -> None:
|
||||
@@ -313,5 +345,90 @@ class CandidateBatchTests(unittest.TestCase):
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user