347a862cae
Schema:
- attendance_records: last_edited_at + last_edited_by (was: is_manually_edited boolean only)
- accidents: last_edited_at + last_edited_by (was: no edit tracking)
Migration: ALTER TABLE adds columns (idempotent)
Logic:
- Attendance import: skip if last_edited_at IS NOT NULL OR is_manually_edited (backward compat)
- Accident import: skip if last_edited_at IS NOT NULL (was: always overwrite)
PUT endpoints:
- PUT /api/attendance/{record_id}: set last_edited_at + last_edited_by + is_manually_edited
- PUT /api/accident/{record_id} (NEW): set last_edited_at + last_edited_by, whitelist editable fields
Verified via direct _import_attendance_to_db / _import_accident_to_db call:
- Edited attendance: skipped on re-import (status_text preserved)
- Edited accident: skipped on re-import (description preserved)
- Not-edited accident: updated to new values (description changed)
Note: HTTP /api/upload/{section} returned 404 in test, but function logic verified working
233 lines
9.3 KiB
Python
233 lines
9.3 KiB
Python
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, Date, JSON, ForeignKey, UniqueConstraint, Numeric
|
|
from sqlalchemy.orm import relationship
|
|
from sqlalchemy.sql import func
|
|
from database import Base
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
def now_hkt():
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
# ============ EXISTING MODELS (kept) ============
|
|
|
|
class User(Base):
|
|
__tablename__ = "users"
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
email = Column(String(255), unique=True, index=True, nullable=False)
|
|
password_hash = Column(String(255), nullable=False)
|
|
name = Column(String(255), nullable=False)
|
|
role = Column(String(50), default="user")
|
|
is_active = Column(Boolean, default=True)
|
|
created_at = Column(DateTime, default=now_hkt)
|
|
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
|
|
deleted_at = Column(DateTime, nullable=True)
|
|
|
|
|
|
class Accident(Base):
|
|
__tablename__ = "accidents"
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
date = Column(Date, nullable=False)
|
|
time = Column(String(10), nullable=True)
|
|
location = Column(String(255), nullable=False)
|
|
employee_name = Column(String(255), nullable=False)
|
|
employee_id = Column(String(100))
|
|
department = Column(String(100))
|
|
description = Column(Text, nullable=False)
|
|
severity = Column(String(50), nullable=False)
|
|
medical_report = Column(Text, nullable=True)
|
|
action_taken = Column(Text, nullable=True)
|
|
responsible_person = Column(String(255), nullable=True)
|
|
|
|
# Excel-derived (Google Form) extra columns (full map) -- 2026-07-18
|
|
patient_or_staff = Column(Text, nullable=True) # Excel col 8
|
|
long_description = Column(Text, nullable=True) # Excel col 9
|
|
incident_photos = Column(Text, nullable=True) # Excel col 10 (URL)
|
|
needs_escalation = Column(String(20), nullable=True) # Excel col 12
|
|
incident_score = Column(Integer, nullable=True) # Excel col 14
|
|
submitted_at = Column(DateTime, nullable=True) # Excel col 1 (timestamp)
|
|
excel_row = Column(Integer, nullable=True) # original Excel row (1-based)
|
|
|
|
custom_fields = Column(JSON, default={})
|
|
created_by = Column(Integer, ForeignKey("users.id"))
|
|
|
|
# Manual edit tracking - upload skips records with non-null last_edited_at
|
|
last_edited_at = Column(DateTime, nullable=True)
|
|
last_edited_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
|
|
|
created_at = Column(DateTime, default=now_hkt)
|
|
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
|
|
deleted_at = Column(DateTime, nullable=True)
|
|
|
|
|
|
# ============ NEW MODELS ============
|
|
|
|
class Shift(Base):
|
|
"""班次定義"""
|
|
__tablename__ = "shifts"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
shift_code = Column(String(20), unique=True, nullable=False, index=True)
|
|
description = Column(String(100), nullable=True)
|
|
|
|
# 每天的上班時間 (HH:MM format as String)
|
|
mon_start = Column(String(5), nullable=True)
|
|
mon_end = Column(String(5), nullable=True)
|
|
tue_start = Column(String(5), nullable=True)
|
|
tue_end = Column(String(5), nullable=True)
|
|
wed_start = Column(String(5), nullable=True)
|
|
wed_end = Column(String(5), nullable=True)
|
|
thu_start = Column(String(5), nullable=True)
|
|
thu_end = Column(String(5), nullable=True)
|
|
fri_start = Column(String(5), nullable=True)
|
|
fri_end = Column(String(5), nullable=True)
|
|
sat_start = Column(String(5), nullable=True)
|
|
sat_end = Column(String(5), nullable=True)
|
|
sun_start = Column(String(5), nullable=True)
|
|
sun_end = Column(String(5), nullable=True)
|
|
|
|
created_at = Column(DateTime, default=now_hkt)
|
|
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
|
|
|
|
def get_schedule(self, weekday: str):
|
|
"""取得指定星期幾的上班時間 (start, end)"""
|
|
day_map = {
|
|
'monday': ('mon_start', 'mon_end'),
|
|
'tuesday': ('tue_start', 'tue_end'),
|
|
'wednesday': ('wed_start', 'wed_end'),
|
|
'thursday': ('thu_start', 'thu_end'),
|
|
'friday': ('fri_start', 'fri_end'),
|
|
'saturday': ('sat_start', 'sat_end'),
|
|
'sunday': ('sun_start', 'sun_end'),
|
|
# 中文
|
|
'星期一': ('mon_start', 'mon_end'),
|
|
'星期二': ('tue_start', 'tue_end'),
|
|
'星期三': ('wed_start', 'wed_end'),
|
|
'星期四': ('thu_start', 'thu_end'),
|
|
'星期五': ('fri_start', 'fri_end'),
|
|
'星期六': ('sat_start', 'sat_end'),
|
|
'星期日': ('sun_start', 'sun_end'),
|
|
'星期天': ('sun_start', 'sun_end'),
|
|
}
|
|
key = weekday.lower().strip()
|
|
if key not in day_map:
|
|
return None, None
|
|
start_attr, end_attr = day_map[key]
|
|
start = getattr(self, start_attr, None)
|
|
end = getattr(self, end_attr, None)
|
|
# 休息標記
|
|
if start in ('-', '休息', None, ''):
|
|
return None, None
|
|
if end in ('-', '休息', None, ''):
|
|
return None, None
|
|
return start, end
|
|
|
|
|
|
class AttendanceRecord(Base):
|
|
"""出勤記錄"""
|
|
__tablename__ = "attendance_records"
|
|
__table_args__ = (
|
|
UniqueConstraint('employee_name', 'date', name='uix_employee_date'),
|
|
)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
# 員工信息
|
|
employee_name = Column(String(255), nullable=False, index=True)
|
|
company = Column(String(100), nullable=True)
|
|
department = Column(String(100), nullable=True)
|
|
|
|
# 日期
|
|
date = Column(Date, nullable=False, index=True)
|
|
weekday = Column(String(20), nullable=True)
|
|
|
|
# 打卡時間
|
|
check_in = Column(DateTime, nullable=True)
|
|
check_out = Column(DateTime, nullable=True)
|
|
|
|
# 班次
|
|
shift_code = Column(String(20), nullable=True)
|
|
|
|
# 計算結果
|
|
status_code = Column(String(20), nullable=False, default='normal')
|
|
status_text = Column(String(100), nullable=True)
|
|
late_minutes = Column(Integer, default=0)
|
|
early_minutes = Column(Integer, default=0)
|
|
ot_minutes = Column(Integer, default=0)
|
|
|
|
# Expected times
|
|
expected_in = Column(String(10), nullable=True)
|
|
expected_out = Column(String(10), nullable=True)
|
|
actual_in = Column(String(10), nullable=True)
|
|
actual_out = Column(String(10), nullable=True)
|
|
|
|
# 人手修改標記 (deprecated - use last_edited_at instead)
|
|
is_manually_edited = Column(Boolean, default=False)
|
|
|
|
# 原始 Excel 數據
|
|
raw_data = Column(JSON, default={})
|
|
|
|
# Manual edit tracking - upload skips records with non-null last_edited_at
|
|
last_edited_at = Column(DateTime, nullable=True)
|
|
last_edited_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
|
|
|
created_at = Column(DateTime, default=now_hkt)
|
|
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
|
|
|
|
# Foreign key
|
|
shift_id = Column(Integer, ForeignKey("shifts.id"), nullable=True)
|
|
shift = relationship("Shift", foreign_keys=[shift_id])
|
|
|
|
|
|
class Holiday(Base):
|
|
"""公眾假期 / Public Holidays
|
|
|
|
Source: HK Gov (https://www.gov.hk/en/about/abouthk/holiday/{year}.htm)
|
|
Auto-imported by /api/holidays/refresh endpoint, manually editable.
|
|
"""
|
|
__tablename__ = "holidays"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
date = Column(Date, nullable=False, unique=True, index=True)
|
|
name = Column(String(100), nullable=False) # 中文 e.g. "元旦"
|
|
name_en = Column(String(200), nullable=True) # English e.g. "The first day of January"
|
|
region = Column(String(20), nullable=True, default="HK") # "HK" / "CN"
|
|
is_mandatory = Column(Boolean, default=True) # 勞工假 vs 公假 (Sunday replacement)
|
|
source = Column(String(50), nullable=True) # "gov_hk_1823" / "manual"
|
|
notes = Column(Text, nullable=True)
|
|
|
|
# Manual edit tracking - upload skips records with non-null last_edited_at
|
|
last_edited_at = Column(DateTime, nullable=True)
|
|
last_edited_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
|
|
|
created_at = Column(DateTime, default=now_hkt)
|
|
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
|
|
|
|
|
|
class LeaveRecord(Base):
|
|
"""員工請假 / Employee Leave Records
|
|
|
|
Per-employee, per-day, per-type leave. Hours-based granularity (0.5 ~ 8.0).
|
|
HR uploads via CSV/Excel; manually editable.
|
|
"""
|
|
__tablename__ = "leave_records"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
employee_name = Column(String(255), nullable=False, index=True)
|
|
employee_id = Column(String(100), nullable=True) # future, currently NULL
|
|
date = Column(Date, nullable=False, index=True)
|
|
leave_type = Column(String(20), nullable=False) # "SL" / "CL" / "AL"
|
|
hours = Column(Numeric(4, 2), nullable=False) # 0.5 ~ 8.0
|
|
reason = Column(Text, nullable=True)
|
|
approved_by = Column(String(100), nullable=True)
|
|
source = Column(String(50), nullable=True) # "csv_upload" / "manual"
|
|
notes = Column(Text, nullable=True)
|
|
|
|
# Manual edit tracking - upload skips records with non-null last_edited_at
|
|
last_edited_at = Column(DateTime, nullable=True)
|
|
last_edited_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
|
|
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
|
created_at = Column(DateTime, default=now_hkt)
|
|
updated_at = Column(DateTime, default=now_hkt, onupdate=now_hkt)
|