fix: use API workdays and final delivery date in bot replies
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
**日期**:2026-09-02
|
**日期**:2026-09-02
|
||||||
**目的**:将 AM516 交期预测输出的自然日转换为工作日,并据此得到最终交货日期。
|
**目的**:将 AM516 交期预测输出的自然日转换为工作日,并据此得到最终交货日期。
|
||||||
|
|
||||||
**当前临时处理**:因 AR836 排期较晚,AM516 飞书机器人先在 API 返回后的输出层执行同一换算;不修改 AR836 API 原始数据。待 API 正式提供工作日口径后,应移除本地临时换算,避免重复转换。
|
**2026-09-20 实现更新**:API 已返回 `AM516工作日交期` 和 `AM516最终交货日期`。本仓库机器人回复已改为直接使用这两个字段,移除本地临时换算及日期推算;运行端需同步本次改动后生效。下文保留为原始需求与验证记录。
|
||||||
|
|
||||||
## 一、需求背景
|
## 一、需求背景
|
||||||
|
|
||||||
|
|||||||
@@ -40,17 +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
|
## Sample 1B | API Workdays And Final Delivery Date
|
||||||
|
|
||||||
Rules and expected results:
|
Rules and expected results:
|
||||||
|
|
||||||
- treat `AM516总交期`, or numeric `总交期` when the AM516 field is absent, as the original natural-day lead time `N`;
|
- read `data.交期汇总.AM516工作日交期` and `data.交期汇总.AM516最终交货日期` directly;
|
||||||
- convert `N` with the approved quotient-and-remainder rule, including `r = 4` as one rest day and `r = 0` as no remainder workday;
|
- when the API returns `8` and `2026-10-08`, output `AM516预测交期:8个工作日,最终交货日期:2026-10-08`;
|
||||||
- exclude the local query date itself, then count the converted workdays using the configured China statutory workday calendar;
|
- use the same workdays-and-date form in the fastest-candidate summary, initial and continuation candidate details, and newly appended record rows;
|
||||||
- statutory holidays are excluded and official makeup weekend workdays are included;
|
- ignore natural-day totals and the old `AM516预测交期` field when selecting the displayed result; do not recalculate workdays or dates locally;
|
||||||
- for a query date of `2026-09-02`, `31` natural days convert to `22` workdays and produce `2026-10-09`;
|
- a valid API date in another calendar year remains usable without a local holiday calendar;
|
||||||
- output `2026-10-09(原始31个自然日,折算22个工作日)` instead of adding a separate National Day exception;
|
- accept zero workdays; reject negative, fractional, boolean, or non-numeric workday values and invalid dates;
|
||||||
- when counting reaches an unconfigured calendar year, do not silently fall back to a Monday-Friday estimate.
|
- 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
|
## Sample 1A | Continue With Other Candidates
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
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. Immediately add the Feishu `OnIt` (typing) reaction to the original message to acknowledge the valid request.
|
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.
|
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.
|
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, 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.
|
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.
|
11. Append a non-secret record.
|
||||||
12. Return Feishu-friendly text with the fixed disclaimer.
|
12. Return Feishu-friendly text with the fixed disclaimer.
|
||||||
|
|
||||||
|
|||||||
@@ -93,12 +93,10 @@ Do not use temporary curl, WebFetch, or any API method outside the controlled sc
|
|||||||
|
|
||||||
## Output Selection
|
## 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`.
|
- Read the workday count directly from `data.交期汇总.AM516工作日交期` and the corresponding final delivery date from `data.交期汇总.AM516最终交货日期`.
|
||||||
- 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`.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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
|
||||||
|
|
||||||
@@ -163,13 +161,13 @@ Use this field order for a successful Feishu report:
|
|||||||
总体判断:
|
总体判断:
|
||||||
- 现货候选:{fully covered candidate or none}
|
- 现货候选:{fully covered candidate or none}
|
||||||
- 最快候选型号:{fastest candidate}
|
- 最快候选型号:{fastest candidate}
|
||||||
- 最快候选交期:{calculated delivery date, original natural days, and converted workdays}
|
- 最快候选交期:{API workdays}个工作日,最终交货日期:{API final delivery date}
|
||||||
- 是否完全覆盖数量:{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预测交期:{calculated delivery date}(原始{N}个自然日,折算{W}个工作日)
|
- AM516预测交期:{API workdays}个工作日,最终交货日期:{API final delivery date}
|
||||||
- 可用库存/覆盖情况:{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 date, datetime, timedelta
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -82,40 +82,6 @@ CANDIDATE_CONTINUATION_TTL_SECONDS = 6 * 60 * 60
|
|||||||
RECEIVED_REACTION_TYPE = "OnIt"
|
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 = {
|
CANDIDATE_CONTINUATION_SHORT_PHRASES = {
|
||||||
"还有吗",
|
"还有吗",
|
||||||
"还有呢",
|
"还有呢",
|
||||||
@@ -209,12 +175,6 @@ 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()
|
||||||
|
|
||||||
@@ -536,52 +496,7 @@ def run_controlled_query(request: RequestInput) -> dict[str, Any]:
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def natural_days_to_workdays(natural_days: int) -> int:
|
def whole_day_count(value: Any) -> int | None:
|
||||||
"""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)):
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
return None
|
return None
|
||||||
if isinstance(value, float) and not value.is_integer():
|
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(
|
def lead_time_details(
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
requested_quantity: int,
|
requested_quantity: int,
|
||||||
calculation_date: date | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> 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 {
|
||||||
@@ -635,31 +548,23 @@ def lead_time_details(
|
|||||||
if isinstance(main_factor, (str, int, float))
|
if isinstance(main_factor, (str, int, float))
|
||||||
else "接口未返回,需人工复核"
|
else "接口未返回,需人工复核"
|
||||||
)
|
)
|
||||||
predicted_date = summary.get("AM516预测交期")
|
total_days = whole_day_count(summary.get("AM516总交期"))
|
||||||
total_days = whole_natural_day_count(summary.get("AM516总交期"))
|
|
||||||
if total_days is None:
|
if total_days is None:
|
||||||
total_days = whole_natural_day_count(summary.get("总交期"))
|
total_days = whole_day_count(summary.get("总交期"))
|
||||||
workdays: int | None = None
|
workdays = whole_day_count(summary.get("AM516工作日交期"))
|
||||||
delivery_date: date | None = None
|
delivery_date = summary.get("AM516最终交货日期")
|
||||||
if total_days is not None:
|
if isinstance(delivery_date, str) and re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", delivery_date):
|
||||||
workdays = natural_days_to_workdays(total_days)
|
|
||||||
try:
|
try:
|
||||||
delivery_date = add_china_official_workdays(calculation_date, workdays)
|
date.fromisoformat(delivery_date)
|
||||||
except WorkdayCalendarUnavailableError as exc:
|
except ValueError:
|
||||||
prediction_value = (
|
delivery_date = None
|
||||||
f"【{exc}】(原始{total_days}个自然日,折算{workdays}个工作日)"
|
else:
|
||||||
)
|
delivery_date = None
|
||||||
else:
|
if workdays is not None and delivery_date is not None:
|
||||||
prediction_value = (
|
prediction_value = f"{workdays}个工作日,最终交货日期:{delivery_date}"
|
||||||
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}"
|
prediction = f"AM516预测交期:{prediction_value}"
|
||||||
else:
|
else:
|
||||||
prediction = "接口未返回可用交期汇总,需人工复核。"
|
prediction = "接口未返回有效工作日交期和最终交货日期,需人工复核。"
|
||||||
prediction_value = None
|
prediction_value = None
|
||||||
|
|
||||||
inventory_info = data.get("库存信息")
|
inventory_info = data.get("库存信息")
|
||||||
@@ -738,7 +643,7 @@ def lead_time_details(
|
|||||||
"prediction_value": prediction_value,
|
"prediction_value": prediction_value,
|
||||||
"total_days": total_days,
|
"total_days": total_days,
|
||||||
"workdays": workdays,
|
"workdays": workdays,
|
||||||
"delivery_date": delivery_date.isoformat() if delivery_date else None,
|
"delivery_date": delivery_date,
|
||||||
"main_factor": main_factor_text,
|
"main_factor": main_factor_text,
|
||||||
"inventory": inventory_text,
|
"inventory": inventory_text,
|
||||||
"available_inventory": (
|
"available_inventory": (
|
||||||
@@ -936,7 +841,6 @@ 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
|
||||||
@@ -952,10 +856,9 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
|
|||||||
baseline = lead_time_details(
|
baseline = lead_time_details(
|
||||||
baseline_payload,
|
baseline_payload,
|
||||||
request.quantity,
|
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(
|
||||||
{
|
{
|
||||||
"index": 1,
|
"index": 1,
|
||||||
@@ -979,7 +882,6 @@ def build_report(request: RequestInput, candidate_offset: int = 0) -> str:
|
|||||||
details = lead_time_details(
|
details = lead_time_details(
|
||||||
payload,
|
payload,
|
||||||
request.quantity,
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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
|
||||||
|
|
||||||
@@ -34,8 +33,9 @@ def successful_payload() -> dict[str, object]:
|
|||||||
"data": {
|
"data": {
|
||||||
"时间戳": "2026-08-31 12:00:00",
|
"时间戳": "2026-08-31 12:00:00",
|
||||||
"交期汇总": {
|
"交期汇总": {
|
||||||
"AM516预测交期": "2026-09-01",
|
|
||||||
"AM516总交期": 1,
|
"AM516总交期": 1,
|
||||||
|
"AM516工作日交期": 1,
|
||||||
|
"AM516最终交货日期": "2026-09-01",
|
||||||
"AM516主导因素": "现货",
|
"AM516主导因素": "现货",
|
||||||
},
|
},
|
||||||
"库存信息": {"可用库存(扣除了待产数量)": 100},
|
"库存信息": {"可用库存(扣除了待产数量)": 100},
|
||||||
@@ -437,89 +437,120 @@ class CandidateBatchTests(unittest.TestCase):
|
|||||||
self.assertNotIn(model, report)
|
self.assertNotIn(model, report)
|
||||||
|
|
||||||
|
|
||||||
class WorkdayConversionTests(unittest.TestCase):
|
class ApiWorkdayOutputTests(unittest.TestCase):
|
||||||
def test_natural_day_conversion_rule(self) -> None:
|
def test_api_workdays_and_date_override_legacy_fields(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()
|
payload = successful_payload()
|
||||||
summary = payload["data"]["交期汇总"]
|
summary = payload["data"]["交期汇总"]
|
||||||
summary["AM516预测交期"] = "2026-10-03"
|
summary["AM516预测交期"] = "2026-10-03"
|
||||||
summary["AM516总交期"] = 31
|
summary["AM516总交期"] = 31
|
||||||
|
summary["AM516工作日交期"] = 8
|
||||||
|
summary["AM516最终交货日期"] = "2026-10-08"
|
||||||
|
|
||||||
details = bridge.lead_time_details(
|
details = bridge.lead_time_details(payload, requested_quantity=10)
|
||||||
payload,
|
|
||||||
requested_quantity=10,
|
|
||||||
calculation_date=date(2026, 9, 2),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(details["total_days"], 31)
|
self.assertEqual(details["total_days"], 31)
|
||||||
self.assertEqual(details["workdays"], 22)
|
self.assertEqual(details["workdays"], 8)
|
||||||
self.assertEqual(details["delivery_date"], "2026-10-09")
|
self.assertEqual(details["delivery_date"], "2026-10-08")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
details["prediction"],
|
details["prediction"],
|
||||||
"AM516预测交期:2026-10-09(原始31个自然日,折算22个工作日)",
|
"AM516预测交期:8个工作日,最终交货日期:2026-10-08",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_unconfigured_calendar_year_is_not_silently_estimated(self) -> None:
|
def test_new_fields_work_without_natural_days_or_local_calendar(self) -> None:
|
||||||
with self.assertRaisesRegex(
|
payload = successful_payload()
|
||||||
bridge.WorkdayCalendarUnavailableError,
|
summary = payload["data"]["交期汇总"]
|
||||||
"2027年国家法定工作日历未配置",
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user