"""Build the Feishu confirmation-card envelope for an attendance report. We hand the semantic payload (stats / highlights / actions) to ``NotificationService.dispatch`` — it renders the Feishu interactive card and resolves the recipient's open_id, so this module never touches card JSON or the Feishu API directly. """ from __future__ import annotations from app.domains.attendance.constants import SIDE_INTERN from app.domains.attendance.facts import ( CAT_ABSENT, CAT_EARLY, CAT_LACK_IN, CAT_LACK_OUT, CAT_LATE, ) from app.domains.attendance.models import AttendanceReport from app.identity.person_models import IdentityPerson from app.notifications.service import ( CardAction, CardHighlight, CardStat, CardText, LocalizedText, NotificationEnvelope, ) def _fmt_days(value: float | int | None) -> str: """Render a fractional day count without a trailing ``.0`` (18.0→"18", 18.4→"18.4").""" n = float(value or 0) return f"{n:g}" def _en_plural(value: float | int | None, singular: str, plural: str) -> str: """English unit noun for a card value ("1 day" vs "18 days").""" return singular if float(value or 0) == 1 else plural # Per-language issue label. The reader of this card is the intern (or # their leader), so the abnormal-day breakdown has to be translated, not # just the chrome around it. _ISSUE_LABEL: dict[str, dict[str, str]] = { CAT_LATE: {"zh": "迟到", "en": "Late", "ja": "遅刻"}, CAT_EARLY: {"zh": "早退", "en": "Left early", "ja": "早退"}, CAT_LACK_IN: { "zh": "缺上班卡", "en": "Missing clock-in", "ja": "出勤打刻漏れ", }, CAT_LACK_OUT: { "zh": "缺下班卡", "en": "Missing clock-out", "ja": "退勤打刻漏れ", }, CAT_ABSENT: { "zh": "旷工(全天)", "en": "Absent (full day)", "ja": "欠勤(終日)", }, } # Separator between several issues on the same day. _ISSUE_JOIN: dict[str, str] = {"zh": "、", "en": ", ", "ja": "、"} def _abnormal_lines(report: AttendanceReport, limit: int = 12) -> list[CardText]: days = (report.snapshot_json or {}).get("days") or [] lines: list[CardText] = [] for day in days: if not day.get("is_abnormal"): continue codes = day.get("issues", []) labels = { lg: _ISSUE_JOIN[lg].join( (_ISSUE_LABEL.get(code) or {}).get(lg, code) for code in codes ) for lg in ("zh", "en", "ja") } lines.append(LocalizedText( zh=f"{day['date']}:{labels['zh']}", en=f"{day['date']}: {labels['en']}", ja=f"{day['date']}:{labels['ja']}", )) if len(lines) >= limit: lines.append("…") break return lines def build_confirmation_card( *, report: AttendanceReport, recipient: IdentityPerson, role: str, # SIDE_INTERN | SIDE_LEADER confirm_token: str, period_label: str, intern_display: str | None = None, ) -> NotificationEnvelope: """Card asking ``recipient`` (the intern, or their leader) to confirm or return the report. The one-click ``confirm`` button carries a capability token bound to (recipient, report); 查看明细/退回 deep-links into the H5 detail page (login is seamless inside Feishu).""" is_intern = role == SIDE_INTERN if is_intern: title = f"请确认你的考勤月报({period_label})" body = ( "以下是本考勤周期的打卡结果。若无误请点「确认无误」;" "如有缺卡未补等问题,请点「查看明细 / 退回」说明原因退回 HR。" ) else: who = intern_display or "实习生" title = f"请复核 {who} 的考勤月报({period_label})" body = ( f"{who} 已确认本周期考勤。请复核:无误点「确认」," "有问题点「查看明细 / 退回」退回 HR 处理。" ) work_days = _fmt_days(report.work_days) stats = [ CardStat( label=LocalizedText(zh="出勤天数", en="Work days", ja="出勤日数"), value=LocalizedText( zh=f"{work_days} 天", en=f"{work_days} {_en_plural(report.work_days, 'day', 'days')}", ja=f"{work_days} 日", ), ), CardStat( label=LocalizedText( zh="异常天数", en="Days with issues", ja="異常日数", ), value=LocalizedText( zh=f"{report.abnormal_days} 天", en=( f"{report.abnormal_days} " f"{_en_plural(report.abnormal_days, 'day', 'days')}" ), ja=f"{report.abnormal_days} 日", ), ), CardStat( label=LocalizedText( zh="缺卡(未补)", en="Missing punches (unfixed)", ja="打刻漏れ(未修正)", ), value=LocalizedText( zh=f"{report.lack_count} 次", en=( f"{report.lack_count} " f"{_en_plural(report.lack_count, 'time', 'times')}" ), ja=f"{report.lack_count} 回", ), ), CardStat( label=LocalizedText( zh="迟到 / 早退", en="Late / left early", ja="遅刻 / 早退", ), value=LocalizedText( zh=f"{report.late_count} / {report.early_count} 次", en=f"{report.late_count} / {report.early_count} times", ja=f"{report.late_count} / {report.early_count} 回", ), ), ] highlights: list[CardHighlight] = [] abnormal = _abnormal_lines(report) if abnormal: highlights.append(CardHighlight( label=LocalizedText( zh="异常明细", en="Issue details", ja="異常の内訳", ), items=abnormal, )) elif report.remedied_count: remedied = report.remedied_count highlights.append(CardHighlight( label=LocalizedText(zh="说明", en="Note", ja="補足"), items=LocalizedText( zh=f"本周期有 {remedied} 次缺卡已补卡,无遗留异常。", en=( f"{remedied} missing " f"{_en_plural(remedied, 'punch was', 'punches were')} " f"fixed this period; no issues left outstanding." ), ja=( f"今回の期間中、打刻漏れ {remedied} 件は修正済みで、" f"未解決の異常はありません。" ), ), )) actions = [ CardAction( label=( LocalizedText( zh="✅ 确认无误", en="✅ Confirm — all correct", ja="✅ 確認(相違ありません)", ) if is_intern else LocalizedText(zh="✅ 确认", en="✅ Confirm", ja="✅ 確認") ), url=f"/api/v1/attendance/confirm?token={confirm_token}", style="primary", ), CardAction( label=LocalizedText( zh="📋 查看明细 / 退回", en="📋 View details / Return", ja="📋 明細を見る / 差し戻し", ), url=f"/m/attendance/{report.id}", style="default", ), ] return NotificationEnvelope( recipient=recipient, title=title, body_text=body, channels=["feishu"], card_subtitle=LocalizedText( zh=f"考勤周期 {period_label}", en=f"Attendance period {period_label}", ja=f"勤怠期間 {period_label}", ), card_stats=stats, card_highlights=highlights, card_actions=actions, related_resource_type="attendance_report", related_resource_id=report.id, )