feat: convert AM516 lead times to workdays
This commit is contained in:
@@ -5,6 +5,8 @@
|
|||||||
**日期**:2026-09-02
|
**日期**:2026-09-02
|
||||||
**目的**:将 AM516 交期预测输出的自然日转换为工作日,并据此得到最终交货日期。
|
**目的**:将 AM516 交期预测输出的自然日转换为工作日,并据此得到最终交货日期。
|
||||||
|
|
||||||
|
**当前临时处理**:因 AR836 排期较晚,AM516 飞书机器人先在 API 返回后的输出层执行同一换算;不修改 AR836 API 原始数据。待 API 正式提供工作日口径后,应移除本地临时换算,避免重复转换。
|
||||||
|
|
||||||
## 一、需求背景
|
## 一、需求背景
|
||||||
|
|
||||||
AM516 当前输出的是自然日交期。销售希望得到工作日口径的交期,即:
|
AM516 当前输出的是自然日交期。销售希望得到工作日口径的交期,即:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Expected:
|
|||||||
|
|
||||||
- model parses successfully;
|
- model parses successfully;
|
||||||
- quantity is accepted;
|
- quantity is accepted;
|
||||||
|
- the bot immediately replies with `👀` after the valid trigger is accepted and before the API query starts;
|
||||||
- the controlled script uses the macOS system trust store and keeps TLS verification enabled;
|
- the controlled script uses the macOS system trust store and keeps TLS verification enabled;
|
||||||
- no temporary TLS-bypass parameter is required for the normal happy path;
|
- no temporary TLS-bypass parameter is required for the normal happy path;
|
||||||
- API script runs;
|
- API script runs;
|
||||||
@@ -39,6 +40,18 @@ Expected:
|
|||||||
- record-table columns follow this order: 序号、记录时间、型号、推荐类型、差异项、API状态、AM516预测交期、可用库存、主导因素;
|
- record-table columns follow this order: 序号、记录时间、型号、推荐类型、差异项、API状态、AM516预测交期、可用库存、主导因素;
|
||||||
- unavailable candidates remain in their original rule order and are recorded as `❌ 不存在` with unavailable result fields shown as `—`;
|
- unavailable candidates remain in their original rule order and are recorded as `❌ 不存在` with unavailable result fields shown as `—`;
|
||||||
|
|
||||||
|
## Sample 1B | Natural Days To Workdays
|
||||||
|
|
||||||
|
Rules and expected results:
|
||||||
|
|
||||||
|
- treat `AM516总交期`, or numeric `总交期` when the AM516 field is absent, as the original natural-day lead time `N`;
|
||||||
|
- convert `N` with the approved quotient-and-remainder rule, including `r = 4` as one rest day and `r = 0` as no remainder workday;
|
||||||
|
- exclude the local query date itself, then count the converted workdays using the configured China statutory workday calendar;
|
||||||
|
- statutory holidays are excluded and official makeup weekend workdays are included;
|
||||||
|
- for a query date of `2026-09-02`, `31` natural days convert to `22` workdays and produce `2026-10-09`;
|
||||||
|
- output `2026-10-09(原始31个自然日,折算22个工作日)` instead of adding a separate National Day exception;
|
||||||
|
- when counting reaches an unconfigured calendar year, do not silently fall back to a Monday-Friday estimate.
|
||||||
|
|
||||||
## Sample 1A | Continue With Other Candidates
|
## Sample 1A | Continue With Other Candidates
|
||||||
|
|
||||||
Preconditions:
|
Preconditions:
|
||||||
@@ -59,6 +72,7 @@ is not required.
|
|||||||
|
|
||||||
Expected:
|
Expected:
|
||||||
|
|
||||||
|
- the bot immediately replies with `👀` after the continuation and active-context gates pass;
|
||||||
- the bot reuses the active model and quantity only for that same sender and chat;
|
- the bot reuses the active model and quantity only for that same sender and chat;
|
||||||
- the bot queries candidate positions 5-8 in the unchanged rule order;
|
- the bot queries candidate positions 5-8 in the unchanged rule order;
|
||||||
- the original model and candidate positions 1-4 are not queried or displayed again;
|
- the original model and candidate positions 1-4 are not queried or displayed again;
|
||||||
@@ -83,6 +97,7 @@ eRob110H160I-FHM-18ET[V6]
|
|||||||
Expected:
|
Expected:
|
||||||
|
|
||||||
- bot stays silent;
|
- bot stays silent;
|
||||||
|
- no acknowledgement emoji is sent;
|
||||||
- no API call is made;
|
- no API call is made;
|
||||||
- no record is appended.
|
- no record is appended.
|
||||||
|
|
||||||
|
|||||||
@@ -8,20 +8,22 @@
|
|||||||
4. Confirm a separate trusted AR51 approval record explicitly covers `api.zeroerr-agent.com`, the AM516 use case, and API-key use.
|
4. Confirm a separate trusted AR51 approval record explicitly covers `api.zeroerr-agent.com`, the AM516 use case, and API-key use.
|
||||||
5. If that domain-and-permission record is absent, stop before any outbound request; a general functional-test approval is insufficient by itself.
|
5. If that domain-and-permission record is absent, stop before any outbound request; a general functional-test approval is insufficient by itself.
|
||||||
6. Use model recommendation rules to generate the complete ordered internal candidate pool.
|
6. Use model recommendation rules to generate the complete ordered internal candidate pool.
|
||||||
7. Run the controlled script for the original model baseline and only the first four candidate models.
|
7. Immediately reply with `👀` to acknowledge the valid request.
|
||||||
8. Summarize the current batch, state how many candidates remain, and invite the same sender to ask naturally for more when candidates remain; exact wording is not required.
|
8. Run the controlled script for the original model baseline and only the first four candidate models.
|
||||||
9. Append a non-secret record.
|
9. Convert each numeric natural-day lead time to workdays with the approved quotient-and-remainder rule, then count those workdays from the local query date through the configured China statutory workday calendar. The query date itself is excluded.
|
||||||
10. Return Feishu-friendly text with the fixed disclaimer.
|
10. Summarize the current batch, including the original natural days, converted workdays, and calculated delivery date; state how many candidates remain and invite the same sender to ask naturally for more when candidates remain.
|
||||||
|
11. Append a non-secret record.
|
||||||
|
12. Return Feishu-friendly text with the fixed disclaimer.
|
||||||
|
|
||||||
## Candidate Continuation
|
## Candidate Continuation
|
||||||
|
|
||||||
1. Accept a clear request for more candidates only from the same sender in the same Feishu chat as the active initial query. Equivalent expressions such as `还有吗`、`有没有别的`、`再推荐几个`、`继续`、`下一批` or `more candidates` are valid; no exact phrase is required.
|
1. Accept a clear request for more candidates only from the same sender in the same Feishu chat as the active initial query. Equivalent expressions such as `还有吗`、`有没有别的`、`再推荐几个`、`继续`、`下一批` or `more candidates` are valid; no exact phrase is required.
|
||||||
2. The in-memory continuation context expires after six hours and is cleared by a bridge restart. If no matching context exists, stay silent and require a new complete model-and-quantity query.
|
2. The in-memory continuation context expires after six hours and is cleared by a bridge restart. If no matching context exists, stay silent and require a new complete model-and-quantity query.
|
||||||
3. Query the next four unqueried rule candidates; do not re-query the original baseline or any earlier candidate.
|
3. Immediately reply with `👀`, then query the next four unqueried rule candidates; do not re-query the original baseline or any earlier candidate.
|
||||||
4. Missing or unsupported candidates still consume their rule-order positions and remain under `不可用候选`.
|
4. Missing or unsupported candidates still consume their rule-order positions and remain under `不可用候选`.
|
||||||
5. Append one record block for the models queried in this continuation batch, then state whether more candidates remain.
|
5. Append one record block for the models queried in this continuation batch, then state whether more candidates remain.
|
||||||
6. A new complete model-and-quantity request replaces the earlier continuation context for that sender and chat.
|
6. A new complete model-and-quantity request replaces the earlier continuation context for that sender and chat.
|
||||||
7. Keep the dual gate of continuation intent plus valid context: unrelated text such as `more interesting` or `继续查询英语例句` must not trigger the old query.
|
7. Keep the dual gate of continuation intent plus valid context: unrelated text such as `more interesting` or `继续查询英语例句` must not trigger the old query or acknowledgement.
|
||||||
|
|
||||||
## Approved Script
|
## Approved Script
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,12 @@ Stop if:
|
|||||||
|
|
||||||
- Enter the query workflow only when one message contains exactly one complete,
|
- Enter the query workflow only when one message contains exactly one complete,
|
||||||
rule-valid eRob model and exactly one positive quantity.
|
rule-valid eRob model and exactly one positive quantity.
|
||||||
|
- After a valid initial query or valid same-conversation candidate continuation
|
||||||
|
passes this gate, immediately reply with `👀` before starting the API query so
|
||||||
|
the sender knows the request was received.
|
||||||
- If the model or quantity is missing, invalid, multiple, or ambiguous, stay
|
- If the model or quantity is missing, invalid, multiple, or ambiguous, stay
|
||||||
silent: send no Feishu reply, make no API request, and append no record.
|
silent: send no acknowledgement or other Feishu reply, make no API request,
|
||||||
|
and append no record.
|
||||||
- An @mention by itself does not bypass this content gate.
|
- An @mention by itself does not bypass this content gate.
|
||||||
- After a valid message passes the gate, API or runtime failures may still return
|
- After a valid message passes the gate, API or runtime failures may still return
|
||||||
the bounded internal error response defined by this capability.
|
the bounded internal error response defined by this capability.
|
||||||
@@ -89,9 +93,12 @@ Do not use temporary curl, WebFetch, or any API method outside the controlled sc
|
|||||||
|
|
||||||
## Output Selection
|
## Output Selection
|
||||||
|
|
||||||
- Prefer `data.交期汇总.AM516预测交期` when the API returns it and AR51 confirms it is usable.
|
- Prefer numeric `data.交期汇总.AM516总交期`; if it is absent, fall back to numeric `data.交期汇总.总交期`. Treat this value as the original natural-day lead time `N`.
|
||||||
- If AM516 fields are absent, use original API prediction and mark it as original algorithm reference.
|
- Convert `N` with `(q, r) = divmod(N, 7)`: `r = 0` gives `q × 5`; `1 ≤ r ≤ 4` gives `q × 5 + MAX(r - 1, 0)`; `5 ≤ r ≤ 6` gives `q × 5 + r - 2`. The result is workday lead time `W`.
|
||||||
- If only total days are available, state the total days and ask for human review.
|
- Starting from the local query date, exclude the query date itself and count forward `W` workdays using the configured China statutory calendar. Statutory holidays are not workdays, ordinary Saturdays and Sundays are not workdays, and official makeup Saturdays or Sundays are workdays.
|
||||||
|
- The configured 2026 calendar follows `国办发明电〔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
|
## Candidate Batch Rule
|
||||||
|
|
||||||
@@ -156,13 +163,13 @@ Use this field order for a successful Feishu report:
|
|||||||
总体判断:
|
总体判断:
|
||||||
- 现货候选:{fully covered candidate or none}
|
- 现货候选:{fully covered candidate or none}
|
||||||
- 最快候选型号:{fastest candidate}
|
- 最快候选型号:{fastest candidate}
|
||||||
- 最快候选交期:{predicted date and total days}
|
- 最快候选交期:{calculated delivery date, original natural days, and converted workdays}
|
||||||
- 是否完全覆盖数量:{yes / no / unavailable}
|
- 是否完全覆盖数量:{yes / no / unavailable}
|
||||||
- 主导因素:{main factor}
|
- 主导因素:{main factor}
|
||||||
|
|
||||||
1. {model}({candidate type and reducer brand})
|
1. {model}({candidate type and reducer brand})
|
||||||
- 数量:{quantity}台
|
- 数量:{quantity}台
|
||||||
- AM516预测交期:{date and total days}
|
- AM516预测交期:{calculated delivery date}(原始{N}个自然日,折算{W}个工作日)
|
||||||
- 可用库存/覆盖情况:{summary-level inventory and coverage only}
|
- 可用库存/覆盖情况:{summary-level inventory and coverage only}
|
||||||
- 主导因素:{main factor}
|
- 主导因素:{main factor}
|
||||||
- 推荐理由:{rule-based recommendation reason}
|
- 推荐理由:{rule-based recommendation reason}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import date, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -79,6 +79,43 @@ ALLOWED_VERSIONS = {
|
|||||||
}
|
}
|
||||||
CANDIDATE_BATCH_SIZE = 4
|
CANDIDATE_BATCH_SIZE = 4
|
||||||
CANDIDATE_CONTINUATION_TTL_SECONDS = 6 * 60 * 60
|
CANDIDATE_CONTINUATION_TTL_SECONDS = 6 * 60 * 60
|
||||||
|
RECEIVED_ACKNOWLEDGEMENT = "👀"
|
||||||
|
|
||||||
|
|
||||||
|
def _inclusive_date_range(start: date, end: date) -> frozenset[date]:
|
||||||
|
return frozenset(
|
||||||
|
start + timedelta(days=offset)
|
||||||
|
for offset in range((end - start).days + 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 国办发明电〔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 = {
|
CANDIDATE_CONTINUATION_SHORT_PHRASES = {
|
||||||
"还有吗",
|
"还有吗",
|
||||||
"还有呢",
|
"还有呢",
|
||||||
@@ -172,6 +209,12 @@ class CandidateUnavailableError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WorkdayCalendarUnavailableError(ValueError):
|
||||||
|
def __init__(self, year: int) -> None:
|
||||||
|
self.year = year
|
||||||
|
super().__init__(f"{year}年国家法定工作日历未配置")
|
||||||
|
|
||||||
|
|
||||||
_candidate_continuations: dict[str, CandidateContinuation] = {}
|
_candidate_continuations: dict[str, CandidateContinuation] = {}
|
||||||
_candidate_continuation_lock = threading.Lock()
|
_candidate_continuation_lock = threading.Lock()
|
||||||
|
|
||||||
@@ -493,13 +536,74 @@ def run_controlled_query(request: RequestInput) -> dict[str, Any]:
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def lead_time_details(payload: dict[str, Any], requested_quantity: int) -> dict[str, Any]:
|
def natural_days_to_workdays(natural_days: int) -> int:
|
||||||
|
"""Convert natural days with AR51's fixed quotient-and-remainder rule."""
|
||||||
|
if isinstance(natural_days, bool) or not isinstance(natural_days, int):
|
||||||
|
raise TypeError("自然日交期必须是整数")
|
||||||
|
if natural_days < 0:
|
||||||
|
raise ValueError("自然日交期不能小于0")
|
||||||
|
|
||||||
|
full_weeks, remainder = divmod(natural_days, 7)
|
||||||
|
if remainder == 0:
|
||||||
|
remainder_workdays = 0
|
||||||
|
elif remainder <= 4:
|
||||||
|
remainder_workdays = max(remainder - 1, 0)
|
||||||
|
else:
|
||||||
|
remainder_workdays = remainder - 2
|
||||||
|
return full_weeks * 5 + remainder_workdays
|
||||||
|
|
||||||
|
|
||||||
|
def is_china_official_workday(day: date) -> bool:
|
||||||
|
holidays = CHINA_OFFICIAL_HOLIDAYS.get(day.year)
|
||||||
|
makeup_workdays = CHINA_OFFICIAL_MAKEUP_WORKDAYS.get(day.year)
|
||||||
|
if holidays is None or makeup_workdays is None:
|
||||||
|
raise WorkdayCalendarUnavailableError(day.year)
|
||||||
|
if day in makeup_workdays:
|
||||||
|
return True
|
||||||
|
if day in holidays:
|
||||||
|
return False
|
||||||
|
return day.weekday() < 5
|
||||||
|
|
||||||
|
|
||||||
|
def add_china_official_workdays(start: date, workdays: int) -> date:
|
||||||
|
"""Count official workdays after start; the start date itself is excluded."""
|
||||||
|
if isinstance(workdays, bool) or not isinstance(workdays, int):
|
||||||
|
raise TypeError("工作日交期必须是整数")
|
||||||
|
if workdays < 0:
|
||||||
|
raise ValueError("工作日交期不能小于0")
|
||||||
|
|
||||||
|
current = start
|
||||||
|
counted = 0
|
||||||
|
while counted < workdays:
|
||||||
|
current += timedelta(days=1)
|
||||||
|
if is_china_official_workday(current):
|
||||||
|
counted += 1
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def whole_natural_day_count(value: Any) -> int | None:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
return None
|
||||||
|
if isinstance(value, float) and not value.is_integer():
|
||||||
|
return None
|
||||||
|
result = int(value)
|
||||||
|
return result if result >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def lead_time_details(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
requested_quantity: int,
|
||||||
|
calculation_date: date | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
calculation_date = calculation_date or datetime.now().astimezone().date()
|
||||||
data = payload.get("data")
|
data = payload.get("data")
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return {
|
return {
|
||||||
"prediction": "接口未返回可用交期汇总,需人工复核。",
|
"prediction": "接口未返回可用交期汇总,需人工复核。",
|
||||||
"prediction_value": None,
|
"prediction_value": None,
|
||||||
"total_days": None,
|
"total_days": None,
|
||||||
|
"workdays": None,
|
||||||
|
"delivery_date": None,
|
||||||
"main_factor": "接口未返回,需人工复核",
|
"main_factor": "接口未返回,需人工复核",
|
||||||
"inventory": "接口未返回可用库存汇总,需人工复核。",
|
"inventory": "接口未返回可用库存汇总,需人工复核。",
|
||||||
"available_inventory": None,
|
"available_inventory": None,
|
||||||
@@ -514,6 +618,8 @@ def lead_time_details(payload: dict[str, Any], requested_quantity: int) -> dict[
|
|||||||
"prediction": "接口未返回可用交期汇总,需人工复核。",
|
"prediction": "接口未返回可用交期汇总,需人工复核。",
|
||||||
"prediction_value": None,
|
"prediction_value": None,
|
||||||
"total_days": None,
|
"total_days": None,
|
||||||
|
"workdays": None,
|
||||||
|
"delivery_date": None,
|
||||||
"main_factor": "接口未返回,需人工复核",
|
"main_factor": "接口未返回,需人工复核",
|
||||||
"inventory": "接口未返回可用库存汇总,需人工复核。",
|
"inventory": "接口未返回可用库存汇总,需人工复核。",
|
||||||
"available_inventory": None,
|
"available_inventory": None,
|
||||||
@@ -530,14 +636,28 @@ def lead_time_details(payload: dict[str, Any], requested_quantity: int) -> dict[
|
|||||||
else "接口未返回,需人工复核"
|
else "接口未返回,需人工复核"
|
||||||
)
|
)
|
||||||
predicted_date = summary.get("AM516预测交期")
|
predicted_date = summary.get("AM516预测交期")
|
||||||
total_days = summary.get("AM516总交期", summary.get("总交期"))
|
total_days = whole_natural_day_count(summary.get("AM516总交期"))
|
||||||
if isinstance(predicted_date, (str, int, float)):
|
if total_days is None:
|
||||||
days_suffix = f"({total_days}天)" if isinstance(total_days, (int, float)) else ""
|
total_days = whole_natural_day_count(summary.get("总交期"))
|
||||||
prediction = f"AM516预测交期:{predicted_date}{days_suffix}"
|
workdays: int | None = None
|
||||||
prediction_value = str(predicted_date)
|
delivery_date: date | None = None
|
||||||
elif isinstance(total_days, (int, float)):
|
if total_days is not None:
|
||||||
prediction = f"原算法总交期参考:{total_days}天"
|
workdays = natural_days_to_workdays(total_days)
|
||||||
prediction_value = f"{total_days}天"
|
try:
|
||||||
|
delivery_date = add_china_official_workdays(calculation_date, workdays)
|
||||||
|
except WorkdayCalendarUnavailableError as exc:
|
||||||
|
prediction_value = (
|
||||||
|
f"【{exc}】(原始{total_days}个自然日,折算{workdays}个工作日)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
prediction_value = (
|
||||||
|
f"{delivery_date.isoformat()}"
|
||||||
|
f"(原始{total_days}个自然日,折算{workdays}个工作日)"
|
||||||
|
)
|
||||||
|
prediction = f"AM516预测交期:{prediction_value}"
|
||||||
|
elif isinstance(predicted_date, (str, int, float)):
|
||||||
|
prediction_value = f"{predicted_date}(接口未返回自然日交期,未换算)"
|
||||||
|
prediction = f"AM516预测交期:{prediction_value}"
|
||||||
else:
|
else:
|
||||||
prediction = "接口未返回可用交期汇总,需人工复核。"
|
prediction = "接口未返回可用交期汇总,需人工复核。"
|
||||||
prediction_value = None
|
prediction_value = None
|
||||||
@@ -616,7 +736,9 @@ def lead_time_details(payload: dict[str, Any], requested_quantity: int) -> dict[
|
|||||||
return {
|
return {
|
||||||
"prediction": prediction,
|
"prediction": prediction,
|
||||||
"prediction_value": prediction_value,
|
"prediction_value": prediction_value,
|
||||||
"total_days": total_days if isinstance(total_days, (int, float)) else None,
|
"total_days": total_days,
|
||||||
|
"workdays": workdays,
|
||||||
|
"delivery_date": delivery_date.isoformat() if delivery_date else None,
|
||||||
"main_factor": main_factor_text,
|
"main_factor": main_factor_text,
|
||||||
"inventory": inventory_text,
|
"inventory": inventory_text,
|
||||||
"available_inventory": (
|
"available_inventory": (
|
||||||
@@ -642,8 +764,8 @@ def overall_judgment_lines(candidates: list[dict[str, Any]]) -> list[str]:
|
|||||||
fastest = min(
|
fastest = min(
|
||||||
available,
|
available,
|
||||||
key=lambda candidate: (
|
key=lambda candidate: (
|
||||||
candidate["details"]["total_days"]
|
candidate["details"]["workdays"]
|
||||||
if candidate["details"]["total_days"] is not None
|
if candidate["details"]["workdays"] is not None
|
||||||
else float("inf")
|
else float("inf")
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -814,6 +936,7 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
|
|||||||
if candidate_offset < 0:
|
if candidate_offset < 0:
|
||||||
raise InputError("候选批次位置无效,请重新发起型号和数量查询。")
|
raise InputError("候选批次位置无效,请重新发起型号和数量查询。")
|
||||||
|
|
||||||
|
calculation_date = datetime.now().astimezone().date()
|
||||||
all_candidate_specs = generate_candidate_specs(request.model)
|
all_candidate_specs = generate_candidate_specs(request.model)
|
||||||
candidate_specs = all_candidate_specs[
|
candidate_specs = all_candidate_specs[
|
||||||
candidate_offset : candidate_offset + CANDIDATE_BATCH_SIZE
|
candidate_offset : candidate_offset + CANDIDATE_BATCH_SIZE
|
||||||
@@ -826,7 +949,11 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
|
|||||||
baseline: dict[str, Any] | None = None
|
baseline: dict[str, Any] | None = None
|
||||||
if is_initial_batch:
|
if is_initial_batch:
|
||||||
baseline_payload = run_controlled_query(request)
|
baseline_payload = run_controlled_query(request)
|
||||||
baseline = lead_time_details(baseline_payload, request.quantity)
|
baseline = lead_time_details(
|
||||||
|
baseline_payload,
|
||||||
|
request.quantity,
|
||||||
|
calculation_date=calculation_date,
|
||||||
|
)
|
||||||
if not baseline["has_lead_time"]:
|
if not baseline["has_lead_time"]:
|
||||||
raise RuntimeError("原型号接口未返回可用交期汇总,未生成候选结果。")
|
raise RuntimeError("原型号接口未返回可用交期汇总,未生成候选结果。")
|
||||||
candidates.append(
|
candidates.append(
|
||||||
@@ -849,7 +976,11 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
|
|||||||
payload = run_controlled_query(RequestInput(spec["model"], request.quantity))
|
payload = run_controlled_query(RequestInput(spec["model"], request.quantity))
|
||||||
except CandidateUnavailableError:
|
except CandidateUnavailableError:
|
||||||
return spec, None
|
return spec, None
|
||||||
details = lead_time_details(payload, request.quantity)
|
details = lead_time_details(
|
||||||
|
payload,
|
||||||
|
request.quantity,
|
||||||
|
calculation_date=calculation_date,
|
||||||
|
)
|
||||||
return spec, details if details["has_lead_time"] else None
|
return spec, details if details["has_lead_time"] else None
|
||||||
|
|
||||||
if candidate_specs:
|
if candidate_specs:
|
||||||
@@ -1029,6 +1160,35 @@ def reply(client: lark.Client, message_id: str, text: str) -> None:
|
|||||||
logging.info("Feishu reply sent: message_id=%s parts=%s", message_id, len(post_contents))
|
logging.info("Feishu reply sent: message_id=%s parts=%s", message_id, len(post_contents))
|
||||||
|
|
||||||
|
|
||||||
|
def reply_received_acknowledgement(client: lark.Client, message_id: str) -> None:
|
||||||
|
request = (
|
||||||
|
lark.im.v1.ReplyMessageRequest.builder()
|
||||||
|
.message_id(message_id)
|
||||||
|
.request_body(
|
||||||
|
lark.im.v1.ReplyMessageRequestBody.builder()
|
||||||
|
.msg_type("text")
|
||||||
|
.content(
|
||||||
|
json.dumps(
|
||||||
|
{"text": RECEIVED_ACKNOWLEDGEMENT},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.uuid(f"{message_id[:44]}-ack")
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
response = client.im.v1.message.reply(request)
|
||||||
|
if not response.success():
|
||||||
|
logging.error(
|
||||||
|
"Feishu acknowledgement failed: code=%s message_id=%s",
|
||||||
|
response.code,
|
||||||
|
message_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
logging.info("Feishu acknowledgement sent: message_id=%s", message_id)
|
||||||
|
|
||||||
|
|
||||||
_seen_message_ids: set[str] = set()
|
_seen_message_ids: set[str] = set()
|
||||||
_seen_message_lock = threading.Lock()
|
_seen_message_lock = threading.Lock()
|
||||||
|
|
||||||
@@ -1055,6 +1215,8 @@ def handle_message(client: lark.Client, data: lark.im.v1.P2ImMessageReceiveV1) -
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
reply_received_acknowledgement(client, message_id)
|
||||||
|
|
||||||
if continuation_state is None:
|
if continuation_state is None:
|
||||||
clear_candidate_continuation(key)
|
clear_candidate_continuation(key)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import date
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
@@ -103,11 +104,16 @@ class TriggerGateTests(unittest.TestCase):
|
|||||||
with (
|
with (
|
||||||
mock.patch.object(bridge, "message_text", return_value="他怎么什么消息都回复啊"),
|
mock.patch.object(bridge, "message_text", return_value="他怎么什么消息都回复啊"),
|
||||||
mock.patch.object(bridge, "build_report") as build_report,
|
mock.patch.object(bridge, "build_report") as build_report,
|
||||||
|
mock.patch.object(
|
||||||
|
bridge,
|
||||||
|
"reply_received_acknowledgement",
|
||||||
|
) as acknowledge,
|
||||||
mock.patch.object(bridge, "reply") as reply,
|
mock.patch.object(bridge, "reply") as reply,
|
||||||
):
|
):
|
||||||
bridge.handle_message(object(), data)
|
bridge.handle_message(object(), data)
|
||||||
|
|
||||||
build_report.assert_not_called()
|
build_report.assert_not_called()
|
||||||
|
acknowledge.assert_not_called()
|
||||||
reply.assert_not_called()
|
reply.assert_not_called()
|
||||||
|
|
||||||
def test_valid_message_builds_and_replies(self) -> None:
|
def test_valid_message_builds_and_replies(self) -> None:
|
||||||
@@ -121,11 +127,16 @@ class TriggerGateTests(unittest.TestCase):
|
|||||||
"message_text",
|
"message_text",
|
||||||
return_value="eRob70H50I-BHM-18CTC[V5] 10台",
|
return_value="eRob70H50I-BHM-18CTC[V5] 10台",
|
||||||
),
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
bridge,
|
||||||
|
"reply_received_acknowledgement",
|
||||||
|
) as acknowledge,
|
||||||
mock.patch.object(bridge, "build_report", return_value="report") as build_report,
|
mock.patch.object(bridge, "build_report", return_value="report") as build_report,
|
||||||
mock.patch.object(bridge, "reply") as reply,
|
mock.patch.object(bridge, "reply") as reply,
|
||||||
):
|
):
|
||||||
bridge.handle_message("client", data)
|
bridge.handle_message("client", data)
|
||||||
|
|
||||||
|
acknowledge.assert_called_once_with("client", "om_valid")
|
||||||
build_report.assert_called_once_with(
|
build_report.assert_called_once_with(
|
||||||
bridge.RequestInput(model="eRob70H50I-BHM-18CTC[V5]", quantity=10)
|
bridge.RequestInput(model="eRob70H50I-BHM-18CTC[V5]", quantity=10)
|
||||||
)
|
)
|
||||||
@@ -177,11 +188,16 @@ class TriggerGateTests(unittest.TestCase):
|
|||||||
return_value="还有没有其他候选型号?",
|
return_value="还有没有其他候选型号?",
|
||||||
),
|
),
|
||||||
mock.patch.object(bridge, "build_report") as build_report,
|
mock.patch.object(bridge, "build_report") as build_report,
|
||||||
|
mock.patch.object(
|
||||||
|
bridge,
|
||||||
|
"reply_received_acknowledgement",
|
||||||
|
) as acknowledge,
|
||||||
mock.patch.object(bridge, "reply") as reply,
|
mock.patch.object(bridge, "reply") as reply,
|
||||||
):
|
):
|
||||||
bridge.handle_message("client", data)
|
bridge.handle_message("client", data)
|
||||||
|
|
||||||
build_report.assert_not_called()
|
build_report.assert_not_called()
|
||||||
|
acknowledge.assert_not_called()
|
||||||
reply.assert_not_called()
|
reply.assert_not_called()
|
||||||
|
|
||||||
def test_follow_up_continues_from_fifth_candidate_in_same_conversation(self) -> None:
|
def test_follow_up_continues_from_fifth_candidate_in_same_conversation(self) -> None:
|
||||||
@@ -201,6 +217,10 @@ class TriggerGateTests(unittest.TestCase):
|
|||||||
"build_report",
|
"build_report",
|
||||||
side_effect=["initial report", "continued report"],
|
side_effect=["initial report", "continued report"],
|
||||||
) as build_report,
|
) as build_report,
|
||||||
|
mock.patch.object(
|
||||||
|
bridge,
|
||||||
|
"reply_received_acknowledgement",
|
||||||
|
) as acknowledge,
|
||||||
mock.patch.object(bridge, "reply") as reply,
|
mock.patch.object(bridge, "reply") as reply,
|
||||||
):
|
):
|
||||||
bridge.handle_message("client", initial_data)
|
bridge.handle_message("client", initial_data)
|
||||||
@@ -210,6 +230,13 @@ class TriggerGateTests(unittest.TestCase):
|
|||||||
build_report.call_args_list,
|
build_report.call_args_list,
|
||||||
[mock.call(request), mock.call(request, candidate_offset=4)],
|
[mock.call(request), mock.call(request, candidate_offset=4)],
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
acknowledge.call_args_list,
|
||||||
|
[
|
||||||
|
mock.call("client", "om_initial"),
|
||||||
|
mock.call("client", "om_follow_up"),
|
||||||
|
],
|
||||||
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
reply.call_args_list,
|
reply.call_args_list,
|
||||||
[
|
[
|
||||||
@@ -229,6 +256,10 @@ class TriggerGateTests(unittest.TestCase):
|
|||||||
"message_text",
|
"message_text",
|
||||||
side_effect=[request_text, "还有没有其他候选型号?"],
|
side_effect=[request_text, "还有没有其他候选型号?"],
|
||||||
),
|
),
|
||||||
|
mock.patch.object(
|
||||||
|
bridge,
|
||||||
|
"reply_received_acknowledgement",
|
||||||
|
) as acknowledge,
|
||||||
mock.patch.object(bridge, "build_report", return_value="report") as build_report,
|
mock.patch.object(bridge, "build_report", return_value="report") as build_report,
|
||||||
mock.patch.object(bridge, "reply") as reply,
|
mock.patch.object(bridge, "reply") as reply,
|
||||||
):
|
):
|
||||||
@@ -238,6 +269,7 @@ class TriggerGateTests(unittest.TestCase):
|
|||||||
build_report.assert_called_once_with(
|
build_report.assert_called_once_with(
|
||||||
bridge.RequestInput(model="eRob70H50I-BHM-18CTC[V5]", quantity=10)
|
bridge.RequestInput(model="eRob70H50I-BHM-18CTC[V5]", quantity=10)
|
||||||
)
|
)
|
||||||
|
acknowledge.assert_called_once_with("client", "om_small")
|
||||||
reply.assert_called_once_with("client", "om_small", "report")
|
reply.assert_called_once_with("client", "om_small", "report")
|
||||||
|
|
||||||
def test_expired_candidate_context_is_removed(self) -> None:
|
def test_expired_candidate_context_is_removed(self) -> None:
|
||||||
@@ -313,5 +345,90 @@ class CandidateBatchTests(unittest.TestCase):
|
|||||||
self.assertNotIn(model, report)
|
self.assertNotIn(model, report)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkdayConversionTests(unittest.TestCase):
|
||||||
|
def test_natural_day_conversion_rule(self) -> None:
|
||||||
|
expected = {
|
||||||
|
0: 0,
|
||||||
|
1: 0,
|
||||||
|
2: 1,
|
||||||
|
3: 2,
|
||||||
|
4: 3,
|
||||||
|
5: 3,
|
||||||
|
6: 4,
|
||||||
|
7: 5,
|
||||||
|
18: 13,
|
||||||
|
23: 16,
|
||||||
|
26: 18,
|
||||||
|
31: 22,
|
||||||
|
33: 23,
|
||||||
|
36: 25,
|
||||||
|
43: 30,
|
||||||
|
48: 34,
|
||||||
|
}
|
||||||
|
|
||||||
|
for natural_days, workdays in expected.items():
|
||||||
|
with self.subTest(natural_days=natural_days):
|
||||||
|
self.assertEqual(
|
||||||
|
bridge.natural_days_to_workdays(natural_days),
|
||||||
|
workdays,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_2026_official_holiday_and_makeup_workdays(self) -> None:
|
||||||
|
self.assertTrue(bridge.is_china_official_workday(date(2026, 9, 20)))
|
||||||
|
self.assertFalse(bridge.is_china_official_workday(date(2026, 9, 25)))
|
||||||
|
self.assertFalse(bridge.is_china_official_workday(date(2026, 10, 1)))
|
||||||
|
self.assertTrue(bridge.is_china_official_workday(date(2026, 10, 10)))
|
||||||
|
self.assertFalse(bridge.is_china_official_workday(date(2026, 10, 11)))
|
||||||
|
|
||||||
|
def test_workday_dates_match_validation_samples(self) -> None:
|
||||||
|
start = date(2026, 9, 2)
|
||||||
|
samples = {
|
||||||
|
18: date(2026, 9, 20),
|
||||||
|
23: date(2026, 9, 23),
|
||||||
|
26: date(2026, 9, 28),
|
||||||
|
16: date(2026, 9, 17),
|
||||||
|
31: date(2026, 10, 9),
|
||||||
|
33: date(2026, 10, 10),
|
||||||
|
36: date(2026, 10, 13),
|
||||||
|
43: date(2026, 10, 20),
|
||||||
|
48: date(2026, 10, 26),
|
||||||
|
}
|
||||||
|
|
||||||
|
for natural_days, expected_date in samples.items():
|
||||||
|
with self.subTest(natural_days=natural_days):
|
||||||
|
workdays = bridge.natural_days_to_workdays(natural_days)
|
||||||
|
self.assertEqual(
|
||||||
|
bridge.add_china_official_workdays(start, workdays),
|
||||||
|
expected_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_api_natural_days_drive_displayed_delivery_date(self) -> None:
|
||||||
|
payload = successful_payload()
|
||||||
|
summary = payload["data"]["交期汇总"]
|
||||||
|
summary["AM516预测交期"] = "2026-10-03"
|
||||||
|
summary["AM516总交期"] = 31
|
||||||
|
|
||||||
|
details = bridge.lead_time_details(
|
||||||
|
payload,
|
||||||
|
requested_quantity=10,
|
||||||
|
calculation_date=date(2026, 9, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(details["total_days"], 31)
|
||||||
|
self.assertEqual(details["workdays"], 22)
|
||||||
|
self.assertEqual(details["delivery_date"], "2026-10-09")
|
||||||
|
self.assertEqual(
|
||||||
|
details["prediction"],
|
||||||
|
"AM516预测交期:2026-10-09(原始31个自然日,折算22个工作日)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unconfigured_calendar_year_is_not_silently_estimated(self) -> None:
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
bridge.WorkdayCalendarUnavailableError,
|
||||||
|
"2027年国家法定工作日历未配置",
|
||||||
|
):
|
||||||
|
bridge.add_china_official_workdays(date(2026, 12, 31), 1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user