v2.13.0: 익일 퇴근 처리 수정 + 날짜 지정 일일보고 + 연차 직접입력
Fixed: - clock_out()이 datetime.now().date() 행에 퇴근을 써서 자정 넘긴 퇴근이 UPDATE 0건으로 유실되던 버그. 출근일(근무일) 기준으로 수정. cancel_clock_out() 및 주말/공휴일 판정·외출 집계도 동일 기준으로 통일. - 캘린더 시간 수정 / 과거기록 추가가 퇴근<=출근을 반려해 익일 퇴근을 저장할 수 없던 문제. 익일로 해석하고 동시각만 반려. 총 근무시간·연장근무 음수 계산도 수정. Added: - generate_daily_report(target_date) 날짜 파라미터화 + 캘린더 '보고서 복사' 버튼. 기본값은 진행 중인 근무일(경계시간 전이면 출근일). - 연차 직접입력 버튼(메인 창) — 미연결 상태였던 use_custom_leave()를 기존 AddLeaveDialog 재사용으로 연결. - resolve_work_datetimes() / is_overnight() 공용 헬퍼 + 유닛 테스트 6개. Changed: - 연차 빠른 사용에서 날짜·메모 팝업 제거 (연장근무와 동일하게 확인 1회). - get_today_overtime_usage() / get_total_break_minutes_today()에 선택적 date_str 파라미터 추가. 테스트: 유닛 200 / 통합 53 / GUI 8 / i18n 5 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d8c6a9d784
commit
f28f0619a4
34
CHANGELOG.md
34
CHANGELOG.md
@ -4,6 +4,40 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [2.13.0] — 2026-08-12
|
||||
|
||||
### Fixed — 익일(자정 넘김) 퇴근
|
||||
- **새벽 퇴근이 출근일 기록에 저장되지 않던 버그** — `clock_out()`이 `datetime.now().date()`를
|
||||
대상 행으로 써서, 자정을 넘겨 퇴근하면 해당 날짜 행이 없어 `UPDATE`가 0건 처리되고 출근일
|
||||
기록이 영구 미퇴근으로 남았다. 근무일 경계(기본 6시) 전까지는 출근일이 근무일이라는
|
||||
`handle_workday_rollover()`의 기준을 `clock_out()`에도 적용. `cancel_clock_out()` 동일 수정.
|
||||
- **야근이 주말/공휴일 근무로 오분류되던 문제** — 근무 성격 판정(`is_non_working_day`/
|
||||
`get_day_type`)과 외출 시간 집계를 `now`가 아닌 출근일 기준으로 변경. 금요일 야근이
|
||||
토요일 근무로 뒤바뀌어 전 시간이 연장근무로 적립되지 않는다.
|
||||
- **익일 퇴근 기록을 수정할 수 없던 문제** — 캘린더의 시간 수정과 과거기록 추가 다이얼로그가
|
||||
`퇴근 <= 출근`을 입력 오류로 반려해 익일 퇴근을 저장할 방법이 아예 없었다. 이제 익일로
|
||||
해석하고, 동시각인 경우만 반려. 총 근무시간·연장근무가 음수로 깨지던 계산도 함께 수정.
|
||||
|
||||
### Added
|
||||
- **일일보고를 다른 날짜로도 복사** — `generate_daily_report(target_date)`로 날짜 파라미터화.
|
||||
캘린더에서 날짜 선택 → **보고서 복사** 버튼으로 지난 날짜 보고서를 다시 뽑을 수 있다.
|
||||
생략 시 기본값은 진행 중인 근무일(자정~경계시간 사이면 출근일)이라 새벽 퇴근 후에도
|
||||
"기록 없음"이 뜨지 않는다.
|
||||
- **연차 직접입력 버튼** (메인 창) — 연장근무 줄과 동일한 구성. 기존 `AddLeaveDialog`를
|
||||
재사용해 날짜(캘린더)/유형/시간/사유를 한 화면에서 입력. 그쪽은 주말·공휴일 차단과
|
||||
같은 날 1일 초과 중복 검증까지 갖추고 있다. (구현돼 있었으나 어떤 버튼에도 연결되지
|
||||
않았던 `use_custom_leave()`를 연결)
|
||||
- 익일 퇴근 표기 — 보고서/캘린더 상세에 `익일 01:30`, 수정 다이얼로그에 실시간 안내
|
||||
(`🌙 익일 퇴근으로 저장됩니다 (출근~퇴근 16시간 30분)`).
|
||||
- `core.time_calculator.resolve_work_datetimes()` / `is_overnight()` — 익일 판정을 한 곳으로
|
||||
통일 (외출 기록의 자정 처리와 같은 규칙). 유닛 테스트 6개 추가.
|
||||
|
||||
### Changed
|
||||
- **연차 빠른 사용에서 날짜·메모 입력 팝업 제거** — 30분/1시간/반차/종일 버튼이 연장근무
|
||||
사용과 동일하게 확인 1회로 끝난다(팝업 3개 → 1개). 날짜·사유 지정은 직접입력으로.
|
||||
- `Database.get_today_overtime_usage()` / `get_total_break_minutes_today()`에 선택적
|
||||
`date_str` 파라미터 추가 (기본값은 기존과 동일한 오늘).
|
||||
|
||||
## [2.12.0] — 2026-06-16
|
||||
|
||||
### Added — 전체 i18n 키화 완료
|
||||
|
||||
34
CLAUDE.md
34
CLAUDE.md
@ -8,6 +8,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
**Tech Stack:** Python 3.9+, PyQt5, SQLite, pywin32, matplotlib, optional `holidays`.
|
||||
|
||||
[AGENTS.md](AGENTS.md) is the long-form reference (invariants, past incidents, security notes). This file is the quick map — when the two disagree, AGENTS.md is more detailed but may also be staler; trust the code.
|
||||
|
||||
Companion docs: [AGENTS.md](AGENTS.md), [INSTALL.md](INSTALL.md), [README.md](README.md), [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## Build and Run
|
||||
@ -32,9 +34,18 @@ python -m pytest tests # unit tests
|
||||
|
||||
# Release (one-shot to Gitea)
|
||||
$env:GITEA_TOKEN = '<PAT>'
|
||||
.\release.ps1 v2.7.0
|
||||
.\release.ps1 v2.12.0
|
||||
```
|
||||
|
||||
Env vars (all optional):
|
||||
|
||||
| Var | Effect |
|
||||
| --- | --- |
|
||||
| `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` | Skips the background holiday-sync thread in `init_database()`. **Required for any Qt test** — the thread segfaults offscreen runs. Already set in `tests/conftest.py` and the three `_*_test.py` scripts. |
|
||||
| `CLOCKOUT_HOLIDAY_API_KEY` | 공공데이터포털 특일정보 key for [holiday_api.py](utils/holiday_api.py) (externalized in v2.12.0). |
|
||||
| `CLOCKOUT_DEBUG` / `CLOCKOUT_DEBUG_DIR` | Enables `dlog()` output + its target dir. |
|
||||
| `CLOCKOUT_RELEASES_API` / `CLOCKOUT_ASSET_NAME` | Override the Gitea endpoint / asset name for update checks. |
|
||||
|
||||
## Architecture
|
||||
|
||||
### core/
|
||||
@ -43,8 +54,10 @@ $env:GITEA_TOKEN = '<PAT>'
|
||||
- **[event_monitor.py](core/event_monitor.py)** — Windows Event IDs 6005/4624/6006.
|
||||
- **[notifier.py](core/notifier.py)** — 7 notifications, each gated by `NOTIF_*` setting + db.has_notification_today guard for daily dedupe. Reads `notification_before_minutes` for clock-out alert threshold.
|
||||
- **[salary.py](core/salary.py)** — `estimate_pay(records, hourly_wage, overtime_rate=1.5)` simple month estimator.
|
||||
- **[i18n.py](core/i18n.py)** — `_DICT` (ko/en, 30+ categories) + `_HELP_HTML` (6 tabs). API: `tr(key, **kwargs)`, `tr_html(key)`, `set_language()`. Runtime retranslate via observer pattern (see B2 in CHANGELOG v2.7.0).
|
||||
- **[settings_keys.py](core/settings_keys.py)** — All setting keys as constants. Modules import these instead of raw strings. ~35 keys.
|
||||
- **[achievements.py](core/achievements.py)** — ~357 achievements as `Achievement` dataclass + per-item evaluator `(db) -> (progress, target)`. Evaluated on a 5-min throttle, unearned only; new unlocks emit `notify_achievement_unlocked`. Names/descriptions live in i18n as `achieve.{code}.name/desc`, not in the dataclass.
|
||||
- **[recurring_leaves.py](core/recurring_leaves.py)** — Pattern parser (`weekly:friday`, `weekly:mon,wed,fri`, `biweekly:friday`, `monthly:15`). Instances are **not** persisted — `expand_for_range()` computes them on call, and callers must reconcile against concrete `leave_records` rows for the same date.
|
||||
- **[i18n.py](core/i18n.py)** — `_DICT` (ko/en) + `_HELP_HTML` (6 tabs). API: `tr(key, **kwargs)`, `tr_html(key)`, `set_language()`. UI string keying is **complete as of v2.12.0** (all 22 `ui/` files + achievement metadata + chart labels) — add new user-facing strings as keys, never literals.
|
||||
- **[settings_keys.py](core/settings_keys.py)** — All setting keys as constants (60 keys). Modules import these instead of raw strings.
|
||||
- **[version.py](core/version.py)** — `__version__` single source of truth.
|
||||
|
||||
### ui/
|
||||
@ -61,7 +74,11 @@ $env:GITEA_TOKEN = '<PAT>'
|
||||
- **[help_view.py](ui/help_view.py)** — 6 tabs from `_HELP_HTML`. Bottom-left "Re-run Onboarding" button.
|
||||
- **[chart_widget.py](ui/chart_widget.py)** — matplotlib QtAgg helpers: `draw_daily_hours` (with hover annotation), `draw_weekday_avg`, `draw_clock_in_distribution`.
|
||||
- **[accessibility.py](ui/accessibility.py)** — Font scale + high-contrast QSS overlay.
|
||||
- Dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`. Window titles use `tr()`; deeper labels Korean (incremental i18n).
|
||||
- **[achievements_view.py](ui/achievements_view.py)** — 4 tabs (all / in-progress / done / secret). Tier-gradient cards.
|
||||
- **[schedule_view.py](ui/schedule_view.py)** — Unified holiday + leave + recurring-pattern calendar. Recurring patterns managed via `recurring_leave_dialog.py`.
|
||||
- **[dark_components.py](ui/dark_components.py)** — Shared card/header/tab widgets for the dark dialogs. Every sub-label sets explicit `transparent` + `border:none` to escape the global QSS cascade — keep that when adding components.
|
||||
- **[icons.py](ui/icons.py)** — `get_icon(name, color=None)`: QtSvg 24x24 line icons cached by `(name, color, size)`, tinted with the current theme color. Replaced emoji in v2.11.0.
|
||||
- Dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`.
|
||||
|
||||
### ui/controllers/
|
||||
- **[lock_monitor.py](ui/controllers/lock_monitor.py)** — Windows screen-lock 5s polling. Two modes: AUTO_BREAK_ON_LOCK (lock→break_out, unlock→break_in) and CLOCK_IN_ON_UNLOCK (first unlock = clock-in for users who never reboot).
|
||||
@ -71,7 +88,7 @@ $env:GITEA_TOKEN = '<PAT>'
|
||||
|
||||
### ui/ (cross-cutting)
|
||||
- **[i18n_runtime.py](ui/i18n_runtime.py)** — Runtime retranslate plumbing. `register(widget, key)` keeps a weakref; `set_language_and_retranslate(lang)` re-fetches all live widgets via `tr()`. Dead widgets auto-cleaned. Main window title + bottom 5 menu buttons currently registered; dialogs migrate incrementally.
|
||||
- **[styles.py](ui/styles.py)** — Shared QSS / color tokens.
|
||||
- **[styles.py](ui/styles.py)** — `LIGHT_COLORS`/`DARK_COLORS` token dicts → `generate_theme(colors, is_dark)` QSS. **Dark is the default** for new installs (v2.11.0). Read colors at paint time via `ThemeColors.get('key')` — never hardcode hex in a widget, or it will ignore the theme (that was the v2.11.1 bug: stats/help/achievements stayed dark in light mode). `apply_dark_titlebar(widget)` for the Win32 titlebar; dialogs call it in `__init__`. Theme choice is the `THEME` setting key.
|
||||
|
||||
### utils/
|
||||
- **[backup.py](utils/backup.py)** — `backup_db_if_needed()`. Daily, 7-file rotation, `sqlite3.Connection.backup` API.
|
||||
@ -85,6 +102,8 @@ $env:GITEA_TOKEN = '<PAT>'
|
||||
- **[debug_log.py](utils/debug_log.py)** — `dlog()` env-gated by `CLOCKOUT_DEBUG`.
|
||||
- **[time_format.py](utils/time_format.py)** — `format_hours_minutes(minutes)` shared helper.
|
||||
- **[system_tray.py](utils/system_tray.py)** — Tray menu, tooltips i18n.
|
||||
- **[holiday_api.py](utils/holiday_api.py)** — 한국천문연구원 특일정보 `getRestDeInfo` client, backfilling the temporary holidays the `holidays` package misses. Network failure returns `None` silently — callers must fall back.
|
||||
- **[font_loader.py](utils/font_loader.py)** — Registers bundled NanumSquare TTFs from `font/` into `QFontDatabase` (frozen + dev paths). Falls back to Malgun Gothic via the QSS font chain if registration fails.
|
||||
|
||||
### Top-level
|
||||
- **[main.py](main.py)** — Entry point. Bootstraps DB, reads `db_path_override`, runs auto-backup, registers crash handler, shows onboarding (if needed), instantiates MainWindow.
|
||||
@ -126,7 +145,7 @@ Migration sentinels prevent re-running.
|
||||
|
||||
## i18n
|
||||
|
||||
`tr('key', **kwargs)` reads `_DICT[current_lang]`, falls back to `ko`, then literal key. `tr_html('help.html.X')` for HelpView. Many deeper dialog labels still Korean — `_DICT['ko']/['en']`에 키 추가 + `tr()` 교체로 점진 확장.
|
||||
`tr('key', **kwargs)` reads `_DICT[current_lang]`, falls back to `ko`, then literal key. `tr_html('help.html.X')` for HelpView. Full UI keying landed in v2.12.0 — new strings go in `_DICT['ko']` **and** `['en']`. Watch apostrophes in English values (`Children's`) — they broke `i18n.py` with a SyntaxError in v2.12.0.
|
||||
|
||||
Runtime retranslate (v2.7.0+): observer pattern. Widgets register their text via `register_translatable(widget, key)` from `ui/i18n_runtime.py`; on `set_language()` change, all registered widgets are re-fetched.
|
||||
|
||||
@ -138,13 +157,14 @@ Runtime retranslate (v2.7.0+): observer pattern. Widgets register their text via
|
||||
- Single-instance dev: `QLocalServer` blocks second `python main.py`. Use `QT_QPA_PLATFORM=offscreen` for GUI smoke tests.
|
||||
- PyInstaller frozen: `getattr(sys, 'frozen', False)` + `sys._MEIPASS` for resource paths.
|
||||
- main.exe self-extracts updater.exe to its own folder on first launch (`_ensure_updater_extracted()` in main.py).
|
||||
- matplotlib in frozen builds: import `backend_qtagg` (with a `backend_qt5agg` fallback) and keep the `numpy.core._multiarray_tests` hiddenimport in [main.spec](main.spec). Both were silent "matplotlib 필요" fallbacks in v2.11.0–2.11.1 that only reproduced in main.exe, never in `python main.py` — chart changes need a real build to verify.
|
||||
|
||||
## Tests
|
||||
|
||||
- [_integration_test.py](_integration_test.py) — Business-logic scenarios (no Qt).
|
||||
- [_gui_smoke_test.py](_gui_smoke_test.py) — Widget instantiation via `QT_QPA_PLATFORM=offscreen`.
|
||||
- [_i18n_gui_test.py](_i18n_gui_test.py) — ko/en switching on real widgets.
|
||||
- [tests/](tests/) — pytest unit tests: `test_time_calculator`, `test_database`, `test_i18n`, `test_i18n_runtime`, `test_updater`, `test_csv_importer`, `test_discord_webhook`, `test_salary`, `test_crash_handler`. Auto-discovered via [pytest.ini](pytest.ini) (`testpaths = tests`).
|
||||
- [tests/](tests/) — pytest unit tests: `test_time_calculator`, `test_database`, `test_i18n`, `test_i18n_runtime`, `test_updater`, `test_csv_importer`, `test_discord_webhook`, `test_salary`, `test_crash_handler`, `test_holiday_api`, `test_overtime_accrual_guard`, `test_recurring_leaves`. Auto-discovered via [pytest.ini](pytest.ini) (`testpaths = tests`); [tests/conftest.py](tests/conftest.py) sets `CLOCKOUT_DISABLE_HOLIDAY_SYNC` at import time.
|
||||
|
||||
Run a single test: `python -m pytest tests/test_time_calculator.py::TestX::test_y -v`.
|
||||
|
||||
|
||||
@ -1026,10 +1026,10 @@ class Database:
|
||||
balance = cursor.fetchone()[0]
|
||||
return initial_overtime + balance
|
||||
|
||||
def get_today_overtime_usage(self) -> int:
|
||||
"""오늘 사용한 추가근무 시간 조회 (분)"""
|
||||
def get_today_overtime_usage(self, date_str: str = None) -> int:
|
||||
"""오늘 사용한 추가근무 시간 조회 (분). date_str 지정 시 그 날짜 기준."""
|
||||
from datetime import date
|
||||
today = date.today().isoformat()
|
||||
today = date_str or date.today().isoformat()
|
||||
with self._conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT SUM(used_minutes) FROM overtime_usage WHERE date = ?',
|
||||
@ -1590,10 +1590,14 @@ class Database:
|
||||
cursor.execute('DELETE FROM break_records WHERE id = ?', (break_id,))
|
||||
conn.commit()
|
||||
|
||||
def get_total_break_minutes_today(self) -> int:
|
||||
"""오늘의 총 외출 시간 (분), 진행 중인 외출 포함"""
|
||||
def get_total_break_minutes_today(self, date_str: str = None) -> int:
|
||||
"""오늘의 총 외출 시간 (분), 진행 중인 외출 포함.
|
||||
|
||||
date_str 지정 시 그 근무일 기준 (야근으로 자정을 넘긴 뒤 퇴근할 때
|
||||
호출자가 출근일을 넘겨줘야 그 날의 외출이 집계된다).
|
||||
"""
|
||||
from datetime import date, datetime
|
||||
today = date.today().isoformat()
|
||||
today = date_str or date.today().isoformat()
|
||||
with self._conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
|
||||
16
core/i18n.py
16
core/i18n.py
@ -466,6 +466,10 @@ _DICT = {
|
||||
'report.memo': '📝 메모: {memo}',
|
||||
'report.copied.title': '보고서 복사 완료',
|
||||
'report.copied.body': '일일 근무 보고서가 클립보드에 복사되었습니다.\n\n{report}',
|
||||
# 익일(자정 넘김) 퇴근 표기
|
||||
'label.next_day_time': '익일 {time}',
|
||||
'edit.overnight_note': '🌙 익일 퇴근으로 저장됩니다 (출근~퇴근 {hours}시간 {minutes}분)',
|
||||
'cal.copy_report': '보고서 복사',
|
||||
'label.lunch': '🍱 점심시간',
|
||||
'label.lunch_short': '점심',
|
||||
'label.dinner': '🍽️ 저녁시간',
|
||||
@ -732,7 +736,7 @@ _DICT = {
|
||||
'cal.save_memo': '메모 저장',
|
||||
'cal.save_memo_body': '{date}의 메모가 저장되었습니다.',
|
||||
'cal.save_memo_title': '메모 저장',
|
||||
'cal.time_error_body': '퇴근 시간은 출근 시간보다 늦어야 합니다.',
|
||||
'cal.time_error_body': '퇴근 시간이 출근 시간과 같습니다.\n(퇴근이 출근보다 이르면 익일 퇴근으로 저장됩니다.)',
|
||||
'cal.time_error_title': '시간 오류',
|
||||
'clock_in_dialog.cancelled': '취소됨',
|
||||
'clock_in_dialog.selected': '선택된 시간: {time}',
|
||||
@ -814,7 +818,7 @@ _DICT = {
|
||||
'past_record.check_lunch': '점심시간 포함',
|
||||
'past_record.dialog_title': '기록 추가 — {date}',
|
||||
'past_record.info': '{date} 근무 기록을 입력하세요.',
|
||||
'past_record.input_error_body': '퇴근 시간이 출근 시간보다 빠르거나 같습니다.',
|
||||
'past_record.input_error_body': '퇴근 시간이 출근 시간과 같습니다.\n(퇴근이 출근보다 이르면 익일 퇴근으로 저장됩니다.)',
|
||||
'past_record.input_error_title': '입력 오류',
|
||||
'past_record.label_clock_in': '출근:',
|
||||
'past_record.label_clock_out': '퇴근:',
|
||||
@ -1625,6 +1629,10 @@ _DICT = {
|
||||
'report.memo': '📝 Memo: {memo}',
|
||||
'report.copied.title': 'Report Copied',
|
||||
'report.copied.body': 'Daily work report copied to clipboard.\n\n{report}',
|
||||
# Next-day (past-midnight) clock-out
|
||||
'label.next_day_time': 'next day {time}',
|
||||
'edit.overnight_note': '🌙 Saved as a next-day clock-out ({hours}h {minutes}m in to out)',
|
||||
'cal.copy_report': 'Copy Report',
|
||||
'label.lunch': '🍱 Lunch',
|
||||
'label.lunch_short': 'Lunch',
|
||||
'label.dinner': '🍽️ Dinner',
|
||||
@ -1891,7 +1899,7 @@ _DICT = {
|
||||
'cal.save_memo': 'Save Memo',
|
||||
'cal.save_memo_body': 'Memo saved for {date}.',
|
||||
'cal.save_memo_title': 'Save Memo',
|
||||
'cal.time_error_body': 'Clock-out must be later than clock-in.',
|
||||
'cal.time_error_body': 'Clock-out is the same as clock-in.\n(An earlier clock-out is saved as the next day.)',
|
||||
'cal.time_error_title': 'Time Error',
|
||||
'clock_in_dialog.cancelled': 'Cancelled',
|
||||
'clock_in_dialog.selected': 'Selected time: {time}',
|
||||
@ -1973,7 +1981,7 @@ _DICT = {
|
||||
'past_record.check_lunch': 'Include lunch',
|
||||
'past_record.dialog_title': 'Add Record — {date}',
|
||||
'past_record.info': 'Enter work record for {date}.',
|
||||
'past_record.input_error_body': 'Clock-out must be later than clock-in.',
|
||||
'past_record.input_error_body': 'Clock-out is the same as clock-in.\n(An earlier clock-out is saved as the next day.)',
|
||||
'past_record.input_error_title': 'Input Error',
|
||||
'past_record.label_clock_in': 'Clock-in:',
|
||||
'past_record.label_clock_out': 'Clock-out:',
|
||||
|
||||
@ -6,6 +6,32 @@ from datetime import datetime, time, timedelta
|
||||
from typing import Tuple, Optional
|
||||
|
||||
|
||||
def resolve_work_datetimes(date_str: str, clock_in: str, clock_out: str) -> Tuple[datetime, datetime]:
|
||||
"""출근일 기준 (출근, 퇴근) datetime. 퇴근이 출근보다 이르면 익일로 해석.
|
||||
|
||||
work_records는 출근일 날짜 한 행에 clock_in/clock_out TIME만 저장하므로
|
||||
"09:00 출근 → 01:30 퇴근"(야근)은 clock_out < clock_in 으로 나타난다.
|
||||
break_records의 자정 처리(복귀 < 외출 → 익일)와 같은 규칙.
|
||||
|
||||
Args:
|
||||
date_str: 출근일 'YYYY-MM-DD'
|
||||
clock_in / clock_out: 'HH:MM:SS' (또는 'HH:MM')
|
||||
"""
|
||||
day = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
fmt = '%H:%M:%S' if clock_in.count(':') == 2 else '%H:%M'
|
||||
ci = datetime.combine(day, datetime.strptime(clock_in, fmt).time())
|
||||
fmt = '%H:%M:%S' if clock_out.count(':') == 2 else '%H:%M'
|
||||
co = datetime.combine(day, datetime.strptime(clock_out, fmt).time())
|
||||
if co < ci:
|
||||
co += timedelta(days=1)
|
||||
return ci, co
|
||||
|
||||
|
||||
def is_overnight(clock_in: str, clock_out: str) -> bool:
|
||||
"""퇴근 시각이 익일인지 (문자열 비교 — 'HH:MM:SS' 는 사전순=시간순)."""
|
||||
return bool(clock_out) and clock_out < clock_in
|
||||
|
||||
|
||||
class TimeCalculator:
|
||||
"""근무시간 계산 클래스"""
|
||||
|
||||
|
||||
@ -4,4 +4,4 @@
|
||||
릴리스 시 이 값을 올린 후 git tag → push.
|
||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||
"""
|
||||
__version__ = '2.12.0'
|
||||
__version__ = '2.13.0'
|
||||
|
||||
@ -9,7 +9,7 @@ import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.time_calculator import TimeCalculator
|
||||
from core.time_calculator import TimeCalculator, resolve_work_datetimes, is_overnight
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -172,3 +172,39 @@ class TestHolidayOvertime:
|
||||
ci, now, include_lunch=True, include_dinner=True
|
||||
)
|
||||
assert actual == 0 and earned == 0
|
||||
|
||||
|
||||
class TestResolveWorkDatetimes:
|
||||
"""익일(자정 넘김) 퇴근 해석 — 야근 기록 저장/수정의 기준."""
|
||||
|
||||
def test_same_day(self):
|
||||
ci, co = resolve_work_datetimes('2026-05-01', '09:00:00', '18:00:00')
|
||||
assert ci == datetime(2026, 5, 1, 9, 0)
|
||||
assert co == datetime(2026, 5, 1, 18, 0)
|
||||
|
||||
def test_overnight_clock_out_rolls_to_next_day(self):
|
||||
ci, co = resolve_work_datetimes('2026-05-01', '09:00:00', '01:30:00')
|
||||
assert co == datetime(2026, 5, 2, 1, 30)
|
||||
assert (co - ci) == timedelta(hours=16, minutes=30)
|
||||
|
||||
def test_equal_times_stay_same_day(self):
|
||||
# 동시각은 익일로 밀지 않음 (UI에서 반려하는 케이스)
|
||||
ci, co = resolve_work_datetimes('2026-05-01', '09:00:00', '09:00:00')
|
||||
assert ci == co
|
||||
|
||||
def test_accepts_hh_mm(self):
|
||||
ci, co = resolve_work_datetimes('2026-05-01', '09:00', '01:30')
|
||||
assert (co - ci) == timedelta(hours=16, minutes=30)
|
||||
|
||||
def test_is_overnight(self):
|
||||
assert is_overnight('09:00:00', '01:30:00') is True
|
||||
assert is_overnight('09:00:00', '18:00:00') is False
|
||||
assert is_overnight('09:00:00', '') is False
|
||||
|
||||
def test_overtime_uses_resolved_datetimes(self, calc_8h):
|
||||
"""익일 퇴근도 정상 연장근무로 계산되는지 (음수 방지)."""
|
||||
ci, co = resolve_work_datetimes('2026-05-01', '09:00:00', '01:30:00')
|
||||
actual, earned = calc_8h.calculate_overtime(ci, co, include_lunch=True)
|
||||
# 09:00 출근 + 8h + 점심 1h → 정시 18:00, 익일 01:30 퇴근 = 450분 초과
|
||||
assert actual == 450
|
||||
assert earned == 450
|
||||
|
||||
@ -85,6 +85,11 @@ class CalendarView(QDialog):
|
||||
self.edit_time_button.clicked.connect(self.edit_work_time)
|
||||
button_layout.addWidget(self.edit_time_button)
|
||||
|
||||
self.copy_report_button = QPushButton(tr('cal.copy_report'))
|
||||
self.copy_report_button.setEnabled(False)
|
||||
self.copy_report_button.clicked.connect(self.copy_daily_report)
|
||||
button_layout.addWidget(self.copy_report_button)
|
||||
|
||||
self.delete_record_button = QPushButton(tr('cal.delete_record'))
|
||||
self.delete_record_button.setObjectName("btn_danger")
|
||||
self.delete_record_button.setEnabled(False)
|
||||
@ -196,10 +201,9 @@ class CalendarView(QDialog):
|
||||
try:
|
||||
wid = self.db.add_work_record(date_str, data['clock_in'], is_manual=True)
|
||||
if data.get('clock_out'):
|
||||
# 총 시간/연장근무 계산
|
||||
from datetime import datetime as _dt
|
||||
ci = _dt.strptime(f"{date_str} {data['clock_in']}", '%Y-%m-%d %H:%M:%S')
|
||||
co = _dt.strptime(f"{date_str} {data['clock_out']}", '%Y-%m-%d %H:%M:%S')
|
||||
# 총 시간/연장근무 계산 (익일 퇴근이면 퇴근에 +1일)
|
||||
from core.time_calculator import resolve_work_datetimes
|
||||
ci, co = resolve_work_datetimes(date_str, data['clock_in'], data['clock_out'])
|
||||
from core.time_calculator import TimeCalculator
|
||||
wm = self.db.get_work_minutes()
|
||||
lunch = self.db.get_setting_int('lunch_duration_minutes', 60)
|
||||
@ -275,7 +279,11 @@ class CalendarView(QDialog):
|
||||
detail += tr('cal.detail_clock_in', time=record['clock_in']) + '\n'
|
||||
|
||||
if record.get('clock_out'):
|
||||
detail += tr('cal.detail_clock_out', time=record['clock_out']) + '\n'
|
||||
from core.time_calculator import is_overnight
|
||||
out_str = record['clock_out']
|
||||
if is_overnight(record['clock_in'], out_str):
|
||||
out_str = tr('label.next_day_time', time=out_str)
|
||||
detail += tr('cal.detail_clock_out', time=out_str) + '\n'
|
||||
detail += tr('cal.detail_total_hours', hours=record.get('total_hours', 0)) + '\n'
|
||||
|
||||
if record.get('lunch_break'):
|
||||
@ -302,6 +310,7 @@ class CalendarView(QDialog):
|
||||
self.detail_text.setText(detail)
|
||||
self.edit_time_button.setEnabled(True)
|
||||
self.delete_record_button.setEnabled(True)
|
||||
self.copy_report_button.setEnabled(True)
|
||||
|
||||
# 메모 필드 업데이트
|
||||
self.memo_edit.setPlainText(record.get('memo', ''))
|
||||
@ -310,9 +319,22 @@ class CalendarView(QDialog):
|
||||
self.detail_text.setText(tr('cal.detail_date_fmt', year=selected_date.year, month=selected_date.month, day=selected_date.day) + '\n\n' + tr('cal.no_record'))
|
||||
self.edit_time_button.setEnabled(False)
|
||||
self.delete_record_button.setEnabled(False)
|
||||
self.copy_report_button.setEnabled(False)
|
||||
self.memo_edit.setPlainText('')
|
||||
self.save_memo_button.setEnabled(False)
|
||||
|
||||
def copy_daily_report(self):
|
||||
"""선택한 날짜의 일일보고서를 클립보드로 복사.
|
||||
|
||||
메인 창의 생성기를 그대로 재사용 — 당일 보고를 놓쳐도 나중에 다시 뽑을 수 있다.
|
||||
"""
|
||||
if not self.selected_date_str:
|
||||
return
|
||||
main_window = self.parent()
|
||||
if main_window is None or not hasattr(main_window, 'generate_daily_report'):
|
||||
return # 단독 실행(__main__) 등 부모가 메인 창이 아닌 경우
|
||||
main_window.generate_daily_report(self.selected_date_str)
|
||||
|
||||
def delete_selected_record(self):
|
||||
"""선택된 날짜의 출근 기록 삭제"""
|
||||
if not self.selected_date_str:
|
||||
@ -455,6 +477,10 @@ class EditWorkTimeDialog(QDialog):
|
||||
if self.record.get('clock_out'):
|
||||
clock_out_time = QTime.fromString(self.record['clock_out'], "HH:mm:ss")
|
||||
self.clock_out_edit.setTime(clock_out_time)
|
||||
else:
|
||||
# 미퇴근 기록: 기본값 00:00은 "익일 자정 퇴근"으로 오인될 수 있으므로
|
||||
# 출근 시각과 동일하게 두어 save_changes()의 동시각 반려에 걸리게 한다.
|
||||
self.clock_out_edit.setTime(clock_in_time)
|
||||
clock_out_layout.addWidget(self.clock_out_edit)
|
||||
|
||||
clock_out_plus_btn = QPushButton(tr('cal.btn_plus_30'))
|
||||
@ -463,6 +489,15 @@ class EditWorkTimeDialog(QDialog):
|
||||
clock_out_layout.addWidget(clock_out_plus_btn)
|
||||
layout.addLayout(clock_out_layout)
|
||||
|
||||
# 익일 퇴근 안내 (퇴근 < 출근이면 표시)
|
||||
self.overnight_note = QLabel('')
|
||||
self.overnight_note.setObjectName("note_text")
|
||||
self.overnight_note.setVisible(False)
|
||||
layout.addWidget(self.overnight_note)
|
||||
self.clock_in_edit.timeChanged.connect(self._refresh_overnight_note)
|
||||
self.clock_out_edit.timeChanged.connect(self._refresh_overnight_note)
|
||||
self._refresh_overnight_note()
|
||||
|
||||
# 점심/저녁 체크박스 - 한 줄에
|
||||
from PyQt5.QtWidgets import QCheckBox
|
||||
check_layout = QHBoxLayout()
|
||||
@ -501,6 +536,18 @@ class EditWorkTimeDialog(QDialog):
|
||||
new_time = current_time.addSecs(minutes * 60)
|
||||
time_edit.setTime(new_time)
|
||||
|
||||
def _refresh_overnight_note(self):
|
||||
"""퇴근이 출근보다 이르면 익일 퇴근으로 해석됨을 표시."""
|
||||
ci = self.clock_in_edit.time()
|
||||
co = self.clock_out_edit.time()
|
||||
if co >= ci:
|
||||
self.overnight_note.setVisible(False)
|
||||
return
|
||||
total_min = ci.secsTo(co) // 60 + 24 * 60 # 익일까지
|
||||
self.overnight_note.setText(tr('edit.overnight_note',
|
||||
hours=total_min // 60, minutes=total_min % 60))
|
||||
self.overnight_note.setVisible(True)
|
||||
|
||||
def save_changes(self):
|
||||
"""변경사항 저장"""
|
||||
clock_in = self.clock_in_edit.time().toString("HH:mm:ss")
|
||||
@ -508,8 +555,8 @@ class EditWorkTimeDialog(QDialog):
|
||||
lunch_break = self.lunch_check.isChecked()
|
||||
dinner_break = self.dinner_check.isChecked()
|
||||
|
||||
# 퇴근 시간이 출근 시간보다 빠른지 확인
|
||||
if clock_out <= clock_in:
|
||||
# 퇴근 < 출근은 익일 퇴근(야근)으로 허용. 같은 시각만 반려.
|
||||
if clock_out == clock_in:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
tr('cal.time_error_title'),
|
||||
@ -519,12 +566,10 @@ class EditWorkTimeDialog(QDialog):
|
||||
|
||||
# 근무 시간 계산
|
||||
from datetime import datetime, timedelta
|
||||
from core.time_calculator import TimeCalculator
|
||||
from core.time_calculator import TimeCalculator, resolve_work_datetimes
|
||||
|
||||
# 해당 날짜의 datetime 객체 생성
|
||||
date_obj = datetime.strptime(self.date_str, "%Y-%m-%d").date()
|
||||
clock_in_dt = datetime.combine(date_obj, datetime.strptime(clock_in, "%H:%M:%S").time())
|
||||
clock_out_dt = datetime.combine(date_obj, datetime.strptime(clock_out, "%H:%M:%S").time())
|
||||
# 해당 날짜의 datetime 객체 생성 (익일 퇴근이면 퇴근에 +1일)
|
||||
clock_in_dt, clock_out_dt = resolve_work_datetimes(self.date_str, clock_in, clock_out)
|
||||
|
||||
# 총 근무시간 계산
|
||||
total_hours = (clock_out_dt - clock_in_dt).total_seconds() / 3600
|
||||
|
||||
@ -25,7 +25,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.database import Database
|
||||
from core.event_monitor import EventMonitor
|
||||
from core.time_calculator import TimeCalculator
|
||||
from core.time_calculator import TimeCalculator, resolve_work_datetimes
|
||||
from ui.clock_in_dialog import ClockInDialog
|
||||
from ui.calendar_view import CalendarView
|
||||
from ui.stats_view import StatsView
|
||||
@ -608,9 +608,11 @@ class MainWindow(QMainWindow):
|
||||
use_1hour_leave_button = QPushButton(tr('btn.use_1hour'))
|
||||
use_half_leave_button = QPushButton(tr('btn.half_leave'))
|
||||
use_full_leave_button = QPushButton(tr('btn.full_leave'))
|
||||
use_custom_leave_button = QPushButton(tr('btn.custom_input'))
|
||||
leave_detail_button = QPushButton(tr('btn.detail'))
|
||||
|
||||
for btn in [use_30min_leave_button, use_1hour_leave_button, use_half_leave_button, use_full_leave_button, leave_detail_button]:
|
||||
for btn in [use_30min_leave_button, use_1hour_leave_button, use_half_leave_button,
|
||||
use_full_leave_button, use_custom_leave_button, leave_detail_button]:
|
||||
btn.setObjectName("btn_small")
|
||||
leave_button_layout.addWidget(btn)
|
||||
|
||||
@ -618,6 +620,7 @@ class MainWindow(QMainWindow):
|
||||
use_1hour_leave_button.clicked.connect(lambda: self.use_leave(1.0/8)) # 0.125일
|
||||
use_half_leave_button.clicked.connect(lambda: self.use_leave(0.5))
|
||||
use_full_leave_button.clicked.connect(lambda: self.use_leave(1.0))
|
||||
use_custom_leave_button.clicked.connect(self.use_custom_leave) # 연장근무와 동일한 상세입력
|
||||
leave_detail_button.clicked.connect(self.show_leave_detail)
|
||||
|
||||
layout.addLayout(leave_button_layout)
|
||||
@ -1060,8 +1063,13 @@ class MainWindow(QMainWindow):
|
||||
# 다이얼로그 종료 후 잔액 업데이트
|
||||
self.update_overtime_balance()
|
||||
|
||||
def use_leave(self, days: float):
|
||||
"""연차 사용"""
|
||||
def use_leave(self, days: float, date_str: str = None, memo: str = None):
|
||||
"""연차 사용.
|
||||
|
||||
date_str 생략 시 오늘 날짜로 바로 확인만 받고 처리 — 연장근무 사용
|
||||
(use_overtime)과 같은 흐름. 날짜·사유를 지정하려면 상세입력
|
||||
(use_custom_leave → AddLeaveDialog)을 쓴다.
|
||||
"""
|
||||
balance = self.db.get_leave_balance()
|
||||
|
||||
if balance < days:
|
||||
@ -1072,44 +1080,21 @@ class MainWindow(QMainWindow):
|
||||
)
|
||||
return
|
||||
|
||||
# 사용 날짜 입력
|
||||
from PyQt5.QtWidgets import QInputDialog, QLineEdit
|
||||
from datetime import date
|
||||
|
||||
today = date.today().isoformat()
|
||||
date_str, ok = QInputDialog.getText(
|
||||
self,
|
||||
tr('msg.leave_use_date.title'),
|
||||
tr('msg.leave_use_date.body'),
|
||||
QLineEdit.Normal,
|
||||
today
|
||||
)
|
||||
|
||||
if not ok or not date_str:
|
||||
return
|
||||
|
||||
# 날짜 형식 검증
|
||||
try:
|
||||
datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
tr('msg.input_error.title'),
|
||||
tr('msg.input_error.date_format')
|
||||
)
|
||||
return
|
||||
|
||||
# 메모 입력
|
||||
memo, ok = QInputDialog.getText(
|
||||
self,
|
||||
tr('msg.leave_use_reason.title'),
|
||||
tr('msg.leave_use_reason.body'),
|
||||
QLineEdit.Normal,
|
||||
""
|
||||
)
|
||||
|
||||
if not ok:
|
||||
return
|
||||
if date_str is None:
|
||||
date_str = date.today().isoformat()
|
||||
else:
|
||||
# 날짜 형식 검증
|
||||
try:
|
||||
datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
tr('msg.input_error.title'),
|
||||
tr('msg.input_error.date_format')
|
||||
)
|
||||
return
|
||||
|
||||
# 사용 확인
|
||||
if days == 1.0:
|
||||
@ -1197,38 +1182,18 @@ class MainWindow(QMainWindow):
|
||||
self.use_overtime(minutes)
|
||||
|
||||
def use_custom_leave(self):
|
||||
"""사용자 정의 연차 사용"""
|
||||
from PyQt5.QtWidgets import QInputDialog
|
||||
"""연차 상세 입력 — 날짜(캘린더)/유형/시간/사유를 한 화면에서.
|
||||
|
||||
balance = self.db.get_leave_balance()
|
||||
연차 내역 화면의 AddLeaveDialog를 그대로 재사용한다. 그쪽은 잔액 외에
|
||||
주말·공휴일 차단과 같은 날 1일 초과 중복 검증까지 갖추고 있어,
|
||||
메인 창에서 바로 띄우는 편이 QInputDialog 연쇄보다 안전하고 간편하다.
|
||||
"""
|
||||
from PyQt5.QtWidgets import QDialog
|
||||
from ui.leave_view import AddLeaveDialog
|
||||
|
||||
# 사용할 시간 입력 (시간 단위)
|
||||
hours, ok = QInputDialog.getDouble(
|
||||
self,
|
||||
tr('msg.input.title'),
|
||||
tr('msg.leave_input.body'),
|
||||
0.5,
|
||||
0.5,
|
||||
80.0,
|
||||
1
|
||||
)
|
||||
|
||||
if not ok:
|
||||
return
|
||||
|
||||
# 시간을 일수로 변환 (8시간 = 1일)
|
||||
days = hours / 8.0
|
||||
|
||||
if days > balance:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
tr('msg.leave_short.title'),
|
||||
tr('msg.leave_short_hours.body', balance=balance, balance_hours=int(balance * 8), days=days, hours=hours)
|
||||
)
|
||||
return
|
||||
|
||||
# use_leave 메서드 호출
|
||||
self.use_leave(days)
|
||||
dialog = AddLeaveDialog(self, self.db)
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
self.update_leave_balance()
|
||||
|
||||
def show_leave_detail(self):
|
||||
"""연차 상세 내역 보기"""
|
||||
@ -1247,12 +1212,32 @@ class MainWindow(QMainWindow):
|
||||
# 퇴근 완료 -> 퇴근 취소
|
||||
self.cancel_clock_out()
|
||||
|
||||
def current_workday_date(self) -> str:
|
||||
"""진행 중인(또는 방금 끝난) 근무일 날짜 'YYYY-MM-DD'.
|
||||
|
||||
자정~근무일 경계시간(기본 6시) 사이에는 달력상 날짜가 이미 넘어갔어도
|
||||
출근일이 근무일이다. handle_workday_rollover()가 경계시간 전까지
|
||||
전날 근무를 유지하는 것과 같은 기준.
|
||||
"""
|
||||
if self.is_clocked_in and self.clock_in_time:
|
||||
return self.clock_in_time.date().isoformat()
|
||||
now = datetime.now()
|
||||
boundary = int(self.db.get_setting(WORKDAY_BOUNDARY_HOUR, '6'))
|
||||
today_str = now.date().isoformat()
|
||||
# 새벽에 퇴근 직후: 오늘 기록이 아직 없으면 전날이 근무일
|
||||
if now.hour < boundary and not self.db.get_work_record(today_str):
|
||||
return (now.date() - timedelta(days=1)).isoformat()
|
||||
return today_str
|
||||
|
||||
def clock_out(self):
|
||||
"""퇴근 처리"""
|
||||
if not self.is_clocked_in:
|
||||
return
|
||||
|
||||
now = datetime.now()
|
||||
# 근무일 = 출근일. 자정 넘겨 퇴근하면 now.date()는 이미 다음 날이므로
|
||||
# 이 값을 써야 출근일 행에 퇴근이 기록된다 (UPDATE 0건 방지).
|
||||
workday = self.clock_in_time.date().isoformat()
|
||||
|
||||
# 확인 메시지
|
||||
reply = QMessageBox.question(
|
||||
@ -1272,12 +1257,13 @@ class MainWindow(QMainWindow):
|
||||
self.clock_in_time, now
|
||||
)
|
||||
|
||||
# 주말/공휴일 체크
|
||||
is_non_working_day = self.time_calc.is_non_working_day(now, self.db)
|
||||
day_type = self.time_calc.get_day_type(now, self.db)
|
||||
# 주말/공휴일 체크 — 근무 성격은 출근일로 판정 (금요일 야근이 토요일
|
||||
# 근무로 뒤바뀌지 않게). 롤오버 경로와 동일 기준.
|
||||
is_non_working_day = self.time_calc.is_non_working_day(self.clock_in_time, self.db)
|
||||
day_type = self.time_calc.get_day_type(self.clock_in_time, self.db)
|
||||
|
||||
# 오늘의 외출 시간 가져오기
|
||||
break_minutes = self.db.get_total_break_minutes_today()
|
||||
# 근무일의 외출 시간 가져오기
|
||||
break_minutes = self.db.get_total_break_minutes_today(workday)
|
||||
|
||||
# 적립 단위(분) — 사용자 설정. 기본 30, 옵션 15/60.
|
||||
unit_minutes = self.db.get_setting_int('overtime_unit', 30)
|
||||
@ -1318,7 +1304,7 @@ class MainWindow(QMainWindow):
|
||||
overtime_earned = 0 # 적립 스킵 (overtime_actual은 기록용으로 유지)
|
||||
|
||||
# DB 업데이트
|
||||
today = datetime.now().date().isoformat()
|
||||
today = workday
|
||||
clock_out_str = now.strftime("%H:%M:%S")
|
||||
|
||||
self.db.update_clock_out(
|
||||
@ -1328,7 +1314,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# 연장근무 적립 기록
|
||||
if overtime_earned > 0:
|
||||
today_record = self.db.get_today_record()
|
||||
today_record = self.db.get_work_record(today)
|
||||
if today_record:
|
||||
self.db.add_overtime_earned(
|
||||
today_record['id'], overtime_earned, today
|
||||
@ -1387,9 +1373,8 @@ class MainWindow(QMainWindow):
|
||||
return
|
||||
|
||||
try:
|
||||
# DB에서 퇴근 취소
|
||||
today = datetime.now().date().isoformat()
|
||||
success = self.db.cancel_clock_out(today)
|
||||
# DB에서 퇴근 취소 — 새벽 퇴근이면 달력 날짜가 아니라 출근일 행
|
||||
success = self.db.cancel_clock_out(self.current_workday_date())
|
||||
|
||||
if success:
|
||||
# 상태 복원
|
||||
@ -2315,10 +2300,13 @@ class MainWindow(QMainWindow):
|
||||
return
|
||||
report_lines.append("")
|
||||
|
||||
def generate_daily_report(self):
|
||||
"""오늘 하루 근무 내역 보고서 생성 및 클립보드 복사"""
|
||||
from datetime import date
|
||||
def generate_daily_report(self, target_date: str = None):
|
||||
"""근무 내역 보고서 생성 및 클립보드 복사.
|
||||
|
||||
Args:
|
||||
target_date: 'YYYY-MM-DD'. 생략 시 진행 중인 근무일
|
||||
(자정~경계시간 사이면 출근일). 캘린더에서 과거 날짜로도 호출.
|
||||
"""
|
||||
# 도전과제 카운터 (보고서 생성 횟수)
|
||||
try:
|
||||
cur = self.db.get_setting_int('daily_report_count', 0)
|
||||
@ -2326,10 +2314,10 @@ class MainWindow(QMainWindow):
|
||||
except Exception as e:
|
||||
dlog(f"daily report counter failed: {e}")
|
||||
|
||||
today = date.today().isoformat()
|
||||
date_str = target_date or self.current_workday_date()
|
||||
|
||||
# 오늘의 근무 기록 조회
|
||||
work_record = self.db.get_today_record()
|
||||
# 해당 근무일의 기록 조회
|
||||
work_record = self.db.get_work_record(date_str)
|
||||
|
||||
if not work_record:
|
||||
QMessageBox.warning(
|
||||
@ -2342,17 +2330,21 @@ class MainWindow(QMainWindow):
|
||||
# 보고서 작성
|
||||
report_lines = []
|
||||
report_lines.append("=" * 40)
|
||||
report_lines.append(tr('report.title', date=today))
|
||||
report_lines.append(tr('report.title', date=date_str))
|
||||
report_lines.append("=" * 40)
|
||||
report_lines.append("")
|
||||
|
||||
# 출근/퇴근 시간
|
||||
clock_in_dt = datetime.fromisoformat(f"{today} {work_record['clock_in']}")
|
||||
# 출근/퇴근 시간 (퇴근이 출근보다 이르면 익일 퇴근)
|
||||
clock_in_dt = datetime.fromisoformat(f"{date_str} {work_record['clock_in']}")
|
||||
report_lines.append(tr('report.clock_in', time=self.format_time(clock_in_dt, include_seconds=True)))
|
||||
|
||||
if work_record['clock_out']:
|
||||
clock_out_dt = datetime.fromisoformat(f"{today} {work_record['clock_out']}")
|
||||
report_lines.append(tr('report.clock_out', time=self.format_time(clock_out_dt, include_seconds=True)))
|
||||
_, clock_out_dt = resolve_work_datetimes(
|
||||
date_str, work_record['clock_in'], work_record['clock_out'])
|
||||
out_str = self.format_time(clock_out_dt, include_seconds=True)
|
||||
if clock_out_dt.date() != clock_in_dt.date():
|
||||
out_str = tr('label.next_day_time', time=out_str)
|
||||
report_lines.append(tr('report.clock_out', time=out_str))
|
||||
|
||||
# 총 근무 시간
|
||||
total_work_hours = work_record.get('total_hours') or work_record.get('work_hours', 0)
|
||||
@ -2367,7 +2359,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# 외출 / 점심 / 저녁 분리 — break_type 으로 구분 (v2.7.0+)
|
||||
# 'break'(또는 NULL) = 일반 외출, 'lunch'/'dinner' = 실측 식사 기록
|
||||
all_break_records = self.db.get_today_break_records()
|
||||
all_break_records = self.db.get_break_records_by_date(date_str)
|
||||
real_break_records = [b for b in all_break_records
|
||||
if (b.get('break_type') or 'break') == 'break']
|
||||
lunch_records = [b for b in all_break_records if b.get('break_type') == 'lunch']
|
||||
@ -2379,9 +2371,9 @@ class MainWindow(QMainWindow):
|
||||
if real_break_minutes > 0 or has_active_break:
|
||||
report_lines.append(tr('report.break_time', time=format_hours_minutes(real_break_minutes)))
|
||||
for br in real_break_records:
|
||||
break_out_time = datetime.fromisoformat(f"{today} {br['break_out']}")
|
||||
break_out_time = datetime.fromisoformat(f"{date_str} {br['break_out']}")
|
||||
if br['break_in']:
|
||||
break_in_time = datetime.fromisoformat(f"{today} {br['break_in']}")
|
||||
break_in_time = datetime.fromisoformat(f"{date_str} {br['break_in']}")
|
||||
# 자정 경계 처리: 복귀 시간이 외출 시간보다 이전이면 다음날로 간주
|
||||
if break_in_time < break_out_time:
|
||||
break_in_time += timedelta(days=1)
|
||||
@ -2397,7 +2389,7 @@ class MainWindow(QMainWindow):
|
||||
lunch_flag = bool(work_record.get('lunch_break', False))
|
||||
if lunch_flag or lunch_records:
|
||||
self._append_meal_section(
|
||||
report_lines, today, tr('label.lunch'),
|
||||
report_lines, date_str, tr('label.lunch'),
|
||||
lunch_flag, lunch_records,
|
||||
self.time_calc.lunch_duration_minutes,
|
||||
)
|
||||
@ -2406,7 +2398,7 @@ class MainWindow(QMainWindow):
|
||||
dinner_flag = bool(work_record.get('dinner_break', False))
|
||||
if dinner_flag or dinner_records:
|
||||
self._append_meal_section(
|
||||
report_lines, today, tr('label.dinner'),
|
||||
report_lines, date_str, tr('label.dinner'),
|
||||
dinner_flag, dinner_records,
|
||||
self.time_calc.dinner_duration_minutes,
|
||||
)
|
||||
@ -2425,8 +2417,8 @@ class MainWindow(QMainWindow):
|
||||
report_lines.append(tr('report.overtime_banked', time=tr('label.time_hours_minutes', hours=earned_hours, minutes=earned_mins)))
|
||||
report_lines.append("")
|
||||
|
||||
# 오늘 사용한 추가근무
|
||||
overtime_used_today = self.db.get_today_overtime_usage()
|
||||
# 해당 날짜에 사용한 추가근무
|
||||
overtime_used_today = self.db.get_today_overtime_usage(date_str)
|
||||
if overtime_used_today > 0:
|
||||
used_hours = overtime_used_today // 60
|
||||
used_mins = overtime_used_today % 60
|
||||
@ -2440,7 +2432,7 @@ class MainWindow(QMainWindow):
|
||||
FROM overtime_usage
|
||||
WHERE date = ?
|
||||
ORDER BY created_at ASC
|
||||
''', (today,))
|
||||
''', (date_str,))
|
||||
usage_records = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
@ -2453,8 +2445,8 @@ class MainWindow(QMainWindow):
|
||||
report_lines.append(tr('report.overtime_used_detail', time=tr('label.time_hours_minutes', hours=used_h, minutes=used_m), reason=reason_text))
|
||||
report_lines.append("")
|
||||
|
||||
# 오늘 사용한 연차 (일괄 추가 및 수동 조정 제외)
|
||||
leave_records = self.db.get_leave_records(start_date=today, end_date=today, exclude_bulk=False)
|
||||
# 해당 날짜에 사용한 연차 (일괄 추가 및 수동 조정 제외)
|
||||
leave_records = self.db.get_leave_records(start_date=date_str, end_date=date_str, exclude_bulk=False)
|
||||
|
||||
# manual 타입이거나 메모에 "일괄 추가"가 포함된 것은 제외
|
||||
filtered_leave_records = [
|
||||
|
||||
@ -21,7 +21,7 @@ class PastRecordDialog(QDialog):
|
||||
self.date_str = date_str
|
||||
self.setWindowTitle(tr('past_record.dialog_title', date=date_str))
|
||||
self.setModal(True)
|
||||
self.setFixedSize(380, 320)
|
||||
self.setFixedSize(380, 348)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(8)
|
||||
@ -55,6 +55,15 @@ class PastRecordDialog(QDialog):
|
||||
co_row.addStretch()
|
||||
layout.addLayout(co_row)
|
||||
|
||||
# 익일 퇴근 안내 (퇴근 < 출근이면 표시)
|
||||
self.overnight_note = QLabel('')
|
||||
self.overnight_note.setObjectName("note_text")
|
||||
self.overnight_note.setVisible(False)
|
||||
layout.addWidget(self.overnight_note)
|
||||
self.clock_in_edit.timeChanged.connect(self._refresh_overnight_note)
|
||||
self.clock_out_edit.timeChanged.connect(self._refresh_overnight_note)
|
||||
self.clock_out_check.toggled.connect(self._refresh_overnight_note)
|
||||
|
||||
# 점심/저녁
|
||||
meal_row = QHBoxLayout()
|
||||
self.lunch_check = QCheckBox(tr('past_record.check_lunch'))
|
||||
@ -88,11 +97,25 @@ class PastRecordDialog(QDialog):
|
||||
self.setLayout(layout)
|
||||
apply_dark_titlebar(self)
|
||||
|
||||
def _refresh_overnight_note(self):
|
||||
"""퇴근이 출근보다 이르면 익일 퇴근으로 해석됨을 표시."""
|
||||
if not self.clock_out_check.isChecked():
|
||||
self.overnight_note.setVisible(False)
|
||||
return
|
||||
ci = self.clock_in_edit.time()
|
||||
co = self.clock_out_edit.time()
|
||||
if co >= ci:
|
||||
self.overnight_note.setVisible(False)
|
||||
return
|
||||
total_min = ci.secsTo(co) // 60 + 24 * 60 # 익일까지
|
||||
self.overnight_note.setText(tr('edit.overnight_note',
|
||||
hours=total_min // 60, minutes=total_min % 60))
|
||||
self.overnight_note.setVisible(True)
|
||||
|
||||
def _validate_and_accept(self):
|
||||
# 퇴근 < 출근은 익일 퇴근(야근)으로 허용. 같은 시각만 반려.
|
||||
if self.clock_out_check.isChecked():
|
||||
ci = self.clock_in_edit.time()
|
||||
co = self.clock_out_edit.time()
|
||||
if co <= ci:
|
||||
if self.clock_out_edit.time() == self.clock_in_edit.time():
|
||||
QMessageBox.warning(self, tr('past_record.input_error_title'),
|
||||
tr('past_record.input_error_body'))
|
||||
return
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user