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

View File

@@ -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.

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.
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

View File

@@ -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 `国办发明电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
@@ -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}

View File

@@ -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)
)
# 国办发明电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 = {
"还有吗",
"还有呢",
@@ -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)

View File

@@ -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()