
    &j;                       d Z ddlmZ ddlZddlZddlmZmZ ddlm	Z	mZm
Z
 ddlmZ ddlmZ ddlmZ d	d
lmZ  ed      Z ee      j*                  Zedz  dz  ZdZddddddddZdZdZdZg dZg dZdZg dZ d2dZ!d3dZ"d4d Z#d5d!Z$d6d"Z%d7d#Z&d8d$Z'd9d%Z(d:d&Z)d;d'Z*d<d(Z+e G d) d*             Z,d=d>d+Z-d?d,Z.d@d-Z/d=dAd.Z0dBd/Z1 G d0 d1      Z2y)Cu2  The LFCS timesheet — week maths and the rendered .xlsx.

Sign-on / sign-off events are the presence record and this module never edits one. Hours are
DERIVED here, every time, from the events as they stand. Rebuild is idempotent: same events in,
same sheet out, so it is safe to run on every sign-off.

Two files hold the same events, on purpose:
  <job>/05 - Safety & Compliance/Sign-On/Attendance-Register.csv   per JOB    — the muster
  <Timesheets>/Crew/<Worker>/Attendance.csv                        per WORKER — the timesheet
The job register is the safety record. The worker index exists because a bloke can be on two jobs
in one week and his timesheet is one sheet — without it, the second job's rebuild would overwrite
the first job's hours. It is derivable from the job registers if it is ever lost.
    )annotationsN)	dataclassfield)datedatetime	timedelta)BytesIO)Path)ZoneInfo   )storagezAustralia/SydneyassetszLFCS-Timesheet.xlsx	Timesheet                     )r   r                  F33NzAttendance.csv)	timestampjobnameeventlunchnotesourcedayr   oldnewreasonby)z0:000:30z1:00r*   )MonTueWedThurFriSatSunc                L    t        j                  dd|       j                         S )Nz	[^\w .-]+ )resubstrip)r    s    !/opt/lfcs-signon/app/timesheet.py	safe_namer8   2   s    66,D)//11    c                0   | xs dj                         } | syt        j                  d|       }|r8t        |j	                  d            dz  t        |j	                  d            z   S 	 t        t        t        |       dz              S # t        $ r Y yw xY w)zW'0:30' -> 30. Blank or junk -> 0. Never raises: a bad cell must not lose a day's hours.r3   r   z^(\d+):(\d{1,2})$r   <   r   )r6   r4   matchintgrouproundfloat
ValueError)sms     r7   parse_lunchrD   6   s    	
bA
%q)A1771:#c!''!*o555qB'(( s   )B	 		BBc                    | dz   d| dz  dS )Nr;   :02d )minutess    r7   	fmt_lunchrJ   D   s    m_Agl3/00r9   c                B    | j                  d      j                  d      S )u2   7:12 AM — how the LFCS sheet is written by hand.%I:%M %p0)strftimelstrip)dts    r7   fmt_ampmrQ   H   s    ;;z"))#..r9   c                
   t        j                  dd| xs dj                         j                         j	                  dd            } dD ](  }	 t        j                  | |      j                         c S  y# t        $ r Y 7w xY w)z['17:00' (what a phone's time picker gives) or '5:00 PM' (what a bloke types). None if junk.z\s+ r3   .)z%H:%MrL   z%I:%M%pN)	r4   r5   r6   upperreplacer   strptimetimerA   )rB   fmts     r7   
parse_hhmmrZ   M   s~    
vsQW"OO-335==c2FGA/ 	$$Q,1133
   		s   #A66	BBc                    	 t        j                  | xs dj                               }|j                  |j                  t              S |j                  t              S # t        $ r Y y w xY w)Nr3   tzinfo)r   fromisoformatr6   rA   r]   rV   TZ
astimezone)rB   rP   s     r7   parse_tsra   X   s`    ##QW"OO$56 %'II$52::R: L2==;LL  s   'A   	A,+A,c                n    t        | dz   d      }t        |t        |      z
        dk  rt        |      S |S )z@8.0 -> 8, 7.25 -> 7.25. The sheet's own cells are plain numbers.g&.>r   g{Gzt?)r?   absr=   )hourshs     r7   numrf   `   s4    edlAASV_u,3q63!3r9   c                <    | t        | j                               z
  S )zMonday of the week holding d.days)r   weekday)ds    r7   
