diff --git a/capabilities/am516-delivery-prediction/docs/AM516自然日转工作日需求说明_给AR836_20260902.md b/capabilities/am516-delivery-prediction/docs/AM516自然日转工作日需求说明_给AR836_20260902.md index d52d313..142c855 100644 --- a/capabilities/am516-delivery-prediction/docs/AM516自然日转工作日需求说明_给AR836_20260902.md +++ b/capabilities/am516-delivery-prediction/docs/AM516自然日转工作日需求说明_给AR836_20260902.md @@ -5,7 +5,7 @@ **日期**:2026-09-02 **目的**:将 AM516 交期预测输出的自然日转换为工作日,并据此得到最终交货日期。 -**当前临时处理**:因 AR836 排期较晚,AM516 飞书机器人先在 API 返回后的输出层执行同一换算;不修改 AR836 API 原始数据。待 API 正式提供工作日口径后,应移除本地临时换算,避免重复转换。 +**2026-09-20 实现更新**:API 已返回 `AM516工作日交期` 和 `AM516最终交货日期`。本仓库机器人回复已改为直接使用这两个字段,移除本地临时换算及日期推算;运行端需同步本次改动后生效。下文保留为原始需求与验证记录。 ## 一、需求背景 diff --git a/capabilities/am516-delivery-prediction/docs/acceptance_samples.md b/capabilities/am516-delivery-prediction/docs/acceptance_samples.md index 206715f..166ef03 100644 --- a/capabilities/am516-delivery-prediction/docs/acceptance_samples.md +++ b/capabilities/am516-delivery-prediction/docs/acceptance_samples.md @@ -40,17 +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 +## Sample 1B | API Workdays And Final Delivery Date 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. +- read `data.交期汇总.AM516工作日交期` and `data.交期汇总.AM516最终交货日期` directly; +- when the API returns `8` and `2026-10-08`, output `AM516预测交期:8个工作日,最终交货日期:2026-10-08`; +- use the same workdays-and-date form in the fastest-candidate summary, initial and continuation candidate details, and newly appended record rows; +- ignore natural-day totals and the old `AM516预测交期` field when selecting the displayed result; do not recalculate workdays or dates locally; +- a valid API date in another calendar year remains usable without a local holiday calendar; +- accept zero workdays; reject negative, fractional, boolean, or non-numeric workday values and invalid dates; +- if either new field is missing or invalid, report the lead-time result as unavailable instead of falling back to old fields or estimating; +- choose the fastest candidate using the API workday count, even when the natural-day totals would rank candidates differently. ## Sample 1A | Continue With Other Candidates diff --git a/capabilities/am516-delivery-prediction/docs/runbook.md b/capabilities/am516-delivery-prediction/docs/runbook.md index fe4a12c..c7ac86c 100644 --- a/capabilities/am516-delivery-prediction/docs/runbook.md +++ b/capabilities/am516-delivery-prediction/docs/runbook.md @@ -10,8 +10,8 @@ 6. Use model recommendation rules to generate the complete ordered internal candidate pool. 7. Immediately add the Feishu `OnIt` (typing) reaction to the original message 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. +9. Read `data.交期汇总.AM516工作日交期` and `data.交期汇总.AM516最终交货日期` directly. Require a nonnegative whole-number workday count and a valid `YYYY-MM-DD` date; missing or invalid fields make the result unavailable. Do not convert natural days or recalculate dates locally. +10. Summarize the current batch with the API workdays and final delivery date, for example `AM516预测交期:8个工作日,最终交货日期:2026-10-08`; state how many candidates remain and invite the same sender to ask naturally for more when candidates remain. Newly appended records use the same form; historical records are unchanged. 11. Append a non-secret record. 12. Return Feishu-friendly text with the fixed disclaimer. 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 d4cd6cc..5d7943e 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 @@ -93,12 +93,10 @@ Do not use temporary curl, WebFetch, or any API method outside the controlled sc ## Output Selection -- 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. +- Read the workday count directly from `data.交期汇总.AM516工作日交期` and the corresponding final delivery date from `data.交期汇总.AM516最终交货日期`. +- Require a nonnegative whole-number workday count (including zero) and a valid `YYYY-MM-DD` date. Both must be returned by the API; missing or invalid values make the lead-time result unavailable. +- Display `8个工作日,最终交货日期:2026-10-08` consistently in the overall judgment, candidate details, and newly appended records. Rank the fastest candidate by the API workday count. +- Do not convert natural days, recalculate dates from the local query date, or fall back to the old `AM516预测交期` field. `AM516总交期` and `总交期` remain natural-day reference fields and are not shown in the reply. ## Candidate Batch Rule @@ -163,13 +161,13 @@ Use this field order for a successful Feishu report: 总体判断: - 现货候选:{fully covered candidate or none} - 最快候选型号:{fastest candidate} -- 最快候选交期:{calculated delivery date, original natural days, and converted workdays} +- 最快候选交期:{API workdays}个工作日,最终交货日期:{API final delivery date} - 是否完全覆盖数量:{yes / no / unavailable} - 主导因素:{main factor} 1. {model}({candidate type and reducer brand}) - 数量:{quantity}台 -- AM516预测交期:{calculated delivery date}(原始{N}个自然日,折算{W}个工作日) +- AM516预测交期:{API workdays}个工作日,最终交货日期:{API final delivery date} - 可用库存/覆盖情况:{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 5abb4a9..f5ce0c9 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 date, datetime, timedelta +from datetime import date, datetime from pathlib import Path from typing import Any @@ -82,40 +82,6 @@ CANDIDATE_CONTINUATION_TTL_SECONDS = 6 * 60 * 60 RECEIVED_REACTION_TYPE = "OnIt" -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 = { "还有吗", "还有呢", @@ -209,12 +175,6 @@ 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() @@ -536,52 +496,7 @@ def run_controlled_query(request: RequestInput) -> dict[str, Any]: return payload -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: +def whole_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(): @@ -593,9 +508,7 @@ def whole_natural_day_count(value: Any) -> int | 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 { @@ -635,31 +548,23 @@ def lead_time_details( if isinstance(main_factor, (str, int, float)) else "接口未返回,需人工复核" ) - predicted_date = summary.get("AM516预测交期") - total_days = whole_natural_day_count(summary.get("AM516总交期")) + total_days = whole_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) + total_days = whole_day_count(summary.get("总交期")) + workdays = whole_day_count(summary.get("AM516工作日交期")) + delivery_date = summary.get("AM516最终交货日期") + if isinstance(delivery_date, str) and re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", delivery_date): 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}(接口未返回自然日交期,未换算)" + date.fromisoformat(delivery_date) + except ValueError: + delivery_date = None + else: + delivery_date = None + if workdays is not None and delivery_date is not None: + prediction_value = f"{workdays}个工作日,最终交货日期:{delivery_date}" prediction = f"AM516预测交期:{prediction_value}" else: - prediction = "接口未返回可用交期汇总,需人工复核。" + prediction = "接口未返回有效工作日交期和最终交货日期,需人工复核。" prediction_value = None inventory_info = data.get("库存信息") @@ -738,7 +643,7 @@ def lead_time_details( "prediction_value": prediction_value, "total_days": total_days, "workdays": workdays, - "delivery_date": delivery_date.isoformat() if delivery_date else None, + "delivery_date": delivery_date, "main_factor": main_factor_text, "inventory": inventory_text, "available_inventory": ( @@ -936,7 +841,6 @@ 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 @@ -952,10 +856,9 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str: baseline = lead_time_details( baseline_payload, request.quantity, - calculation_date=calculation_date, ) if not baseline["has_lead_time"]: - raise RuntimeError("原型号接口未返回可用交期汇总,未生成候选结果。") + raise RuntimeError("原型号接口未返回有效工作日交期和最终交货日期,未生成候选结果。") candidates.append( { "index": 1, @@ -979,7 +882,6 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str: details = lead_time_details( payload, request.quantity, - calculation_date=calculation_date, ) return spec, details if details["has_lead_time"] else None diff --git a/tests/test_feishu_bot_bridge.py b/tests/test_feishu_bot_bridge.py index 86d7936..0c89772 100644 --- a/tests/test_feishu_bot_bridge.py +++ b/tests/test_feishu_bot_bridge.py @@ -3,7 +3,6 @@ from __future__ import annotations import sys import types import unittest -from datetime import date from types import SimpleNamespace from unittest import mock @@ -34,8 +33,9 @@ def successful_payload() -> dict[str, object]: "data": { "时间戳": "2026-08-31 12:00:00", "交期汇总": { - "AM516预测交期": "2026-09-01", "AM516总交期": 1, + "AM516工作日交期": 1, + "AM516最终交货日期": "2026-09-01", "AM516主导因素": "现货", }, "库存信息": {"可用库存(扣除了待产数量)": 100}, @@ -437,89 +437,120 @@ 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: +class ApiWorkdayOutputTests(unittest.TestCase): + def test_api_workdays_and_date_override_legacy_fields(self) -> None: payload = successful_payload() summary = payload["data"]["交期汇总"] summary["AM516预测交期"] = "2026-10-03" summary["AM516总交期"] = 31 + summary["AM516工作日交期"] = 8 + summary["AM516最终交货日期"] = "2026-10-08" - details = bridge.lead_time_details( - payload, - requested_quantity=10, - calculation_date=date(2026, 9, 2), - ) + details = bridge.lead_time_details(payload, requested_quantity=10) self.assertEqual(details["total_days"], 31) - self.assertEqual(details["workdays"], 22) - self.assertEqual(details["delivery_date"], "2026-10-09") + self.assertEqual(details["workdays"], 8) + self.assertEqual(details["delivery_date"], "2026-10-08") self.assertEqual( details["prediction"], - "AM516预测交期:2026-10-09(原始31个自然日,折算22个工作日)", + "AM516预测交期:8个工作日,最终交货日期:2026-10-08", ) - def test_unconfigured_calendar_year_is_not_silently_estimated(self) -> None: - with self.assertRaisesRegex( - bridge.WorkdayCalendarUnavailableError, - "2027年国家法定工作日历未配置", + def test_new_fields_work_without_natural_days_or_local_calendar(self) -> None: + payload = successful_payload() + summary = payload["data"]["交期汇总"] + del summary["AM516总交期"] + summary["AM516工作日交期"] = 8 + summary["AM516最终交货日期"] = "2027-01-15" + + details = bridge.lead_time_details(payload, requested_quantity=10) + + self.assertTrue(details["has_lead_time"]) + self.assertEqual(details["workdays"], 8) + self.assertEqual(details["delivery_date"], "2027-01-15") + + def test_zero_workdays_are_preserved(self) -> None: + payload = successful_payload() + payload["data"]["交期汇总"]["AM516工作日交期"] = 0 + + details = bridge.lead_time_details(payload, requested_quantity=10) + + self.assertTrue(details["has_lead_time"]) + self.assertEqual( + details["prediction_value"], "0个工作日,最终交货日期:2026-09-01" + ) + + def test_missing_new_fields_do_not_fall_back_to_old_fields(self) -> None: + for field in ("AM516工作日交期", "AM516最终交货日期"): + with self.subTest(field=field): + payload = successful_payload() + summary = payload["data"]["交期汇总"] + summary["AM516预测交期"] = "2026-09-01" + summary["总交期"] = 15 + del summary[field] + + details = bridge.lead_time_details(payload, requested_quantity=10) + + self.assertFalse(details["has_lead_time"]) + self.assertIsNone(details["prediction_value"]) + self.assertIn("未返回有效工作日交期和最终交货日期", details["prediction"]) + + def test_invalid_workdays_and_dates_are_not_displayed_as_valid(self) -> None: + invalid_values = { + "AM516工作日交期": [None, True, -1, 1.5, "8", float("nan"), float("inf")], + "AM516最终交货日期": [None, True, 20261008, "", "2026-02-30", "20261008"], + } + for field, values in invalid_values.items(): + for value in values: + with self.subTest(field=field, value=value): + payload = successful_payload() + payload["data"]["交期汇总"][field] = value + + details = bridge.lead_time_details(payload, requested_quantity=10) + + self.assertFalse(details["has_lead_time"]) + self.assertIsNone(details["prediction_value"]) + + def test_report_and_record_use_api_workdays_for_fastest_candidate(self) -> None: + request = bridge.RequestInput(model="eRob70H50I-FS-18CN[V5]", quantity=10) + fastest_model = bridge.generate_candidate_specs(request.model)[0]["model"] + + def query(query_request: bridge.RequestInput) -> dict[str, object]: + payload = successful_payload() + summary = payload["data"]["交期汇总"] + fastest = query_request.model == fastest_model + # Deliberately disagree with natural-day ranking to detect old conversion. + summary["AM516总交期"] = 31 if fastest else 1 + summary["AM516工作日交期"] = 8 if fastest else 18 + summary["AM516最终交货日期"] = "2026-10-08" if fastest else "2026-10-21" + return payload + + with ( + mock.patch.object(bridge, "run_controlled_query", side_effect=query), + mock.patch.object(bridge, "write_record") as write_record, ): - bridge.add_china_official_workdays(date(2026, 12, 31), 1) + report = bridge.build_report(request) + + record = bridge.render_record(*write_record.call_args.args) + self.assertIn(f"最快候选型号:{fastest_model}", report) + self.assertIn("最快候选交期:8个工作日,最终交货日期:2026-10-08", report) + for output in (report, record): + self.assertIn("8个工作日,最终交货日期:2026-10-08", output) + self.assertIn("18个工作日,最终交货日期:2026-10-21", output) + self.assertNotIn("自然日", output) + self.assertNotIn("折算", output) + + def test_baseline_missing_new_fields_stops_before_recording(self) -> None: + payload = successful_payload() + del payload["data"]["交期汇总"]["AM516工作日交期"] + request = bridge.RequestInput(model="eRob70H50I-FS-18CN[V5]", quantity=10) + with ( + mock.patch.object(bridge, "run_controlled_query", return_value=payload), + mock.patch.object(bridge, "write_record") as write_record, + ): + with self.assertRaisesRegex(RuntimeError, "工作日交期和最终交货日期"): + bridge.build_report(request) + write_record.assert_not_called() if __name__ == "__main__":