Files
aars/backend/models.py
T
IT Dog 347a862cae Apply conditional upsert to attendance + accident Excel upload
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
2026-07-19 20:47:51 +08:00

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)