Last active 1774262818

Revision d433028d0aa07e237192062acea24079887dec70

42.py Raw
1# Предложить слоты по времени
2
3import logging
4from typing import Any, Dict, Optional, List
5
6from start import (
7 MfcApi,
8 build_buttons_text,
9 get_data_element,
10 CARD,
11 CARD_GEN,
12 PART_OF_DAY,
13 build_sorted_time_slots,
14 get_total_slots_availability_bulk,
15 dedupe_slots,
16 setup_pagination,
17 get_pagination_data,
18)
19
20api = MfcApi()
21
22
23def get_nearest_times(time_list: List[str], time: str, n: int = 3) -> List[str]:
24
25 def _norm(t: str) -> str:
26 h, m = t.split(":")
27 return f"{int(h):02d}:{int(m):02d}"
28
29 def _to_minutes(t: str) -> int:
30 h, m = map(int, t.split(":"))
31 return h * 60 + m
32
33 if not time_list or n <= 0:
34 return []
35
36 times = [_norm(t) for t in time_list]
37 n = min(n, len(times))
38 target = _norm(time)
39 tgt = _to_minutes(target)
40 mins = [_to_minutes(t) for t in times]
41
42 i0 = min(range(len(mins)), key=lambda i: (abs(mins[i] - tgt), mins[i]))
43
44 res = [times[i0]]
45 l, r = i0 - 1, i0 + 1
46
47 while len(res) < n and (l >= 0 or r < len(times)):
48 dl = abs(mins[l] - tgt) if l >= 0 else float("inf")
49 dr = abs(mins[r] - tgt) if r < len(times) else float("inf")
50
51 if dl <= dr:
52 if l >= 0:
53 res.append(times[l])
54 l -= 1
55 elif r < len(times):
56 res.append(times[r])
57 r += 1
58 else:
59 if r < len(times):
60 res.append(times[r])
61 r += 1
62 elif l >= 0:
63 res.append(times[l])
64 l -= 1
65
66 res_sorted = sorted(res, key=lambda t: _to_minutes(t))
67 return res_sorted
68
69
70def num_cardinal(n: int) -> str:
71 if n in CARD:
72 return CARD[n]
73 tens = (n // 10) * 10
74 unit = n % 10
75 return f"{CARD[tens]} {CARD[unit]}"
76
77
78def num_genitive(n: int) -> str:
79 if n in CARD_GEN:
80 return CARD_GEN[n]
81 tens = (n // 10) * 10
82 unit = n % 10
83 return f"{CARD_GEN[tens]} {CARD_GEN[unit]}"
84
85
86def parse_times(times: List[str]) -> List[int]:
87 mins = []
88 for t in times:
89 h, m = t.strip().split(":")
90 mins.append(int(h) * 60 + int(m))
91 return sorted(set(mins))
92
93
94def part_of_day_label(mins: int) -> str:
95 h = (mins // 60) % 24
96 for start, end, label in PART_OF_DAY:
97 if start <= h < end:
98 return label
99 return "дня"
100
101
102def time_words_point(mins: int) -> str:
103 h = mins // 60
104 m = mins % 60
105 if m == 0:
106 return f"{num_cardinal(h)}"
107 return f"{num_cardinal(h)} {num_cardinal(m)}"
108
109
110def time_words_gen(mins: int) -> str:
111 h = mins // 60
112 m = mins % 60
113 if m == 0:
114 return f"{num_genitive(h)}"
115 return f"{num_genitive(h)} {num_genitive(m)}"
116
117
118def make_spans(mins: List[int], max_gap: int = 60) -> List[List[int]]:
119 if not mins:
120 return []
121 spans = [[mins[0], mins[0]]]
122 for x in mins[1:]:
123 if x - spans[-1][1] <= max_gap:
124 spans[-1][1] = x
125 else:
126 spans.append([x, x])
127 return spans
128
129
130def join_times_with_and(parts: List[str]) -> str:
131 if not parts:
132 return ""
133 if len(parts) == 1:
134 return parts[0]
135 if len(parts) == 2:
136 return f"{parts[0]} и {parts[1]}"
137 return f"{', '.join(parts[:-1])} и {parts[-1]}"
138
139
140def group_by_part_of_day(mins: List[int]) -> Dict[str, List[int]]:
141 buckets: Dict[str, List[int]] = {}
142 for t in mins:
143 label = part_of_day_label(t)
144 buckets.setdefault(label, []).append(t)
145 for k in buckets:
146 buckets[k].sort()
147 return buckets
148
149
150def fmt_time_numeric(mins: int) -> str:
151 h = (mins // 60) % 24
152 m = mins % 60
153 return f"{h}:{m:02d}"
154
155
156def format_hour_block(times_in_hour: List[int], *, numeric: bool = False) -> str:
157 if numeric:
158 return join_times_with_and([fmt_time_numeric(t) for t in times_in_hour])
159 return join_times_with_and([time_words_point(t) for t in times_in_hour])
160
161
162def format_part_of_day_group(
163 times: List[int], label: str, *, numeric: bool = False
164) -> str:
165 by_hour: Dict[int, List[int]] = {}
166 for t in times:
167 h = t // 60
168 by_hour.setdefault(h, []).append(t)
169 blocks = []
170 for h in sorted(by_hour):
171 blocks.append(format_hour_block(by_hour[h], numeric=numeric))
172 core = join_times_with_and(blocks)
173 if numeric:
174 return f"в {core}"
175 return f"в {core} {label}"
176
177
178def build_times_phrase(
179 times: List[str], separate: bool = False, numeric: bool = False
180) -> str:
181 mins = parse_times(times)
182 if not mins:
183 return ""
184
185 if separate:
186 pods = group_by_part_of_day(mins)
187 parts = []
188 for label, ts in sorted(pods.items(), key=lambda kv: (min(kv[1]), kv[0])):
189 parts.append(format_part_of_day_group(ts, label, numeric=numeric))
190 return "; ".join(parts)
191
192 spans = make_spans(mins, max_gap=60)
193 phrases = []
194 for a, b in spans:
195 if a == b:
196 if numeric:
197 phrases.append(f"в {fmt_time_numeric(a)}")
198 else:
199 label = part_of_day_label(a)
200 phrases.append(f"в {time_words_point(a)} {label}")
201 else:
202 if numeric:
203 phrases.append(f"с {fmt_time_numeric(a)} до {fmt_time_numeric(b)}")
204 else:
205 label_a = part_of_day_label(a)
206 label_b = part_of_day_label(b)
207 if label_a == label_b:
208 phrases.append(
209 f"с {time_words_gen(a)} до {time_words_gen(b)} {label_a}"
210 )
211 else:
212 phrases.append(
213 f"с {time_words_gen(a)} {label_a} до {time_words_gen(b)} {label_b}"
214 )
215 return "; ".join(phrases)
216
217
218def rebuild_time_slots(
219 *, time_slots: List[Dict[str, Any]], channel: str
220) -> List[Dict[str, Any]]:
221 if channel not in ["max", "telegram"]:
222 return time_slots
223
224 for time_slot in time_slots:
225 time_slot["name"] = time_slot["name"].replace(":", ".")
226
227 return time_slots
228
229
230def handler(
231 data: Optional[Dict[str, Any]] = None,
232 session_id: Optional[str] = None,
233 channel: str = "default",
234):
235 channel = data["variables"].get("channel_type", channel)
236 mfc_id = data["variables"]["mfc_id"]
237 service_ids = data["variables"]["service_ids"]
238 service_count = data["variables"]["service_count"]
239
240 date = get_data_element(
241 data=data,
242 filter={"type": "see", "model": "record_date"},
243 exclude_keys=["declined"],
244 )
245 time = get_data_element(
246 data=data,
247 filter={
248 "type": "see",
249 "model": "record_time",
250 "more_index": date.get("index"),
251 },
252 exclude_keys=["declined"],
253 )
254
255 slots = get_total_slots_availability_bulk(
256 session=data,
257 api=api,
258 mfc_id=mfc_id,
259 service_ids=service_ids,
260 service_count=service_count,
261 )
262
263 # Убираем дубликаты.
264 # Почему они могут возникать?
265 # Потому что мы получаем слоты по нескольким услугам (service_ids).
266 slots = dedupe_slots([x for slot in slots for x in slot["slots"]])
267
268 logging.info(
269 "Suggest time slots with mfc_id %r, service_ids %r, service_count %r",
270 mfc_id,
271 service_ids,
272 service_count,
273 )
274
275 time_slots = build_sorted_time_slots(slots=slots, date=date["param"])
276
277 text, variables = "", {}
278
279 if channel == "voice":
280 # если пользователь сказал время - мы ищем БЛИЖАЙИШЕ среди альтернативных
281 if time:
282 alter_times = get_nearest_times(time_slots, time, n=2)
283
284 alter_times = build_times_phrase(alter_times, separate=True, numeric=True)
285 else:
286 alter_times = build_times_phrase(time_slots, numeric=True)
287
288 variables.update({"alter_times": alter_times})
289 else:
290 if channel == "vkontakte":
291 per_page = api.cfg.vk_buttons_rows
292 else:
293 per_page = api.cfg.max_buttons_rows
294
295 if not data["variables"].get("__pagination") or data["variables"].get("__pagination_type") != "time":
296 setup_pagination(session=data, data=time_slots, per_page=per_page)
297 data["variables"]["__pagination_type"] = "time"
298
299 previous, page_times, next_ = get_pagination_data(session=data)
300
301 pagination_count = int(bool(previous)) + int(bool(next_))
302 max_time_buttons = per_page - pagination_count
303
304 buttons = [{"name": time_slot} for time_slot in page_times[:max_time_buttons]]
305
306 if previous:
307 buttons += [{"name": api.cfg.pagination_previous_button_name}]
308 if next_:
309 buttons += [{"name": api.cfg.pagination_next_button_name}]
310
311 text = build_buttons_text(
312 buttons=rebuild_time_slots(time_slots=buttons, channel=channel),
313 buttons_in_row=1,
314 )
315
316 return {"text": text}, variables
317