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:
KINDNICK 2026-08-12 14:06:18 +09:00
parent d8c6a9d784
commit f28f0619a4
10 changed files with 324 additions and 136 deletions

View File

@ -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 키화 완료

View File

@ -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.02.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`.

View File

@ -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('''

View File

@ -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:',

View File

@ -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:
"""근무시간 계산 클래스"""

View File

@ -4,4 +4,4 @@
릴리스 값을 올린 git tag push.
CHANGELOG.md의 최상단 항목과 일치시킬 .
"""
__version__ = '2.12.0'
__version__ = '2.13.0'

View File

@ -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

View File

@ -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

View File

@ -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,22 +1080,11 @@ 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
if date_str is None:
date_str = date.today().isoformat()
else:
# 날짜 형식 검증
try:
datetime.strptime(date_str, "%Y-%m-%d")
@ -1099,18 +1096,6 @@ class MainWindow(QMainWindow):
)
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 days == 1.0:
leave_type = tr('leave.type.annual')
@ -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 = [

View File

@ -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 _validate_and_accept(self):
if self.clock_out_check.isChecked():
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:
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():
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