diff --git a/capabilities/am516-delivery-prediction/docs/AM516自然日转工作日需求说明_给AR836_20260902.md b/capabilities/am516-delivery-prediction/docs/AM516自然日转工作日需求说明_给AR836_20260902.md index 724c1f8..d52d313 100644 --- a/capabilities/am516-delivery-prediction/docs/AM516自然日转工作日需求说明_给AR836_20260902.md +++ b/capabilities/am516-delivery-prediction/docs/AM516自然日转工作日需求说明_给AR836_20260902.md @@ -5,6 +5,8 @@ **日期**:2026-09-02 **目的**:将 AM516 交期预测输出的自然日转换为工作日,并据此得到最终交货日期。 +**当前临时处理**:因 AR836 排期较晚,AM516 飞书机器人先在 API 返回后的输出层执行同一换算;不修改 AR836 API 原始数据。待 API 正式提供工作日口径后,应移除本地临时换算,避免重复转换。 + ## 一、需求背景 AM516 当前输出的是自然日交期。销售希望得到工作日口径的交期,即: diff --git a/capabilities/am516-delivery-prediction/docs/acceptance_samples.md b/capabilities/am516-delivery-prediction/docs/acceptance_samples.md index bea2b74..56628fc 100644 --- a/capabilities/am516-delivery-prediction/docs/acceptance_samples.md +++ b/capabilities/am516-delivery-prediction/docs/acceptance_samples.md @@ -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. diff --git a/capabilities/am516-delivery-prediction/docs/runbook.md b/capabilities/am516-delivery-prediction/docs/runbook.md index 7eebc8b..eb7e4da 100644 --- a/capabilities/am516-delivery-prediction/docs/runbook.md +++ b/capabilities/am516-delivery-prediction/docs/runbook.md @@ -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 diff --git a/capabilities/am516-delivery-prediction/skills/joint-module-leadtime-report/SKILL.md b/capabilities/am516-delivery-prediction/skills/joint-module-leadtime-report/SKILL.md index 3efc40f..6fe8851 100644 --- a/capabilities/am516-delivery-prediction/skills/joint-module-leadtime-report/SKILL.md +++ b/capabilities/am516-delivery-prediction/skills/joint-module-leadtime-report/SKILL.md @@ -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} diff --git a/shared/runtime/feishu_bot_bridge.py b/shared/runtime/feishu_bot_bridge.py index 4370ae7..46ec582 100644 --- a/shared/runtime/feishu_bot_bridge.py +++ b/shared/runtime/feishu_bot_bridge.py @@ -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) diff --git a/tests/test_feishu_bot_bridge.py b/tests/test_feishu_bot_bridge.py index d6758dd..2b4a18e 100644 --- a/tests/test_feishu_bot_bridge.py +++ b/tests/test_feishu_bot_bridge.py @@ -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()