week_startrl   f   s    yaiik***r9   c                     | t        d      z   S )zCThe LFCS sheet is labelled by its Friday: 'Week Ending 7 Aug 2026'.r   rh   )r   )mondays    r7   week_endingro   k   s    I1%%%r9   c                R    | j                    d| j                   d| j                   S )NrT   )yearmonthr%   )fridays    r7   we_tagrt   p   s%    kk]!FLL>6::,77r9   c                H    t        |        dt        t        |             dS )Nz Timesheet WE z.xlsx)r8   rt   ro   )workerrn   s     r7   week_filenamerw   t   s&     vk&6I/J.K5QQr9   c                  `   e Zd ZU ded<   ded<   dZded<   dZded<   dZd	ed
<   dZded<   dZded<    e	e
      Zded<    e	e
      Zded<   dZded<    e	e      Zded<   dZded<    e	e
      Zded<   ed d       Zed d       Zed d       Zed d       Zed d       Zed d       Zy)!Dayr   strlabelNdatetime | Nonestartfinishr   r=   	lunch_min        r@   rd   Fboolon_now)default_factoryz	list[str]jobsnotesis_todaydictadjusteddayworksdocketsc                8    dj                  | j                        S )Nz, )joinr   selfs    r7   job_textzDay.job_text   s    yy##r9   c                8    dj                  | j                        S )N; )r   r   r   s    r7   	note_textzDay.note_text   s     yy$$r9   c                H    | j                   rt        | j                         S dS Nr3   )r}   rQ   r   s    r7   
start_textzDay.start_text   s    '+zzx

#9r9r9   c                H    | j                   rt        | j                         S dS r   )r~   rQ   r   s    r7   finish_textzDay.finish_text   s    (,x$;;r9   c                d    | j                   sd| j                  v rt        | j                        S dS )Nr"   r3   )r~   r   rJ   r   r   s    r7   
lunch_textzDay.lunch_text   s*     .2[[Gt}}<Ty(][]]r9   c                P    | j                   dk\  rt        | j                          S dS )N{Gz?r3   )rd   rf   r   s    r7   
hours_textzDay.hours_text   s#    '+zzT'9#djj/"ArAr9   )returnrz   )__name__
__module____qualname____annotations__r}   r~   r   rd   r   r   listr   r   r   r   r   r   r   propertyr   r   r   r   r   r   rH   r9   r7   ry   ry   {   s   
JJ!E?!"FO"IsE5FDD1D)1T2E92Hd 40Hd0 Hdt4GY4$ $ % %
 : : < < ^ ^
 B Br9   ry   c           
        |xs t        j                  t              }t        d      D ci c]0  }|t	        |      z   t        |t	        |      z   t        |         2 }}|j                         D ]$  }|j                  |j                         k(  |_	        & i }g }| D ]P  }|j                  d      xs dj                         j                         }	|	dk(  r|j                  |       L|	dk(  r	 |j                  t        j                  |j                  d      xs dj                                     }
|
rUd	|
_        |j                  d
      xs dj                         }|r)||
j"                  vr|
j"                  j                  |       t%        |j                  dd            }|r|j                         |vr |j'                  |j                         g       j                  ||f       S |j)                         D ]  \  }}||   }|j+                  d        t	               }d}|D ]9  \  }}|j                  d      xs dj                         j                         }	|j                  d      xs dj                         }|j                  d
      xs dj                         }|r)||j,                  vr|j,                  j                  |       |r)||j.                  vr|j.                  j                  |       |	dk(  r||}|j0                  ||_        |	dk(  s|xj2                  t5        |j                  dd            z  c_        |
|||z
  z  }d}||_        < |!d	|_        |t;        ||z
  t	                     z  }t;        |j=                         dz  |j2                  dz  z
  d      |_         tA        ||       tC        |j                               S c c}w # t        $ r d}
Y w xY w)a  Pair on/off events into days. An unclosed 'on' counts up to now and is flagged on_now.

    Split shifts are handled: every on pairs with the next off, Start is the first on of the day,
    Finish the last off. Lunch is the sum of what was declared at each sign-off.
       rh   r!   r3   adjustr   r%   NTr#   r   c                    | d   S Nr   rH   xs    r7   <lambda>zbuild_week.<locals>.<lambda>   s
    ! r9   keyr   onoffr"     r;   r   )"r   nowr_   ranger   ry   
DAY_LABELSvaluesr   r   getr6   lowerappendr^   rA   r   r   ra   
setdefaultitemssortr   r   r}   r   rD   r~   r   maxtotal_secondsrd   _apply_adjustmentsr   )eventsrn   r   iri   rk   per_dayadjustsrowevdwreftsrP   rowsr%   workedopen_atr   r#   s                       r7   
build_weekr      s    
!b!C`efg`hi[\FYA&&FYA5F,F
ST(VViDi[[] *VVsxxz)
* 8:GG <ggg$"++-335> NN3XXd00#''%.2FB1M1M1OPQ "wwv,"3353bjj0JJ%%c*cggk2./RWWYd*2779b)00"c;/<2 MMO QD2h		n	%#' 	 GB'''"(b//1779B775>'R..0CGGFO)r002Ds#((*$CII-		  &Tz? Gyy($&	uSWWWb-A!BB&b7l*F"G
%	 & CJc#-55F,,.58JJCP	7Q: tW%{ j$  s   5O*1AO//O>=O>c                T   t        |d       D ]  }	 t        j                  |j                  d      xs dj	                               }| j                  |      }|sO|j                  d      xs dj	                         j                         }|j                  d      xs dj	                         }|dv rTt        |      }|st        j                  ||      j                  t              }|d	k(  r||_        n'||_        d
|_        n|dk(  rt        |      |_        n|j                  d      xs dj	                         }	|j                  d      xs dj	                         }
dj#                  d |
|	fD              |j$                  |<    | j'                         D ]t  }|j$                  s|j                  s|j                  s*|j                  |j                  z
  j)                         dz  }t+        ||j                   dz  z
  d      |_        v y# t
        $ r Y w xY w)u  Latest correction per field per day wins, laid over the taps — which are left alone.

    A corrected day is recomputed straight off start/finish/lunch. If it held a split shift, that
    collapses to the one span the foreman has just said it was, which is the point of him correcting
    it. Presence is untouched: open_since() still reads only the on/off taps, because who was on
    site is the safety record and a foreman's discretion belongs in hours, not in the muster.
    c                ,    | j                  d      xs dS )Nr   r3   )r   )rs    r7   r   z$_apply_adjustments.<locals>.<lambda>   s    aeeK.@.FB r9   r   r%   r3   r   r'   )r}   r~   r\   r}   Fr"   r)   r(   u    — c              3  &   K   | ]	  }|s|  y wNrH   ).0r   s     r7   	<genexpr>z%_apply_adjustments.<locals>.<genexpr>  s     (Dq!(Ds   r   r;   r   N)sortedr   r^   r   r6   rA   r   rZ   r   combinerV   r_   r}   r~   r   rD   r   r   r   r   r   r   rd   )ri   r   r   whenr%   fldvaltstampedwhowhyr   s               r7   r   r      s    g#GH E	%%swwu~';&B&B&DED hhtnwww%2,,.446wwu~#**,%%3A&&tQ/77r7BGg~#	$
"
G^',CMwwt}"))+wwx &B--/#LL(DS#J(DDS5E8 {{} ><<CII#**jj399,;;=DFFS]]R%77=CI>3  		s   6H	H'&H'c                &    t        d | D              S )Nc              3  4   K   | ]  }|j                     y wr   )rd   )r   rk   s     r7   r   zweek_hours.<locals>.<genexpr>  s     %1qww%s   )sumrh   s    r7   
week_hoursr     s    %%%%r9   c                   |xs t        j                  t              }g }| D ]  }t        |j	                  dd            }|s!|j                         |j                         k(  sC|j                  ||j	                  d      xs dj                         j                         f        |j                  d        d}|D ]  \  }}|dk(  r||}|dk(  sd} |S )	u   Is he on right now? Returns the time he signed on, or None. Today's events only —
    a bloke who forgot to sign off yesterday is not still on site this morning.r   r3   r!   c                    | d   S r   rH   r   s    r7   r   zopen_since.<locals>.<lambda>*  s
    ad r9   r   Nr   r   )
r   r   r_   ra   r   r   r   r6   r   r   )r   r   todaysr   r   r   r   s          r7   
open_sincer   !  s     
!b!CF Jcggk2./"'')sxxz)MM2 0 6B==?EEGHIJ KKNK#G B:'/G5[G	
 Nr9   c           	        ddl m} ddlm ddlm}  |t              }|t            |dd      d fd}t        |      }d	|  d
<   d|j                   d|dd<   |dd<   |dd<   d}|D ]  }	t        |	j                  j                            }
|	j                  dd|
 <   |	j                  r|	j                  d|
 <   |	j                  r|	j                   d|
 <   |	j"                  rt%        |	j&                        d|
 <   |	j(                  dk\  r*t+        |	j(                        d|
 <   ||	j(                  z  }dD ].  \  }}||	j,                  v s || |
 |	j,                  |          0 |	j,                  r: |d|
 dj/                  d |	j,                  j1                         D                     |	j2                  r|	j4                  d|
 <   |	j6                  xs |	j4                  }|r|d|
 <   |	j8                  sdt:         |
 <    |rt+        |      ndt<        <   t?               }|jA                  |       |jC                         S )!u   Fill the real LFCS form. Nothing is invented — a day with no events stays blank.

    An adjusted cell is shaded and carries a note saying who changed it and why, so the office can
    see at a glance that a number was corrected rather than tapped.
    r   )load_workbook)Comment)PatternFillsolidFFE9A8)fgColorc                @    |    }|_          d| d      |_        y )Nz
Adjusted: zLFCS Sign-On)fillcomment)cell_refr   cr   adj_fillwss      r7   markzrender_xlsx.<locals>.markE  s(    xLj.?	r9   zName: A3zDates: Week Ending rS   z%b %YA7z%d/%m/%YC6E6r   BCDEr   F))r}   r   )r~   r   )r"   r   r   c              3  0   K   | ]  \  }}| d |   yw)rS   NrH   )r   kvs      r7   r   zrender_xlsx.<locals>.<genexpr>a  s     #N41aqc1#J#Ns   GJYN)r   rz   r   rz   r   None)"openpyxlr   openpyxl.commentsr   openpyxl.stylesr   TEMPLATESHEETro   r%   DAY_ROWSr   rj   r}   r   r~   r   r   rJ   r   rd   rf   r   r   r   r   r   r   r   DAYWORKS_COL
TOTAL_CELLr	   savegetvalue)rv   rn   ri   r   r   wbr   rs   totalrk   r   r   celldescbufr   r   r   s                  @@@r7   render_xlsxr  7  sB    ')+	x	 B	EB7H5H@
  Fx BtH$VZZL&@BtH"BtH"BtHE +QVV^^%&)Qqc777,,B1#wK88--B1#wK<<#AKK0B1#wK77d?agg,B1#wKQWWEJ 	4ICajj vaS\1::c?3	4 ::1QC$))#N1::;K;K;M#NNO66**B1#wK{{(ajjB1#wK::'*B,s#$/+0 $)SZdBzN
)CGGCL<<>r9   c                  :    e Zd ZdZddZd	d
dZddZddZddZy)	CrewFilesu  <Timesheets root>/Crew/<Worker Name>/ — the worker's own events and his rendered weeks.

    Plain files on the Drive mount, same as everything else. Nothing here deletes or edits: the
    events file is append-only and the .xlsx is a rebuild of what the events already say.
    c                $    t        |      | _        y r   )r
   root)r   r  s     r7   __init__zCrewFiles.__init__z  s    J	r9   c                f    | j                   dz  t        |      z  }|r|j                  dd       |S )NCrewT)parentsexist_ok)r  r8   mkdir)r   rv   createps       r7   
worker_dirzCrewFiles.worker_dir}  s2    II6!22GGD4G0r9   c                ~    | j                  |      t        z  }t        j                  |t        |       t        |      S r   )r  
ATTENDANCEr   
append_csvATTENDANCE_HEADERrz   )r   rv   r   r  s       r7   append_eventzCrewFiles.append_event  s1    OOF#j01/51vr9   c                   | j                  |d      t        z  }|j                         sg S |j                  dd      5 }t	        j
                  |      D cg c]  }t        |       c}cd d d        S c c}w # 1 sw Y   y xY w)NF)r  r3   zutf-8)newlineencoding)r  r   existsopencsv
DictReaderr   )r   rv   r  fr   s        r7   list_eventszCrewFiles.list_events  sq    OOF5O1J>xxzIVVBV1 	8Q%(^^A%67DG7	8 	87	8 	8s    A;A6*A;6A;;Bc                v    | j                  |      t        ||      z  }|j                  |       t        |      S r   )r  rw   write_bytesrz   )r   rv   rn   datar  s        r7   
write_weekzCrewFiles.write_week  s1    OOF#mFF&CC	d1vr9   N)r  rz   )T)rv   rz   r  r   r   r
   )rv   rz   r   r   r   rz   )rv   rz   r   
list[dict])rv   rz   rn   r   r/  bytesr   rz   )	r   r   r   __doc__r  r  r#  r,  r0  rH   r9   r7   r  r  s  s     
8r9   r  )r    rz   r   rz   )rB   rz   r   r=   )rI   r=   r   rz   )rP   r   r   rz   )rB   rz   )rB   rz   r   r|   )rd   r@   )rk   r   r   r   )rn   r   r   r   )rs   r   r   rz   )rv   rz   rn   r   r   rz   r   )r   r1  rn   r   r   r|   r   	list[Day])ri   zdict[date, Day]r   r1  r   r  )ri   r4  r   r@   )r   r1  r   r|   r   r|   )rv   rz   rn   r   ri   r4  r   r2  )3r3  
__future__r   r)  r4   dataclassesr   r   r   r   r   ior	   pathlibr
   zoneinfor   r3   r   r_   __file__parentHEREr  r  r  r	  r  r   r"  LUNCH_CHOICESDEFAULT_LUNCHr   r8   rD   rJ   rQ   rZ   ra   rf   rl   ro   rt   rw   ry   r   r   r   r   r  r  rH   r9   r7   <module>r?     s   # 
 	 ( . .     !H~(?22bRB2"<
 
C (?
21/
M4+
&
8R -B -B -B`DN'>T&,6x r9   