|
@@ -12,18 +12,12 @@ from vs_plg import IVSPlg
|
|
|
from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError
|
|
from vs_types import VSPlgConfig, AppointmentType, VSQueryResult, VSBookResult, AvailabilityStatus, TimeSlot, DateAvailability, NotFoundError, PermissionDeniedError, RateLimiteddError, SessionExpiredOrInvalidError, BizLogicError
|
|
|
|
|
|
|
|
MODERN_BROWSERS: List[str] = [
|
|
MODERN_BROWSERS: List[str] = [
|
|
|
- # Chrome (124+)
|
|
|
|
|
- "chrome124", "chrome131", "chrome133a", "chrome136", "chrome142", "chrome145", "chrome146",
|
|
|
|
|
- "chrome131_android",
|
|
|
|
|
-
|
|
|
|
|
- # Safari (18+)
|
|
|
|
|
- "safari180", "safari184", "safari260", "safari2601",
|
|
|
|
|
- "safari180_ios", "safari184_ios", "safari260_ios",
|
|
|
|
|
-
|
|
|
|
|
- # Firefox (133+)
|
|
|
|
|
- "firefox133", "firefox135", "firefox144", "firefox147",
|
|
|
|
|
-
|
|
|
|
|
- "tor145"
|
|
|
|
|
|
|
+ "chrome",
|
|
|
|
|
+ "edge",
|
|
|
|
|
+ "safari",
|
|
|
|
|
+ "safari_ios",
|
|
|
|
|
+ "chrome_android",
|
|
|
|
|
+ "firefox",
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
class GrcPlugin(IVSPlg):
|
|
class GrcPlugin(IVSPlg):
|
|
@@ -41,8 +35,8 @@ class GrcPlugin(IVSPlg):
|
|
|
self.logger = None
|
|
self.logger = None
|
|
|
self.session: Optional[requests.Session] = None
|
|
self.session: Optional[requests.Session] = None
|
|
|
self.resource_id = '1123832'
|
|
self.resource_id = '1123832'
|
|
|
- self.rp_id = None # 用于 AJAX 查询 (例如 778129)
|
|
|
|
|
- self.token = None # 用于 AJAX 查询
|
|
|
|
|
|
|
+ self.rp_id = None
|
|
|
|
|
+ self.token = None
|
|
|
self.session_create_time: float = 0
|
|
self.session_create_time: float = 0
|
|
|
|
|
|
|
|
def get_group_id(self) -> str:
|
|
def get_group_id(self) -> str:
|
|
@@ -72,7 +66,6 @@ class GrcPlugin(IVSPlg):
|
|
|
return True
|
|
return True
|
|
|
|
|
|
|
|
def create_session(self):
|
|
def create_session(self):
|
|
|
- # 1. 初始化 Session
|
|
|
|
|
curlopt = {
|
|
curlopt = {
|
|
|
const.CurlOpt.MAXAGE_CONN: 1800,
|
|
const.CurlOpt.MAXAGE_CONN: 1800,
|
|
|
const.CurlOpt.MAXLIFETIME_CONN: 1800,
|
|
const.CurlOpt.MAXLIFETIME_CONN: 1800,
|
|
@@ -108,10 +101,7 @@ class GrcPlugin(IVSPlg):
|
|
|
|
|
|
|
|
resp = self._perform_request('POST', login_url, headers=headers, data=data)
|
|
resp = self._perform_request('POST', login_url, headers=headers, data=data)
|
|
|
|
|
|
|
|
- # 判断是否登录成功
|
|
|
|
|
if "Sign out" in resp.text or "Signed in as" in resp.text:
|
|
if "Sign out" in resp.text or "Signed in as" in resp.text:
|
|
|
-
|
|
|
|
|
- # [新增修复点]: 检查账号是否已达最大预约数限制
|
|
|
|
|
if "reached the maximum number" in resp.text or "You cannot create new reservations" in resp.text:
|
|
if "reached the maximum number" in resp.text or "You cannot create new reservations" in resp.text:
|
|
|
self.is_healthy = False
|
|
self.is_healthy = False
|
|
|
self._save_debug_html(resp.text, prefix='login_quota_exceeded')
|
|
self._save_debug_html(resp.text, prefix='login_quota_exceeded')
|
|
@@ -121,35 +111,66 @@ class GrcPlugin(IVSPlg):
|
|
|
self.session_create_time = time.time()
|
|
self.session_create_time = time.time()
|
|
|
self._log(f"Session created successfully. (User: {self.config.account.username})")
|
|
self._log(f"Session created successfully. (User: {self.config.account.username})")
|
|
|
|
|
|
|
|
- # 如果登录失败,SuperSaaS 通常会留在当前页面并显示错误信息
|
|
|
|
|
elif "Invalid email or password" in resp.text:
|
|
elif "Invalid email or password" in resp.text:
|
|
|
self._save_debug_html(resp.text, prefix='login_auth_fail')
|
|
self._save_debug_html(resp.text, prefix='login_auth_fail')
|
|
|
raise BizLogicError(message='Login failed: Invalid email or password')
|
|
raise BizLogicError(message='Login failed: Invalid email or password')
|
|
|
|
|
|
|
|
else:
|
|
else:
|
|
|
- # 其他未知错误
|
|
|
|
|
self._save_debug_html(resp.text, prefix='login_unknown_fail')
|
|
self._save_debug_html(resp.text, prefix='login_unknown_fail')
|
|
|
self._log(f"Login check failed. Current URL: {resp.url}")
|
|
self._log(f"Login check failed. Current URL: {resp.url}")
|
|
|
raise BizLogicError(message='Login failed: Unknown response')
|
|
raise BizLogicError(message='Login failed: Unknown response')
|
|
|
|
|
+
|
|
|
|
|
+ def _extract_int(self, pattern: str, text: str, default: int = 0) -> int:
|
|
|
|
|
+ """辅助正则提取数字的函数"""
|
|
|
|
|
+ match = re.search(pattern, text)
|
|
|
|
|
+ if match:
|
|
|
|
|
+ return int(match.group(1))
|
|
|
|
|
+ return default
|
|
|
|
|
|
|
|
- def _get_daily_schedule(self, open_times, date_obj):
|
|
|
|
|
- """根据 open_times 获取当天的开始和结束分钟数"""
|
|
|
|
|
|
|
+ def _get_daily_bounds_and_breaks(self, open_times: list, bit_prefs: int, date_ts: int, js_day_index: int) -> Tuple[Optional[int], Optional[int], Optional[Tuple[int, int]]]:
|
|
|
|
|
+ """完美还原JS逻辑,解析每天的营业起始时间,并提取隐藏的午休时间"""
|
|
|
if not open_times:
|
|
if not open_times:
|
|
|
- return None, None
|
|
|
|
|
- weekday_py = date_obj.weekday()
|
|
|
|
|
- if weekday_py >= 4:
|
|
|
|
|
- return None, None
|
|
|
|
|
- js_day_index = (date_obj.weekday() + 1) % 7
|
|
|
|
|
- start_min = open_times[js_day_index]
|
|
|
|
|
- end_min = open_times[js_day_index + 7]
|
|
|
|
|
- return start_min, end_min
|
|
|
|
|
-
|
|
|
|
|
- def _is_blocked_by_ecache(self, ecache_blocked, timestamp):
|
|
|
|
|
- """检查某个时间点是否在临时关闭范围内 (如节假日)"""
|
|
|
|
|
- for block in ecache_blocked:
|
|
|
|
|
- if block[0] <= timestamp < block[1]:
|
|
|
|
|
- return True
|
|
|
|
|
- return False
|
|
|
|
|
|
|
+ return None, None, None
|
|
|
|
|
+
|
|
|
|
|
+ # =========================================================
|
|
|
|
|
+ # 【终极拦截器】:解析 bit_prefs 位掩码!
|
|
|
|
|
+ # 最低的 7 位控制着周日(0)到周六(6)的物理营业开关。
|
|
|
|
|
+ # 如果为 0,这天就是雷打不动的休息日,直接返回空!
|
|
|
|
|
+ # =========================================================
|
|
|
|
|
+ if bit_prefs > 0:
|
|
|
|
|
+ if not (bit_prefs & (1 << js_day_index)):
|
|
|
|
|
+ return None, None, None
|
|
|
|
|
+
|
|
|
|
|
+ def safe_get(idx):
|
|
|
|
|
+ if idx < len(open_times):
|
|
|
|
|
+ return open_times[idx]
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ # [0-6] 是周日到周六的每天开始时间(分), [7-13] 是结束时间(分)
|
|
|
|
|
+ start_min = safe_get(js_day_index)
|
|
|
|
|
+ end_min = safe_get(js_day_index + 7)
|
|
|
|
|
+
|
|
|
|
|
+ # Null 意味着当天不营业
|
|
|
|
|
+ if start_min is None or end_min is None or start_min >= end_min:
|
|
|
|
|
+ return None, None, None
|
|
|
|
|
+
|
|
|
|
|
+ # JS 中的 day_base_ts = 严格的当天 00:00:00 (根据绝对时间戳截断)
|
|
|
|
|
+ day_base_ts = date_ts - (date_ts % 86400)
|
|
|
|
|
+
|
|
|
|
|
+ start_ts = day_base_ts + start_min * 60
|
|
|
|
|
+ end_ts = day_base_ts + end_min * 60
|
|
|
|
|
+
|
|
|
|
|
+ # [14-20] 是午休开始时间, [21-27] 是午休结束时间
|
|
|
|
|
+ break_block = None
|
|
|
|
|
+ break_start_min = safe_get(js_day_index + 14)
|
|
|
|
|
+ break_end_min = safe_get(js_day_index + 21)
|
|
|
|
|
+
|
|
|
|
|
+ if break_start_min is not None and break_end_min is not None:
|
|
|
|
|
+ b_start = day_base_ts + break_start_min * 60
|
|
|
|
|
+ b_end = day_base_ts + break_end_min * 60
|
|
|
|
|
+ break_block = (b_start, b_end)
|
|
|
|
|
+
|
|
|
|
|
+ return start_ts, end_ts, break_block
|
|
|
|
|
|
|
|
def _fetch_schedule_data(self, start_dt: datetime, days: int) -> Dict:
|
|
def _fetch_schedule_data(self, start_dt: datetime, days: int) -> Dict:
|
|
|
"""发送一次 AJAX 请求,获取指定时间范围内的所有数据"""
|
|
"""发送一次 AJAX 请求,获取指定时间范围内的所有数据"""
|
|
@@ -194,134 +215,187 @@ class GrcPlugin(IVSPlg):
|
|
|
self.is_healthy = False
|
|
self.is_healthy = False
|
|
|
raise SessionExpiredOrInvalidError(message='Session expired.')
|
|
raise SessionExpiredOrInvalidError(message='Session expired.')
|
|
|
|
|
|
|
|
- # 提取核心参数
|
|
|
|
|
- res_id_match = re.search(r'resource\[(\d+)\]\s*=', resp.text)
|
|
|
|
|
- if res_id_match:
|
|
|
|
|
- self.resource_id = res_id_match.group(1)
|
|
|
|
|
-
|
|
|
|
|
- rp_match = re.search(r'rp_id=(\d+)', resp.text)
|
|
|
|
|
- if rp_match:
|
|
|
|
|
- self.rp_id = rp_match.group(1)
|
|
|
|
|
-
|
|
|
|
|
- tok_match = re.search(r'token=(\d+)', resp.text)
|
|
|
|
|
- if tok_match:
|
|
|
|
|
- self.token = tok_match.group(1)
|
|
|
|
|
|
|
+ # 1. 提取核心会话参数
|
|
|
|
|
+ self.resource_id = str(self._extract_int(r'resource\[(\d+)\]\s*=', resp.text))
|
|
|
|
|
+ self.rp_id = str(self._extract_int(r'rp_id=(\d+)', resp.text))
|
|
|
|
|
+ self.token = str(self._extract_int(r'token=(\d+)', resp.text))
|
|
|
|
|
|
|
|
- if not getattr(self, 'rp_id', None) or not getattr(self, 'token', None):
|
|
|
|
|
|
|
+ if not self.rp_id or not self.token or self.resource_id == '0':
|
|
|
self._log("Failed to extract rp_id or token from HTML")
|
|
self._log("Failed to extract rp_id or token from HTML")
|
|
|
raise NotFoundError(message='rp_id or token not found')
|
|
raise NotFoundError(message='rp_id or token not found')
|
|
|
|
|
|
|
|
- # 默认一小时长度 (3600秒)
|
|
|
|
|
- default_length = 3600
|
|
|
|
|
- len_match = re.search(r'default_length\s*=\s*(\d+)', resp.text)
|
|
|
|
|
- if len_match:
|
|
|
|
|
- default_length = int(len_match.group(1))
|
|
|
|
|
-
|
|
|
|
|
- # 开放时间规则
|
|
|
|
|
- open_times = None
|
|
|
|
|
|
|
+ # 2. 提取并还原所有的 JS 限制变量
|
|
|
|
|
+ default_length = self._extract_int(r'default_length\s*=\s*(\d+)', resp.text, 3600)
|
|
|
|
|
+ rounding = self._extract_int(r'rounding\s*=\s*(\d+)', resp.text, 3600)
|
|
|
|
|
+ buffer_time = self._extract_int(r'buffer\s*=\s*(\d+)', resp.text, 0)
|
|
|
|
|
+ add_limit = self._extract_int(r'add_limit\s*=\s*(\d+)', resp.text, 0)
|
|
|
|
|
+ early_limit = self._extract_int(r'early_limit\s*=\s*(\d+)', resp.text, 0)
|
|
|
|
|
+ early_snap = self._extract_int(r'early_snap\s*=\s*(\d+)', resp.text, 0)
|
|
|
|
|
+ bit_prefs = self._extract_int(r'bit_prefs\s*=\s*(\d+)', resp.text, 0)
|
|
|
|
|
+
|
|
|
|
|
+ # 3. 提取 open_times
|
|
|
|
|
+ open_times = []
|
|
|
ot_match = re.search(r'open_times\s*=\s*\[(.*?)\]', resp.text)
|
|
ot_match = re.search(r'open_times\s*=\s*\[(.*?)\]', resp.text)
|
|
|
if ot_match:
|
|
if ot_match:
|
|
|
- open_times =[int(x) for x in ot_match.group(1).split(',')]
|
|
|
|
|
|
|
+ # 巧妙利用 json.loads 处理包含 null 的 JS 数组字符串
|
|
|
|
|
+ open_times_str = f"[{ot_match.group(1)}]"
|
|
|
|
|
+ open_times = json.loads(open_times_str)
|
|
|
|
|
|
|
|
- # 排期结束时间
|
|
|
|
|
- season_end_ts = 9999999999
|
|
|
|
|
- season_match = re.search(r'season\s*=\s*\[(\d+),(\d+)\]', resp.text)
|
|
|
|
|
|
|
+ # 4. 提取放号排期 (Season)
|
|
|
|
|
+ season_start_ts = 0
|
|
|
|
|
+ season_end_ts = float('inf')
|
|
|
|
|
+ season_match = re.search(r'season\s*=\s*\[(\d+),\s*(\d+)\]', resp.text)
|
|
|
if season_match:
|
|
if season_match:
|
|
|
|
|
+ season_start_ts = int(season_match.group(1))
|
|
|
season_end_ts = int(season_match.group(2))
|
|
season_end_ts = int(season_match.group(2))
|
|
|
|
|
|
|
|
- # 提取全局页面屏蔽的假日例外期 (ecache)
|
|
|
|
|
- ecache_blocks =[]
|
|
|
|
|
|
|
+ # 5. 提取全局页面屏蔽的假日例外期 (ecache)
|
|
|
|
|
+ ecache_blocks = []
|
|
|
ecache_match = re.search(r'ecache\s*=\s*\{data:\s*\[(.*?)\]\}', resp.text)
|
|
ecache_match = re.search(r'ecache\s*=\s*\{data:\s*\[(.*?)\]\}', resp.text)
|
|
|
if ecache_match:
|
|
if ecache_match:
|
|
|
- # 匹配形如[1775433600,1775519970,0] 的数据
|
|
|
|
|
triplets = re.findall(r'\[(\d+),\s*(\d+),\s*\d+\]', ecache_match.group(1))
|
|
triplets = re.findall(r'\[(\d+),\s*(\d+),\s*\d+\]', ecache_match.group(1))
|
|
|
for t0, t1 in triplets:
|
|
for t0, t1 in triplets:
|
|
|
ecache_blocks.append((int(t0), int(t1)))
|
|
ecache_blocks.append((int(t0), int(t1)))
|
|
|
|
|
|
|
|
scan_start_dt = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
scan_start_dt = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
|
- current_time_ts = int(time.time())
|
|
|
|
|
|
|
+ chunk_start_ts_initial = int(scan_start_dt.timestamp())
|
|
|
|
|
+
|
|
|
days_total_scan = 60
|
|
days_total_scan = 60
|
|
|
chunk_size = 30
|
|
chunk_size = 30
|
|
|
- valid_slots_map: dict[datetime.date, list[TimeSlot]] = {}
|
|
|
|
|
|
|
+ valid_slots_map: Dict[datetime.date, List[TimeSlot]] = {}
|
|
|
|
|
|
|
|
|
|
+ # Chunk 循环
|
|
|
for i in range(0, days_total_scan, chunk_size):
|
|
for i in range(0, days_total_scan, chunk_size):
|
|
|
- chunk_start = scan_start_dt + timedelta(days=i)
|
|
|
|
|
- json_data = self._fetch_schedule_data(chunk_start, chunk_size)
|
|
|
|
|
|
|
+ chunk_start_dt = scan_start_dt + timedelta(days=i)
|
|
|
|
|
+ json_data = self._fetch_schedule_data(chunk_start_dt, chunk_size)
|
|
|
|
|
|
|
|
- # 把已经预定的订单和节假日统一收集为“阻挡物实体墙”
|
|
|
|
|
|
|
+ # 把已经预定的订单、节假日、以及Gcal双向同步块统一收集为“阻挡物实体墙”
|
|
|
all_blocks: List[Tuple[int, int]] = ecache_blocks.copy()
|
|
all_blocks: List[Tuple[int, int]] = ecache_blocks.copy()
|
|
|
- if 'app' in json_data:
|
|
|
|
|
- for item in json_data['app']:
|
|
|
|
|
- all_blocks.append((int(item[0]), int(item[1])))
|
|
|
|
|
- if 'exc' in json_data:
|
|
|
|
|
- for item in json_data['exc']:
|
|
|
|
|
- all_blocks.append((int(item[0]), int(item[1])))
|
|
|
|
|
|
|
+ for key in ['app', 'exc', 'gcal']:
|
|
|
|
|
+ if key in json_data:
|
|
|
|
|
+ for item in json_data[key]:
|
|
|
|
|
+ all_blocks.append((int(item[0]), int(item[1])))
|
|
|
|
|
|
|
|
for day_offset in range(chunk_size):
|
|
for day_offset in range(chunk_size):
|
|
|
- current_day = chunk_start + timedelta(days=day_offset)
|
|
|
|
|
- start_min, end_min = self._get_daily_schedule(open_times, current_day)
|
|
|
|
|
- if start_min is None or start_min >= end_min: continue
|
|
|
|
|
|
|
+ # 这里必须使用纯净的绝对 UNIX 时间进行加法,避免夏令时导致时间漂移
|
|
|
|
|
+ current_day_ts = int(chunk_start_dt.timestamp()) + day_offset * 86400
|
|
|
|
|
+
|
|
|
|
|
+ # 计算出当前天对应的 JS Weekday (0=Sun, 1=Mon ... 6=Sat)
|
|
|
|
|
+ js_day_index = int((4 + (current_day_ts // 86400)) % 7)
|
|
|
|
|
+
|
|
|
|
|
+ # 这里传入了 bit_prefs,彻底过滤掉伪装成开门的休息日!
|
|
|
|
|
+ start_ts, end_ts, break_block = self._get_daily_bounds_and_breaks(open_times, bit_prefs, current_day_ts, js_day_index)
|
|
|
|
|
+ if start_ts is None:
|
|
|
|
|
+ continue
|
|
|
|
|
|
|
|
- start_ts = int(current_day.timestamp()) + start_min * 60
|
|
|
|
|
- end_ts = int(current_day.timestamp()) + end_min * 60
|
|
|
|
|
|
|
+ # 每天专属的围墙:加入当天的午休时间
|
|
|
|
|
+ daily_blocks = all_blocks.copy()
|
|
|
|
|
+ if break_block:
|
|
|
|
|
+ daily_blocks.append(break_block)
|
|
|
|
|
|
|
|
curr_ts = start_ts
|
|
curr_ts = start_ts
|
|
|
|
|
+ current_time_ts = int(time.time())
|
|
|
|
|
+
|
|
|
|
|
+ # --- 完全还原 JS 的 limits 以及 bit_prefs (early_snap) 机制 ---
|
|
|
|
|
+ min_bookable_ts = current_time_ts + add_limit + 90
|
|
|
|
|
+ max_bookable_ts = float('inf')
|
|
|
|
|
|
|
|
- # 开始执行官方引擎 1:1 的“碰撞与吸附算法”
|
|
|
|
|
|
|
+ if early_limit > 0:
|
|
|
|
|
+ limit_ts = current_time_ts + early_limit
|
|
|
|
|
+ if early_snap:
|
|
|
|
|
+ k = limit_ts % 86400
|
|
|
|
|
+ y = int((4 + (limit_ts // 86400)) % 7)
|
|
|
|
|
+ # 这里正是 bit_prefs 的用武之地:检测位掩码
|
|
|
|
|
+ if (bit_prefs & (1 << y)) and open_times[y] is not None and open_times[y] <= (k / 60):
|
|
|
|
|
+ limit_ts += (86400 - k)
|
|
|
|
|
+ max_bookable_ts = limit_ts + default_length
|
|
|
|
|
+
|
|
|
|
|
+ # ================= 核心碰撞与吸附算法 =================
|
|
|
while curr_ts + default_length <= end_ts:
|
|
while curr_ts + default_length <= end_ts:
|
|
|
- if curr_ts < current_time_ts or curr_ts >= season_end_ts:
|
|
|
|
|
- curr_ts += 1800 # 安全步进
|
|
|
|
|
|
|
+
|
|
|
|
|
+ # 1. 不能早于排期放号的绝对起始时间
|
|
|
|
|
+ if curr_ts < season_start_ts:
|
|
|
|
|
+ curr_ts = season_start_ts
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ # 2. 如果撞到了排期尽头,或超过了后台设置的最远可预约天数,当天循环直接结束
|
|
|
|
|
+ if curr_ts >= season_end_ts or curr_ts >= max_bookable_ts:
|
|
|
|
|
+ break
|
|
|
|
|
+
|
|
|
|
|
+ # 3. 拦截提前量 (比如不允许预约1小时内的票)
|
|
|
|
|
+ if curr_ts < min_bookable_ts:
|
|
|
|
|
+ curr_ts = min_bookable_ts
|
|
|
continue
|
|
continue
|
|
|
|
|
|
|
|
- # 【核心机制 1】:强制网格吸附 (Snap to grid)
|
|
|
|
|
- # 官方代码 start=precalc_constraints('0') 意味着仅允许在整点 (0分) 建立预约
|
|
|
|
|
- dt = datetime.fromtimestamp(curr_ts, tz=timezone.utc)
|
|
|
|
|
- if dt.minute != 0:
|
|
|
|
|
- # 发现不在整点 (如11:30),立刻强制向前吸附到下一个整点 (加上剩余的分钟数)
|
|
|
|
|
- minutes_to_add = 60 - dt.minute
|
|
|
|
|
- curr_ts += minutes_to_add * 60
|
|
|
|
|
- continue # 时间已改变,重新循环执行检查
|
|
|
|
|
-
|
|
|
|
|
|
|
+ # 4. 【核心机制 1】:完美网格吸附 (Rounding Snapping)
|
|
|
|
|
+ # 动态适配 10分、15分、30分、60分的网格,废弃单纯整点逻辑
|
|
|
|
|
+ rem = curr_ts % rounding
|
|
|
|
|
+ if rem > 0:
|
|
|
|
|
+ curr_ts += (rounding - rem)
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
slot_end = curr_ts + default_length
|
|
slot_end = curr_ts + default_length
|
|
|
|
|
|
|
|
- # 【核心机制 2】:贪婪碰撞检测 (Collision detection)
|
|
|
|
|
|
|
+ # 5. 【核心机制 2】:带 Buffer 的贪婪碰撞检测
|
|
|
overlapping_end = 0
|
|
overlapping_end = 0
|
|
|
- for b_start, b_end in all_blocks:
|
|
|
|
|
- # 区间交集判断:起点早于障碍物终点 且 终点晚于障碍物起点 -> 发生碰撞
|
|
|
|
|
- if curr_ts < b_end and slot_end > b_start:
|
|
|
|
|
- if b_end > overlapping_end:
|
|
|
|
|
- overlapping_end = b_end
|
|
|
|
|
|
|
+ for b_start, b_end in daily_blocks:
|
|
|
|
|
+ # 障碍物向前后各膨胀 buffer 的大小
|
|
|
|
|
+ blocked_start = b_start - buffer_time
|
|
|
|
|
+ blocked_end = b_end + buffer_time
|
|
|
|
|
+
|
|
|
|
|
+ # 区间交集判断逻辑
|
|
|
|
|
+ if curr_ts < blocked_end and slot_end > blocked_start:
|
|
|
|
|
+ if blocked_end > overlapping_end:
|
|
|
|
|
+ overlapping_end = blocked_end
|
|
|
|
|
|
|
|
if overlapping_end > 0:
|
|
if overlapping_end > 0:
|
|
|
- # 碰撞触发:指针直接抛弃当前区间,跳跃到“阻挡物最晚结束的时间点”
|
|
|
|
|
|
|
+ # 碰撞触发:指针直接跳跃到带缓冲区的“障碍物最晚结束时间点”
|
|
|
curr_ts = overlapping_end
|
|
curr_ts = overlapping_end
|
|
|
- # 下一轮循环时,【机制1】会自动将其吸附回合理的网格位!
|
|
|
|
|
|
|
+ # 进入下个 while 循环时,机制 1 会将其吸附回最近的合法网格上!
|
|
|
else:
|
|
else:
|
|
|
- # 完美过检:没有碰撞且处于正确网格,记录合法 Slot
|
|
|
|
|
|
|
+ # ========================================================
|
|
|
|
|
+ # 完美过检:记录合法 Slot (在此处增加 Finish Time 的提取)
|
|
|
|
|
+ # ========================================================
|
|
|
|
|
+ dt_start = datetime.fromtimestamp(curr_ts, tz=timezone.utc)
|
|
|
|
|
+
|
|
|
|
|
+ # 计算结束时间戳与时间对象
|
|
|
|
|
+ finish_ts = curr_ts + default_length
|
|
|
|
|
+ dt_finish = datetime.fromtimestamp(finish_ts, tz=timezone.utc)
|
|
|
payload = {
|
|
payload = {
|
|
|
"resource_id": self.resource_id,
|
|
"resource_id": self.resource_id,
|
|
|
"timestamp": curr_ts,
|
|
"timestamp": curr_ts,
|
|
|
- "datetime": dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
|
|
+ "datetime": dt_start.strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
|
|
+ "finish_timestamp": finish_ts,
|
|
|
|
|
+ "finish_datetime": dt_finish.strftime("%Y-%m-%d %H:%M:%S")
|
|
|
}
|
|
}
|
|
|
time_slot = TimeSlot(
|
|
time_slot = TimeSlot(
|
|
|
- time=dt.strftime("%H:%M"),
|
|
|
|
|
|
|
+ time=dt_start.strftime("%H:%M"),
|
|
|
label=json.dumps(payload)
|
|
label=json.dumps(payload)
|
|
|
)
|
|
)
|
|
|
- date_key = dt.date()
|
|
|
|
|
|
|
+
|
|
|
|
|
+ date_key = dt_start.date()
|
|
|
if date_key not in valid_slots_map:
|
|
if date_key not in valid_slots_map:
|
|
|
- valid_slots_map[date_key] =[]
|
|
|
|
|
|
|
+ valid_slots_map[date_key] = []
|
|
|
|
|
|
|
|
valid_slots_map[date_key].append(time_slot)
|
|
valid_slots_map[date_key].append(time_slot)
|
|
|
|
|
|
|
|
- # 已放入一个格后,指针按订单长度向后推移
|
|
|
|
|
|
|
+ # 推进指针,寻找下一个槽位
|
|
|
curr_ts += default_length
|
|
curr_ts += default_length
|
|
|
|
|
|
|
|
|
|
+ # === 结果聚合与整理 ===
|
|
|
if valid_slots_map:
|
|
if valid_slots_map:
|
|
|
res.success = True
|
|
res.success = True
|
|
|
res.availability_status = AvailabilityStatus.Available
|
|
res.availability_status = AvailabilityStatus.Available
|
|
|
sorted_dates = sorted(valid_slots_map.keys())
|
|
sorted_dates = sorted(valid_slots_map.keys())
|
|
|
res.earliest_date = datetime.combine(sorted_dates[0], datetime.min.time())
|
|
res.earliest_date = datetime.combine(sorted_dates[0], datetime.min.time())
|
|
|
- res.availability =[DateAvailability(date=datetime.combine(d, datetime.min.time()), times=valid_slots_map[d]) for d in sorted_dates]
|
|
|
|
|
|
|
+
|
|
|
|
|
+ for d in sorted_dates:
|
|
|
|
|
+ da = DateAvailability(
|
|
|
|
|
+ date=datetime.combine(d, datetime.min.time()),
|
|
|
|
|
+ times=valid_slots_map[d]
|
|
|
|
|
+ )
|
|
|
|
|
+ res.availability.append(da)
|
|
|
|
|
+
|
|
|
self._log(f"Found slots on {len(sorted_dates)} days.")
|
|
self._log(f"Found slots on {len(sorted_dates)} days.")
|
|
|
else:
|
|
else:
|
|
|
self._log("No slots found.")
|
|
self._log("No slots found.")
|
|
@@ -380,10 +454,14 @@ class GrcPlugin(IVSPlg):
|
|
|
try:
|
|
try:
|
|
|
slot_data = json.loads(target_slot.label)
|
|
slot_data = json.loads(target_slot.label)
|
|
|
start_time_str = slot_data.get('datetime')
|
|
start_time_str = slot_data.get('datetime')
|
|
|
|
|
+ finish_time_str = slot_data.get('finish_datetime')
|
|
|
bk_res_id = slot_data.get('resource_id', self.resource_id)
|
|
bk_res_id = slot_data.get('resource_id', self.resource_id)
|
|
|
|
|
|
|
|
start_dt = datetime.strptime(start_time_str, "%Y-%m-%d %H:%M:%S")
|
|
start_dt = datetime.strptime(start_time_str, "%Y-%m-%d %H:%M:%S")
|
|
|
- finish_dt = start_dt + timedelta(hours=1)
|
|
|
|
|
|
|
+ if finish_time_str:
|
|
|
|
|
+ finish_dt = datetime.strptime(finish_time_str, "%Y-%m-%d %H:%M:%S")
|
|
|
|
|
+ else:
|
|
|
|
|
+ finish_dt = start_dt + timedelta(hours=1)
|
|
|
|
|
|
|
|
# 处理抵离日期确保不为空
|
|
# 处理抵离日期确保不为空
|
|
|
arrival_date = (user_inputs.get('arrival_date') or '').strip()
|
|
arrival_date = (user_inputs.get('arrival_date') or '').strip()
|