Last active 1774262818

en2zmax's Avatar en2zmax revised this gist 1774262817. Go to revision

1 file changed, 1 insertion

42.py

@@ -313,3 +313,4 @@ def handler(
313 313 buttons_in_row=1,
314 314 )
315 315
316 + return {"text": text}, variables

en2zmax's Avatar en2zmax revised this gist 1774262607. Go to revision

1 file changed, 315 insertions

42.py(file created)

@@ -0,0 +1,315 @@
1 + # Предложить слоты по времени
2 +
3 + import logging
4 + from typing import Any, Dict, Optional, List
5 +
6 + from 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 +
20 + api = MfcApi()
21 +
22 +
23 + def 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 +
70 + def 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 +
78 + def 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 +
86 + def 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 +
94 + def 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 +
102 + def 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 +
110 + def 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 +
118 + def 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 +
130 + def 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 +
140 + def 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 +
150 + def fmt_time_numeric(mins: int) -> str:
151 + h = (mins // 60) % 24
152 + m = mins % 60
153 + return f"{h}:{m:02d}"
154 +
155 +
156 + def 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 +
162 + def 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 +
178 + def 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 +
218 + def 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 +
230 + def 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 +
Newer Older