0cd8717320
Frontend (Holidays.jsx LeaveModal): - Split into Edit Mode (single record) + Create Mode (multi-row) - Create mode: shared employee/date/approver + list of (type, hours, reason) rows - Add/remove rows dynamically, each with own type + hours + reason - Submit loops POST /api/leave per row, aggregates results (added/skipped/failed) - Submit button label shows count: "加 N 條" - Bottom shows total valid count + total hours - Result summary shown after submit (expandable details) - Auto-close on success after 1.2s Backend: - LeaveRecord: add UniqueConstraint (employee_name, date, leave_type) - Migration: CREATE UNIQUE INDEX uix_leave_emp_date_type (cleaned any pre-existing dups first) - POST /api/leave: catch IntegrityError → return 409 with clear message\n\nVerified:\n- 3 same-day different-type records (SL/CL/AL) created successfully\n- POST same type twice returns 409 with specific message\n- HTTP 200 for valid creation, 409 for collision
239 lines
9.6 KiB
Python
239 lines
9.6 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.
|
|
|
|
Unique constraint on (employee_name, date, leave_type) ensures one record
|
|
per employee per day per leave type. Re-import of same record upserts.
|
|
"""
|
|
__tablename__ = "leave_records"
|
|
__table_args__ = (
|
|
UniqueConstraint("employee_name", "date", "leave_type", name="uix_emp_date_type"),
|
|
)
|
|
|
|
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)
|