commit working state
Too many changes to split them up. This commit contains roughly two months worth of work. This commit introduces the first aggregate and rewrites the ticket system to follow DDD patterns. It adds Spartan NG as the new component library. TanStack Query to replace GraphQL. Oh and Prisma is almost over as well.
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
,,,Vertrag,Vertrag,Vertrag,Vertrag,Vertrag,Vertrag,Vertrag,Vertrag,"Stunden innerhalb der Soll-Stunden","Stunden innerhalb der Soll-Stunden","Stunden innerhalb der Soll-Stunden","Stunden innerhalb der Soll-Stunden","Stunden innerhalb der Soll-Stunden","Stunden innerhalb der Soll-Stunden",Ist-/Soll-Stunden,Ist-/Soll-Stunden,Ist-/Soll-Stunden,Ist-/Soll-Stunden,Berechnung,Berechnung,Berechnung,Berechnung,Berechnung,Berechnung,Berechnung,Berechnung,Berechnung,Berechnung,Berechnung,Berechnung,Berechnung,Berechnung,Berechnung,Salden,Salden,Salden,Salden,Salden,Salden,Salden,Salden,Auszahlen,Auszahlen,Auszahlen,Auszahlen,Auszahlen,Auszahlen,Auszahlen,Auszahlen,Auszahlen,Auszahlen
|
||||
,,,,,,,,,,,"Gearbeitete Stunden","Gearbeitete Stunden","Urlaub nehmen","Urlaub nehmen",Krankheitsstunden,Krankheitsstunden,Gesamt,Gesamt,Vertrag,Vertrag,Plus/Min,Mehrstunden,Überstunden,"Zuschlag P-Schein","Krank ohne AU","Krank ohne AU","Nachtzuschlag 25 (25%)","Nachtzuschlag 25 (25%)","Nachtzuschlag 40 (40%)","Nachtzuschlag 40 (40%)","Sonntagszuschlag (50%)","Sonntagszuschlag (50%)","Verpfl.-pauschale (14%)","Samstagzuschlag (15%)","Samstagzuschlag (15%)",Zeitausgleich,Zeitausgleich,Zeitausgleich,Zeitausgleich,Zeitausgleich,Urlaub,Urlaub,Urlaub,Mehrstunden,"Überstunden auszahlen",Fahrtkosten,Verpflegungspauschale,"Zuschlag P-Schein",25%,40%,50%,14%,15%
|
||||
Persnr.,Nachname,Vorname,Art,P-Schein,"Recht auf Zuschläge?",Start,Tage,Stunden,Max,"Urlaubstage pro Jahr",Tage,Stunden,Tage,Stunden,Tage,Stunden,Tage,Stunden,Tage,Stunden,Stunden,Stunden,Stunden,anzahl,Tage,Stunden,Tage,Stunden,Tage,Stunden,Tage,Stunden,Tage,Tage,Stunden,Start,+/-,Änderungen,Auszahlen,Jetzt,Start,Abbau,Jetzt,Stunden,Stunden,km,Tage,Anzahl,Stunden,Stunden,Stunden,Stunden,Stunden
|
||||
169,Safari,"Abdul Latif ",Fest,Ja,Ja,2025-09-01,5.00,40.00,0.00,20,21,185.2167,1,8,0,0,22,193.21666666667,21.667,173.333,19.88366666667,0.00033333332999064,19.88333333334,21,0,0,0,0,0,0,0,0,18,2,18.933333333333,0.334,19.88366666667,19.88366666667,19.88366666667,0.334,14,1,13,0.00033333332999064,19.88333333334,0.00,20,21,0,0,0,15.783333333333,18.933333333333
|
||||
93,"Moshtagh Askari",Adel,Fest,Ja,Ja,2025-01-01,5.00,40.00,0.00,20,23,198.9167,0,0,0,0,23,198.91666666667,21.667,173.333,25.58366666667,0.00033333332999064,25.58333333334,23,0,0,0,0,0,0,0,0,20,2,17.45,0.007,25.58366666667,25.58366666667,25.58366666667,0.0070000000000014,31,0,31,0.00033333332999064,25.58333333334,0.00,22,23,0,0,0,14.966666666667,17.45
|
||||
18,Mihailescu,"Adrian Lucian",Fest,Ja,Ja,2025-09-01,5.00,40.00,0.00,20,21,178.6,0,0,0,0,21,178.6,21.667,173.333,5.267,0.00033333332999064,5.26666666667,21,0,0,0,0,0,0,0,0,11,3,25.65,0,5.267,5.267,5.267,0,20,0,20,0.00033333332999064,5.26666666667,0.00,13,21,0,0,0,9.6833333333333,25.65
|
||||
,"Esmaeili Dashtbayaz",Amin,Fest,Nein,Ja,2025-04-01,5.00,40.00,0.00,,21,181.0833,0,0,0,0,21,181.08333333333,21.667,173.333,7.75033333333,0.00033333332999064,7.75,,0,0,0,0,0,0,0,0,18,3,24.033333333333,-2.066,7.75033333333,7.75033333333,5.68433333333,8.8817841970013E-16,12,0,12,0.00033333332999064,5.684,0.00,20,0,0,0,0,13.05,24.033333333333
|
||||
,Ahmadpour,Amirhossein,Fest,Ja,Ja,2025-09-01,5.00,40.00,0.00,24,22,194.75,0,0,0,0,22,194.75,21.667,173.333,21.417,0.00033333332999064,21.41666666667,22,0,0,1,1.1333333333333,0,0,0,0,20,1,8.75,0,21.417,21.417,21.417,0,9,0,9,0.00033333332999064,21.41666666667,0.00,21,22,1.1333333333333,0,0,18.366666666667,8.75
|
||||
188,"Malani Karizi",Arasch,Fest,Nein,Ja,2025-09-01,5.00,40.00,0.00,20,24,207.75,0,0,0,0,24,207.75,21.667,173.333,34.417,0.00033333332999064,34.41666666667,,0,0,1,1.5,0,0,1,8.8666666666667,19,3,24.55,-108.997,34.417,34.417,0,-74.58,5,0,5,0,0,0.00,23,0,1.5,0,8.8666666666667,14.333333333333,24.55
|
||||
113,Ziaudin,Atiq,Fest,Ja,Ja,2025-02-24,5.00,40.00,0.00,20,20,172.6,0,0,0,0,20,172.6,21.667,173.333,-0.733,0,0,20,0,0,0,0,0,0,0,0,19,1,8.0166666666667,14.047,-0.733,-0.733,0,13.314,9,0,9,0,0,0.00,20,20,0,0,0,12.583333333333,8.0166666666667
|
||||
,Rahmati,Behnam,Fest,Ja,Ja,2025-04-01,5.00,40.00,0.00,,16,143.95,0,0,2,16,18,159.95,21.667,173.333,-13.383,0,0,16,0,0,0,0,0,0,0,0,16,0,0,0,-13.383,-13.383,0,-13.383,13,0,13,0,0,0.00,16,16,0,0,0,15.95,0
|
||||
,Aba,Cuan,Fest,Nein,Ja,2025-08-01,5.00,40.00,0.00,,10,69.45,0,0,2,16,12,85.45,21.667,173.333,-87.883,0,0,,0,0,0,0,0,0,0,0,0,0,0,-228.333,-87.883,-87.883,0,-316.216,4,0,4,0,0,0.00,1,0,0,0,0,0,0
|
||||
,Haghighivand,Danial,Fest,Nein,Ja,2025-04-01,5.00,40.00,0.00,,17,152.2333,0,0,1,8,18,160.23333333333,21.667,173.333,-13.09966666667,0,0,,0,0,0,0,0,0,0,0,17,0,0,0,-13.09966666667,-13.09966666667,0,-13.09966666667,3,0,3,0,0,0.00,17,0,0,0,0,16.233333333333,0
|
||||
,Mohammadi,Danial,Fest,Ja,Ja,2025-01-01,5.00,40.00,0.00,20,19,165.0167,0,0,0,0,19,165.01666666667,21.667,173.333,-8.31633333333,0,0,19,0,0,0,0,0,0,0,0,17,2,14.5,0,-8.31633333333,-8.31633333333,0,-8.31633333333,7,0,7,0,0,0.00,17,19,0,0,0,14.516666666667,14.5
|
||||
,Shahrasebi,Danial,Fest,,,2021-05-01,0.00,0.00,0.00,,,,0,0,0,0,0,0,0,0,0,0,0,,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,0,5,0,0,,,0,0,0,0,0,0
|
||||
,Emkani,Daniel,Fest,Nein,Ja,2025-07-15,3.00,25.00,0.00,,7,58.9167,3,24,3,24,13,106.91666666667,13,108.333,-1.41633333333,0,0,,0,0,0,0,0,0,0,0,0,2,15.65,0,-1.41633333333,-1.41633333333,0,-1.41633333333,7,3,4,0,0,0.00,6,0,0,0,0,0,15.65
|
||||
,Lolies,Danny,Fest,Ja,Ja,2025-03-01,5.00,40.00,0.00,,19,161.4833,0,0,1,8,20,169.48333333333,21.667,173.333,-3.84966666667,0,0,19,2,16.5,0,0,0,0,0,0,15,2,16.566666666667,-5.933,-3.84966666667,-3.84966666667,0,-9.78266666667,10,0,10,0,0,0.00,17,19,0,0,0,10.7,16.566666666667
|
||||
,Hastedt,David,Fest,Nein,Ja,2025-01-27,4.00,32.00,0.00,20,13,110.2,2,16,0,0,15,126.2,17.333,138.667,-12.467,0,0,,1,8,0,0,0,0,0,0,10,0,0,0,-12.467,-12.467,0,-12.467,2,2,0,0,0,0.00,10,0,0,0,0,8.6666666666667,0
|
||||
2,Shahrasebi,David,Fest,Ja,Ja,2022-10-01,5.00,40.00,0.00,20,,,0,0,0,0,0,0,21.667,173.333,0,0,0,,0,0,0,0,0,0,0,0,0,0,0,151.34,0,0,0,151.34,2,0,2,0,0,,,0,0,0,0,0,0
|
||||
,Golmohammadi,"Davood ",Fest,Ja,Ja,2025-08-01,5.00,40.00,0.00,20,19,171.6667,1,8,0,0,20,179.66666666667,21.667,173.333,6.33366666667,0.00033333332999064,6.33333333334,19,0,0,0,0,0,0,0,0,19,0,0,0,6.33366666667,6.33366666667,6.33366666667,0,2,1,1,0.00033333332999064,6.33333333334,0.00,19,19,0,0,0,19.666666666667,0
|
||||
202,Pouzesh,Esmail,Fest,Ja,Ja,2025-09-01,5.00,40.00,0.00,20,21,170.3667,0,0,0,0,21,170.36666666667,21.667,173.333,-2.96633333333,0,0,21,0,0,20,66.283333333333,11,21.333333333333,0,0,0,0,0,0,-2.96633333333,-2.96633333333,0,-2.96633333333,12,0,12,0,0,0.00,20,21,66.283333333333,21.333333333333,0,0,0
|
||||
187,"Sajaei Khouzestani",Farid,Fest,Ja,Ja,2025-04-07,5.00,40.00,0.00,20,23,203.5,0,0,0,0,23,203.5,21.667,173.333,30.167,0.00033333332999064,30.16666666667,23,0,0,0,0,0,0,0,0,19,3,28.75,0,30.167,30.167,30.167,0,19,0,19,0.00033333332999064,30.16666666667,0.00,22,23,0,0,0,16.25,28.75
|
||||
191,"Faridani Rad",Farzad,Fest,Ja,Ja,2025-02-01,5.00,40.00,0.00,20,19,157.2,0,0,2,16,21,173.2,21.667,173.333,-0.13300000000001,0,0,19,1,8,19,59.9,5,11.55,0,0,0,0,0,0,-0.13300000000001,-0.13300000000001,0,-0.13300000000001,0,0,0,0,0,0.00,19,19,59.9,11.55,0,0,0
|
||||
,Movafagh,"Farzad ",Fest,Ja,Ja,2025-02-03,5.00,40.00,0.00,20,21,182.65,0,0,0,0,21,182.65,21.667,173.333,9.317,0.00033333332999064,9.31666666667,21,0,0,0,0,0,0,0,0,19,2,16.016666666667,0,9.317,9.317,9.317,0,17,0,17,0.00033333332999064,9.31666666667,0.00,20,21,0,0,0,14.633333333333,16.016666666667
|
||||
89,Hashimazada,Firuz,Fest,Ja,Ja,2025-01-01,5.00,40.00,0.00,20,,0,0,0,16,144,16,144,21.667,173.333,-29.333,0,0,,0,0,0,0,0,0,0,0,0,0,0,0,-29.333,-29.333,0,-29.333,8,0,8,0,0,,,0,0,0,0,0,0
|
||||
,Saeedpour,Fouad,Fest,Ja,Ja,2025-09-01,5.00,40.00,0.00,,21,180.6833,0,0,0,0,21,180.68333333333,21.667,173.333,7.35033333333,0.00033333332999064,7.35,21,0,0,5,17.5,5,10.55,2,18.583333333333,10,4,33.033333333333,0,7.35033333333,7.35033333333,7.35033333333,0,26,0,26,0.00033333332999064,7.35,0.00,21,21,17.5,10.55,18.583333333333,8.5166666666667,33.033333333333
|
||||
55,Khodayaran,Hossein,Fest,Ja,Ja,2025-01-01,5.00,40.00,0.00,20,19,152.55,0,0,0,0,19,152.55,21.667,173.333,-20.783,0,0,19,0,0,18,56.733333333333,5,11.55,1,3.5166666666667,0,0,0,0,-20.783,-20.783,0,-20.783,6,0,6,0,0,0.00,18,19,56.733333333333,11.55,3.5166666666667,0,0
|
||||
206,Rasulkhani,Hossein,Fest,Ja,Ja,2025-03-06,5.00,40.00,0.00,20,9,76.4667,10,80,0,0,19,156.46666666667,21.667,173.333,-16.86633333333,0,0,9,0,0,0,0,0,0,1,9,6,2,16.2,0,-16.86633333333,-16.86633333333,0,-16.86633333333,29,10,19,0,0,0.00,9,9,0,0,9,3.2666666666667,16.2
|
||||
,"Mohamed Salah","Hussin Noureldin",Fest,Ja,Ja,2025-06-06,5.00,40.00,0.00,20,20,173.6667,0,0,0,0,20,173.66666666667,21.667,173.333,0.33366666667001,0.00033333332999064,0.33333333334002,20,0,0,0,0,0,0,0,0,18,2,17.616666666667,0,0.33366666667001,0.33366666667001,0.33366666667001,0,0,0,0,0.00033333332999064,0.33333333334002,0.00,19,20,0,0,0,12.05,17.616666666667
|
||||
50,"Rohany Oskoee",Iman,Fest,Nein,Ja,2025-01-01,5.00,40.00,0.00,20,23,196.8667,0,0,0,0,23,196.86666666667,21.667,173.333,23.53366666667,0.00033333332999064,23.53333333334,,0,0,0,0,0,0,0,0,20,2,16.883333333333,0,23.53366666667,23.53366666667,23.53366666667,0,36,0,36,0.00033333332999064,23.53333333334,0.00,21,0,0,0,0,13.483333333333,16.883333333333
|
||||
7,Salehi,Iman,Fest,Ja,Ja,2025-02-01,5.00,40.00,0.00,20,21,176.55,0,0,0,0,21,176.55,21.667,173.333,3.217,0.00033333332999064,3.21666666667,21,0,0,20,62.466666666667,7,10.9,1,10,0,1,8.75,0,3.217,3.217,3.217,0,0,0,0,0.00033333332999064,3.21666666667,0.00,21,21,62.466666666667,10.9,10,0,8.75
|
||||
,Krüger,Julian,Fest,Ja,Ja,2025-01-15,5.00,40.00,0.00,20,20,171.2833,0,0,0,0,20,171.28333333333,21.667,173.333,-2.04966666667,0,0,20,0,0,2,7,2,4.4333333333333,0,0,0,1,6.05,0,-2.04966666667,-2.04966666667,0,-2.04966666667,8,0,8,0,0,0.00,17,20,7,4.4333333333333,0,0,6.05
|
||||
,Hazrati,Kamyar,Fest,,Ja,2025-02-24,5.00,40.00,0.00,20,21,182.2333,0,0,0,0,21,182.23333333333,21.667,173.333,8.90033333333,0.00033333332999064,8.9,,0,0,0,0,0,0,0,0,18,3,22.616666666667,0,8.90033333333,8.90033333333,8.90033333333,0,10,0,10,0.00033333332999064,8.9,0.00,19,0,0,0,0,15.616666666667,22.616666666667
|
||||
,Saei,Kasra,Fest,Nein,Ja,2025-08-01,5.00,40.00,0.00,,8,56.2333,2,16,0,0,10,72.233333333333,21.667,173.333,-101.09966666667,0,0,,2,15,0,0,0,0,0,0,0,0,0,-186.848,-101.09966666667,-101.09966666667,0,-287.94766666667,0,2,-2,0,0,0.00,,0,0,0,0,0,0
|
||||
182,Kalantari,Kaveh,Fest,Ja,Ja,2025-03-01,5.00,40.00,0.00,20,18,155.1667,6,48,0,0,24,203.16666666667,21.667,173.333,29.83366666667,0.00033333332999064,29.83333333334,18,0,0,1,1.05,0,0,2,17.733333333333,13,3,25.533333333333,0,29.83366666667,29.83366666667,29.83366666667,0,17,6,11,0.00033333332999064,29.83333333334,0.00,18,18,1.05,0,17.733333333333,6.85,25.533333333333
|
||||
,Sahem,Khalid,Fest,Ja,Ja,2025-06-16,5.00,40.00,0.00,20,20,168.7167,0,0,0,0,20,168.71666666667,21.667,173.333,-4.61633333333,0,0,20,0,0,0,0,0,0,0,0,18,1,8.6833333333333,0,-4.61633333333,-4.61633333333,0,-4.61633333333,15,0,15,0,0,0.00,19,20,0,0,0,9.5333333333333,8.6833333333333
|
||||
194,Saffarnajib,Majid,Fest,Ja,Ja,2025-04-01,3.00,21.00,0.00,16,17,145.5667,0,0,0,0,17,145.56666666667,13,91,54.56666666667,54.56666666667,0,17,0,0,2,3.85,0,0,0,0,16,0,0,0,54.56666666667,54.56666666667,54.56666666667,0,16,0,16,54.56666666667,0,0.00,17,17,3.85,0,0,8.7166666666667,0
|
||||
177,Juyande,Masoud,Fest,Nein,Ja,2025-01-01,5.00,40.00,0.00,20,14,113.4333,7,56,0,0,21,169.43333333333,21.667,173.333,-3.89966666667,0,0,,0,0,10,33.116666666667,5,11.116666666667,0,0,3,0,0,0.009,-3.89966666667,-3.89966666667,0,-3.89066666667,-7,7,-14,0,0,0.00,13,0,33.116666666667,11.116666666667,0,0.7,0
|
||||
53,Eggerstedt,Matthias,Fest,Ja,Ja,2025-01-01,5.00,40.00,0.00,20,20,171.6833,0,0,0,0,20,171.68333333333,21.667,173.333,-1.64966666667,0,0,20,0,0,0,0,0,0,0,0,18,2,16.933333333333,0,-1.64966666667,-1.64966666667,0,-1.64966666667,10,0,10,0,0,0.00,20,20,0,0,0,10.75,16.933333333333
|
||||
,Arabnia,Mehdi,Fest,Ja,Ja,2025-05-05,5.00,40.00,0.00,,21,185.9333,1,8,0,0,22,193.93333333333,21.667,173.333,20.60033333333,0.00033333332999064,20.6,21,0,0,0,0,0,0,0,0,18,3,24.766666666667,0,20.60033333333,20.60033333333,20.60033333333,0,7,1,6,0.00033333332999064,20.6,0.00,20,21,0,0,0,17.166666666667,24.766666666667
|
||||
,"Jalali Wagener",Mehdi,Fest,Ja,Ja,2025-02-01,5.00,40.00,0.00,,23,195.7167,0,0,0,0,23,195.71666666667,21.667,173.333,22.38366666667,0.00033333332999064,22.38333333334,23,0,0,0,0,0,0,0,0,20,3,24.033333333333,0,22.38366666667,22.38366666667,22.38366666667,0,-1,0,-1,0.00033333332999064,22.38333333334,0.00,22,23,0,0,0,11.683333333333,24.033333333333
|
||||
,Taghadosi,Mehdi,Fest,Ja,Ja,2025-08-08,5.00,40.00,0.00,,19,167.25,0,0,0,0,19,167.25,21.667,173.333,-6.083,0,0,19,0,0,0,0,0,0,0,0,17,2,16.266666666667,0,-6.083,-6.083,0,-6.083,10,0,10,0,0,0.00,19,19,0,0,0,14.983333333333,16.266666666667
|
||||
,Mazidi,Mehrdad,Fest,Ja,Ja,2025-07-01,5.00,40.00,0.00,20,19,166.9167,0,0,0,0,19,166.91666666667,21.667,173.333,-6.41633333333,0,0,19,0,0,0,0,0,0,0,0,17,2,17.616666666667,0,-6.41633333333,-6.41633333333,0,-6.41633333333,10,0,10,0,0,0.00,18,19,0,0,0,13.3,17.616666666667
|
||||
165,Koell,"Melanie Nadine",Fest,Nein,Ja,2025-01-01,5.00,40.00,0.00,20,1,6.5,0,0,0,0,1,6.5,21.667,173.333,-166.833,0,0,,0,0,0,0,0,0,0,0,0,0,0,-306.596,-166.833,-166.833,0,-473.429,12,0,12,0,0,0.00,,0,0,0,0,0,0
|
||||
,Zekarias,Michael,Fest,Nein,Ja,2025-11-01,5.00,40.00,0.00,,21,172.8333,0,0,0,0,21,172.83333333333,21.667,173.333,-0.49966666667001,0,0,,0,0,5,14.616666666667,0,0,0,0,14,1,8.0333333333333,0,-0.49966666667001,-0.49966666667001,0,-0.49966666667001,0,0,0,0,0,0.00,20,0,14.616666666667,0,0,6.1833333333333,8.0333333333333
|
||||
119,Pourhossein,Milad,Fest,Ja,Ja,2025-09-01,5.00,40.00,0.00,20,22,182.6,0,0,0,0,22,182.6,21.667,173.333,9.267,0.00033333332999064,9.26666666667,22,0,0,10,30.9,2,0.35,0,0,9,1,9.25,-49.564,9.267,9.267,0,-40.297,10,0,10,0,0,0.00,20,22,30.9,0.35,0,3.7,9.25
|
||||
144,Maleki,Mohammad,Fest,Ja,Ja,2025-09-01,3.00,24.00,0.00,20,9,72.6833,0,0,0,0,9,72.683333333333,13,104,-31.316666666667,0,0,9,1,8,0,0,0,0,0,0,6,0,0,-313.845,-31.316666666667,-31.316666666667,0,-345.16166666667,-1,0,-1,0,0,0.00,6,9,0,0,0,3.55,0
|
||||
217,"Rasul Khani",Mohammad,Fest,Ja,Ja,2025-03-03,5.00,40.00,0.00,20,11,95,10,80,0,0,21,175,21.667,173.333,1.667,0.00033333332999064,1.66666666667,11,0,0,0,0,0,0,0,0,10,1,8.5166666666667,0,1.667,1.667,1.667,0,20,10,10,0.00033333332999064,1.66666666667,0.00,11,11,0,0,0,6.4833333333333,8.5166666666667
|
||||
96,Sabahi,Mohammad,Fest,Ja,Ja,2025-01-01,5.00,40.00,0.00,20,20,170.15,0,0,0,0,20,170.15,21.667,173.333,-3.183,0,0,20,0,0,0,0,0,0,0,0,19,0,0,0,-3.183,-3.183,0,-3.183,9,0,9,0,0,0.00,19,20,0,0,0,11.65,0
|
||||
78,Tajikzadeh,Mohammad,Fest,Ja,Ja,2025-01-01,5.00,40.00,0.00,20,20,169.3833,0,0,0,0,20,169.38333333333,21.667,173.333,-3.94966666667,0,0,20,0,0,0,0,0,0,0,0,17,3,24.35,0,-3.94966666667,-3.94966666667,0,-3.94966666667,2,0,2,0,0,0.00,20,20,0,0,0,9.0333333333333,24.35
|
||||
,Atrafi,"Mohammad Nasir",Fest,Ja,Ja,2025-10-01,5.00,4.00,0.00,20,20,177.8,1,8,0,0,21,185.8,21.667,17.333,168.467,156.00033333333,12.46666666667,20,0,0,0,0,0,0,0,0,19,1,7.85,0,168.467,168.467,168.467,0,5,1,4,156.00033333333,12.46666666667,0.00,19,20,0,0,0,17.95,7.85
|
||||
,Amiri,"Mohammad Samir",Fest,Ja,Ja,2025-09-01,5.00,40.00,0.00,20,22,197.6667,0,0,0,0,22,197.66666666667,21.667,173.333,24.33366666667,0.00033333332999064,24.33333333334,22,0,0,10,34.75,10,23.166666666667,3,27.833333333333,0,2,19.25,0,24.33366666667,24.33366666667,24.33366666667,0,11,0,11,0.00033333332999064,24.33333333334,0.00,21,22,34.75,23.166666666667,27.833333333333,0,19.25
|
||||
,"El Moustaiqid",Najlae,Fest,,Ja,2025-09-01,5.00,40.00,0.00,,17,126.5,0,0,1,8,18,134.5,21.667,173.333,-38.833,0,0,,2,16,0,0,0,0,0,0,0,0,0,-1154.263,-38.833,-38.833,0,-1193.096,9,0,9,0,0,0.00,,0,0,0,0,0,0
|
||||
,Jasarevic,Noel,Fest,Nein,Ja,2025-09-15,5.00,40.00,0.00,,19,161.9333,1,8,0,0,20,169.93333333333,21.667,173.333,-3.39966666667,0,0,,0,0,0,0,0,0,0,0,0,0,0,-0.7,-3.39966666667,-3.39966666667,0,-4.09966666667,-1,1,-2,0,0,0.00,19,0,0,0,0,0,0
|
||||
,"Sharh Ghaboos",Peyman,Fest,Ja,Ja,2025-03-01,5.00,40.00,0.00,,23,199.0833,0,0,0,0,23,199.08333333333,21.667,173.333,25.75033333333,0.00033333332999064,25.75,23,0,0,1,1.5,0,0,1,8.8666666666667,18,3,24.55,0,25.75033333333,25.75033333333,25.75033333333,0,20,0,20,0.00033333332999064,25.75,0.00,22,23,1.5,0,8.8666666666667,13.666666666667,24.55
|
||||
227,Feilbach,"Raoul Nikolai ",Fest,Nein,Ja,2025-09-01,5.00,40.00,0.00,20,20,173.3667,0,0,0,0,20,173.36666666667,21.667,173.333,0.033666666670001,0.00033333332999064,0.033333333340011,,0,0,8,28,8,16.766666666667,2,19,0,0,0,0.007,0.033666666670001,0.033666666670001,0.033666666670002,0.006999999999999,6,0,6,0.00033333332999064,0.033333333340011,0.00,19,0,28,16.766666666667,19,0,0
|
||||
,Hatami,Reza,Fest,Ja,Ja,2025-09-15,5.00,40.00,0.00,20,20,175.3333,0,0,0,0,20,175.33333333333,21.667,173.333,2.00033333333,0.00033333332999064,2,20,0,0,0,0,0,0,0,0,18,2,16.266666666667,0,2.00033333333,2.00033333333,2.00033333333,0,6,0,6,0.00033333332999064,2,0.00,20,20,0,0,0,15.066666666667,16.266666666667
|
||||
137,Pourhossein,Reza,Fest,Ja,Ja,2025-04-01,5.00,40.00,0.00,20,25,201.4667,0,0,0,0,25,201.46666666667,21.667,173.333,28.13366666667,0.00033333332999064,28.13333333334,25,0,0,15,45.85,5,10.166666666667,3,20.95,5,2,16.266666666667,0,28.13366666667,28.13366666667,28.13366666667,0,29,0,29,0.00033333332999064,28.13333333334,0.00,22,25,45.85,10.166666666667,20.95,3.7333333333333,16.266666666667
|
||||
,"Rohi Pour",Sadroldin,Fest,Ja,Ja,2025-11-01,5.00,40.00,0.00,20,18,155.4667,0,0,0,0,18,155.46666666667,21.667,173.333,-17.86633333333,0,0,18,0,0,0,0,0,0,0,0,16,2,16.016666666667,0,-17.86633333333,-17.86633333333,0,-17.86633333333,0,0,0,0,0,0.00,17,18,0,0,0,11.45,16.016666666667
|
||||
225,"Hosseini Jahed",Sajad,Fest,Ja,Ja,2025-03-03,5.00,40.00,0.00,20,21,184.2,0,0,0,0,21,184.2,21.667,173.333,10.867,0.00033333332999064,10.86666666667,21,0,0,0,0,0,0,0,0,19,1,8.5166666666667,0,10.867,10.867,10.867,0,20,0,20,0.00033333332999064,10.86666666667,0.00,20,21,0,0,0,17.183333333333,8.5166666666667
|
||||
,Saki,Sara,Fest,Ja,Ja,2025-02-01,5.00,40.00,0.00,,4,25.7667,0,0,15,120,19,145.76666666667,21.667,173.333,-27.56633333333,0,0,4,0,0,0,0,0,0,0,0,2,0,0,0,-27.56633333333,-27.56633333333,0,-27.56633333333,11,0,11,0,0,0.00,2,4,0,0,0,1.0166666666667,0
|
||||
,"Perez Y Waldt",Sebastian,Fest,Nein,Ja,2024-09-23,5.00,40.00,0.00,,6,47.3,0,0,7,56,13,103.3,21.667,173.333,-70.033,0,0,,1,8,0,0,0,0,0,0,3,1,7.7166666666667,-747.879,-70.033,-70.033,0,-817.912,14,0,14,0,0,0.00,3,0,0,0,0,1.35,7.7166666666667
|
||||
38,Hosseini,"Seyed Hamid",Fest,Ja,Ja,2025-03-10,5.00,40.00,0.00,20,22,189.2667,0,0,0,0,22,189.26666666667,21.667,173.333,15.93366666667,0.00033333332999064,15.93333333334,22,0,0,0,0,0,0,0,0,19,3,24.333333333333,0,15.93366666667,15.93366666667,15.93366666667,0,26,0,26,0.00033333332999064,15.93333333334,0.00,22,22,0,0,0,12.933333333333,24.333333333333
|
||||
,Jabbari,Shahram,Fest,Nein,Ja,2025-04-01,5.00,40.00,0.00,,19,165.5,0,0,0,0,19,165.5,21.667,173.333,-7.833,0,0,,0,0,0,0,0,0,0,0,17,2,16.816666666667,0,-7.833,-7.833,0,-7.833,-5,0,-5,0,0,0.00,19,0,0,0,0,12.683333333333,16.816666666667
|
||||
124,Imanian,Shahriyar,Fest,Ja,Ja,2025-03-10,5.00,40.00,0.00,20,23,199.2167,0,0,0,0,23,199.21666666667,21.667,173.333,25.88366666667,0.00033333332999064,25.88333333334,23,0,0,2,0.7,0,0,5,44.716666666667,14,4,33.45,0,25.88366666667,25.88366666667,25.88366666667,0,24,0,24,0.00033333332999064,25.88333333334,0.00,23,23,0.7,0,44.716666666667,8.35,33.45
|
||||
164,Tokhi,Shokrollah,Fest,Ja,Ja,2025-03-04,5.00,40.00,0.00,20,20,168.0333,0,0,0,0,20,168.03333333333,21.667,173.333,-5.29966666667,0,0,20,0,0,10,33.016666666667,1,0.05,2,17.116666666667,6,2,16.45,0,-5.29966666667,-5.29966666667,0,-5.29966666667,9,0,9,0,0,0.00,19,20,33.016666666667,0.05,17.116666666667,2.9166666666667,16.45
|
||||
210,Rahimzadeh,Siamak,Fest,Ja,Ja,2025-03-01,5.00,40.00,0.00,20,22,182.9667,0,0,0,0,22,182.96666666667,21.667,173.333,9.63366666667,0.00033333332999064,9.63333333334,22,0,0,0,0,0,0,0,0,18,2,16.433333333333,0.027,9.63366666667,9.63366666667,9.63366666667,0.026999999999999,39,0,39,0.00033333332999064,9.63333333334,0.00,20,22,0,0,0,12.033333333333,16.433333333333
|
||||
,Florescu,Silviu-Cezar,Fest,Nein,Ja,2025-10-01,5.00,40.00,0.00,,21,182.2833,0,0,0,0,21,182.28333333333,21.667,173.333,8.95033333333,0.00033333332999064,8.95,,0,0,2,0.43333333333333,0,0,0,0,18,2,17.533333333333,0,8.95033333333,8.95033333333,8.95033333333,0,9,0,9,0.00033333332999064,8.95,0.00,20,0,0.43333333333333,0,0,13.816666666667,17.533333333333
|
||||
,Vafaei,Soheil,Fest,Ja,Ja,2025-04-01,5.00,40.00,0.00,,20,176.0667,0,0,0,0,20,176.06666666667,21.667,173.333,2.73366666667,0.00033333332999064,2.73333333334,20,0,0,0,0,0,0,0,0,18,2,16.033333333333,0,2.73366666667,2.73366666667,2.73366666667,0,-13,0,-13,0.00033333332999064,2.73333333334,0.00,20,20,0,0,0,16.033333333333,16.033333333333
|
||||
,Toraman,"Tolga Can",Fest,Nein,Ja,2025-09-15,5.00,40.00,0.00,20,21,177.7333,0,0,0,0,21,177.73333333333,21.667,173.333,4.40033333333,0.00033333332999064,4.4,,0,0,6,17.883333333333,0,0,1,9.0833333333333,12,2,15.65,0,4.40033333333,4.40033333333,4.40033333333,0,0,0,0,0.00033333332999064,4.4,0.00,18,0,17.883333333333,0,9.0833333333333,7.6333333333333,15.65
|
||||
14,Haugg,Uwe,Fest,Ja,Ja,2025-01-01,4.00,32.00,0.00,18,17,142.2667,0,0,0,0,17,142.26666666667,17.333,138.667,3.59966666667,3.59966666667,0,17,0,0,0,0,0,0,0,0,12,5,38.4,0,3.59966666667,3.59966666667,3.59966666667,0,-18,0,-18,3.59966666667,0,0.00,14,17,0,0,0,7.8666666666667,38.4
|
||||
231,Shokripour,Vahid,Fest,Ja,Ja,2025-01-01,5.00,40.00,0.00,20,20,174.25,0,0,0,0,20,174.25,21.667,173.333,0.917,0.00033333332999064,0.91666666667001,20,0,0,0,0,0,0,0,0,18,2,17.266666666667,0,0.917,0.917,0.917,0,35,0,35,0.00033333332999064,0.91666666667001,0.00,20,20,0,0,0,12.983333333333,17.266666666667
|
||||
226,Konak,Zidar,Fest,Nein,Ja,2025-05-01,5.00,40.00,0.00,20,21,178.9667,0,0,0,0,21,178.96666666667,21.667,173.333,5.63366666667,0.00033333332999064,5.63333333334,,0,0,2,1.1,0,0,0,0,0,3,26.95,0,5.63366666667,5.63366666667,5.63366666667,0,16,0,16,0.00033333332999064,5.63333333334,0.00,17,0,1.1,0,0,0,26.95
|
||||
,,,,,,,,,,,1234,10534.0502,45,360,50,416,1329,11310.05,1460.354,11517.979,-34.595999999977,,,,10,79.5,170,519.28333333333,66,131.93333333333,25,215.26666666667,796,106,879.75,,-34.595999999977,-34.595999999977,633.31100000003,-3607.16,,45,662,214.1769999999,419.13400000013,0,1126,,519.28333333333,131.93333333333,215.26666666667,591.26666666667,879.75
|
||||
|
@@ -117,13 +117,13 @@ CREATE TABLE public."Anomaly" (
|
||||
description text NOT NULL,
|
||||
"groupKey" text NOT NULL,
|
||||
"groupDescription" text NOT NULL,
|
||||
"associatedEntities" jsonb DEFAULT '{}'::jsonb,
|
||||
"workedOnBy" text NOT NULL,
|
||||
"sleepTimer" integer NOT NULL,
|
||||
"sleepSince" timestamp(3) without time zone,
|
||||
"resolvedAt" timestamp(3) without time zone,
|
||||
"manuallyResolved" boolean DEFAULT false NOT NULL,
|
||||
"createdAt" timestamp(3) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"associatedEntities" jsonb DEFAULT '{}'::jsonb
|
||||
"createdAt" timestamp(3) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
|
||||
@@ -203,16 +203,16 @@ CREATE TABLE public."CostType" (
|
||||
--
|
||||
|
||||
CREATE TABLE public."DyflexisEmployee" (
|
||||
"internalId" uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
id integer NOT NULL,
|
||||
firstname text NOT NULL,
|
||||
surname text NOT NULL,
|
||||
"contractStart" timestamp(3) without time zone NOT NULL,
|
||||
"contractEnd" timestamp(3) without time zone,
|
||||
"contractDaysWeek" integer DEFAULT 0 NOT NULL,
|
||||
"contractHoursWeek" double precision NOT NULL,
|
||||
"contractSalaryHour" double precision NOT NULL,
|
||||
"contractTypeName" text,
|
||||
"internalId" uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
"contractDaysWeek" integer DEFAULT 0 NOT NULL
|
||||
"contractTypeName" text
|
||||
);
|
||||
|
||||
|
||||
@@ -435,8 +435,8 @@ CREATE TABLE public."PlannedTimeStaging" (
|
||||
"endDate" timestamp(3) without time zone NOT NULL,
|
||||
deleted boolean NOT NULL,
|
||||
mark text,
|
||||
duration integer NOT NULL,
|
||||
pause integer NOT NULL
|
||||
pause integer NOT NULL,
|
||||
duration integer NOT NULL
|
||||
);
|
||||
|
||||
|
||||
@@ -506,15 +506,25 @@ CREATE TABLE public."TariffGroup" (
|
||||
--
|
||||
|
||||
CREATE TABLE public."Ticket" (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
"currentState" public."TicketValidationState" NOT NULL,
|
||||
id uuid NOT NULL,
|
||||
"patientId" uuid,
|
||||
stage text NOT NULL,
|
||||
"approvalRequirement" text NOT NULL,
|
||||
"approvalStatus" text NOT NULL,
|
||||
"approvalRequestedAt" timestamp(3) without time zone,
|
||||
"approvalLastInteractionAt" timestamp(3) without time zone,
|
||||
"approvalReminderCount" integer DEFAULT 0 NOT NULL,
|
||||
"hasPhysicalTransportDocument" boolean DEFAULT false NOT NULL,
|
||||
"hasDigitalTransportDocument" boolean DEFAULT false NOT NULL,
|
||||
"updatedAt" timestamp(3) without time zone,
|
||||
"createdAt" timestamp(3) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"currentState" public."TicketValidationState",
|
||||
notes text[],
|
||||
errors text[],
|
||||
"isUrgent" boolean DEFAULT false NOT NULL,
|
||||
urgency timestamp(3) without time zone DEFAULT '2099-12-31 23:59:59.999'::timestamp without time zone NOT NULL,
|
||||
"approvalState" public."ApprovalState",
|
||||
"documentInfo" jsonb DEFAULT '{"approval": {"isNeeded": null, "answeredFrom": "", "whoRequested": ""}, "tdLocation": ""}'::jsonb,
|
||||
"createdAt" timestamp(3) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
"documentInfo" jsonb DEFAULT '{"approval": {"isNeeded": null, "answeredFrom": "", "whoRequested": ""}, "tdLocation": ""}'::jsonb
|
||||
);
|
||||
|
||||
|
||||
@@ -582,13 +592,13 @@ CREATE TABLE public."Tour" (
|
||||
revenue double precision DEFAULT 0 NOT NULL,
|
||||
"consumptionCosts" double precision DEFAULT 0 NOT NULL,
|
||||
"revenueDeviation" double precision DEFAULT 0 NOT NULL,
|
||||
"createdAt" timestamp(3) without time zone,
|
||||
"deletedAt" timestamp(3) without time zone,
|
||||
"ticketId" uuid,
|
||||
"updatedAt" timestamp(3) without time zone,
|
||||
"companyName" text,
|
||||
"overRTW" boolean DEFAULT false NOT NULL,
|
||||
schwerlast boolean DEFAULT false NOT NULL
|
||||
"companyName" text,
|
||||
schwerlast boolean DEFAULT false NOT NULL,
|
||||
"createdAt" timestamp(3) without time zone,
|
||||
"updatedAt" timestamp(3) without time zone,
|
||||
"deletedAt" timestamp(3) without time zone,
|
||||
"ticketId" uuid
|
||||
);
|
||||
|
||||
|
||||
@@ -620,6 +630,7 @@ CREATE TABLE public."TourSource" (
|
||||
id text,
|
||||
"check" text,
|
||||
"createTime" text,
|
||||
"lastUpdate" text,
|
||||
"empfangenVonId" text,
|
||||
endgen text,
|
||||
"serienId" text,
|
||||
@@ -681,9 +692,8 @@ CREATE TABLE public."TourSource" (
|
||||
"fahrerName" text,
|
||||
fahrer text,
|
||||
color text,
|
||||
"lastUpdate" text,
|
||||
"companyName" text,
|
||||
"overRTW" text,
|
||||
"companyName" text,
|
||||
schwerlast text
|
||||
);
|
||||
|
||||
@@ -741,11 +751,11 @@ CREATE TABLE public."TourStaging" (
|
||||
revenue double precision DEFAULT 0 NOT NULL,
|
||||
"revenueDeviation" double precision DEFAULT 0 NOT NULL,
|
||||
"consumptionCosts" double precision DEFAULT 0 NOT NULL,
|
||||
"createdAt" timestamp(3) without time zone,
|
||||
"updatedAt" timestamp(3) without time zone,
|
||||
"companyName" text,
|
||||
"overRTW" boolean DEFAULT false NOT NULL,
|
||||
schwerlast boolean DEFAULT false NOT NULL
|
||||
"companyName" text,
|
||||
schwerlast boolean DEFAULT false NOT NULL,
|
||||
"createdAt" timestamp(3) without time zone,
|
||||
"updatedAt" timestamp(3) without time zone
|
||||
);
|
||||
|
||||
|
||||
@@ -812,8 +822,8 @@ CREATE TABLE public."WorkTimeStaging" (
|
||||
"departmentName" text,
|
||||
"startDate" timestamp(3) without time zone NOT NULL,
|
||||
"endDate" timestamp(3) without time zone NOT NULL,
|
||||
duration integer NOT NULL,
|
||||
pause integer NOT NULL
|
||||
pause integer NOT NULL,
|
||||
duration integer NOT NULL
|
||||
);
|
||||
|
||||
|
||||
@@ -827,22 +837,6 @@ CREATE TABLE public."_AvicennaTenantToTariffGroup" (
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: _prisma_migrations; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public._prisma_migrations (
|
||||
id character varying(36) NOT NULL,
|
||||
checksum character varying(64) NOT NULL,
|
||||
finished_at timestamp with time zone,
|
||||
migration_name character varying(255) NOT NULL,
|
||||
logs text,
|
||||
rolled_back_at timestamp with time zone,
|
||||
started_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
applied_steps_count integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: Anomaly Anomaly_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
@@ -1115,14 +1109,6 @@ ALTER TABLE ONLY public."_AvicennaTenantToTariffGroup"
|
||||
ADD CONSTRAINT "_AvicennaTenantToTariffGroup_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
|
||||
--
|
||||
-- Name: _prisma_migrations _prisma_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public._prisma_migrations
|
||||
ADD CONSTRAINT _prisma_migrations_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: CostType_id_key; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
@@ -1131,10 +1117,10 @@ CREATE UNIQUE INDEX "CostType_id_key" ON public."CostType" USING btree (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: TourFile_tourId_ticketId_idx; Type: INDEX; Schema: public; Owner: -
|
||||
-- Name: TourFile_tourId_ticketId_key; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX "TourFile_tourId_ticketId_idx" ON public."TourFile" USING btree ("tourId", "ticketId");
|
||||
CREATE UNIQUE INDEX "TourFile_tourId_ticketId_key" ON public."TourFile" USING btree ("tourId", "ticketId");
|
||||
|
||||
|
||||
--
|
||||
|
||||
@@ -14,7 +14,7 @@ SELECT
|
||||
FROM "DyflexisEmployee" as de
|
||||
INNER JOIN "Employee" as e ON de.id::text = e."personnelNumber"
|
||||
WHERE de."contractStart" < CURRENT_DATE AND de."contractEnd" > (CURRENT_DATE - INTERVAL '2 months')
|
||||
AND de.surname <> 'Shahrasebhi'
|
||||
AND de.surname <> 'Shahrasebi'
|
||||
ORDER BY de.firstname ASC;
|
||||
|
||||
-- name: GetWeeklyTimeSummaryForEmployee :many
|
||||
|
||||
@@ -205,13 +205,13 @@ type Anomaly struct {
|
||||
Description string `json:"description"`
|
||||
GroupKey string `json:"groupKey"`
|
||||
GroupDescription string `json:"groupDescription"`
|
||||
AssociatedEntities []byte `json:"associatedEntities"`
|
||||
WorkedOnBy string `json:"workedOnBy"`
|
||||
SleepTimer int32 `json:"sleepTimer"`
|
||||
SleepSince *time.Time `json:"sleepSince"`
|
||||
ResolvedAt *time.Time `json:"resolvedAt"`
|
||||
ManuallyResolved bool `json:"manuallyResolved"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
AssociatedEntities []byte `json:"associatedEntities"`
|
||||
}
|
||||
|
||||
type AttendanceRegistration struct {
|
||||
@@ -266,16 +266,16 @@ type CostType struct {
|
||||
}
|
||||
|
||||
type DyflexisEmployee struct {
|
||||
InternalId pgtype.UUID `json:"internalId"`
|
||||
ID int32 `json:"id"`
|
||||
Firstname string `json:"firstname"`
|
||||
Surname string `json:"surname"`
|
||||
ContractStart time.Time `json:"contractStart"`
|
||||
ContractEnd *time.Time `json:"contractEnd"`
|
||||
ContractDaysWeek int32 `json:"contractDaysWeek"`
|
||||
ContractHoursWeek float64 `json:"contractHoursWeek"`
|
||||
ContractSalaryHour float64 `json:"contractSalaryHour"`
|
||||
ContractTypeName pgtype.Text `json:"contractTypeName"`
|
||||
InternalId pgtype.UUID `json:"internalId"`
|
||||
ContractDaysWeek int32 `json:"contractDaysWeek"`
|
||||
}
|
||||
|
||||
type Employee struct {
|
||||
@@ -438,19 +438,8 @@ type PlannedTimeStaging struct {
|
||||
EndDate time.Time `json:"endDate"`
|
||||
Deleted bool `json:"deleted"`
|
||||
Mark pgtype.Text `json:"mark"`
|
||||
Duration int32 `json:"duration"`
|
||||
Pause int32 `json:"pause"`
|
||||
}
|
||||
|
||||
type PrismaMigration struct {
|
||||
ID string `json:"id"`
|
||||
Checksum string `json:"checksum"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
MigrationName string `json:"migration_name"`
|
||||
Logs pgtype.Text `json:"logs"`
|
||||
RolledBackAt *time.Time `json:"rolled_back_at"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
AppliedStepsCount int32 `json:"applied_steps_count"`
|
||||
Duration int32 `json:"duration"`
|
||||
}
|
||||
|
||||
type Sickday struct {
|
||||
@@ -490,15 +479,25 @@ type TariffGroup struct {
|
||||
}
|
||||
|
||||
type Ticket struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
CurrentState TicketValidationState `json:"currentState"`
|
||||
Notes []string `json:"notes"`
|
||||
Errors []string `json:"errors"`
|
||||
IsUrgent bool `json:"isUrgent"`
|
||||
Urgency time.Time `json:"urgency"`
|
||||
ApprovalState NullApprovalState `json:"approvalState"`
|
||||
DocumentInfo []byte `json:"documentInfo"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
PatientId pgtype.UUID `json:"patientId"`
|
||||
Stage string `json:"stage"`
|
||||
ApprovalRequirement string `json:"approvalRequirement"`
|
||||
ApprovalStatus string `json:"approvalStatus"`
|
||||
ApprovalRequestedAt *time.Time `json:"approvalRequestedAt"`
|
||||
ApprovalLastInteractionAt *time.Time `json:"approvalLastInteractionAt"`
|
||||
ApprovalReminderCount int32 `json:"approvalReminderCount"`
|
||||
HasPhysicalTransportDocument bool `json:"hasPhysicalTransportDocument"`
|
||||
HasDigitalTransportDocument bool `json:"hasDigitalTransportDocument"`
|
||||
UpdatedAt *time.Time `json:"updatedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CurrentState NullTicketValidationState `json:"currentState"`
|
||||
Notes []string `json:"notes"`
|
||||
Errors []string `json:"errors"`
|
||||
IsUrgent bool `json:"isUrgent"`
|
||||
Urgency time.Time `json:"urgency"`
|
||||
ApprovalState NullApprovalState `json:"approvalState"`
|
||||
DocumentInfo []byte `json:"documentInfo"`
|
||||
}
|
||||
|
||||
type TicketStateHistory struct {
|
||||
@@ -556,13 +555,13 @@ type Tour struct {
|
||||
Revenue float64 `json:"revenue"`
|
||||
ConsumptionCosts float64 `json:"consumptionCosts"`
|
||||
RevenueDeviation float64 `json:"revenueDeviation"`
|
||||
OverRTW bool `json:"overRTW"`
|
||||
CompanyName pgtype.Text `json:"companyName"`
|
||||
Schwerlast bool `json:"schwerlast"`
|
||||
CreatedAt *time.Time `json:"createdAt"`
|
||||
UpdatedAt *time.Time `json:"updatedAt"`
|
||||
DeletedAt *time.Time `json:"deletedAt"`
|
||||
TicketId pgtype.UUID `json:"ticketId"`
|
||||
UpdatedAt *time.Time `json:"updatedAt"`
|
||||
CompanyName pgtype.Text `json:"companyName"`
|
||||
OverRTW bool `json:"overRTW"`
|
||||
Schwerlast bool `json:"schwerlast"`
|
||||
}
|
||||
|
||||
type TourFile struct {
|
||||
@@ -584,6 +583,7 @@ type TourSource struct {
|
||||
ID pgtype.Text `json:"id"`
|
||||
Check pgtype.Text `json:"check"`
|
||||
CreateTime pgtype.Text `json:"createTime"`
|
||||
LastUpdate pgtype.Text `json:"lastUpdate"`
|
||||
EmpfangenVonId pgtype.Text `json:"empfangenVonId"`
|
||||
Endgen pgtype.Text `json:"endgen"`
|
||||
SerienId pgtype.Text `json:"serienId"`
|
||||
@@ -645,9 +645,8 @@ type TourSource struct {
|
||||
FahrerName pgtype.Text `json:"fahrerName"`
|
||||
Fahrer pgtype.Text `json:"fahrer"`
|
||||
Color pgtype.Text `json:"color"`
|
||||
LastUpdate pgtype.Text `json:"lastUpdate"`
|
||||
CompanyName pgtype.Text `json:"companyName"`
|
||||
OverRTW pgtype.Text `json:"overRTW"`
|
||||
CompanyName pgtype.Text `json:"companyName"`
|
||||
Schwerlast pgtype.Text `json:"schwerlast"`
|
||||
}
|
||||
|
||||
@@ -700,11 +699,11 @@ type TourStaging struct {
|
||||
Revenue float64 `json:"revenue"`
|
||||
RevenueDeviation float64 `json:"revenueDeviation"`
|
||||
ConsumptionCosts float64 `json:"consumptionCosts"`
|
||||
OverRTW bool `json:"overRTW"`
|
||||
CompanyName pgtype.Text `json:"companyName"`
|
||||
Schwerlast bool `json:"schwerlast"`
|
||||
CreatedAt *time.Time `json:"createdAt"`
|
||||
UpdatedAt *time.Time `json:"updatedAt"`
|
||||
CompanyName pgtype.Text `json:"companyName"`
|
||||
OverRTW bool `json:"overRTW"`
|
||||
Schwerlast bool `json:"schwerlast"`
|
||||
}
|
||||
|
||||
type Trace struct {
|
||||
@@ -751,6 +750,6 @@ type WorkTimeStaging struct {
|
||||
DepartmentName pgtype.Text `json:"departmentName"`
|
||||
StartDate time.Time `json:"startDate"`
|
||||
EndDate time.Time `json:"endDate"`
|
||||
Duration int32 `json:"duration"`
|
||||
Pause int32 `json:"pause"`
|
||||
Duration int32 `json:"duration"`
|
||||
}
|
||||
|
||||
@@ -21,16 +21,7 @@ WITH ToursInPeriod AS (
|
||||
CASE
|
||||
WHEN "occupiedKm" <= 30 THEN 1.0
|
||||
ELSE ROUND("occupiedKm" / 30.0)
|
||||
END AS tour_value,
|
||||
CASE
|
||||
WHEN EXTRACT(ISODOW FROM "startDate") = 6 AND "occupiedKm" <= 30 THEN 0.5
|
||||
ELSE 0.0
|
||||
END AS weekend_bonus,
|
||||
"infectionName",
|
||||
CASE
|
||||
WHEN "infectionName" = 'Covid-19' THEN 0.5
|
||||
ELSE 0.0
|
||||
END AS infection_bonus
|
||||
END AS tour_value
|
||||
FROM
|
||||
"Tour"
|
||||
WHERE
|
||||
@@ -62,7 +53,7 @@ WITH ToursInPeriod AS (
|
||||
DailyCounts AS (
|
||||
SELECT
|
||||
day,
|
||||
SUM(tour_value) + SUM(weekend_bonus) + SUM(infection_bonus) AS tour_amount
|
||||
SUM(tour_value) AS tour_amount
|
||||
FROM
|
||||
ToursInPeriod
|
||||
GROUP BY
|
||||
@@ -358,7 +349,7 @@ SELECT
|
||||
FROM "DyflexisEmployee" as de
|
||||
INNER JOIN "Employee" as e ON de.id::text = e."personnelNumber"
|
||||
WHERE de."contractStart" < CURRENT_DATE AND de."contractEnd" > (CURRENT_DATE - INTERVAL '2 months')
|
||||
AND de.surname <> 'Shahrasebhi'
|
||||
AND de.surname <> 'Shahrasebi'
|
||||
ORDER BY de.firstname ASC
|
||||
`
|
||||
|
||||
|
||||
@@ -194,6 +194,15 @@ func genLineItemsFromStatement(stmt domain.PayrollStatement) []LineItem {
|
||||
Faktor: 125,
|
||||
})
|
||||
}
|
||||
if stmt.Feiertagszuschlag150 > 0 {
|
||||
items = append(items, LineItem{
|
||||
Lohnart: "0124",
|
||||
Beschreibung: "Feiertagszuschlag 150%",
|
||||
Anzahl: stmt.Feiertagszuschlag150,
|
||||
Betrag: stmt.VertragStundenlohn,
|
||||
Faktor: 150,
|
||||
})
|
||||
}
|
||||
if stmt.Nachtzuschlag25 > 0 {
|
||||
items = append(items, LineItem{
|
||||
Lohnart: "0505",
|
||||
|
||||
@@ -25,7 +25,7 @@ func NewEmployeeHandler(service Service, logger *slog.Logger) *EmployeeHandler {
|
||||
}
|
||||
|
||||
func (h *EmployeeHandler) RunPayroll(w http.ResponseWriter, r *http.Request) {
|
||||
month := time.Date(2025, time.December, 1, 0, 0, 0, 0, time.UTC)
|
||||
month := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
employees, err := h.service.FetchActiveEmployees(r.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
Nachname;Vorname;Verpflegungspauschale;Vertr. Stundenlohn;Arbeitsstunden lt. Vertrag;Gearbeitet Stunden;Gearbeitet Tage;Pünktlichkeitsprämie anzahl;Krankheitstage x 8 Stunden;davon Überstunden;Tourengeld Anzahl;Tourengeld-Bonus (Platz 1-3;Gesundheitsbonus abzuziehen? (ja/nein);Gesundheitsbonus;Urlaubsgeld (44 EUR pro tag);P-schein Zulage;Wochenendzuschlag 15%;Sonntagszuschlag 50%;Feiertagszuschlag 125%;Feiertagszuschlag 150%;Nachtzuschlag 25%;Nachtzuschlag 40%
|
||||
Ahmadpour;Amirhossein;21;14.42;173.32;205.68333333333334;24;23;0;32.35133333333332;44;500;;200;0;24;26.833333333333;0;5.75;0;0;0
|
||||
Hosseini Jahed;Sajad;19;14.42;173.32;187.76666666666665;22;21;0;14.434666666666637;40;400;;200;0;22;26.833333333333;0;6;0;0;0
|
||||
Salehi;Iman;25;17.31;173.32;223.78333333333333;26;25;0;50.45133333333332;28;300;;200;0;26;16.5;19.45;9.25;8.6;63.45;21.033333333333
|
||||
Khodayaran;Hossein;22;16.15;173.32;185.89999999999998;22;22;0;12.567999999999962;26;0;;200;0;22;8.2666666666667;8.1666666666667;8.0166666666667;8.1333333333333;49.583333333333;11.633333333333
|
||||
Saki;Sara;24;15;173.32;220.23333333333332;26;25;0;46.901333333333305;25;0;;200;0;26;0;28.75;9.25;8.6;62.35;21.033333333333
|
||||
Rohany Oskoee;Iman;23;16.15;173.32;213.84999999999997;25;8;0;40.51799999999995;23;0;;200;0;0;34.566666666667;0;7.7333333333333;0;0;0
|
||||
Moshtagh Askari;Adel;26;15.58;173.32;226;26;20;0;52.667999999999985;23;0;;200;0;26;34.666666666667;0;9.0333333333333;0;0;0
|
||||
Faridani Rad;Farzad;21;15;173.32;183.75;22;22;0;10.417999999999985;23;0;;200;0;22;7.5;6.1166666666667;8.0166666666667;8.1333333333333;50.25;11.633333333333
|
||||
Malani Karizi;Arasch;22;14.42;173.32;199.3;24;23;0;25.967999999999996;17;0;;200;0;0;8.6666666666667;0;16.533333333333;0;0.25;0
|
||||
Sharh Ghaboos;Peyman;19;14.42;173.32;169.08333333333331;20;19;0;19.7513333333333;16;0;;200;3;20;8.2833333333333;0;16.533333333333;0;0.25;0
|
||||
Sajaei Khouzestani;Farid;23;14.42;173.32;203.6;24;23;0;30.26799999999998;16;0;;200;0;24;18.25;0;9.0333333333333;0.58333333333333;0;0
|
||||
Hosseini;Seyed Hamid;23;14.42;173.32;197.3;24;24;0;23.967999999999996;15;0;;200;0;24;8.0833333333333;0;15.25;0.25;0;0
|
||||
Saeedpour;Fouad;21;14.42;173.32;179.15;21;21;0;5.817999999999991;15;0;;200;0;21;26.033333333333;17.3;0;0;13.733333333333;0
|
||||
Zekarias;Michael;22;15;173.32;186.54999999999998;23;22;0;13.217999999999968;14;0;;200;0;0;17.466666666667;0;8.2833333333333;0.8;12.166666666667;0
|
||||
Rahimzadeh;Siamak;24;14.42;173.32;217.91666666666666;26;25;0;44.58466666666664;14;0;;200;0;26;18.25;0;15.25;0.25;0;0
|
||||
Safari;Abdul Latif ;22;14.42;173.32;203.8;24;23;0;54.467999999999996;14;0;;200;3;24;25.416666666667;0;14.533333333333;0;0;0
|
||||
Sabahi;Mohammad;22;16.15;173.32;192.00000000000003;23;22;0;18.668000000000013;12;0;;200;0;23;8.2333333333333;0;8.0166666666667;0;0;0
|
||||
Pourhossein;Milad;17;14.42;173.32;148.56666666666666;18;18;0;-0.7653333333333521;11;0;;200;3;18;16.716666666667;0;8.0333333333333;0;9.7333333333333;0
|
||||
Juyande;Masoud;23;15;173.32;197.75;24;23;0;24.417999999999985;11;0;;200;0;0;0;0;16.05;6;50.4;21.45
|
||||
Pourhossein;Reza;13;14.42;173.32;122.65;15;14;0;21.31799999999999;11;0;;200;9;15;8.5833333333333;15.033333333333;0;0;13.733333333333;0
|
||||
Eggerstedt;Matthias;18;15.58;173.32;156.64999999999998;19;19;0;7.317999999999962;10;0;;200;3;19;8.6666666666667;0;8.4166666666667;0;0;0
|
||||
Pouzesh;Esmail;24;16.15;173.32;199.58333333333337;24;24;0;26.251333333333356;10;0;;200;0;24;0;8.1666666666667;16.05;6;62.416666666667;21.45
|
||||
Saffarnajib;Majid;18;15.38;90.99300000000001;150.1;18;18;0;59.10069999999999;10;0;;0;0;18;8.2666666666667;0;8.0333333333333;4.2666666666667;0;0
|
||||
Tajikzadeh;Mohammad;22;16.15;173.32;180.29999999999998;22;22;0;6.967999999999968;9;0;;200;0;22;16.316666666667;0;8.0333333333333;4.0333333333333;0;0
|
||||
Rasulkhani;Hossein;12;14.42;173.32;105.26666666666668;13;13;0;27.934666666666665;8;0;;200;12;13;0;0;14.033333333333;0.58333333333333;0;0
|
||||
Shokripour;Vahid;21;15;173.32;177.85000000000002;22;21;4;36.51800000000001;8;0;ja;0;0;22;17.083333333333;0;16.05;0.8;0;0
|
||||
Maleki;Mohammad;5;14.44;103.992;66.5;8;2;1;-29.49920000000001;7;0;ja;0;0;8;0;0;0;0;0;0
|
||||
Imanian;Shahriyar;17;14.42;173.32;150.01666666666668;18;16;0;32.684666666666665;7;0;;200;7;18;25.65;9.2666666666667;13.783333333333;4.1166666666667;0.26666666666667;0
|
||||
Sahem;Khalid;16;14.42;173.32;137.71666666666667;16;16;0;28.384666666666654;7;0;;200;8;16;17.233333333333;0;0;0;0;0
|
||||
Toraman;Tolga Can;17;14.42;173.32;152.91666666666666;19;17;4;11.584666666666642;6;0;ja;0;0;0;0;0;14.666666666667;0;0;0
|
||||
Rasul Khani;Mohammad;14;14.42;173.32;117.41666666666667;14;14;0;8.08466666666667;5;0;;200;8;14;8.75;8.9166666666667;8.0333333333333;4.2666666666667;6.05;0
|
||||
Kalantari;Kaveh;17;14.42;173.32;143.3;17;17;0;9.967999999999996;5;0;;200;5;17;17.05;16.95;16.05;4.1333333333333;0.43333333333333;0
|
||||
Hastedt;David;8;14.93;138.656;76.08333333333333;9;3;4;-30.582266666666683;5;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Florescu;Silviu-Cezar;18;15;173.32;159.36666666666667;19;18;0;18.03466666666666;4;0;;200;4;0;8.7333333333333;0;0;0;0.26666666666667;0
|
||||
Tokhi;Shokrollah;15;14.42;173.32;136.05;17;16;0;2.7179999999999964;3;0;;200;5;17;0;0;0;4.0333333333333;3.5333333333333;0
|
||||
Najafgholizadeh;Masoud;11;12.82;103.992;95.9;11;0;0;-8.099200000000003;0;0;ja;0;0;0;8.25;0;0;0;0;0
|
||||
Heucke;Niklas;0;15;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Latza;Lelia Eleonore;0;6.35;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Hazrati;Kamyar;12;12.82;173.32;112.03333333333335;13;0;1;-28.798666666666655;0;0;ja;0;0;0;15.066666666667;0;0;0;0;0
|
||||
Haralambous;Martin;0;12.41;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Krüger;Julian;0;22.5;173.32;90.48333333333333;11;0;5;21.151333333333334;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Mohamed Salah;Hussin Noureldin;13;12.82;173.32;125.28333333333333;15;0;1;-40.04866666666668;0;0;ja;0;0;0;15.766666666667;0;0;0;0;0
|
||||
Tokhi;Haroun;0;14.42;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Arabnia;Mehdi;18;12.82;173.32;181.06666666666666;21;0;0;7.734666666666648;0;0;ja;0;0;0;16;0;7.7833333333333;0;0;0
|
||||
Taghadosi;Mehdi;16;12.82;173.32;145.41666666666666;17;0;0;-27.915333333333358;0;0;ja;0;0;0;25.033333333333;0;7.6833333333333;0.083333333333333;0;0
|
||||
Jalali Wagener;Mehdi;17;12.82;173.32;167.61666666666667;20;0;0;-5.715333333333341;0;0;ja;0;0;0;16.016666666667;0;6.2666666666667;0;0;0
|
||||
Mazidi;Mehrdad;13;12.82;173.32;121.28333333333335;14;0;1;3.951333333333345;0;0;ja;0;0;0;15.816666666667;0;0;0;0;0
|
||||
Koell;Melanie Nadine;0;15.58;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Karimloo;Hamidreza;0;12.82;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Hashimazada;Firuz;15;12.82;173.32;135.96666666666667;16;0;0;-37.365333333333346;0;0;ja;0;0;0;0;0;8.5;0;0;0
|
||||
Esmaeili Miandehi;Milad;0;12.82;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Movafagh;Farzad ;21;12.82;173.32;187.56666666666666;22;0;0;14.234666666666648;0;0;ja;0;0;0;16.266666666667;0;7.1833333333333;0.083333333333333;0;0
|
||||
Esser;Detlef;0;12;121.32400000000001;0;0;0;0;-121.3324;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Abdallah;Delshad;0;12.82;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Golmohammadi;Davood ;18;12.82;173.32;164.38333333333333;19;0;0;-8.94866666666669;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Atrafi;Mohammad Nasir;13;12.82;173.32;124.25;15;0;0;-17.082000000000015;0;0;ja;0;0;0;8.0166666666667;0;0;0;0;0
|
||||
Sedighian Bidgoli;Mohammadreza;12;12.82;173.32;118.75;14;0;0;-54.582000000000015;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Amiri;Mohammad Samir;0;19.62;173.32;165.4333333333333;19;0;0;8.101333333333294;0;0;ja;0;0;0;14.416666666667;27;17.05;6.5;24.5;19.4
|
||||
Babaei Dezghi;Mojtaba;0;15;160.321;0;0;0;0;-162.49875;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
El Moustaiqid;Najlae;0;12.82;173.32;117.19999999999999;16;0;0;-32.132000000000026;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Saei;Kasra;0;5.65;173.32;15.133333333333333;2;0;0;-158.19866666666667;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Jasarevic;Noel;0;14.42;173.32;128.36666666666667;15;0;4;-10.96533333333334;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Lolies;Danny;11;12.82;173.32;102.41666666666667;12;0;1;-38.41533333333333;0;0;ja;0;0;0;16.016666666667;0;0;0;0;0
|
||||
Feilbach;Raoul Nikolai ;0;19.62;173.32;191.36666666666667;22;0;0;18.03466666666666;0;0;ja;0;0;0;8.7;9.4166666666667;7.25;0.016666666666667;17.25;14.366666666667
|
||||
Hatami;Reza;17;12.82;173.32;170.96666666666667;20;0;0;-2.3653333333333464;0;0;ja;0;0;0;16.283333333333;0;8.7;0;0;0
|
||||
Emkani;Daniel;0;22.48;108.325;106.23333333333333;13;0;0;6.2341666666666535;0;0;ja;0;0;0;6.85;0;8.7;0;0;0
|
||||
Rohi Pour;Sadroldin;20;12.82;173.32;183.7;21;0;0;10.367999999999974;0;0;ja;0;0;0;0;0;7.7833333333333;0;0;0
|
||||
Sidiqi;Sahel;0;14.42;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Mohammadi;Danial;13;12.82;173.32;127.31666666666666;15;0;0;-22.015333333333352;0;0;ja;0;0;0;15.066666666667;0;0;0;0;0
|
||||
Haghighivand;Danial;16;12.82;173.32;148.93333333333334;17;0;0;-24.398666666666678;0;0;ja;0;0;0;0;0;7.65;0;0;0
|
||||
Perez Y Waldt;Sebastian;0;14.42;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Aba;Cuan;0;5.65;173.32;81.26666666666667;11;0;0;-84.06533333333334;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Askarian Pour;Seyed Hosein;0;15.58;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Jabbari;Shahram;16;12.82;173.32;151.01666666666665;18;0;0;-22.315333333333363;0;0;ja;0;0;0;17.3;0;6.2666666666667;0;0;0
|
||||
Altner;Christian Van Manh;0;15;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Alami;Shoaib;0;12.82;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Modrau;Cara Marleen;0;14.42;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Rahmati;Behnam;16;12.82;173.32;148.93333333333334;17;0;0;-24.398666666666678;0;0;ja;0;0;0;0;0;7.65;0;0;0
|
||||
Ziaudin;Atiq;12;12.82;173.32;119.6;14;0;2;-37.23200000000002;0;0;ja;0;0;0;8;0;0;0;0;0
|
||||
Vafaei;Soheil;13;12.82;173.32;136.26666666666665;16;0;2;-21.065333333333363;0;0;ja;0;0;0;16;0;6.0166666666667;0;0;0
|
||||
Esmaeili Dashtbayaz;Amin;8;12.82;173.32;70.48333333333333;8;0;0;17.151333333333334;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Haugg;Uwe;11;12.82;138.656;109.53333333333333;13;0;0;-13.13226666666668;0;0;ja;0;0;0;30.45;0;0;0;0;0
|
||||
Mihailescu;Adrian Lucian;1;16.73;173.32;8.1;1;0;0;-5.232000000000021;0;0;;200;20;1;0;0;0;0;0;0
|
||||
Konak;Zidar;0;22.5;173.32;176.85;21;0;0;11.51799999999998;0;0;ja;0;0;0;5.65;0;8.5333333333333;3.15;17;12.066666666667
|
||||
|
@@ -0,0 +1,86 @@
|
||||
Nachname;Vorname;Verpflegungspauschale;Vertr. Stundenlohn;Arbeitsstunden lt. Vertrag;Gearbeitet Stunden;Gearbeitet Tage;Pünktlichkeitsprämie anzahl;Krankheitstage x 8 Stunden;davon Überstunden;Tourengeld Anzahl;Tourengeld-Bonus (Platz 1-3;Gesundheitsbonus abzuziehen? (ja/nein);Gesundheitsbonus;Urlaubsgeld (44 EUR pro tag);P-schein Zulage;Wochenendzuschlag 15%;Sonntagszuschlag 50%;Feiertagszuschlag 125%;Feiertagszuschlag 150%;Nachtzuschlag 25%;Nachtzuschlag 40%
|
||||
Malani Karizi;Arasch;23;14.42;173.32;207.74999999999997;24;23;0;34.41799999999996;41.5;500;;200;0;0;24.55;8.8666666666667;0;0;1.5;0
|
||||
Hosseini;Seyed Hamid;22;14.42;173.32;189.26666666666668;22;22;0;15.934666666666665;35;400;;200;0;22;24.333333333333;0;0;0;0;0
|
||||
Sharh Ghaboos;Peyman;22;14.42;173.32;199.08333333333334;23;22;0;25.751333333333328;34.5;300;;200;0;23;24.55;8.8666666666667;0;0;1.5;0
|
||||
Moshtagh Askari;Adel;22;15.58;173.32;198.91666666666666;23;20;0;25.584666666666642;31;0;;200;0;23;17.45;0;0;0;0;0
|
||||
Imanian;Shahriyar;23;14.42;173.32;199.2166666666667;23;23;0;25.884666666666682;30.5;0;;200;0;23;33.45;44.716666666667;0;0;0.7;0
|
||||
Saeedpour;Fouad;21;14.42;173.32;180.68333333333334;21;21;0;7.351333333333322;30.5;0;;200;0;21;33.033333333333;18.583333333333;0;0;17.5;10.55
|
||||
Rahimzadeh;Siamak;20;14.42;173.32;182.96666666666664;22;21;0;9.634666666666625;27;0;;200;0;22;16.433333333333;0;0;0;0;0
|
||||
Rohany Oskoee;Iman;21;16.15;173.32;196.86666666666667;23;10;0;23.53466666666666;26.5;0;;200;0;0;16.883333333333;0;0;0;0;0
|
||||
Sahem;Khalid;19;14.42;173.32;168.71666666666664;20;19;0;-4.615333333333375;25;0;;200;0;20;8.6833333333333;0;0;0;0;0
|
||||
Florescu;Silviu-Cezar;20;15;173.32;182.28333333333336;21;20;0;8.951333333333345;25;0;;200;0;0;17.533333333333;0;0;0;0.43333333333333;0
|
||||
Safari;Abdul Latif ;20;14.42;173.32;185.21666666666667;21;20;0;19.884666666666654;24.5;0;;200;1;21;18.933333333333;0;0;0;0;0
|
||||
Ahmadpour;Amirhossein;21;14.42;173.32;194.75;22;21;0;21.417999999999985;24.5;0;;200;0;22;8.75;0;0;0;1.1333333333333;0
|
||||
Sajaei Khouzestani;Farid;22;14.42;173.32;203.5;23;22;0;30.167999999999985;24.5;0;;200;0;23;28.75;0;0;0;0;0
|
||||
Mihailescu;Adrian Lucian;13;16.73;173.32;178.59999999999997;21;5;0;5.267999999999951;24;0;;200;0;21;25.65;0;0;0;0;0
|
||||
Hosseini Jahed;Sajad;20;14.42;173.32;184.20000000000002;21;20;0;10.868000000000002;24;0;;200;0;21;8.5166666666667;0;0;0;0;0
|
||||
Tajikzadeh;Mohammad;20;16.15;173.32;169.38333333333335;20;20;0;-3.9486666666666608;24;0;;200;0;20;24.35;0;0;0;0;0
|
||||
Shokripour;Vahid;20;15;173.32;174.25;20;20;0;0.917999999999985;21;0;;200;0;20;17.266666666667;0;0;0;0;0
|
||||
Saffarnajib;Majid;17;15.38;90.99300000000001;145.56666666666666;17;17;0;54.56736666666666;20;0;;0;0;17;0;0;0;0;3.85;0
|
||||
Kalantari;Kaveh;18;14.42;173.32;155.16666666666666;18;18;0;29.834666666666642;19.5;0;;200;6;18;25.533333333333;17.733333333333;0;0;1.05;0
|
||||
Pourhossein;Milad;20;14.42;173.32;182.6;22;19;0;9.26799999999998;18.5;0;;200;0;22;9.25;0;0;0;30.9;0.35
|
||||
Hastedt;David;10;14.93;138.656;110.19999999999999;13;8;1;-7.665600000000012;17;0;ja;0;2;0;0;0;0;0;0;0
|
||||
Eggerstedt;Matthias;20;15.58;173.32;171.68333333333334;20;20;0;-1.6486666666666778;17;0;;200;0;20;16.933333333333;0;0;0;0;0
|
||||
Rasulkhani;Hossein;9;14.42;173.32;76.46666666666667;9;9;0;-16.865333333333346;15.5;0;;200;10;9;16.2;9;0;0;0;0
|
||||
Pourhossein;Reza;22;14.42;173.32;201.4666666666667;25;24;0;28.134666666666682;14;0;;200;0;25;16.266666666667;20.95;0;0;45.85;10.166666666667
|
||||
Rasul Khani;Mohammad;11;14.42;173.32;95;11;11;0;1.667999999999985;12;0;;200;10;11;8.5166666666667;0;0;0;0;0
|
||||
Zekarias;Michael;20;15;173.32;172.83333333333334;21;20;0;-0.49866666666667214;12;0;;200;0;0;8.0333333333333;0;0;0;14.616666666667;0
|
||||
Salehi;Iman;21;17.31;173.32;176.55;21;20;0;3.2179999999999964;10.5;0;;200;0;21;8.75;10;0;0;62.466666666667;10.9
|
||||
Sabahi;Mohammad;19;16.15;173.32;170.14999999999998;20;19;0;-3.1820000000000377;10;0;;200;0;20;0;0;0;0;0;0
|
||||
Toraman;Tolga Can;18;14.42;173.32;177.73333333333332;21;18;0;4.401333333333305;10;0;;200;0;0;15.65;9.0833333333333;0;0;17.883333333333;0
|
||||
Tokhi;Shokrollah;19;14.42;173.32;168.03333333333336;20;19;0;-5.298666666666655;9;0;;200;0;20;16.45;17.116666666667;0;0;33.016666666667;0.05
|
||||
Juyande;Masoud;13;15;173.32;113.43333333333334;14;13;0;-3.898666666666678;8;0;;200;7;0;0;0;0;0;33.116666666667;11.116666666667
|
||||
Perez Y Waldt;Sebastian;3;14.42;173.32;47.3;6;2;8;-62.03200000000002;6.5;0;ja;0;0;0;7.7166666666667;0;0;0;0;0
|
||||
Pouzesh;Esmail;20;16.15;173.32;170.36666666666665;21;20;0;-2.965333333333369;5;0;;200;0;21;0;0;0;0;66.283333333333;21.333333333333
|
||||
Khodayaran;Hossein;18;16.15;173.32;152.54999999999998;19;19;0;-20.782000000000032;3;0;;200;0;19;0;3.5166666666667;0;0;56.733333333333;11.55
|
||||
Faridani Rad;Farzad;19;15;173.32;157.2;19;19;3;7.867999999999974;3;0;ja;0;0;19;0;0;0;0;59.9;11.55
|
||||
Saki;Sara;2;15;173.32;25.766666666666666;4;3;15;-27.565333333333363;2;0;ja;0;0;4;0;0;0;0;0;0
|
||||
Maleki;Mohammad;6;14.44;103.992;72.68333333333334;9;1;1;-23.315866666666672;2;0;ja;0;0;9;0;0;0;0;0;0
|
||||
Babaei Dezghi;Mojtaba;0;15;160.321;0;0;0;0;-162.49875;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Mazidi;Mehrdad;18;12.82;173.32;166.91666666666669;19;0;0;-6.415333333333329;0;0;ja;0;0;0;17.616666666667;0;0;0;0;0
|
||||
Tokhi;Haroun;0;14.42;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Haralambous;Martin;0;12.41;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Karimloo;Hamidreza;0;12.82;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Najafgholizadeh;Masoud;1;12.82;173.32;8.55;1;0;0;-164.782;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Hashimazada;Firuz;0;12.82;173.32;0;0;0;18;-29.332000000000015;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Taghadosi;Mehdi;19;12.82;173.32;167.25;19;0;0;-6.082000000000015;0;0;ja;0;0;0;16.266666666667;0;0;0;0;0
|
||||
Arabnia;Mehdi;20;12.82;173.32;185.93333333333334;21;0;0;20.601333333333322;0;0;ja;0;0;0;24.766666666667;0;0;0;0;0
|
||||
Heucke;Niklas;0;15;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
El Moustaiqid;Najlae;0;12.82;173.32;126.5;17;0;3;-22.832000000000015;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Koell;Melanie Nadine;0;15.58;173.32;6.5;1;0;0;-166.83200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Movafagh;Farzad ;20;12.82;173.32;182.65;21;0;0;9.31799999999999;0;0;ja;0;0;0;16.016666666667;0;0;0;0;0
|
||||
Esmaeili Miandehi;Milad;0;12.82;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Mohamed Salah;Hussin Noureldin;19;12.82;173.32;173.66666666666666;20;0;0;0.33466666666664224;0;0;ja;0;0;0;17.616666666667;0;0;0;0;0
|
||||
Esser;Detlef;0;12;121.32400000000001;0;0;0;0;-121.3324;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Abdallah;Delshad;0;12.82;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Saei;Kasra;0;5.65;173.32;56.233333333333334;8;0;1;-102.09866666666667;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Golmohammadi;Davood ;19;12.82;173.32;171.66666666666666;19;0;0;6.334666666666642;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Atrafi;Mohammad Nasir;19;12.82;17.332;177.8;20;0;0;161.26680000000002;0;0;ja;0;0;0;7.85;0;0;0;0;0
|
||||
Sedighian Bidgoli;Mohammadreza;0;12.82;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Amiri;Mohammad Samir;0;19.62;173.32;197.66666666666669;22;0;0;24.33466666666667;0;0;ja;0;0;0;19.25;27.833333333333;0;0;34.75;23.166666666667
|
||||
Latza;Lelia Eleonore;0;6.35;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Krüger;Julian;0;22.5;173.32;171.28333333333333;20;0;0;-2.0486666666666835;0;0;ja;0;0;0;6.05;0;0;0;7;4.4333333333333
|
||||
Jalali Wagener;Mehdi;22;12.82;173.32;195.71666666666667;23;0;0;22.384666666666654;0;0;ja;0;0;0;24.033333333333;0;0;0;0;0
|
||||
Jasarevic;Noel;0;14.42;173.32;161.93333333333334;19;0;0;-3.398666666666678;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Shahrasebi;David;0;0;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Feilbach;Raoul Nikolai ;0;19.62;173.32;173.36666666666665;20;0;0;0.034666666666630874;0;0;ja;0;0;0;0;19;0;0;28;16.766666666667
|
||||
Lolies;Danny;17;12.82;173.32;161.48333333333332;19;0;3;12.651333333333305;0;0;ja;0;0;0;16.566666666667;0;0;0;0;0
|
||||
Hatami;Reza;20;12.82;173.32;175.33333333333334;20;0;0;2.001333333333328;0;0;ja;0;0;0;16.266666666667;0;0;0;0;0
|
||||
Rohi Pour;Sadroldin;17;12.82;173.32;155.46666666666667;18;0;0;-17.865333333333346;0;0;ja;0;0;0;16.016666666667;0;0;0;0;0
|
||||
Sidiqi;Sahel;0;14.42;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Emkani;Daniel;0;22.48;108.325;58.916666666666664;7;0;3;-10.415833333333353;0;0;ja;0;0;0;15.65;0;0;0;0;0
|
||||
Hazrati;Kamyar;19;12.82;173.32;182.23333333333335;21;0;0;8.901333333333334;0;0;ja;0;0;0;22.616666666667;0;0;0;0;0
|
||||
Haghighivand;Danial;17;12.82;173.32;152.23333333333335;17;0;1;-13.098666666666666;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Mohammadi;Danial;17;12.82;173.32;165.01666666666665;19;0;0;-8.315333333333363;0;0;ja;0;0;0;14.5;0;0;0;0;0
|
||||
Askarian Pour;Seyed Hosein;0;15.58;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Jabbari;Shahram;19;12.82;173.32;165.5;19;0;0;-7.832000000000015;0;0;ja;0;0;0;16.816666666667;0;0;0;0;0
|
||||
Aba;Cuan;0;5.65;173.32;69.45;10;0;2;-87.882;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Alami;Shoaib;0;12.82;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Altner;Christian Van Manh;0;15;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Modrau;Cara Marleen;0;14.42;173.32;0;0;0;0;-173.33200000000002;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Rahmati;Behnam;16;12.82;173.32;143.95;16;0;2;-13.382000000000026;0;0;ja;0;0;0;0;0;0;0;0;0
|
||||
Vafaei;Soheil;20;12.82;173.32;176.06666666666666;20;0;0;2.734666666666648;0;0;ja;0;0;0;16.033333333333;0;0;0;0;0
|
||||
Ziaudin;Atiq;20;12.82;173.32;172.6;20;0;0;-0.7320000000000206;0;0;ja;0;0;0;8.0166666666667;0;0;0;0;0
|
||||
Haugg;Uwe;14;12.82;138.656;142.26666666666665;17;0;0;3.6010666666666395;0;0;ja;0;0;0;38.4;0;0;0;0;0
|
||||
Esmaeili Dashtbayaz;Amin;20;12.82;173.32;181.08333333333331;21;0;0;7.751333333333299;0;0;ja;0;0;0;24.033333333333;0;0;0;0;0
|
||||
Konak;Zidar;0;22.5;173.32;178.96666666666667;21;0;0;5.634666666666654;0;0;ja;0;0;0;26.95;0;0;0;1.1;0
|
||||
|
@@ -1,11 +1,17 @@
|
||||
FROM node:20.15.1-alpine AS builder
|
||||
FROM node:20.19.1-slim AS builder
|
||||
|
||||
WORKDIR /app/builder
|
||||
COPY package.json .
|
||||
COPY package-lock.json .
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
COPY package*.json .
|
||||
COPY packages/shared-dtos/package*.json ./packages/shared-dtos/
|
||||
COPY data-hub/data-connector/package*.json ./data-hub/data-connector/
|
||||
|
||||
RUN npm ci --verbose
|
||||
|
||||
COPY packages/shared-dtos ./packages/shared-dtos/
|
||||
COPY data-hub/data-connector ./data-hub/data-connector/
|
||||
|
||||
WORKDIR /app/builder/data-hub/data-connector
|
||||
|
||||
RUN npm run build
|
||||
|
||||
@@ -27,9 +33,10 @@ FROM node:20.15.1-alpine
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/builder/node_modules ./node_modules
|
||||
COPY --from=builder /app/builder/prisma ./prisma
|
||||
COPY --from=builder /app/builder/dist ./dist
|
||||
COPY --from=builder /app/builder/docker-entrypoint.sh .
|
||||
COPY --from=builder /app/builder/packages ./packages
|
||||
COPY --from=builder /app/builder/data-hub/data-connector/prisma ./prisma
|
||||
COPY --from=builder /app/builder/data-hub/data-connector/dist ./dist
|
||||
COPY --from=builder /app/builder/data-hub/data-connector/docker-entrypoint.sh .
|
||||
|
||||
ENTRYPOINT [ "./docker-entrypoint.sh" ]
|
||||
|
||||
|
||||
-20550
File diff suppressed because it is too large
Load Diff
@@ -6,12 +6,12 @@
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"build": "npm run prisma -- generate && nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"start": "TZ=UTC nest start",
|
||||
"start:dev": "TZ=UTC nest start --watch",
|
||||
"start:debug": "TZ=UTC nest start --debug --watch",
|
||||
"start:prod": "TZ=UTC node dist/main",
|
||||
"prisma": "prisma",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
@@ -24,7 +24,9 @@
|
||||
"seed": "ts-node prisma/seed/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/server": "^4.12.0",
|
||||
"@apollo/server": "^5.0.0",
|
||||
"@as-integrations/express5": "^1.1.2",
|
||||
"@avicenna/shared-dtos": "^1.0.0",
|
||||
"@faker-js/faker": "^9.6.0",
|
||||
"@hyperdx/node-logger": "^0.4.0",
|
||||
"@hyperdx/node-opentelemetry": "^0.10.2",
|
||||
@@ -39,12 +41,14 @@
|
||||
"@nestjs/graphql": "^13.0.4",
|
||||
"@nestjs/platform-express": "^11.0.15",
|
||||
"@nestjs/schedule": "^5.0.1",
|
||||
"@nestjs/swagger": "^11.2.5",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.60.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.202.0",
|
||||
"@opentelemetry/instrumentation-runtime-node": "^0.22.0",
|
||||
"@opentelemetry/sdk-node": "^0.202.0",
|
||||
"@prisma/client": "^6.6.0",
|
||||
"axios": "^1.13.2",
|
||||
"bullmq": "^5.56.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
@@ -77,7 +81,7 @@
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-prettier": "^5.2.6",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier": "^3.8.0",
|
||||
"prisma": "^6.6.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.1.0",
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[tourId,ticketId]` on the table `TourFile` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- DropIndex
|
||||
DROP INDEX "TourFile_tourId_ticketId_idx";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Patient" ADD COLUMN "insuranceNo" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "PatientSource" ADD COLUMN "stammVersNr" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "PatientStaging" ADD COLUMN "insuranceNo" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Ticket" ADD COLUMN "approvalFullRequestMessageId" TEXT,
|
||||
ADD COLUMN "approvalIsOverriden" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "approvalLastInteractionAt" TIMESTAMP(3),
|
||||
ADD COLUMN "approvalPreInquiryMessageId" TEXT,
|
||||
ADD COLUMN "approvalRecipient" TEXT,
|
||||
ADD COLUMN "approvalReminderCount" INTEGER DEFAULT 0,
|
||||
ADD COLUMN "approvalRequestedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "approvalRequirement" TEXT DEFAULT 'UNKNOWN',
|
||||
ADD COLUMN "approvalStatus" TEXT DEFAULT 'NOT_STARTED',
|
||||
ADD COLUMN "hasDigitalTransportDocument" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "hasPhysicalTransportDocument" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "patientId" UUID,
|
||||
ADD COLUMN "stage" TEXT DEFAULT 'DRAFT',
|
||||
ADD COLUMN "updatedAt" TIMESTAMP(3),
|
||||
ALTER COLUMN "id" DROP DEFAULT,
|
||||
ALTER COLUMN "currentState" DROP NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Tour" ADD COLUMN "insuranceNo" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TourSource" ADD COLUMN "stammVersNr" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TourStaging" ADD COLUMN "insuranceNo" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TourFile_tourId_ticketId_key" ON "TourFile"("tourId", "ticketId");
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Ticket" ADD COLUMN "lastStageBeforeClosed" TEXT;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Tour" ADD COLUMN "optimizationInMinutes" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -59,6 +59,7 @@ model TourSource {
|
||||
itName String?
|
||||
kkId String?
|
||||
krankenkasse String?
|
||||
stammVersNr String?
|
||||
patId String?
|
||||
patName String?
|
||||
patSurname String?
|
||||
@@ -196,6 +197,7 @@ model PatientSource {
|
||||
birthday String?
|
||||
kkId String?
|
||||
krankenkasse String?
|
||||
stammVersNr String?
|
||||
pflegestufe String?
|
||||
behinderung1 String?
|
||||
behinderung2 String?
|
||||
@@ -223,6 +225,7 @@ model TourStaging {
|
||||
patientCity String?
|
||||
healthInsurance String?
|
||||
healthInsuranceNumber String?
|
||||
insuranceNo String?
|
||||
|
||||
type String?
|
||||
category String?
|
||||
@@ -282,6 +285,7 @@ model PatientStaging {
|
||||
birthday DateTime?
|
||||
healthinsuranceId String?
|
||||
healthinsurance String?
|
||||
insuranceNo String?
|
||||
careDegree String?
|
||||
disabilityMark1 String?
|
||||
disabilityMark2 String?
|
||||
@@ -371,6 +375,7 @@ model Tour {
|
||||
patientCity String?
|
||||
healthInsurance String?
|
||||
healthInsuranceNumber String?
|
||||
insuranceNo String?
|
||||
|
||||
type String?
|
||||
category String?
|
||||
@@ -414,11 +419,12 @@ model Tour {
|
||||
companyName String?
|
||||
schwerlast Boolean @default(false)
|
||||
|
||||
createdAt DateTime?
|
||||
updatedAt DateTime?
|
||||
deletedAt DateTime?
|
||||
Ticket Ticket? @relation(fields: [ticketId], references: [id])
|
||||
ticketId String? @db.Uuid
|
||||
createdAt DateTime?
|
||||
updatedAt DateTime?
|
||||
deletedAt DateTime?
|
||||
optimizationInMinutes Int @default(0)
|
||||
Ticket Ticket? @relation(fields: [ticketId], references: [id])
|
||||
ticketId String? @db.Uuid
|
||||
}
|
||||
|
||||
model TourFile {
|
||||
@@ -435,7 +441,7 @@ model TourFile {
|
||||
|
||||
createdAt DateTime? @default(now())
|
||||
|
||||
@@index([tourId, ticketId])
|
||||
@@unique([tourId, ticketId])
|
||||
}
|
||||
|
||||
model Patient {
|
||||
@@ -449,6 +455,7 @@ model Patient {
|
||||
birthday DateTime?
|
||||
healthinsuranceId String?
|
||||
healthinsurance String?
|
||||
insuranceNo String?
|
||||
careDegree String?
|
||||
disabilityMark1 String?
|
||||
disabilityMark2 String?
|
||||
@@ -480,17 +487,36 @@ enum ApprovalState {
|
||||
}
|
||||
|
||||
model Ticket {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
id String @id @db.Uuid
|
||||
patientId String? @db.Uuid
|
||||
stage String? @default("DRAFT")
|
||||
|
||||
approvalRequirement String? @default("UNKNOWN")
|
||||
approvalStatus String? @default("NOT_STARTED")
|
||||
approvalRequestedAt DateTime?
|
||||
approvalLastInteractionAt DateTime?
|
||||
approvalReminderCount Int? @default(0)
|
||||
approvalRecipient String?
|
||||
approvalPreInquiryMessageId String?
|
||||
approvalFullRequestMessageId String?
|
||||
approvalIsOverriden Boolean @default(false)
|
||||
|
||||
hasPhysicalTransportDocument Boolean @default(false)
|
||||
hasDigitalTransportDocument Boolean @default(false)
|
||||
lastStageBeforeClosed String?
|
||||
|
||||
updatedAt DateTime? @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tours Tour[]
|
||||
state TicketStateHistory[]
|
||||
currentState TicketValidationState
|
||||
currentState TicketValidationState?
|
||||
notes String[]
|
||||
errors String[]
|
||||
isUrgent Boolean @default(false)
|
||||
urgency DateTime @default("2099-12-31T23:59:59.999Z")
|
||||
isUrgent Boolean @default(false)
|
||||
urgency DateTime @default("2099-12-31T23:59:59.999Z")
|
||||
approvalState ApprovalState?
|
||||
documentInfo Json? @default("{\"approval\": {\"isNeeded\": null, \"whoRequested\": \"\",\"answeredFrom\": \"\"},\"tdLocation\": \"\"}")
|
||||
createdAt DateTime @default(now())
|
||||
documentInfo Json? @default("{\"approval\": {\"isNeeded\": null, \"whoRequested\": \"\",\"answeredFrom\": \"\"},\"tdLocation\": \"\"}")
|
||||
}
|
||||
|
||||
model TicketStateHistory {
|
||||
|
||||
@@ -25,7 +25,7 @@ import { FeatCalculationModule } from './modules/feat-calculation/feat-calculati
|
||||
import { FeatureEtlModule } from './modules/feat-etl/feat-etl.module';
|
||||
import { FeatReportsModule } from './modules/feat-reports/feat-reports.module';
|
||||
import { FeatTicketSystemModule } from './modules/feat-tickets/ticket-system.module';
|
||||
import { OtelMetricsService } from './core/service/otel-metrics.service';
|
||||
import { AppPlaygroundModule } from './modules/app-playground/app-playground.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -60,7 +60,7 @@ import { OtelMetricsService } from './core/service/otel-metrics.service';
|
||||
EventEmitterModule.forRoot(),
|
||||
GraphQLModule.forRoot<ApolloDriverConfig>({
|
||||
driver: ApolloDriver,
|
||||
playground: true,
|
||||
graphiql: true,
|
||||
autoSchemaFile: join(process.cwd(), 'src/core/database/schema.gql'),
|
||||
sortSchema: true,
|
||||
subscriptions: {
|
||||
@@ -78,9 +78,10 @@ import { OtelMetricsService } from './core/service/otel-metrics.service';
|
||||
ApplicationControlCenterModule,
|
||||
ApplicationTicketSystemModule,
|
||||
AppHomeModule,
|
||||
AppPlaygroundModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService, Logger, OtelMetricsService],
|
||||
providers: [AppService, Logger],
|
||||
})
|
||||
export class AppModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
|
||||
@@ -24,13 +24,6 @@ export interface PrismaServiceOptions {
|
||||
* See: https://www.prisma.io/docs/concepts/components/prisma-client/working-with-prismaclient/connection-management
|
||||
*/
|
||||
explicitConnect?: boolean;
|
||||
|
||||
/**
|
||||
* Apply Prisma middlewares to perform actions before or after db queries.
|
||||
*
|
||||
* See: https://www.prisma.io/docs/concepts/components/prisma-client/middleware
|
||||
*/
|
||||
middlewares?: Array<Prisma.Middleware>;
|
||||
}
|
||||
|
||||
export interface PrismaOptionsFactory {
|
||||
|
||||
@@ -31,12 +31,6 @@ export class PrismaService extends PrismaClient implements OnModuleInit {
|
||||
],
|
||||
});
|
||||
|
||||
if (this.prismaServiceOptions.middlewares) {
|
||||
this.prismaServiceOptions.middlewares.forEach((middleware) =>
|
||||
this.$use(middleware),
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
Sadly typescript does not recognize the Prisma Client configuration passed in the super call.
|
||||
As Prisma defines the events generically, based on the configuration, the event type, typescript inferred, is wrong.
|
||||
|
||||
@@ -191,7 +191,7 @@ type PlannedTime {
|
||||
|
||||
type Query {
|
||||
accountingStateMeta(state: AccountingState!): TourStateMeta!
|
||||
accountingTours(cursor: String, filters: AccountingFilterArgs, state: AccountingState!, take: Int! = 10): PaginatedTour!
|
||||
accountingTours(cursor: String, filters: AccountingFilterArgs, state: AccountingState!, take: Int): PaginatedTour!
|
||||
anomalies(cursor: String, take: Int! = 10): PaginatedAnomaly!
|
||||
anomaliesProto: [Anomaly!]!
|
||||
anomaly(id: String!): Anomaly!
|
||||
|
||||
@@ -187,6 +187,7 @@ export type Patient = {
|
||||
birthday: Timestamp | null;
|
||||
healthinsuranceId: string | null;
|
||||
healthinsurance: string | null;
|
||||
insuranceNo: string | null;
|
||||
careDegree: string | null;
|
||||
disabilityMark1: string | null;
|
||||
disabilityMark2: string | null;
|
||||
@@ -204,6 +205,7 @@ export type PatientSource = {
|
||||
birthday: string | null;
|
||||
kkId: string | null;
|
||||
krankenkasse: string | null;
|
||||
stammVersNr: string | null;
|
||||
pflegestufe: string | null;
|
||||
behinderung1: string | null;
|
||||
behinderung2: string | null;
|
||||
@@ -219,6 +221,7 @@ export type PatientStaging = {
|
||||
birthday: Timestamp | null;
|
||||
healthinsuranceId: string | null;
|
||||
healthinsurance: string | null;
|
||||
insuranceNo: string | null;
|
||||
careDegree: string | null;
|
||||
disabilityMark1: string | null;
|
||||
disabilityMark2: string | null;
|
||||
@@ -298,15 +301,30 @@ export type TariffGroup = {
|
||||
ordinanceType: string;
|
||||
};
|
||||
export type Ticket = {
|
||||
id: Generated<string>;
|
||||
currentState: TicketValidationState;
|
||||
id: string;
|
||||
patientId: string | null;
|
||||
stage: Generated<string | null>;
|
||||
approvalRequirement: Generated<string | null>;
|
||||
approvalStatus: Generated<string | null>;
|
||||
approvalRequestedAt: Timestamp | null;
|
||||
approvalLastInteractionAt: Timestamp | null;
|
||||
approvalReminderCount: Generated<number | null>;
|
||||
approvalRecipient: string | null;
|
||||
approvalPreInquiryMessageId: string | null;
|
||||
approvalFullRequestMessageId: string | null;
|
||||
approvalIsOverriden: Generated<boolean>;
|
||||
hasPhysicalTransportDocument: Generated<boolean>;
|
||||
hasDigitalTransportDocument: Generated<boolean>;
|
||||
lastStageBeforeClosed: string | null;
|
||||
updatedAt: Timestamp | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
currentState: TicketValidationState | null;
|
||||
notes: string[];
|
||||
errors: string[];
|
||||
isUrgent: Generated<boolean>;
|
||||
urgency: Generated<Timestamp>;
|
||||
approvalState: ApprovalState | null;
|
||||
documentInfo: Generated<unknown | null>;
|
||||
createdAt: Generated<Timestamp>;
|
||||
};
|
||||
export type TicketStateHistory = {
|
||||
id: Generated<string>;
|
||||
@@ -332,6 +350,7 @@ export type Tour = {
|
||||
patientCity: string | null;
|
||||
healthInsurance: string | null;
|
||||
healthInsuranceNumber: string | null;
|
||||
insuranceNo: string | null;
|
||||
type: string | null;
|
||||
category: string | null;
|
||||
transportType: string | null;
|
||||
@@ -368,6 +387,7 @@ export type Tour = {
|
||||
createdAt: Timestamp | null;
|
||||
updatedAt: Timestamp | null;
|
||||
deletedAt: Timestamp | null;
|
||||
optimizationInMinutes: Generated<number>;
|
||||
ticketId: string | null;
|
||||
};
|
||||
export type TourFile = {
|
||||
@@ -415,6 +435,7 @@ export type TourSource = {
|
||||
itName: string | null;
|
||||
kkId: string | null;
|
||||
krankenkasse: string | null;
|
||||
stammVersNr: string | null;
|
||||
patId: string | null;
|
||||
patName: string | null;
|
||||
patSurname: string | null;
|
||||
@@ -473,6 +494,7 @@ export type TourStaging = {
|
||||
patientCity: string | null;
|
||||
healthInsurance: string | null;
|
||||
healthInsuranceNumber: string | null;
|
||||
insuranceNo: string | null;
|
||||
type: string | null;
|
||||
category: string | null;
|
||||
transportType: string | null;
|
||||
|
||||
@@ -1,35 +1,97 @@
|
||||
import { MailerService } from '@nestjs-modules/mailer';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { TourFile } from '@prisma/client';
|
||||
import { SentMessageInfo } from 'nodemailer';
|
||||
import { ConfigService } from '../config/config.service';
|
||||
|
||||
export interface VorabGenehmigungsanfrageParams {
|
||||
patientName: string;
|
||||
birthday: string;
|
||||
insuranceNo: string;
|
||||
healthInsurance: string;
|
||||
startAddressLine: string;
|
||||
targetAddressLine: string;
|
||||
transportDate: string;
|
||||
}
|
||||
export interface MailAttachment {
|
||||
originalFilename: string;
|
||||
storagePath: string;
|
||||
mimeType: string;
|
||||
}
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
private readonly logger = new Logger(MailService.name);
|
||||
|
||||
constructor(private readonly mailerService: MailerService, private readonly config: ConfigService) {}
|
||||
constructor(
|
||||
private readonly mailerService: MailerService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async sendGenehmigungsanfrage(recipient: string, transportschein: TourFile) {
|
||||
async sendGenehmigungsanfrage(
|
||||
recipient: string,
|
||||
attachment: MailAttachment,
|
||||
replyToId?: string,
|
||||
): Promise<SentMessageInfo> {
|
||||
this.logger.log(
|
||||
`sending Genehmigungsanfrage mail to ${recipient} with Transportschein ${transportschein.originalFilename} for Tour ${transportschein.tourId}`,
|
||||
`sending Genehmigungsanfrage mail to ${recipient} with Transportschein ${attachment.originalFilename}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.mailerService.sendMail({
|
||||
to: recipient,
|
||||
bcc: 'info@avicenna.hamburg',
|
||||
subject: `Antrag auf Kostenübernahme`,
|
||||
template: 'genehmigungsanfrage',
|
||||
attachments: [
|
||||
{
|
||||
filename: transportschein.originalFilename,
|
||||
path: transportschein.storagePath,
|
||||
contentType: transportschein.mimeType,
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('failed to send mail', error)
|
||||
}
|
||||
return await this.mailerService.sendMail({
|
||||
to: recipient,
|
||||
bcc: 'info@avicenna.hamburg',
|
||||
subject: `Antrag auf Kostenübernahme`,
|
||||
template: 'genehmigungsanfrage',
|
||||
headers: this.getThreadingHeaders(replyToId),
|
||||
attachments: [
|
||||
{
|
||||
filename: attachment.originalFilename,
|
||||
path: attachment.storagePath,
|
||||
contentType: attachment.mimeType,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async sendVorabGenehmigungsanfrage(
|
||||
recipient: string,
|
||||
params: VorabGenehmigungsanfrageParams,
|
||||
replyToId?: string,
|
||||
): Promise<SentMessageInfo> {
|
||||
this.logger.log(
|
||||
`sending Vorab-Genehmigungsanfrage mail to ${recipient} for Patient ${params.patientName} and transport at ${params.transportDate}`,
|
||||
);
|
||||
|
||||
return await this.mailerService.sendMail({
|
||||
to: recipient,
|
||||
bcc: 'info@avicenna.hamburg',
|
||||
headers: this.getThreadingHeaders(replyToId),
|
||||
subject: `Antrag auf Kostenübernahme – Transport am ${params.transportDate} für ${params.patientName}`,
|
||||
template: 'genehmigung-vorab-anfrage',
|
||||
context: params,
|
||||
});
|
||||
}
|
||||
|
||||
async sendReminder(
|
||||
recipient: string,
|
||||
params: { patientName: string; transportDate: Date; reminderCount: number },
|
||||
replyToId?: string,
|
||||
): Promise<SentMessageInfo> {
|
||||
this.logger.log(`sending Reminder ${params.reminderCount} to ${recipient}`);
|
||||
|
||||
return await this.mailerService.sendMail({
|
||||
to: recipient,
|
||||
bcc: 'info@avicenna.hamburg',
|
||||
subject: `Erinnerung: Antrag für ${params.patientName}`, // Ggf. gleicher Betreff wie Vorher für Threading
|
||||
template: 'genehmigung-reminder',
|
||||
headers: this.getThreadingHeaders(replyToId),
|
||||
context: params,
|
||||
});
|
||||
}
|
||||
|
||||
private getThreadingHeaders(replyToMessageId?: string) {
|
||||
if (!replyToMessageId) return {};
|
||||
return {
|
||||
'In-Reply-To': replyToMessageId,
|
||||
References: replyToMessageId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
<div style="font-family: sans-serif; white-space: pre-wrap;">
|
||||
Sehr geehrte Damen und Herren,
|
||||
|
||||
für den betreffenden Transport wurde bereits eine Kostenübernahme gemäß § 6 Abs. 3 der Krankentransport-Richtlinie beantragt. Leider liegt uns bislang keine Genehmigung vor.
|
||||
|
||||
Da uns ohne Genehmigung keine Abrechnung mit der Krankenkasse möglich ist, verzögert sich der gesamte Abrechnungsprozess. Um eine private Rechnungsstellung an den Patienten zu vermeiden, bitten wir Sie um zeitnahe Übermittlung der Genehmigung.
|
||||
|
||||
Bitte senden Sie die Unterlagen an eine der folgenden Stellen:
|
||||
• E-Mail: info@avicenna.hamburg
|
||||
• Fax: 040 460006820
|
||||
• Postanschrift: Thomas-Mann-Straße 21, 22175 Hamburg
|
||||
• Institutionskennzeichen: 600224841
|
||||
|
||||
Für Rückfragen stehen wir Ihnen selbstverständlich gerne zur Verfügung.
|
||||
Vielen Dank vorab.
|
||||
Mit freundlichen Grüßen
|
||||
Ihr Team von Avicenna
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
<div style="font-family: sans-serif; white-space: pre-wrap;">
|
||||
Sehr geehrte Damen und Herren,<br>
|
||||
<br>
|
||||
für den betreffenden Transport wurde bereits eine Kostenübernahme gemäß § 6 Abs. 3 der Krankentransport-Richtlinie beantragt. Leider liegt uns bislang keine Genehmigung vor.<br>
|
||||
<br>
|
||||
Da uns ohne Genehmigung keine Abrechnung mit der Krankenkasse möglich ist, verzögert sich der gesamte Abrechnungsprozess. Um eine private Rechnungsstellung an den Patienten zu vermeiden, bitten wir Sie um zeitnahe Übermittlung der Genehmigung.<br>
|
||||
<br>
|
||||
Bitte senden Sie die Unterlagen an eine der folgenden Stellen:<br>
|
||||
• E-Mail: info@avicenna.hamburg<br>
|
||||
• Fax: 040 460006820<br>
|
||||
• Postanschrift: Thomas-Mann-Straße 21, 22175 Hamburg<br>
|
||||
• Institutionskennzeichen: 600224841<br>
|
||||
<br>
|
||||
Für Rückfragen stehen wir Ihnen selbstverständlich gerne zur Verfügung.<br>
|
||||
Vielen Dank vorab.<br>
|
||||
Mit freundlichen Grüßen<br>
|
||||
Ihr Team von Avicenna<br>
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<div style="font-family: sans-serif; white-space: pre-wrap">
|
||||
Sehr geehrte Damen und Herren,<br>
|
||||
<br>
|
||||
hiermit beantragen wir im Auftrag des Versicherten {{patientName}} die Kostenübernahme gemäß § 6 Abs. 3 der Krankentransport-Richtlinie für den geplanten Transport am {{transportDate}}.<br>
|
||||
<br>
|
||||
Eine ärztliche Verordnung liegt uns zum jetzigen Zeitpunkt noch nicht vor. Diese wird nachgereicht, sobald sie uns übermittelt wurde.<br>
|
||||
<br>
|
||||
Für die Vorabprüfung übermitteln wir Ihnen die relevanten Eckdaten:<br>
|
||||
<br>
|
||||
Patient: {{patientName}}<br>
|
||||
Geburtsdatum: {{birthday}}<br>
|
||||
Versicherungsnummer: {{insuranceNo}}<br>
|
||||
Krankenkasse: {{healthInsurance}}<br>
|
||||
Abholadresse: {{startAddressLine}}<br>
|
||||
Zieladresse: {{targetAddressLine}}<br>
|
||||
Datum & Uhrzeit: {{transportDate}}<br>
|
||||
<br>
|
||||
Bitte senden Sie uns die Bestätigung an:<br>
|
||||
• E-Mail: info@avicenna.hamburg<br>
|
||||
• Fax: 040 460006820<br>
|
||||
• Postanschrift: Thomas-Mann-Straße 21, 22175 Hamburg<br>
|
||||
• Institutionskennzeichen: 600224841<br>
|
||||
<br>
|
||||
Vielen Dank für Ihre Unterstützung.<br>
|
||||
Mit freundlichen Grüßen<br>
|
||||
Ihr Team der Avicenna Ambulance<br>
|
||||
</div>
|
||||
@@ -1,13 +1,11 @@
|
||||
// const tracer = require('./tracer');
|
||||
// import tracer from './tracer'
|
||||
import { HyperDXNestLoggerModule } from '@hyperdx/node-logger';
|
||||
import * as HyperDX from '@hyperdx/node-opentelemetry';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import './instrumentation';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
|
||||
async function bootstrap() {
|
||||
|
||||
const logger = HyperDXNestLoggerModule.createLogger({
|
||||
apiKey: '71addf09-bcd2-4e83-8158-97458402137b',
|
||||
service: 'avicenna-data-connector',
|
||||
@@ -16,6 +14,15 @@ async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: logger,
|
||||
});
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.enableCors();
|
||||
HyperDX.setupExpressErrorHandler(app);
|
||||
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import {
|
||||
ArgsType,
|
||||
Field,
|
||||
Float,
|
||||
InputType,
|
||||
Int,
|
||||
ObjectType,
|
||||
} from '@nestjs/graphql';
|
||||
import { ArgsType, Field, InputType } from '@nestjs/graphql';
|
||||
import { PaginationArgs } from 'src/core/base/pagination.input';
|
||||
import { AccountingState } from './accounting-state';
|
||||
import { TourAccountingFilterDto } from '../feat-business-objects/entities/tour/tours.filter';
|
||||
import { AccountingState } from './accounting-state';
|
||||
|
||||
@InputType()
|
||||
export class AccountingFilterArgs implements TourAccountingFilterDto {
|
||||
@@ -27,15 +20,3 @@ export class AccountingTourArgs extends PaginationArgs {
|
||||
@Field(() => AccountingFilterArgs, { nullable: true })
|
||||
filters!: AccountingFilterArgs;
|
||||
}
|
||||
|
||||
@ObjectType('TourStateMeta')
|
||||
export class TourStateMetaObjectType {
|
||||
@Field(() => Int)
|
||||
count!: number;
|
||||
@Field(() => Float, { nullable: true })
|
||||
revenueSum?: number;
|
||||
@Field(() => Float, { nullable: true })
|
||||
positiveRevenueDeviation?: number;
|
||||
@Field(() => Float, { nullable: true })
|
||||
negativeRevenueDeviation?: number;
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
|
||||
import { DB } from 'src/core/database/types';
|
||||
import { AccountingState } from './accounting-state';
|
||||
import {
|
||||
AccountingListDto,
|
||||
PaginatedResult,
|
||||
TicketStage,
|
||||
} from '@avicenna/shared-dtos';
|
||||
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
|
||||
import { Tour } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class AccountingQueryService {
|
||||
constructor(private readonly tourService: TourService) {}
|
||||
|
||||
async toursByState(
|
||||
state: AccountingState,
|
||||
page: number,
|
||||
limit: number = 10,
|
||||
): Promise<PaginatedResult<AccountingListDto>> {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { tours, total } = await this.tourService.findByAccountingState(
|
||||
state,
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
|
||||
return {
|
||||
data: tours.map((tour) => this.toAccountingListDto(tour)),
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private toAccountingListDto(
|
||||
tour: Tour & { stage: string },
|
||||
): AccountingListDto {
|
||||
return {
|
||||
tourId: tour.id,
|
||||
operationId: tour.operationId,
|
||||
ordinanceType: tour.ordinanceType,
|
||||
startDate: tour.startDate,
|
||||
revenue: tour.revenue,
|
||||
|
||||
isTicketOpen: [
|
||||
TicketStage.DRAFT,
|
||||
TicketStage.DATA_INCOMPLETE,
|
||||
TicketStage.ASSESSMENT_PENDING,
|
||||
TicketStage.APPROVAL_IN_PROGRESS,
|
||||
TicketStage.DOCUMENTS_PENDING,
|
||||
].includes(tour.stage as TicketStage),
|
||||
hasRevenueDeviation:
|
||||
Math.abs(tour.revenue - tour.revenueDispoLive) > 0.01,
|
||||
hasOptimizationPotential: tour.optimizationInMinutes !== 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
+21
-7
@@ -1,12 +1,22 @@
|
||||
import { Args, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
import {
|
||||
Args,
|
||||
Int,
|
||||
Parent,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { TicketObjectType } from '../app-ticket-system/ticket.object-type';
|
||||
import {
|
||||
AccountingTourArgs,
|
||||
TourStateMetaObjectType,
|
||||
AccountingFilterArgs
|
||||
} from './accounting-arg.types';
|
||||
import { AccountingState } from './accounting-state';
|
||||
import { AccountingValidationService } from './accounting-validation.service';
|
||||
import { PaginatedTourObjectType, TourCostBreakdownObjectType } from './accounting.object-type';
|
||||
import {
|
||||
PaginatedTourObjectType,
|
||||
TourCostBreakdownObjectType,
|
||||
TourStateMetaObjectType,
|
||||
} from './accounting.object-type';
|
||||
import { TourObjectType } from './tour.object-type';
|
||||
|
||||
@Resolver(() => TourObjectType)
|
||||
@@ -15,9 +25,13 @@ export class AccountingValidationResolver {
|
||||
|
||||
@Query(() => PaginatedTourObjectType)
|
||||
accountingTours(
|
||||
@Args() { state, filters, cursor, take }: AccountingTourArgs,
|
||||
@Args('state', { type: () => AccountingState }) state: AccountingState,
|
||||
@Args('filters', { type: () => AccountingFilterArgs, nullable: true })
|
||||
filters: AccountingFilterArgs,
|
||||
@Args('cursor', { nullable: true }) cursor: string,
|
||||
@Args('take', { type: () => Int, nullable: true }) take: number = 10,
|
||||
) {
|
||||
return this.service.paginateToursByState(state, filters, take, cursor);
|
||||
return this.service.paginateToursByState(state, take, cursor);
|
||||
}
|
||||
|
||||
@Query(() => TourStateMetaObjectType)
|
||||
@@ -30,7 +44,7 @@ export class AccountingValidationResolver {
|
||||
|
||||
@Query(() => TourCostBreakdownObjectType)
|
||||
tourCostBreakdown(@Args('operationId') operationId: string) {
|
||||
return this.service.getTourCostBreakdown(operationId)
|
||||
return this.service.getTourCostBreakdown(operationId);
|
||||
}
|
||||
|
||||
@ResolveField(() => TicketObjectType, { nullable: true })
|
||||
|
||||
+39
-13
@@ -1,15 +1,18 @@
|
||||
import {
|
||||
PaginatedResult,
|
||||
PaginatedTourDto,
|
||||
TourDto,
|
||||
TourStateMetaDto,
|
||||
} from '@avicenna/shared-dtos';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
decodeOffsetCursor,
|
||||
encodeCursor,
|
||||
} from 'src/core/base/pagination.util';
|
||||
import { TourStateMetaObjectType } from './accounting-arg.types';
|
||||
import { AccountingState } from './accounting-state';
|
||||
import { PaginatedTourObjectType } from './accounting.object-type';
|
||||
import { AccountingService } from './accounting.service';
|
||||
import { TicketService } from '../feat-business-objects/entities/ticket/ticket.service';
|
||||
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
|
||||
import { TourAccountingFilterDto } from '../feat-business-objects/entities/tour/tours.filter';
|
||||
import { AccountingState } from './accounting-state';
|
||||
import { AccountingService } from './accounting.service';
|
||||
|
||||
@Injectable()
|
||||
export class AccountingValidationService {
|
||||
@@ -19,16 +22,39 @@ export class AccountingValidationService {
|
||||
private readonly tourService: TourService,
|
||||
) {}
|
||||
|
||||
async paginateToursByState(
|
||||
async toursByState(
|
||||
state: AccountingState,
|
||||
filter: TourAccountingFilterDto,
|
||||
take: number = 10,
|
||||
cursor?: string,
|
||||
): Promise<PaginatedTourObjectType> {
|
||||
const skip = cursor ? decodeOffsetCursor(cursor) : 0;
|
||||
page: number,
|
||||
limit: number = 10,
|
||||
): Promise<PaginatedResult<TourDto>> {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { tours, total } = await this.tourService.findByAccountingState(
|
||||
state,
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
|
||||
return {
|
||||
data: tours,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async paginateToursByState(
|
||||
state: AccountingState,
|
||||
take: number = 10,
|
||||
cursor?: string,
|
||||
): Promise<PaginatedTourDto> {
|
||||
const skip = cursor ? decodeOffsetCursor(cursor) : 0;
|
||||
|
||||
const { tours, total } = await this.tourService.findByAccountingState(
|
||||
state,
|
||||
filter,
|
||||
take,
|
||||
skip,
|
||||
);
|
||||
@@ -47,7 +73,7 @@ export class AccountingValidationService {
|
||||
|
||||
async getAccountingStateMeta(
|
||||
accountingState: AccountingState,
|
||||
): Promise<TourStateMetaObjectType> {
|
||||
): Promise<TourStateMetaDto> {
|
||||
return this.tourService.calculateAccountingStateMeta(accountingState);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
OptimizationCarDto,
|
||||
PaginatedTourDto,
|
||||
TourCostBreakdownDto,
|
||||
TourDto,
|
||||
TourStateMetaDto,
|
||||
} from '@avicenna/shared-dtos';
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { resolveDateFromTime } from 'src/utils/resolve-timestamp';
|
||||
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
|
||||
import { AccountingQueryService } from './accounting-query.service';
|
||||
import { AccountingState } from './accounting-state';
|
||||
import { AccountingValidationService } from './accounting-validation.service';
|
||||
import { RevenueOptimizationService } from './revenue-optimization.service';
|
||||
|
||||
@ApiTags('Accounting')
|
||||
@Controller('api/accounting')
|
||||
export class AccountingController {
|
||||
constructor(
|
||||
private readonly service: AccountingValidationService,
|
||||
private readonly queryService: AccountingQueryService,
|
||||
private readonly optimizationService: RevenueOptimizationService,
|
||||
private readonly tourService: TourService,
|
||||
) {}
|
||||
|
||||
@Get('cars/:name')
|
||||
async getCarForOptimization(
|
||||
@Param('name') name: string,
|
||||
@Query('operation-day') operationDay: string,
|
||||
): Promise<OptimizationCarDto> {
|
||||
const date = new Date(operationDay);
|
||||
|
||||
const tours = await this.tourService.getOptimizationToursForCar(name, date);
|
||||
|
||||
return {
|
||||
name,
|
||||
tours: tours.map((tour) => ({
|
||||
id: tour.id,
|
||||
operationId: tour.operationId,
|
||||
ordinanceType: tour.ordinanceType,
|
||||
startDate: tour.startDate.toISOString(),
|
||||
optimizationInMinutes: tour.optimizationInMinutes ?? 0,
|
||||
|
||||
departedForPickup: tour.startBegin
|
||||
? resolveDateFromTime(tour.startDate, tour.startBegin).toISOString()
|
||||
: null,
|
||||
arrivedAtPickup: tour.startEnd
|
||||
? resolveDateFromTime(tour.startDate, tour.startEnd).toISOString()
|
||||
: null,
|
||||
departedPickup: tour.target
|
||||
? resolveDateFromTime(tour.startDate, tour.target).toISOString()
|
||||
: null,
|
||||
arrivedAtTarget: tour.targetBegin
|
||||
? resolveDateFromTime(tour.startDate, tour.targetBegin).toISOString()
|
||||
: null,
|
||||
completed: tour.empty
|
||||
? resolveDateFromTime(tour.startDate, tour.empty).toISOString()
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('tours')
|
||||
@ApiOkResponse({ type: PaginatedTourDto })
|
||||
async getAccountingTours(
|
||||
@Query('state') state: AccountingState,
|
||||
@Query('page') page: number,
|
||||
@Query('take') take: number = 10,
|
||||
) {
|
||||
return this.queryService.toursByState(state, page, take);
|
||||
}
|
||||
|
||||
@Get('tours/:id')
|
||||
@ApiOkResponse({ type: TourDto })
|
||||
getTourDetail(@Param('id') tourId: string): Promise<TourDto> {
|
||||
return this.tourService.findById(tourId);
|
||||
}
|
||||
|
||||
@Get('state-meta')
|
||||
@ApiOkResponse({ type: TourStateMetaDto })
|
||||
async getAccountingStateMeta(@Query('state') state: AccountingState) {
|
||||
return this.service.getAccountingStateMeta(state);
|
||||
}
|
||||
|
||||
@Get('cost-breakdown/:operationId')
|
||||
@ApiOkResponse({ type: TourCostBreakdownDto })
|
||||
async getTourCostBreakdown(@Param('operationId') operationId: string) {
|
||||
return this.service.getTourCostBreakdown(operationId);
|
||||
}
|
||||
|
||||
@Get('optimizations')
|
||||
async getOptimizations(@Query('day') day: Date) {
|
||||
return this.optimizationService.findOptimizationsForOperationDay(day);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { eachDayOfInterval, startOfDay, subWeeks } from 'date-fns';
|
||||
import { Kysely } from 'kysely';
|
||||
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
|
||||
import { DB } from 'src/core/database/types';
|
||||
import { LoadingDoneEvent } from '../feat-etl/feat-importers/events/loading-done.event';
|
||||
import {
|
||||
RevenueOptimizationDto,
|
||||
RevenueOptimizationService,
|
||||
} from './revenue-optimization.service';
|
||||
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
|
||||
import { ETLEventTypes } from '../feat-etl/feat-importers/events/event-types.enum';
|
||||
|
||||
@Injectable()
|
||||
export class AccountingListener {
|
||||
private readonly logger = new Logger(AccountingListener.name);
|
||||
|
||||
constructor(
|
||||
private readonly service: RevenueOptimizationService,
|
||||
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
|
||||
private readonly tourService: TourService,
|
||||
) {}
|
||||
|
||||
@OnEvent(ETLEventTypes.LoadingDone)
|
||||
async handleRevenueOptimizationCheck() {
|
||||
this.logger.log(`looking for revenue optimizations`);
|
||||
const batchSize = 10;
|
||||
const days = eachDayOfInterval({
|
||||
start: subWeeks(startOfDay(new Date()), 4),
|
||||
end: startOfDay(new Date()),
|
||||
});
|
||||
|
||||
const optimizationsInPeriod: RevenueOptimizationDto[] = [];
|
||||
for (let i = 0; i < days.length; i += batchSize) {
|
||||
const batch = days.slice(i, i + batchSize);
|
||||
|
||||
const optimizations = await Promise.all(
|
||||
batch.map((day) => this.service.findOptimizationsForOperationDay(day)),
|
||||
);
|
||||
optimizationsInPeriod.push(...optimizations.flat(2));
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
optimizationsInPeriod.map((optimization) =>
|
||||
this.tourService.updateTour(optimization.tourId, {
|
||||
optimizationInMinutes: optimization.timeModificationInMinutes,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return optimizationsInPeriod;
|
||||
}
|
||||
}
|
||||
+23
-5
@@ -1,8 +1,14 @@
|
||||
import { Field, Float, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
import { AccountingState } from './accounting-state';
|
||||
import { Paginated } from 'src/core/base/pagination.input';
|
||||
import { TourObjectType } from './tour.object-type';
|
||||
import {
|
||||
Field,
|
||||
Float,
|
||||
Int,
|
||||
ObjectType,
|
||||
registerEnumType,
|
||||
} from '@nestjs/graphql';
|
||||
import { CostTypeVariant } from '@prisma/client';
|
||||
import { Paginated } from 'src/core/base/pagination.input';
|
||||
import { AccountingState } from './accounting-state';
|
||||
import { TourObjectType } from './tour.object-type';
|
||||
|
||||
registerEnumType(AccountingState, {
|
||||
name: 'AccountingState',
|
||||
@@ -13,7 +19,7 @@ export class PaginatedTourObjectType extends Paginated(TourObjectType) {}
|
||||
|
||||
@ObjectType('ServicePosition')
|
||||
export class ServicePositionObjectType {
|
||||
@Field()
|
||||
@Field(() => String)
|
||||
variant: CostTypeVariant;
|
||||
@Field()
|
||||
description: string;
|
||||
@@ -31,3 +37,15 @@ export class TourCostBreakdownObjectType {
|
||||
@Field(() => [ServicePositionObjectType])
|
||||
servicePositions: ServicePositionObjectType[];
|
||||
}
|
||||
|
||||
@ObjectType('TourStateMeta')
|
||||
export class TourStateMetaObjectType {
|
||||
@Field(() => Int)
|
||||
count!: number;
|
||||
@Field(() => Float, { nullable: true })
|
||||
revenueSum?: number;
|
||||
@Field(() => Float, { nullable: true })
|
||||
positiveRevenueDeviation?: number;
|
||||
@Field(() => Float, { nullable: true })
|
||||
negativeRevenueDeviation?: number;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,22 @@ import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-busines
|
||||
import { FeatCalculationModule } from '../feat-calculation/feat-calculation.module';
|
||||
import { AccountingValidationResolver } from './accounting-validation.resolver';
|
||||
import { AccountingValidationService } from './accounting-validation.service';
|
||||
import { AccountingController } from './accounting.controller';
|
||||
import { AccountingListener } from './accounting.listener';
|
||||
import { AccountingService } from './accounting.service';
|
||||
import { RevenueOptimizationService } from './revenue-optimization.service';
|
||||
import { AccountingQueryService } from './accounting-query.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, FeatCalculationModule, FeatBusinessObjectsModule],
|
||||
controllers: [AccountingController],
|
||||
providers: [
|
||||
AccountingValidationResolver,
|
||||
AccountingValidationService,
|
||||
AccountingService,
|
||||
AccountingListener,
|
||||
RevenueOptimizationService,
|
||||
AccountingQueryService
|
||||
],
|
||||
exports: [],
|
||||
})
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { CostTypeVariant, Tour } from '@prisma/client';
|
||||
import {
|
||||
differenceInMinutes,
|
||||
isAfter,
|
||||
isBefore,
|
||||
isWithinInterval,
|
||||
} from 'date-fns';
|
||||
import { Kysely } from 'kysely';
|
||||
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
|
||||
import { DB } from 'src/core/database/types';
|
||||
import { generateIntervalForTimeRange } from 'src/utils';
|
||||
import { resolveDateFromTime } from 'src/utils/resolve-timestamp';
|
||||
import { TariffService } from '../feat-business-objects/entities/tariff/tariff.service';
|
||||
|
||||
export interface RevenueOptimizationDto {
|
||||
tourId: string;
|
||||
timeModificationInMinutes: number;
|
||||
costDifference: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RevenueOptimizationService {
|
||||
private readonly logger = new Logger(RevenueOptimizationService.name);
|
||||
|
||||
private healthInsurancesWithNoTenant = [];
|
||||
|
||||
constructor(
|
||||
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
|
||||
private readonly tariffService: TariffService,
|
||||
) {}
|
||||
|
||||
async findOptimizationsForOperationDay(
|
||||
day: Date = new Date('2026-01-15'),
|
||||
): Promise<RevenueOptimizationDto[]> {
|
||||
const tours = await this.loadToursForOperationDay(day);
|
||||
const groupedByCar = tours.reduce(
|
||||
(acc, tour) => {
|
||||
(acc[tour.carName] ??= []).push(tour);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof tours>,
|
||||
);
|
||||
const result = [];
|
||||
for (const groupName of Object.keys(groupedByCar)) {
|
||||
const optimizations = await this.findOptimizationPotential(
|
||||
groupedByCar[groupName],
|
||||
);
|
||||
if (optimizations.length > 0) {
|
||||
result.push(optimizations);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async findOptimizationPotential(
|
||||
tours: Tour[],
|
||||
): Promise<RevenueOptimizationDto[]> {
|
||||
const result: RevenueOptimizationDto[] = [];
|
||||
for (let i = 0; i < tours.length; i++) {
|
||||
const currentTour = tours[i];
|
||||
const bonusPeriods = await this.getBonusPeriods(currentTour);
|
||||
|
||||
// TODO: Make sure startEnd is the correct timestamp
|
||||
const tourInvoiceTime = resolveDateFromTime(
|
||||
currentTour.startDate,
|
||||
currentTour.startEnd,
|
||||
);
|
||||
|
||||
const bonusPeriod = bonusPeriods
|
||||
.filter(({ interval }) => isWithinInterval(tourInvoiceTime, interval))
|
||||
.at(0);
|
||||
|
||||
const possiblePeriod = bonusPeriods
|
||||
.filter(
|
||||
({ interval }) =>
|
||||
(Math.abs(differenceInMinutes(tourInvoiceTime, interval.start)) <=
|
||||
15 ||
|
||||
Math.abs(differenceInMinutes(tourInvoiceTime, interval.end)) <
|
||||
15) &&
|
||||
!isWithinInterval(tourInvoiceTime, interval),
|
||||
)
|
||||
.at(0);
|
||||
|
||||
if (
|
||||
(!bonusPeriod && possiblePeriod) ||
|
||||
bonusPeriod?.costAmount < possiblePeriod?.costAmount
|
||||
) {
|
||||
let timeModification = 0;
|
||||
if (isBefore(tourInvoiceTime, possiblePeriod.interval.start)) {
|
||||
timeModification = differenceInMinutes(
|
||||
possiblePeriod.interval.start,
|
||||
tourInvoiceTime,
|
||||
);
|
||||
} else if (isAfter(tourInvoiceTime, possiblePeriod.interval.end)) {
|
||||
timeModification = differenceInMinutes(
|
||||
possiblePeriod.interval.end,
|
||||
tourInvoiceTime,
|
||||
);
|
||||
}
|
||||
|
||||
let costDifference = 0;
|
||||
if (bonusPeriod) {
|
||||
costDifference = possiblePeriod.costAmount - bonusPeriod.costAmount;
|
||||
} else {
|
||||
costDifference = possiblePeriod.costAmount;
|
||||
}
|
||||
|
||||
result.push({
|
||||
tourId: currentTour.id,
|
||||
timeModificationInMinutes: timeModification,
|
||||
costDifference,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async getBonusPeriods(tour: Tour) {
|
||||
if (!tour.ordinanceType) {
|
||||
this.logger.warn(`Tour ${tour.operationId} has no ordinanceType`, {
|
||||
operationId: tour.operationId,
|
||||
healthInsurance: tour.healthInsurance,
|
||||
ordinanceType: tour.ordinanceType,
|
||||
startDate: tour.startDate,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
const tariff = await this.findTariff(
|
||||
tour.healthInsurance,
|
||||
tour.ordinanceType.split(' ').at(1),
|
||||
tour.startDate,
|
||||
);
|
||||
|
||||
if (!tariff) {
|
||||
this.logger.warn(`no Tariff found for Tour ${tour.operationId}`, {
|
||||
operationId: tour.operationId,
|
||||
healthInsurance: tour.healthInsurance,
|
||||
ordinanceType: tour.ordinanceType,
|
||||
startDate: tour.startDate,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const bonusPeriods = tariff.costTypes
|
||||
.filter(
|
||||
({ variant }) =>
|
||||
variant === CostTypeVariant.NACHTZUSCHLAG ||
|
||||
variant === CostTypeVariant.GRUNDPREIS_ZEIT,
|
||||
)
|
||||
.map(({ timeRange, costAmount, description }) => ({
|
||||
description,
|
||||
interval: generateIntervalForTimeRange(
|
||||
timeRange,
|
||||
'Europe/Berlin',
|
||||
tour.startDate,
|
||||
),
|
||||
costAmount,
|
||||
}));
|
||||
|
||||
return bonusPeriods;
|
||||
}
|
||||
|
||||
private async loadToursForOperationDay(day: Date) {
|
||||
const operationDayInterval = generateIntervalForTimeRange(
|
||||
'05:00-04:59',
|
||||
'Europe/Berlin',
|
||||
day,
|
||||
);
|
||||
|
||||
const rows = await this.db
|
||||
.selectFrom('Tour')
|
||||
.selectAll()
|
||||
.where('Tour.startDate', '>=', new Date(operationDayInterval.start))
|
||||
.where('Tour.startDate', '<=', new Date(operationDayInterval.end))
|
||||
.where('Tour.healthInsurance', 'is not', null)
|
||||
.execute();
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private async findTariff(
|
||||
healthInsurance: string,
|
||||
ordinanceType: string,
|
||||
tourDate: Date,
|
||||
) {
|
||||
if (!healthInsurance || healthInsurance === '') {
|
||||
return;
|
||||
}
|
||||
const tenant = await this.tariffService.findTenant(
|
||||
healthInsurance,
|
||||
ordinanceType,
|
||||
);
|
||||
if (!tenant) {
|
||||
this.healthInsurancesWithNoTenant.push({
|
||||
healthInsurance,
|
||||
ordinanceType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
return this.tariffService.findByTenantId(
|
||||
tenant.id,
|
||||
ordinanceType,
|
||||
tourDate,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { Tour } from '@prisma/client';
|
||||
|
||||
@ObjectType('Tour')
|
||||
export class TourObjectType implements Tour {
|
||||
optimizationInMinutes: number;
|
||||
insuranceNo: string;
|
||||
overRTW: boolean;
|
||||
companyName: string;
|
||||
schwerlast: boolean;
|
||||
|
||||
+1
@@ -139,6 +139,7 @@ export class ControlCenterResolver {
|
||||
AnomalyTypes.EmployeesLoggedIn,
|
||||
AnomalyTypes.OneTourAtATime,
|
||||
AnomalyTypes.IsGermanZip,
|
||||
AnomalyTypes.KtwTourNotWithKtwCar
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from 'src/core/database/prisma.module';
|
||||
import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
|
||||
import { FeatCalculationModule } from '../feat-calculation/feat-calculation.module';
|
||||
import { ManagerController } from './manager.controller';
|
||||
import { ManagerResolver } from './manager.resolver';
|
||||
import { TimePerTourKpiService } from './time-per-tour-kpi.service';
|
||||
import { TimePerTourMetricsService } from './time-per-tour-metrics.service';
|
||||
@@ -9,6 +10,7 @@ import { TourAmountForecastService } from './tour-amount-forecast.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, FeatCalculationModule, FeatBusinessObjectsModule],
|
||||
controllers: [ManagerController],
|
||||
providers: [
|
||||
TourAmountForecastService,
|
||||
TimePerTourKpiService,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
TimePerTourKPISummaryDto,
|
||||
TimePerTourMetricDto,
|
||||
} from '@avicenna/shared-dtos';
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
Logger,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOkResponse } from '@nestjs/swagger';
|
||||
import { TimePerTourKpiService } from './time-per-tour-kpi.service';
|
||||
import { TimePerTourMetricsService } from './time-per-tour-metrics.service';
|
||||
|
||||
@Controller('api/manager')
|
||||
export class ManagerController {
|
||||
private readonly logger = new Logger(ManagerController.name);
|
||||
|
||||
constructor(
|
||||
private readonly timePerTourKpiService: TimePerTourKpiService,
|
||||
private readonly timePerTourMetricService: TimePerTourMetricsService,
|
||||
) {}
|
||||
|
||||
@Get('overview')
|
||||
@ApiOkResponse({ type: [TimePerTourKPISummaryDto] })
|
||||
async overview(
|
||||
@Query('ordinanceType') ordinanceType: string,
|
||||
): Promise<TimePerTourKPISummaryDto[]> {
|
||||
if (!ordinanceType) {
|
||||
throw new BadRequestException(`no ordinanceType provided`);
|
||||
}
|
||||
return this.timePerTourKpiService.toursPerTimeKPI(ordinanceType);
|
||||
}
|
||||
|
||||
@Get('dayview')
|
||||
@ApiOkResponse({ type: [TimePerTourMetricDto] })
|
||||
async dayview(
|
||||
@Query('day') day: Date,
|
||||
@Query('ordinanceType') ordinanceType: string,
|
||||
): Promise<TimePerTourMetricDto[]> {
|
||||
if (!day || !ordinanceType) {
|
||||
throw new BadRequestException(`no day or ordinanceType provided`);
|
||||
}
|
||||
return this.timePerTourMetricService.forDay(day, ordinanceType);
|
||||
}
|
||||
|
||||
@Get('monthview')
|
||||
@ApiOkResponse({ type: [TimePerTourMetricDto] })
|
||||
async monthview(
|
||||
@Query('day') day: Date,
|
||||
@Query('ordinanceType') ordinanceType: string,
|
||||
): Promise<TimePerTourMetricDto[]> {
|
||||
if (!day || !ordinanceType) {
|
||||
throw new BadRequestException(`no day or ordinanceType provided`);
|
||||
}
|
||||
return this.timePerTourMetricService.forMonth(day, ordinanceType);
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { addMilliseconds } from 'date-fns';
|
||||
import { addMilliseconds, Interval } from 'date-fns';
|
||||
import { PubSub } from 'graphql-subscriptions';
|
||||
import { generateIntervalForTimeRange } from 'src/utils';
|
||||
import { CalculationService } from '../feat-calculation/calculation.service';
|
||||
@@ -115,7 +115,10 @@ export class TimePerTourKpiService {
|
||||
tourAmount,
|
||||
);
|
||||
return {
|
||||
interval,
|
||||
interval: {
|
||||
start: new Date(interval.start),
|
||||
end: new Date(interval.end)
|
||||
},
|
||||
kpi: {
|
||||
value: baseValue,
|
||||
meta: {
|
||||
|
||||
+8
-5
@@ -1,3 +1,4 @@
|
||||
import { TimePerTourMetricDto } from '@avicenna/shared-dtos';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
addDays,
|
||||
@@ -15,11 +16,10 @@ import {
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
} from 'date-fns';
|
||||
import { TourAmountForecastService } from './tour-amount-forecast.service';
|
||||
import { TimePerTourMetricObjectType } from './manager.object-type';
|
||||
import { CalculationService } from '../feat-calculation/calculation.service';
|
||||
import { generateIntervalForTimeRange, groupBy } from 'src/utils';
|
||||
import { CarEntityService } from '../feat-business-objects/car-entity/car-entity.service';
|
||||
import { CalculationService } from '../feat-calculation/calculation.service';
|
||||
import { TourAmountForecastService } from './tour-amount-forecast.service';
|
||||
|
||||
@Injectable()
|
||||
export class TimePerTourMetricsService {
|
||||
@@ -84,7 +84,7 @@ export class TimePerTourMetricsService {
|
||||
intervalClamp: Interval,
|
||||
subIntervals: Interval[],
|
||||
ordinanceType: string,
|
||||
): Promise<TimePerTourMetricObjectType[]> {
|
||||
): Promise<TimePerTourMetricDto[]> {
|
||||
const carEntities = await this.carEntityService.getCarsInInterval(
|
||||
intervalClamp,
|
||||
ordinanceType,
|
||||
@@ -180,7 +180,10 @@ export class TimePerTourMetricsService {
|
||||
}
|
||||
|
||||
return {
|
||||
interval,
|
||||
interval: {
|
||||
start: new Date(interval.start),
|
||||
end: new Date(interval.end),
|
||||
},
|
||||
tourAmount: Math.round(tourAmount * 100) / 100,
|
||||
availableTime: Math.round(availableTime * 100) / 100,
|
||||
breakEven:
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
|
||||
import { PlaygroundController } from './playground.controller';
|
||||
import { PlaygroundService } from './playground.service';
|
||||
|
||||
@Module({
|
||||
imports: [FeatBusinessObjectsModule],
|
||||
controllers: [PlaygroundController],
|
||||
providers: [PlaygroundService],
|
||||
})
|
||||
export class AppPlaygroundModule {}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { PlaygroundService } from './playground.service';
|
||||
|
||||
@Controller('api/playground')
|
||||
export class PlaygroundController {
|
||||
constructor(private readonly service: PlaygroundService) {}
|
||||
|
||||
@Get('operation-day')
|
||||
async getOperationDay(@Query('day') day: Date) {
|
||||
return this.service.getCarsByOperationDay(new Date(day));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
|
||||
import { DB } from 'src/core/database/types';
|
||||
import { CarEntityService } from '../feat-business-objects/car-entity/car-entity.service';
|
||||
import { CarEntity } from '../feat-business-objects/car-entity/car.entity';
|
||||
|
||||
@Injectable()
|
||||
export class PlaygroundService {
|
||||
constructor(
|
||||
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
|
||||
private readonly carService: CarEntityService,
|
||||
) {}
|
||||
|
||||
async getCarsByOperationDay(day: Date) {
|
||||
const ordinanceTypes = ['KTW', 'TSW', 'BTW'];
|
||||
|
||||
const cars = await Promise.all(
|
||||
ordinanceTypes.map((ot) =>
|
||||
this.carService.getCarsForOperationDay(day, ot),
|
||||
),
|
||||
);
|
||||
|
||||
return cars
|
||||
.flat(2)
|
||||
.filter(({ carName }) => !!carName)
|
||||
.map((car) => this.carMapper(car))
|
||||
.filter(
|
||||
({ tours }) =>
|
||||
tours.filter(
|
||||
({ optimizationInMinutes }) => optimizationInMinutes !== 0,
|
||||
).length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
private carMapper(car: CarEntity) {
|
||||
return {
|
||||
name: car.carName,
|
||||
drivers: car['plannedTimes'].map((pt) => ({
|
||||
name: `${pt.firstname} ${pt.surname}`,
|
||||
})),
|
||||
tours: car['tours'].map((tour) => ({
|
||||
id: tour.id,
|
||||
operationId: tour.operationId,
|
||||
ordinanceType: tour.ordinanceType,
|
||||
startDate: tour.startDate,
|
||||
optimizationInMinutes: tour.optimizationInMinutes,
|
||||
|
||||
departedForPickup: tour.startBegin,
|
||||
arrivedAtPickup: tour.startEnd,
|
||||
departedPickup: tour.target,
|
||||
arrivedAtTarget: tour.targetBegin,
|
||||
completed: tour.empty,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
+18
-14
@@ -1,18 +1,19 @@
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from 'src/core/database/prisma.module';
|
||||
import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
|
||||
import { FeatTicketSystemModule } from '../feat-tickets/ticket-system.module';
|
||||
import { TicketRepository } from './ticket.repository';
|
||||
import { TicketsResolver } from './tickets.resolver';
|
||||
import { TicketSystemService } from './ticket-system.service';
|
||||
import { TicketsController } from './tickets.controller';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { ConfigModule } from 'src/core/config/config.module';
|
||||
import { ConfigService } from 'src/core/config/config.service';
|
||||
import { createMulterOptions } from 'src/core/storage/multer.config';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { TOUR_FILE_UPLOAD_QUEUE } from '../feat-business-objects/entities/tour/tour.tokens';
|
||||
import { PrismaModule } from 'src/core/database/prisma.module';
|
||||
import { MAILING_QUEUE } from 'src/core/mail/mail.tokens';
|
||||
import { createMulterOptions } from 'src/core/storage/multer.config';
|
||||
import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
|
||||
import { FeatTicketSystemModule } from '../feat-tickets/ticket-system.module';
|
||||
import { TicketQueryService } from './queries/ticket-query.service';
|
||||
import { TicketSystemService } from './ticket-system.service';
|
||||
import { TicketRepository } from './ticket.repository';
|
||||
import { TicketsController } from './tickets.controller';
|
||||
import { TicketsResolver } from './tickets.resolver';
|
||||
import { TicketReadMapper } from './mappers/ticket-read.mapper';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -24,14 +25,17 @@ import { MAILING_QUEUE } from 'src/core/mail/mail.tokens';
|
||||
useFactory: (config: ConfigService) => createMulterOptions(config),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: TOUR_FILE_UPLOAD_QUEUE,
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: MAILING_QUEUE,
|
||||
}),
|
||||
],
|
||||
providers: [TicketRepository, TicketSystemService, TicketsResolver],
|
||||
providers: [
|
||||
TicketRepository,
|
||||
TicketSystemService,
|
||||
TicketReadMapper,
|
||||
TicketsResolver,
|
||||
TicketQueryService,
|
||||
],
|
||||
controllers: [TicketsController],
|
||||
exports: [TicketSystemService],
|
||||
})
|
||||
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
ApprovalRequestStatus,
|
||||
ApprovalRequirement,
|
||||
TicketApprovalViewDto,
|
||||
TicketDetailDto,
|
||||
TicketTransportDocumentView,
|
||||
} from '@avicenna/shared-dtos';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isDate } from 'date-fns';
|
||||
import { Ticket } from 'src/modules/feat-tickets/domain/ticket.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TicketReadMapper {
|
||||
toDetailDto(ticket: Ticket): TicketDetailDto {
|
||||
const tour = ticket.tours
|
||||
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())
|
||||
.at(0);
|
||||
|
||||
return {
|
||||
id: ticket.id.value,
|
||||
stage: ticket.currentStage,
|
||||
|
||||
general: {
|
||||
ordinanceType: tour.ordinanceType,
|
||||
transportType: tour.transportType,
|
||||
category: tour.category,
|
||||
hasInfection: tour.hasInfection,
|
||||
infectionName: tour.infectionName,
|
||||
isSeries: isDate(tour.seriesEndDate),
|
||||
seriesEndDate: tour.seriesEndDate,
|
||||
},
|
||||
|
||||
validationErrors: ticket.validationErrors,
|
||||
|
||||
patient: {
|
||||
id: ticket.patientId.value,
|
||||
name: `${ticket.patient.firstName} ${ticket.patient.lastName}`,
|
||||
healthInsurance: ticket.patient.healthInsurance,
|
||||
address: {
|
||||
street: ticket.patient.street,
|
||||
city: ticket.patient.city,
|
||||
zip: ticket.patient.zip,
|
||||
},
|
||||
},
|
||||
|
||||
approval: this.toApprovalView(ticket),
|
||||
|
||||
transportDocument: this.toTransportDocumentView(ticket),
|
||||
|
||||
tours: ticket.tours
|
||||
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())
|
||||
.map((tour) => ({
|
||||
id: tour.id.value,
|
||||
operationId: tour.operationId,
|
||||
date: tour.startDate.toISOString(),
|
||||
startAddress: {
|
||||
street: tour.startStreet,
|
||||
city: tour.startCity,
|
||||
zip: tour.startZip,
|
||||
},
|
||||
targetAddress: {
|
||||
street: tour.targetStreet,
|
||||
city: tour.targetCity,
|
||||
zip: tour.targetZip,
|
||||
},
|
||||
})),
|
||||
|
||||
createdAt: ticket.createdAt,
|
||||
updatedAt: ticket.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
toApprovalView(ticket: Ticket): TicketApprovalViewDto {
|
||||
const approval = ticket.approval;
|
||||
|
||||
return {
|
||||
requirement: approval.requirement,
|
||||
status: approval.status,
|
||||
statusLabel: this.translateApprovalStatus(approval.status),
|
||||
|
||||
actions: {
|
||||
override: {
|
||||
isPossible: ApprovalRequestStatus.NOT_STARTED === approval.status,
|
||||
label:
|
||||
ApprovalRequirement.REQUIRED === approval.requirement
|
||||
? 'Genehmigung wird nicht benötigt'
|
||||
: 'Genehmigung wird benötigt',
|
||||
confirmationMessage:
|
||||
'Sind Sie sicher, dass sie die Genehmigungsanforderung des Tickets überschreiben wollen?',
|
||||
},
|
||||
|
||||
registerExternalRequest: {
|
||||
isPossible:
|
||||
approval.status !== ApprovalRequestStatus.FULL_REQUEST_SENT &&
|
||||
approval.status !== ApprovalRequestStatus.EXTERNAL_REQUEST &&
|
||||
approval.status !== ApprovalRequestStatus.GRANTED &&
|
||||
approval.requirement === ApprovalRequirement.REQUIRED,
|
||||
label: 'Bereits außerhalb angefragt',
|
||||
},
|
||||
|
||||
sendRequest: this.canSendApprovalRequest(ticket),
|
||||
|
||||
sendReminder: this.canSendApprovalReminder(ticket),
|
||||
|
||||
approve: {
|
||||
isPossible:
|
||||
approval.requirement !== ApprovalRequirement.NOT_REQUIRED &&
|
||||
approval.status !== ApprovalRequestStatus.GRANTED,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
toTransportDocumentView(ticket: Ticket): TicketTransportDocumentView {
|
||||
const td = ticket.transportDocument;
|
||||
const isPresent =
|
||||
ticket.hasDigitalTransportDocument || ticket.hasPhysicalTransportDocument;
|
||||
|
||||
return {
|
||||
isDocumentPresent: isPresent,
|
||||
label: isPresent ? 'Transportschein liegt vor' : 'Dokument erforderlich',
|
||||
digitalDownloadLink: td?.storagePath,
|
||||
|
||||
actions: {
|
||||
upload: {
|
||||
isPossible: !ticket.hasDigitalTransportDocument,
|
||||
},
|
||||
receivePhysical: {
|
||||
isPossible: !ticket.hasPhysicalTransportDocument,
|
||||
label: 'Transportschein liegt vor (Erfassen)',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private translateApprovalStatus(status: ApprovalRequestStatus): string {
|
||||
switch (status) {
|
||||
case ApprovalRequestStatus.NOT_STARTED:
|
||||
return 'Noch keine Anfrage gesendet';
|
||||
case ApprovalRequestStatus.PRE_INQUIRY_SENT:
|
||||
return 'Vorab-Anfrage versendet';
|
||||
case ApprovalRequestStatus.FULL_REQUEST_SENT:
|
||||
return 'Anfrage versendet';
|
||||
case ApprovalRequestStatus.EXTERNAL_REQUEST:
|
||||
return 'Anfrage von Extern versendet';
|
||||
case ApprovalRequestStatus.GRANTED:
|
||||
return 'Genehmigung erteilt';
|
||||
}
|
||||
}
|
||||
|
||||
private canSendApprovalRequest(ticket: Ticket): {
|
||||
isPossible: boolean;
|
||||
label: string;
|
||||
description: string;
|
||||
} {
|
||||
const approval = ticket.approval;
|
||||
|
||||
let isPossible = false;
|
||||
if (approval.requirement === ApprovalRequirement.REQUIRED) {
|
||||
if (approval.status === ApprovalRequestStatus.NOT_STARTED) {
|
||||
isPossible = true;
|
||||
} else if (
|
||||
approval.status === ApprovalRequestStatus.PRE_INQUIRY_SENT &&
|
||||
ticket.hasDigitalTransportDocument
|
||||
) {
|
||||
isPossible = true;
|
||||
}
|
||||
}
|
||||
|
||||
let label = 'Anfrage senden';
|
||||
if (
|
||||
ticket.approval.status === ApprovalRequestStatus.NOT_STARTED &&
|
||||
!ticket.hasDigitalTransportDocument
|
||||
) {
|
||||
label = 'Vorab-Anfrage senden';
|
||||
}
|
||||
|
||||
let description = undefined;
|
||||
if (
|
||||
approval.status === ApprovalRequestStatus.PRE_INQUIRY_SENT &&
|
||||
!ticket.hasDigitalTransportDocument
|
||||
) {
|
||||
description = 'Transportschein fehlt';
|
||||
}
|
||||
|
||||
return {
|
||||
isPossible: isPossible,
|
||||
label: label,
|
||||
description: description,
|
||||
};
|
||||
}
|
||||
|
||||
private canSendApprovalReminder(ticket: Ticket): {
|
||||
isPossible: boolean;
|
||||
label: string;
|
||||
description: string;
|
||||
} {
|
||||
const approval = ticket.approval;
|
||||
|
||||
let isPossible = false;
|
||||
if (
|
||||
approval.status === ApprovalRequestStatus.FULL_REQUEST_SENT &&
|
||||
approval.isOverdue() &&
|
||||
approval.hasRemindersLeft()
|
||||
) {
|
||||
isPossible = true;
|
||||
}
|
||||
|
||||
const label = `${approval.reminderCount + 1}. Erinnerung senden`;
|
||||
|
||||
return {
|
||||
isPossible: isPossible,
|
||||
label: label,
|
||||
description: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
PaginatedResult,
|
||||
TicketListItemDto,
|
||||
TicketStage,
|
||||
} from '@avicenna/shared-dtos';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { Kysely, sql } from 'kysely';
|
||||
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
|
||||
import { DB } from 'src/core/database/types';
|
||||
|
||||
@Injectable()
|
||||
export class TicketQueryService {
|
||||
private readonly logger = new Logger(TicketQueryService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
|
||||
) {}
|
||||
|
||||
async searchTicket(searchTerm: string): Promise<TicketListItemDto[]> {
|
||||
const searchTerms = searchTerm
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map((str) => `%${str}%`);
|
||||
|
||||
const rows = await this.db
|
||||
.selectFrom('Ticket')
|
||||
.distinctOn('Ticket.id')
|
||||
.where('stage', 'not in', [TicketStage.ARCHIVED, TicketStage.CLOSED])
|
||||
.leftJoin('Patient', (join) =>
|
||||
join.onRef(sql`"Patient"."id"::uuid`, '=', 'Ticket.patientId'),
|
||||
)
|
||||
.innerJoin('Tour', 'Ticket.id', 'Tour.ticketId')
|
||||
.where((eb) =>
|
||||
eb.and(
|
||||
searchTerms.map((term) =>
|
||||
eb.or([
|
||||
eb('Patient.name', 'ilike', term),
|
||||
eb('Patient.surname', 'ilike', term),
|
||||
eb('Tour.patientName', 'ilike', term),
|
||||
eb('Tour.patientSurname', 'ilike', term),
|
||||
eb('Tour.operationId', 'ilike', term),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
.select((eb) => [
|
||||
'Ticket.id',
|
||||
'Ticket.stage',
|
||||
'Ticket.approvalRequirement',
|
||||
'Ticket.approvalStatus',
|
||||
'Ticket.approvalRequestedAt',
|
||||
'Ticket.createdAt',
|
||||
'Ticket.updatedAt',
|
||||
sql<string>`concat("Tour"."patientName", ' ', "Tour"."patientSurname")`.as(
|
||||
'tourPatientName',
|
||||
),
|
||||
sql<string>`concat("Patient".name, ' ', "Patient".surname)`.as(
|
||||
'patientName',
|
||||
),
|
||||
eb
|
||||
.selectFrom('Tour')
|
||||
.whereRef('Tour.ticketId', '=', 'Ticket.id')
|
||||
.select(eb.fn.countAll<number>().as('count'))
|
||||
.as('tourCount'),
|
||||
eb
|
||||
.selectFrom('Tour')
|
||||
.whereRef('Tour.ticketId', '=', 'Ticket.id')
|
||||
.select('ordinanceType')
|
||||
.limit(1)
|
||||
.as('ordinanceType'),
|
||||
eb
|
||||
.selectFrom('Tour')
|
||||
.whereRef('Tour.ticketId', '=', 'Ticket.id')
|
||||
.select('Tour.startDate')
|
||||
.where('Tour.startDate', '>=', sql<Date>`now()`)
|
||||
.orderBy('Tour.startDate', 'asc')
|
||||
.limit(1)
|
||||
.as('nextStartDate'),
|
||||
])
|
||||
.limit(5)
|
||||
.execute();
|
||||
|
||||
return rows.map(
|
||||
(row): TicketListItemDto => ({
|
||||
id: row.id,
|
||||
ordinanceType: row.ordinanceType,
|
||||
patientName:
|
||||
`${row.patientName}`.length > `${row.tourPatientName}`.length
|
||||
? `${row.patientName}`
|
||||
: row.tourPatientName,
|
||||
stage: row.stage,
|
||||
nextStart: row.nextStartDate,
|
||||
tourCount: row.tourCount,
|
||||
approvalRequirement: row.approvalRequirement,
|
||||
approvalStatus: row.approvalStatus,
|
||||
isOverdue: row.approvalRequestedAt
|
||||
? differenceInDays(new Date(), row.approvalRequestedAt) >= 3
|
||||
: false,
|
||||
errorCount: 0,
|
||||
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getTicketsForLane(
|
||||
stage: string,
|
||||
page: number,
|
||||
limit: number = 10,
|
||||
): Promise<PaginatedResult<TicketListItemDto>> {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const baseQuery = this.db
|
||||
.selectFrom('Ticket')
|
||||
.where('Ticket.stage', '=', stage);
|
||||
|
||||
const countResult = await baseQuery
|
||||
.select((eb) => eb.fn.countAll<string>().as('total'))
|
||||
.executeTakeFirst();
|
||||
|
||||
const total = Number(countResult?.total ?? 0);
|
||||
|
||||
const rows = await baseQuery
|
||||
.select((eb) => [
|
||||
'Ticket.id',
|
||||
'Ticket.stage',
|
||||
'Ticket.approvalRequirement',
|
||||
'Ticket.approvalStatus',
|
||||
'Ticket.approvalRequestedAt',
|
||||
'Ticket.createdAt',
|
||||
'Ticket.updatedAt',
|
||||
eb
|
||||
.selectFrom('Tour')
|
||||
.whereRef('Tour.ticketId', '=', 'Ticket.id')
|
||||
.select(eb.fn.countAll<number>().as('count'))
|
||||
.as('tourCount'),
|
||||
eb
|
||||
.selectFrom('Tour')
|
||||
.whereRef('Tour.ticketId', '=', 'Ticket.id')
|
||||
.select('patientName')
|
||||
.limit(1)
|
||||
.as('patientName'),
|
||||
eb
|
||||
.selectFrom('Tour')
|
||||
.whereRef('Tour.ticketId', '=', 'Ticket.id')
|
||||
.select('patientSurname')
|
||||
.limit(1)
|
||||
.as('patientSurname'),
|
||||
eb
|
||||
.selectFrom('Tour')
|
||||
.whereRef('Tour.ticketId', '=', 'Ticket.id')
|
||||
.select('ordinanceType')
|
||||
.limit(1)
|
||||
.as('ordinanceType'),
|
||||
eb
|
||||
.selectFrom('Tour')
|
||||
.select('Tour.startDate')
|
||||
.whereRef('Tour.ticketId', '=', 'Ticket.id')
|
||||
.where('Tour.startDate', '>=', sql<Date>`now()`)
|
||||
.orderBy('Tour.startDate', 'asc')
|
||||
.limit(1)
|
||||
.as('nextStartDate'),
|
||||
])
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.execute();
|
||||
|
||||
const data = rows.map((row) => ({
|
||||
id: row.id,
|
||||
ordinanceType: row.ordinanceType,
|
||||
patientName: `${row.patientName} ${row.patientSurname}`,
|
||||
stage: row.stage,
|
||||
nextStart: row.nextStartDate,
|
||||
tourCount: row.tourCount,
|
||||
approvalRequirement: row.approvalRequirement,
|
||||
approvalStatus: row.approvalStatus,
|
||||
isOverdue: row.approvalRequestedAt
|
||||
? differenceInDays(new Date(), row.approvalRequestedAt) >= 3
|
||||
: false,
|
||||
errorCount: 0,
|
||||
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}));
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+8
-9
@@ -1,5 +1,5 @@
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Ticket, TicketValidationState, Tour } from '@prisma/client';
|
||||
import { Queue } from 'bullmq';
|
||||
@@ -19,12 +19,14 @@ import {
|
||||
import { DocumentInfoArgs, TicketFilterArgs } from './ticket-arg.types';
|
||||
import {
|
||||
PaginatedTicketObjectType,
|
||||
TicketObjectType,
|
||||
TicketStateKeyFiguresObjectType,
|
||||
TicketStateMetaObjectType,
|
||||
} from './ticket.object-type';
|
||||
import { TicketRepository } from './ticket.repository';
|
||||
import { TicketRepository as LegacyTicketRepository } from './ticket.repository';
|
||||
import { TourFilesService } from '../feat-business-objects/entities/tour-files/tour-files.service';
|
||||
|
||||
import { TICKET_REPOSITORY, TicketRepository } from '../feat-tickets/domain/ticket.repository.interface';
|
||||
import { TicketId } from '../feat-tickets/domain/ticket.entity';
|
||||
@Injectable()
|
||||
export class TicketSystemService {
|
||||
public readonly pubSub = new PubSub();
|
||||
@@ -37,7 +39,8 @@ export class TicketSystemService {
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly repository: TicketRepository,
|
||||
private readonly repository: LegacyTicketRepository,
|
||||
@Inject(TICKET_REPOSITORY) private readonly dddRepo: TicketRepository,
|
||||
private readonly ticketService: TicketService,
|
||||
private readonly tourService: TourService,
|
||||
private readonly tourFileService: TourFilesService,
|
||||
@@ -67,10 +70,6 @@ export class TicketSystemService {
|
||||
return true;
|
||||
}
|
||||
|
||||
findById(id: string): Promise<Ticket> {
|
||||
return this.ticketService.findById(id);
|
||||
}
|
||||
|
||||
findAll(): Promise<Ticket[]> {
|
||||
return this.ticketService.findAll() as Promise<Ticket[]>;
|
||||
}
|
||||
@@ -97,7 +96,7 @@ export class TicketSystemService {
|
||||
const nextPage = skip + take < total ? skip + take : skip;
|
||||
|
||||
return {
|
||||
nodes: tickets,
|
||||
nodes: [], // tickets,
|
||||
prevCursor: encodeCursor(prevPage),
|
||||
endCursor: encodeCursor(nextPage),
|
||||
hasNextPage: nextPage !== skip,
|
||||
|
||||
@@ -29,6 +29,24 @@ export class DocumentInfoObjectType {
|
||||
|
||||
@ObjectType('Ticket')
|
||||
export class TicketObjectType implements Ticket {
|
||||
lastStageBeforeClosed: string;
|
||||
approvalPreInquiryMessageId: string;
|
||||
approvalFullRequestMessageId: string;
|
||||
approvalRecipient: string;
|
||||
approvalIsOverriden: boolean;
|
||||
approvalStatus: string;
|
||||
approvalLastInteractionAt: Date;
|
||||
approvalReminderCount: number;
|
||||
hasPhysicalTransportDocument: boolean;
|
||||
hasDigitalTransportDocument: boolean;
|
||||
patientId: string;
|
||||
updatedAt: Date;
|
||||
stage: string;
|
||||
approvalRequirement: string;
|
||||
approvalRequestedAt: Date;
|
||||
approvalIsGranted: boolean;
|
||||
hasTransportDocument: boolean;
|
||||
|
||||
@Field()
|
||||
id: string;
|
||||
@Field(() => TicketValidationState)
|
||||
|
||||
+161
-31
@@ -1,31 +1,179 @@
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import {
|
||||
GetBoardQueryDto,
|
||||
PaginatedResult,
|
||||
TicketApprovalViewDto,
|
||||
TicketDetailDto,
|
||||
TicketListItemDto,
|
||||
} from '@avicenna/shared-dtos';
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Queue } from 'bullmq';
|
||||
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
|
||||
import { TOUR_FILE_UPLOAD_QUEUE } from '../feat-business-objects/entities/tour/tour.tokens';
|
||||
import { TourFilesService } from '../feat-business-objects/entities/tour-files/tour-files.service';
|
||||
import { MarkAsApprovedUseCase } from '../feat-tickets/application/mark-as-approved.usecase';
|
||||
import { OverrideApprovalRequirementUseCase } from '../feat-tickets/application/override-approval-requirement.usecase';
|
||||
import { ReceivePhysicalTransportDocumentUseCase } from '../feat-tickets/application/receive-physical-transport-document.usecase';
|
||||
import { RequestTicketApprovalUseCase } from '../feat-tickets/application/request-ticket-approval.usecase';
|
||||
import { SendApprovalReminderUseCase } from '../feat-tickets/application/send-approval-reminder.usecase';
|
||||
import { UploadTransportDocumentUseCase } from '../feat-tickets/application/upload-transport-document.usecase';
|
||||
import { TicketId } from '../feat-tickets/domain/ticket.entity';
|
||||
import {
|
||||
TICKET_REPOSITORY,
|
||||
TicketRepository,
|
||||
} from '../feat-tickets/domain/ticket.repository.interface';
|
||||
import { TicketReadMapper } from './mappers/ticket-read.mapper';
|
||||
import { TicketQueryService } from './queries/ticket-query.service';
|
||||
import { RegisterExternalApprovalRequestUseCase } from '../feat-tickets/application/register-external-approval-request.usecase';
|
||||
|
||||
@Controller('api/tickets')
|
||||
export class TicketsController {
|
||||
private readonly logger = new Logger(TicketsController.name);
|
||||
|
||||
constructor(
|
||||
private readonly tourService: TourService,
|
||||
private readonly tourFilesService: TourFilesService,
|
||||
@InjectQueue(TOUR_FILE_UPLOAD_QUEUE)
|
||||
private readonly transportscheinQueue: Queue,
|
||||
private readonly queryService: TicketQueryService,
|
||||
private readonly ticketReadMapper: TicketReadMapper,
|
||||
|
||||
@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository,
|
||||
private readonly uploadUseCase: UploadTransportDocumentUseCase,
|
||||
private readonly overrideUseCase: OverrideApprovalRequirementUseCase,
|
||||
private readonly requestApprovalUseCase: RequestTicketApprovalUseCase,
|
||||
private readonly sendReminderUseCase: SendApprovalReminderUseCase,
|
||||
private readonly approveUseCase: MarkAsApprovedUseCase,
|
||||
private readonly receiveUseCase: ReceivePhysicalTransportDocumentUseCase,
|
||||
private readonly registerUseCase: RegisterExternalApprovalRequestUseCase,
|
||||
) {}
|
||||
|
||||
@Get('search')
|
||||
async searchTickets(
|
||||
@Query('q') searchTerm: string,
|
||||
): Promise<TicketListItemDto[]> {
|
||||
if (!searchTerm) {
|
||||
throw new BadRequestException("no 'q' provided");
|
||||
}
|
||||
|
||||
return this.queryService.searchTicket(searchTerm);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async getBoard(
|
||||
@Query() query: GetBoardQueryDto,
|
||||
): Promise<PaginatedResult<TicketListItemDto>> {
|
||||
this.logger.log(
|
||||
`fetching tickets board for stage ${query.stage} at page ${query.page}`,
|
||||
);
|
||||
return this.queryService.getTicketsForLane(
|
||||
query.stage,
|
||||
query.page,
|
||||
query.limit,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getTicket(@Param('id') id: string): Promise<TicketDetailDto> {
|
||||
this.logger.log(`fetch ticket ${id}`);
|
||||
const ticketId = new TicketId(id);
|
||||
const ticket = await this.ticketRepo.findById(ticketId);
|
||||
|
||||
if (!ticket) {
|
||||
throw new NotFoundException(`Ticket ${id} not found`);
|
||||
}
|
||||
|
||||
return this.ticketReadMapper.toDetailDto(ticket);
|
||||
}
|
||||
|
||||
@Get(':id/approval')
|
||||
async getTicketApprovalView(
|
||||
@Param('id') id: string,
|
||||
): Promise<TicketApprovalViewDto> {
|
||||
this.logger.log(`fetch ticket ${id}`);
|
||||
const ticketId = new TicketId(id);
|
||||
const ticket = await this.ticketRepo.findById(ticketId);
|
||||
|
||||
if (!ticket) {
|
||||
throw new NotFoundException(`Ticket ${id} not found`);
|
||||
}
|
||||
|
||||
return this.ticketReadMapper.toApprovalView(ticket);
|
||||
}
|
||||
|
||||
@Post(':id/approval/request')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async requestApproval(
|
||||
@Param('id') id: string,
|
||||
@Body('recipient') recipient: string,
|
||||
) {
|
||||
if (!id) {
|
||||
throw new BadRequestException(`no id provided`);
|
||||
}
|
||||
if (!recipient) {
|
||||
throw new BadRequestException(`no recipient provided`);
|
||||
}
|
||||
|
||||
await this.requestApprovalUseCase.execute(id, recipient);
|
||||
}
|
||||
|
||||
@Post(':id/approval/register-request')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async registerExternalApprovalRequest(@Param('id') id: string) {
|
||||
if (!id) {
|
||||
throw new BadRequestException(`no id provided`);
|
||||
}
|
||||
|
||||
await this.registerUseCase.execute(id);
|
||||
}
|
||||
|
||||
@Post(':id/approval/remind')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async sendReminder(@Param('id') id: string) {
|
||||
if (!id) {
|
||||
throw new BadRequestException(`no id provided`);
|
||||
}
|
||||
|
||||
await this.sendReminderUseCase.execute(id);
|
||||
}
|
||||
|
||||
@Post(':id/approval/approve')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async approve(@Param('id') id: string) {
|
||||
if (!id) {
|
||||
throw new BadRequestException(`no id provided`);
|
||||
}
|
||||
|
||||
await this.approveUseCase.execute(id);
|
||||
}
|
||||
|
||||
@Post(':id/approval/override')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async overrideApproval(@Param('id') id: string) {
|
||||
if (!id) {
|
||||
throw new BadRequestException(`no id provided`);
|
||||
}
|
||||
|
||||
await this.overrideUseCase.execute(id);
|
||||
}
|
||||
|
||||
@Post(':id/transport-document/receive')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async receiveTD(@Param('id') id: string) {
|
||||
if (!id) {
|
||||
throw new BadRequestException(`no id provided`);
|
||||
}
|
||||
|
||||
await this.receiveUseCase.execute(id);
|
||||
}
|
||||
|
||||
@Post(':ticketId/files')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
@@ -33,30 +181,12 @@ export class TicketsController {
|
||||
@Param('ticketId') ticketId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
this.logger.log(
|
||||
`file received for ticket ${ticketId ?? ''}: ${file.filename}`,
|
||||
);
|
||||
|
||||
const existingFile = await this.tourFilesService.findByTicketId(ticketId);
|
||||
if (existingFile) {
|
||||
return {message: 'File already exists!'}
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
const storage = file.path;
|
||||
|
||||
const tours = await this.tourService.findByTicketId(ticketId);
|
||||
await this.uploadUseCase.execute(ticketId, file);
|
||||
|
||||
for (const tour of tours) {
|
||||
const tourFile = await this.tourFilesService.createTourFile({
|
||||
ticketId,
|
||||
tourId: tour.id,
|
||||
storagePath: storage,
|
||||
file: file,
|
||||
});
|
||||
|
||||
await this.transportscheinQueue.add('Transportschein', {
|
||||
tourFileId: tourFile.id,
|
||||
});
|
||||
}
|
||||
return { message: 'File accepted and processing started' };
|
||||
return { message: 'File accepted and processing' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export class TicketsResolver {
|
||||
|
||||
@Query(() => TicketObjectType)
|
||||
ticket(@Args('id') id: string) {
|
||||
return this.ticketsService.findById(id);
|
||||
// return this.ticketsService.findById(id);
|
||||
}
|
||||
|
||||
@Query(() => PaginatedTicketObjectType)
|
||||
|
||||
@@ -14,6 +14,7 @@ import { IsGermanZipDetector } from './anomaly-detectors/is-german-zip.detector'
|
||||
import { EmployeeStillWorkingDetector } from './anomaly-detectors/employee-still-working.detector';
|
||||
import { IsAttendanceBeforeWorktimeDetector } from './anomaly-detectors/is-attendance-before-worktime.detector';
|
||||
import { IsWorkTimePlannedDetector } from './anomaly-detectors/is-work-time-planned.detector';
|
||||
import { KtwTourNotWithKtwCarDetector } from './anomaly-detectors/ktw-tour-with-ktw-car.detector';
|
||||
|
||||
@Injectable()
|
||||
export class AnomaliesService {
|
||||
@@ -35,6 +36,7 @@ export class AnomaliesService {
|
||||
new EmployeeStillWorkingDetector(this.anomalyDetectorService),
|
||||
new IsAttendanceBeforeWorktimeDetector(this.anomalyDetectorService),
|
||||
// new IsWorkTimePlannedDetector(this.anomalyDetectorService)
|
||||
new KtwTourNotWithKtwCarDetector(this.anomalyDetectorService)
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+16
@@ -34,6 +34,22 @@ export class AnomalyDetectorRepository {
|
||||
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
|
||||
) {}
|
||||
|
||||
async ktwToursNotOnKtwCars(interval: Interval): Promise<Tour[]> {
|
||||
return await this.db
|
||||
.selectFrom('Tour')
|
||||
.selectAll()
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
eb('Tour.ordinanceType', 'ilike', '%ktw%'),
|
||||
eb('Tour.carName', 'not ilike', '%ktw%'),
|
||||
eb('Tour.startDate', '>=', new Date(interval.start)),
|
||||
eb('Tour.startDate', '<=', new Date(interval.end)),
|
||||
onlyRealTours(eb),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async toursWithoutGermanZipInInterval(interval: Interval): Promise<Tour[]> {
|
||||
return await this.db
|
||||
.selectFrom('Tour')
|
||||
|
||||
+4
@@ -37,6 +37,10 @@ export class AnomalyDetectorService {
|
||||
private readonly anomalyRepository: AnomalyDetectorRepository,
|
||||
) {}
|
||||
|
||||
public async findKtwToursNotOnKtwCars(interval: Interval): Promise<Tour[]> {
|
||||
return this.anomalyRepository.ktwToursNotOnKtwCars(interval)
|
||||
}
|
||||
|
||||
public findToursWithoutGermanZipInInterval(
|
||||
interval: Interval,
|
||||
): Promise<Tour[]> {
|
||||
|
||||
+46
-48
@@ -16,56 +16,54 @@ export class DriverCertificateDetector extends AnomalyDetector {
|
||||
);
|
||||
const toursInRange = await this.dataService.findToursInInterval(inInterval);
|
||||
|
||||
const anomalies = await Promise.all(
|
||||
toursInRange
|
||||
.filter(({ driverId }) => !!driverId)
|
||||
.map(
|
||||
async ({
|
||||
id: tourId,
|
||||
driverId,
|
||||
startDate,
|
||||
driverName,
|
||||
carName,
|
||||
operationId,
|
||||
}) => {
|
||||
try {
|
||||
const driverEmployee =
|
||||
await this.dataService.findEmployeeById(driverId);
|
||||
if (!driverEmployee) {
|
||||
try {
|
||||
const anomalies = await Promise.all(
|
||||
toursInRange
|
||||
.filter((tour) => !!tour?.driverId && !!tour?.startDate)
|
||||
.map(
|
||||
async ({
|
||||
id: tourId,
|
||||
driverId,
|
||||
startDate,
|
||||
driverName,
|
||||
carName,
|
||||
operationId,
|
||||
}) => {
|
||||
try {
|
||||
const driverEmployee =
|
||||
await this.dataService.findEmployeeById(driverId);
|
||||
if (!driverEmployee) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!this.isPersonTransportCertificateValidAtStartOfTour(
|
||||
driverEmployee.personTransportCertificate,
|
||||
startDate,
|
||||
)
|
||||
) {
|
||||
this.addAssociatedEntity('employee', driverEmployee.id);
|
||||
this.addAssociatedEntity('tour', tourId);
|
||||
|
||||
return this.createAnomaly(
|
||||
`${operationId}-${driverId}-${this.type}`,
|
||||
operationId,
|
||||
carName,
|
||||
driverName,
|
||||
new Date(startDate),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
return;
|
||||
return this.createAnomaly(
|
||||
`${operationId}-${driverId}-${this.type}`,
|
||||
operationId,
|
||||
carName,
|
||||
driverName ?? '<Fahrer gelöscht>',
|
||||
new Date(startDate),
|
||||
);
|
||||
}
|
||||
if (
|
||||
!this.isPersonTransportCertificateValidAtStartOfTour(
|
||||
driverEmployee.personTransportCertificate,
|
||||
startDate,
|
||||
)
|
||||
) {
|
||||
this.addAssociatedEntity('employee', driverEmployee.id);
|
||||
this.addAssociatedEntity('tour', tourId);
|
||||
|
||||
return this.createAnomaly(
|
||||
`${operationId}-${driverId}-${this.type}`,
|
||||
operationId,
|
||||
carName,
|
||||
driverName,
|
||||
new Date(startDate),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
return;
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
return anomalies.filter((anomaly) => !!anomaly);
|
||||
},
|
||||
),
|
||||
);
|
||||
return anomalies.filter((anomaly) => !!anomaly);
|
||||
} catch (error) {
|
||||
this.logger.error(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async validate(anomaly: Anomaly): Promise<Anomaly> {
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ export class EmployeesLoggedInDetector extends AnomalyDetector {
|
||||
const tour = await this.dataService.findTourByOperationId(operationId);
|
||||
const employee = await this.dataService.findEmployeeById(employeeId);
|
||||
if(!tour?.startDate) {
|
||||
this.logger.error(`tour ${tour?.operationId} has no startDate`)
|
||||
this.logger.error(`tour ${tour?.operationId ?? operationId} has no startDate`)
|
||||
}
|
||||
const startOfTour = this.getStartOfTour(
|
||||
new Date(tour?.startDate),
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { AnomalyDetector } from './anomaly-detector.class';
|
||||
import { Prisma, Anomaly } from '@prisma/client';
|
||||
import { format, Interval } from 'date-fns';
|
||||
import { AnomalyTypes } from 'src/modules/feat-business-objects/entities/anomaly/anomaly-types.enum';
|
||||
import { toZonedTime } from 'date-fns-tz';
|
||||
|
||||
export class KtwTourNotWithKtwCarDetector extends AnomalyDetector {
|
||||
private readonly logger = new Logger(KtwTourNotWithKtwCarDetector.name);
|
||||
readonly type = AnomalyTypes.KtwTourNotWithKtwCar;
|
||||
|
||||
async detect(interval: Interval): Promise<Prisma.AnomalyCreateInput[]> {
|
||||
this.logger.log(
|
||||
`start '${KtwTourNotWithKtwCarDetector.name}' with interval ${format(interval.start, 'yyyy-MM-dd HH:mm')} - ${format(interval.end, 'yyyy-MM-dd HH:mm')}`,
|
||||
);
|
||||
|
||||
const tours = await this.dataService.findKtwToursNotOnKtwCars(interval);
|
||||
|
||||
return await Promise.all(
|
||||
tours.map((tour) =>
|
||||
this.createAnomaly(
|
||||
`${tour.operationId}-${this.type}`,
|
||||
tour.id,
|
||||
tour.operationId,
|
||||
tour.startDate,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async validate(anomaly: Anomaly): Promise<Anomaly> {
|
||||
const [operationId] = anomaly.id.split('-');
|
||||
try {
|
||||
const tour = await this.dataService.findTourByOperationId(operationId);
|
||||
const isKtwTour = tour?.ordinanceType.toLowerCase().indexOf('ktw') > -1;
|
||||
const isKtwResource = tour?.carName.toLowerCase().indexOf('ktw') > -1;
|
||||
|
||||
if (!isKtwTour || isKtwResource) {
|
||||
return {
|
||||
...anomaly,
|
||||
resolvedAt: new Date(),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...anomaly,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(error, { anomalyId: anomaly.id });
|
||||
// Im Fehlerfall geben wir die Anomalie unverändert zurück, um Datenverlust zu vermeiden
|
||||
return anomaly;
|
||||
}
|
||||
}
|
||||
|
||||
createDescription({ operationId }: { operationId: string }): string {
|
||||
return `${operationId}: KTW-Tour auf falschem Fahrzeugtyp`;
|
||||
}
|
||||
|
||||
createSolution({
|
||||
tourId,
|
||||
}: {
|
||||
tourId: string;
|
||||
}): Prisma.SolutionTargetCreateInput[] {
|
||||
return [
|
||||
{
|
||||
name: 'DispoLive',
|
||||
steps: {
|
||||
create: [
|
||||
{
|
||||
description: `Öffnen Sie den <a class="dispolive-btn" href="https://avicenna.dispolive.de/fahrbericht/${tourId}" target="_blank">Fahrbericht</a> und weisen Sie der Tour ein Fahrzeug des Typs <strong>KTW</strong> zu.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private createAnomaly(
|
||||
anomalyId: string,
|
||||
tourId: string,
|
||||
operationId: string,
|
||||
startDate: Date,
|
||||
): Prisma.AnomalyCreateInput {
|
||||
return {
|
||||
...this.getAnomalySkeleton(),
|
||||
id: anomalyId,
|
||||
type: this.type,
|
||||
groupKey: `${format(
|
||||
new Date(startDate),
|
||||
'yyyy-MM-dd-HH-mm',
|
||||
)}-${operationId}`,
|
||||
groupDescription: `${operationId} - ${format(
|
||||
toZonedTime(new Date(startDate), 'Europe/Berlin'),
|
||||
'HH:mm dd.MM.yy',
|
||||
)}`,
|
||||
solution: {
|
||||
create: this.createSolution({ tourId }),
|
||||
},
|
||||
description: this.createDescription({ operationId }),
|
||||
};
|
||||
}
|
||||
}
|
||||
+1
@@ -6,6 +6,7 @@ export enum AnomalyTypes {
|
||||
IsAttendanceBeforeWorktime = 'IsAttendanceBeforeWorktime',
|
||||
IsGermanZip = 'IsGermanZip',
|
||||
IsWorktimePlanned = 'IsWorktimePlanned',
|
||||
KtwTourNotWithKtwCar = 'KtwTourWithKtwCar',
|
||||
OneTourAtATime = 'OneTourAtATime',
|
||||
WorkingEmployeeNotInDistributionSystem = 'WorkingEmployeeNotInDistributionSystem',
|
||||
}
|
||||
|
||||
+51
-30
@@ -1,7 +1,7 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Prisma, TicketValidationState, Tour } from '@prisma/client';
|
||||
import { endOfDay, Interval, startOfDay } from 'date-fns';
|
||||
import { ExpressionBuilder, ExpressionWrapper, Kysely, SqlBool } from 'kysely';
|
||||
import { ExpressionBuilder, ExpressionWrapper, Kysely, sql, SqlBool } from 'kysely';
|
||||
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
|
||||
import { PrismaService } from 'src/core/database/prisma.service';
|
||||
import { DB } from 'src/core/database/types';
|
||||
@@ -26,8 +26,8 @@ function onlyRealToursKysely(eb: TourEb) {
|
||||
eb('deletedAt', 'is', null),
|
||||
|
||||
eb.or([
|
||||
eb('patientId', 'is', null),
|
||||
eb('patientId', 'not in', [PAUSE_PATIENT_ID, FEIERABEND_PATIENT_ID]),
|
||||
eb('Tour.patientId', 'is', null),
|
||||
eb('Tour.patientId', 'not in', [PAUSE_PATIENT_ID, FEIERABEND_PATIENT_ID]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
@@ -77,6 +77,36 @@ export class TourRepository {
|
||||
.orderBy('Tour.startDate asc')
|
||||
.execute();
|
||||
}
|
||||
// tour.repository.ts
|
||||
|
||||
async findForCarInInterval(
|
||||
carName: string,
|
||||
interval: Interval,
|
||||
): Promise<Tour[]> {
|
||||
return await this.db
|
||||
.selectFrom('Tour')
|
||||
.selectAll('Tour')
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
eb('Tour.carName', 'ilike', `%${carName}%`),
|
||||
eb('Tour.startDate', '>=', new Date(interval.start)),
|
||||
eb('Tour.startDate', '<', new Date(interval.end)),
|
||||
onlyRealToursKysely(eb),
|
||||
]),
|
||||
)
|
||||
.orderBy('Tour.startDate', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findWithoutTicket(): Promise<Tour[]> {
|
||||
return this.db
|
||||
.selectFrom('Tour')
|
||||
.selectAll()
|
||||
.where('ticketId', 'is', null)
|
||||
.where((eb) => onlyRealToursKysely(eb))
|
||||
.limit(1000)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findUpdatedAtBetween(
|
||||
firstId: string,
|
||||
@@ -297,26 +327,39 @@ export class TourRepository {
|
||||
// --------------------- Accounting --------------------- //
|
||||
async findByAccountingState(
|
||||
state: AccountingState,
|
||||
filter: TourAccountingFilterDto,
|
||||
take?: number,
|
||||
skip?: number,
|
||||
): Promise<{ tours: Tour[]; total: number }> {
|
||||
): Promise<{ tours: (Tour & { stage: string })[]; total: number }> {
|
||||
return await this.db.transaction().execute(async (trx) => {
|
||||
const tours = await trx
|
||||
const tours = (await trx
|
||||
.selectFrom('Tour')
|
||||
.selectAll()
|
||||
// .selectAll('Tour')
|
||||
.select([
|
||||
'Tour.id',
|
||||
'Tour.operationId',
|
||||
'Tour.startDate',
|
||||
'Tour.revenue',
|
||||
'Tour.revenueDispoLive',
|
||||
'Tour.optimizationInMinutes',
|
||||
'Tour.ordinanceType',
|
||||
])
|
||||
.innerJoin('Ticket', 'Ticket.id', 'Tour.ticketId')
|
||||
.select(['Ticket.stage', 'Ticket.patientId as ticketPatientId'])
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
this.getFilterForAccountingState(eb, state),
|
||||
onlyRealToursKysely(eb),
|
||||
]),
|
||||
)
|
||||
.orderBy(sql`abs("Tour"."optimizationInMinutes")`, 'desc')
|
||||
.orderBy('Tour.revenueDeviation desc')
|
||||
.offset(skip)
|
||||
.limit(take)
|
||||
.execute();
|
||||
.execute()) as unknown as (Tour & { stage: string })[];
|
||||
|
||||
const total = await trx
|
||||
.selectFrom('Tour')
|
||||
.innerJoin('Ticket', 'Ticket.id', 'Tour.ticketId')
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
this.getFilterForAccountingState(eb, state),
|
||||
@@ -393,33 +436,11 @@ export class TourRepository {
|
||||
return eb.and([
|
||||
eb('Tour.check', '=', 0),
|
||||
eb('Tour.revenueDeviation', '<>', 0),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Ticket')
|
||||
.select('Ticket.id')
|
||||
.whereRef('Ticket.id', '=', 'Tour.ticketId')
|
||||
.where(
|
||||
'Ticket.currentState',
|
||||
'<>',
|
||||
TicketValidationState.ARCHIVED,
|
||||
),
|
||||
),
|
||||
]);
|
||||
case AccountingState.Billable:
|
||||
return eb.and([
|
||||
eb('Tour.check', '=', 0),
|
||||
eb('Tour.revenueDeviation', '=', 0),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Ticket')
|
||||
.select('Ticket.id')
|
||||
.whereRef('Ticket.id', '=', 'Tour.ticketId')
|
||||
.where(
|
||||
'Ticket.currentState',
|
||||
'=',
|
||||
TicketValidationState.ARCHIVED,
|
||||
),
|
||||
),
|
||||
]);
|
||||
case AccountingState.PreparedForBilling:
|
||||
return eb.and([eb('Tour.check', '=', 1)]);
|
||||
|
||||
+25
-5
@@ -4,6 +4,8 @@ import { Interval } from 'date-fns';
|
||||
import { AccountingState } from 'src/modules/app-accounting/accounting-state';
|
||||
import { TourRepository } from './tour.repository';
|
||||
import { TourAccountingFilterDto } from './tours.filter';
|
||||
import { OptimizationCarDto } from '@avicenna/shared-dtos';
|
||||
import { generateIntervalForTimeRange } from 'src/utils';
|
||||
|
||||
@Injectable()
|
||||
export class TourService {
|
||||
@@ -16,6 +18,10 @@ export class TourService {
|
||||
return this.repository.findInInterval(interval, ordinanceTypes);
|
||||
}
|
||||
|
||||
async findWithoutTicket(): Promise<Tour[]> {
|
||||
return this.repository.findWithoutTicket();
|
||||
}
|
||||
|
||||
async findUpdatedAtBetween(
|
||||
firstId: string,
|
||||
lastId: string,
|
||||
@@ -35,6 +41,22 @@ export class TourService {
|
||||
);
|
||||
}
|
||||
|
||||
async getOptimizationToursForCar(
|
||||
carName: string,
|
||||
day: Date,
|
||||
): Promise<Tour[]> {
|
||||
const operationDayInterval = generateIntervalForTimeRange(
|
||||
'05:00-04:59',
|
||||
'Europe/Berlin',
|
||||
day,
|
||||
);
|
||||
|
||||
return await this.repository.findForCarInInterval(
|
||||
carName,
|
||||
operationDayInterval,
|
||||
);
|
||||
}
|
||||
|
||||
async findById(tourId: string): Promise<Tour> {
|
||||
return this.repository.findById(tourId);
|
||||
}
|
||||
@@ -56,7 +78,7 @@ export class TourService {
|
||||
}
|
||||
|
||||
async findTragehilfeTour(tour: Tour): Promise<Tour | null> {
|
||||
return this.repository.findTragehilfeTour(tour)
|
||||
return this.repository.findTragehilfeTour(tour);
|
||||
}
|
||||
|
||||
async findToursWithTicket(interval: Interval): Promise<Tour[]> {
|
||||
@@ -71,7 +93,6 @@ export class TourService {
|
||||
return this.repository.createTour(tourInput);
|
||||
}
|
||||
|
||||
|
||||
async updateTour(
|
||||
id: string,
|
||||
tourInput: Prisma.TourUpdateInput,
|
||||
@@ -85,11 +106,10 @@ export class TourService {
|
||||
|
||||
async findByAccountingState(
|
||||
state: AccountingState,
|
||||
filter: TourAccountingFilterDto,
|
||||
take?: number,
|
||||
skip?: number,
|
||||
): Promise<{ tours: Tour[]; total: number }> {
|
||||
return this.repository.findByAccountingState(state, filter, take, skip);
|
||||
): Promise<{ tours: (Tour & { stage: string })[]; total: number }> {
|
||||
return this.repository.findByAccountingState(state, take, skip);
|
||||
}
|
||||
|
||||
async calculateAccountingStateMeta(state: AccountingState): Promise<{
|
||||
|
||||
+8
-5
@@ -269,6 +269,7 @@ export class DispoLiveExtractorsService {
|
||||
importPersId,
|
||||
qualifikation,
|
||||
} = employee;
|
||||
const quali = qualifikation as string | { _id: string };
|
||||
return {
|
||||
id: _id,
|
||||
pScheinAblaufDatum,
|
||||
@@ -277,11 +278,11 @@ export class DispoLiveExtractorsService {
|
||||
persId,
|
||||
importPersId,
|
||||
qualifikation:
|
||||
!!qualifikation && typeof qualifikation === 'object'
|
||||
? //@ts-expect-error ts is wrong here
|
||||
qualifikation?._id
|
||||
: (qualifikation ?? undefined),
|
||||
};
|
||||
(quali && typeof quali === 'object' && '_id' in quali)
|
||||
?
|
||||
quali?._id
|
||||
: (quali ?? undefined),
|
||||
} as EmployeeDispoLiveRaw;
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -378,6 +379,7 @@ export class DispoLiveExtractorsService {
|
||||
birthday,
|
||||
kkId,
|
||||
krankenkasse,
|
||||
stammVersNr,
|
||||
pflegestufe,
|
||||
behinderung1,
|
||||
behinderung2,
|
||||
@@ -392,6 +394,7 @@ export class DispoLiveExtractorsService {
|
||||
birthday,
|
||||
kkId,
|
||||
krankenkasse,
|
||||
stammVersNr,
|
||||
pflegestufe: careDegrees.find(({ id }) => id === pflegestufe)
|
||||
?.careDegree,
|
||||
behinderung1: disabilityMarks.find(
|
||||
|
||||
+2
-2
@@ -45,12 +45,12 @@ export class DyflexisUserAccessExtractorsService {
|
||||
|
||||
loadData(): Observable<any> {
|
||||
return combineLatest([
|
||||
// this.extractWorkdays(),
|
||||
this.extractWorkdays(),
|
||||
// this.extractSicknessDays({
|
||||
// start: new Date('2025-01-01T00:00:00.000Z'),
|
||||
// end: new Date('2025-12-31T23:59:59.999Z'),
|
||||
// }),
|
||||
this.extractHolidays(new Date('2025-01-01T00:00:00.000Z')),
|
||||
// this.extractHolidays(new Date('2025-01-01T00:00:00.000Z')),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -34,6 +34,7 @@ export class TourExtractor {
|
||||
itName,
|
||||
kkId,
|
||||
krankenkasse,
|
||||
stammVersNr,
|
||||
patId,
|
||||
patName,
|
||||
patSurname,
|
||||
@@ -104,6 +105,7 @@ export class TourExtractor {
|
||||
itName: itName ? `${itName}` : undefined,
|
||||
kkId: kkId ? `${kkId}` : undefined,
|
||||
krankenkasse: krankenkasse ? `${krankenkasse}` : undefined,
|
||||
stammVersNr: stammVersNr ? `${stammVersNr}` : undefined,
|
||||
patId: patId ? `${patId}` : undefined,
|
||||
patName: patName ? `${patName}` : undefined,
|
||||
patSurname: patSurname ? `${patSurname}` : undefined,
|
||||
|
||||
+2
@@ -31,6 +31,7 @@ export interface TourRaw {
|
||||
itName?: string;
|
||||
kkId?: string;
|
||||
krankenkasse?: string;
|
||||
stammVersNr?: string;
|
||||
patId?: string;
|
||||
patName?: string;
|
||||
patSurname?: string;
|
||||
@@ -95,6 +96,7 @@ export interface PatientRaw {
|
||||
city?: string;
|
||||
kkId?: string;
|
||||
krankenkasse?: string;
|
||||
stammVersNr?: string;
|
||||
pflegestufe?: string;
|
||||
behinderung1?: string;
|
||||
behinderung2?: string;
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bullmq';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { lastValueFrom, of } from 'rxjs';
|
||||
import { TOUR_FILE_UPLOAD_QUEUE } from 'src/modules/feat-business-objects/entities/tour/tour.tokens';
|
||||
import { DispoLiveExtractorsService } from '../../feat-extractors/dispo-live-extractors.service';
|
||||
import { TourFilesService } from 'src/modules/feat-business-objects/entities/tour-files/tour-files.service';
|
||||
|
||||
+2
@@ -54,6 +54,7 @@ export class PatientLoader {
|
||||
birthday,
|
||||
healthinsurance,
|
||||
healthinsuranceId,
|
||||
insuranceNo,
|
||||
careDegree,
|
||||
disabilityMark1,
|
||||
disabilityMark2,
|
||||
@@ -69,6 +70,7 @@ export class PatientLoader {
|
||||
birthday,
|
||||
healthinsurance,
|
||||
healthinsuranceId,
|
||||
insuranceNo,
|
||||
careDegree,
|
||||
disabilityMark1,
|
||||
disabilityMark2,
|
||||
|
||||
+14
-3
@@ -30,7 +30,10 @@ export class TourLoader {
|
||||
);
|
||||
if (finalTour) {
|
||||
updatedTours.push(
|
||||
this.tourStagingToFinal(stagingTour, finalTour.ticketId),
|
||||
this.tourStagingToFinal(stagingTour, {
|
||||
ticketId: finalTour?.ticketId,
|
||||
optimizationInMinutes: finalTour?.optimizationInMinutes,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
newTours.push(this.tourStagingToFinal(stagingTour));
|
||||
@@ -44,7 +47,10 @@ export class TourLoader {
|
||||
};
|
||||
}
|
||||
|
||||
private static tourStagingToFinal(tour: TourStaging, ticketId?: string) {
|
||||
private static tourStagingToFinal(
|
||||
tour: TourStaging,
|
||||
keepData?: { ticketId: string; optimizationInMinutes: number },
|
||||
) {
|
||||
const {
|
||||
id,
|
||||
done,
|
||||
@@ -63,6 +69,7 @@ export class TourLoader {
|
||||
patientCity,
|
||||
healthInsurance,
|
||||
healthInsuranceNumber,
|
||||
insuranceNo,
|
||||
type,
|
||||
category,
|
||||
transportType,
|
||||
@@ -118,6 +125,7 @@ export class TourLoader {
|
||||
patientCity,
|
||||
healthInsurance,
|
||||
healthInsuranceNumber,
|
||||
insuranceNo,
|
||||
type,
|
||||
category,
|
||||
transportType,
|
||||
@@ -153,7 +161,10 @@ export class TourLoader {
|
||||
billNumber,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
...(ticketId ? { ticketId } : {}),
|
||||
...(keepData?.ticketId ? { ticketId: keepData.ticketId } : {}),
|
||||
...(keepData?.optimizationInMinutes
|
||||
? { optimizationInMinutes: keepData.optimizationInMinutes }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -18,6 +18,7 @@ export interface TourPreStaging {
|
||||
patientCity?: string;
|
||||
healthInsurance?: string;
|
||||
healthInsuranceNumber?: string;
|
||||
insuranceNo?: string;
|
||||
|
||||
type?: string;
|
||||
category?: string;
|
||||
@@ -75,6 +76,7 @@ export interface PatientPreStaging {
|
||||
birthday?: Date;
|
||||
healthinsuranceId?: string;
|
||||
healthinsurance?: string;
|
||||
insuranceNo?: string;
|
||||
careDegree?: string;
|
||||
disabilityMark1?: string;
|
||||
disabilityMark2?: string;
|
||||
|
||||
+2
@@ -31,6 +31,7 @@ export class PatientTransformer {
|
||||
birthday,
|
||||
kkId,
|
||||
krankenkasse,
|
||||
stammVersNr,
|
||||
pflegestufe,
|
||||
behinderung1,
|
||||
behinderung2,
|
||||
@@ -46,6 +47,7 @@ export class PatientTransformer {
|
||||
birthday: isValidDate(new Date(birthday)) ? birthday : undefined,
|
||||
healthinsurance: krankenkasse,
|
||||
healthinsuranceId: kkId,
|
||||
insuranceNo: stammVersNr,
|
||||
careDegree: pflegestufe,
|
||||
disabilityMark1: behinderung1,
|
||||
disabilityMark2: behinderung2,
|
||||
|
||||
+2
@@ -85,6 +85,7 @@ export class TourTransformer {
|
||||
patCity,
|
||||
krankenkasse,
|
||||
kkId,
|
||||
stammVersNr,
|
||||
type,
|
||||
serienKategorName,
|
||||
transportKrz,
|
||||
@@ -146,6 +147,7 @@ export class TourTransformer {
|
||||
patientCity: patCity,
|
||||
healthInsurance: krankenkasse,
|
||||
healthInsuranceNumber: kkId,
|
||||
insuranceNo: stammVersNr,
|
||||
category: serienKategorName,
|
||||
transportType: transportKrz,
|
||||
ordinanceType: verordnungsName,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { KpiInfoDto } from '@avicenna/shared-dtos';
|
||||
import { BadRequestException, Controller, Get, Query } from '@nestjs/common';
|
||||
import { KpiService } from './kpi.service';
|
||||
|
||||
@Controller('api/kpis')
|
||||
export class KpiController {
|
||||
constructor(private readonly kpiService: KpiService) {}
|
||||
|
||||
@Get('manager')
|
||||
managerKpi(
|
||||
@Query('ordinanceType') ordinanceType: string,
|
||||
): Promise<KpiInfoDto> {
|
||||
if(!ordinanceType) {
|
||||
throw new BadRequestException('no ordinanceType provided')
|
||||
}
|
||||
return this.kpiService.managerKpi(ordinanceType);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-busines
|
||||
import { FeatCalculationModule } from '../feat-calculation/feat-calculation.module';
|
||||
import { KpiResolver } from './kpi.resolver';
|
||||
import { KpiService } from './kpi.service';
|
||||
import { KpiController } from './ kpi.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, FeatBusinessObjectsModule, FeatCalculationModule],
|
||||
controllers: [KpiController],
|
||||
providers: [KpiService, KpiResolver],
|
||||
exports: [KpiService],
|
||||
})
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { TICKET_REPOSITORY, TicketRepository } from '../domain/ticket.repository.interface';
|
||||
import { TicketId } from '../domain/ticket.entity';
|
||||
import { TOUR_PROVIDER, TourProvider } from '../domain/ports/tour-provider.interface';
|
||||
import { PATIENT_PROVIDER, PatientProvider } from '../domain/ports/patient-provider.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AssignTourToTicketUseCase {
|
||||
private readonly logger = new Logger(AssignTourToTicketUseCase.name)
|
||||
|
||||
constructor(
|
||||
@Inject(TICKET_REPOSITORY) private ticketRepo: TicketRepository,
|
||||
@Inject(TOUR_PROVIDER) private tourAdapter: TourProvider,
|
||||
@Inject(PATIENT_PROVIDER) private readonly patientProvider: PatientProvider
|
||||
) {}
|
||||
|
||||
async execute(ticketIdRaw: string, tourIdRaw: string): Promise<void> {
|
||||
this.logger.log(`assigning Tour ${tourIdRaw} to Ticket ${ticketIdRaw}`)
|
||||
const ticketId = new TicketId(ticketIdRaw)
|
||||
const ticket = await this.ticketRepo.findById(ticketId);
|
||||
|
||||
if (!ticket) {
|
||||
throw new NotFoundException(`Ticket with ID ${ticketIdRaw} not found`)
|
||||
}
|
||||
|
||||
const tour = await this.tourAdapter.findTourById(tourIdRaw)
|
||||
if(!tour) {
|
||||
throw new NotFoundException(`Tour with ID ${tourIdRaw} not found`)
|
||||
}
|
||||
|
||||
if(tour.patientId !== ticket.patientId.value && tour.patientId) {
|
||||
const patient = await this.patientProvider.findPatientById(tour.patientId);
|
||||
if(patient) {
|
||||
ticket.updatePatientData(patient)
|
||||
}
|
||||
}
|
||||
|
||||
// if(ticket.patientId.value !== tour.patientId) {
|
||||
// this.logger.warn('patient mismatch', {ticketPatientId: ticket.patientId.value, tourPatientId: tour.patientId})
|
||||
// throw new BadRequestException(`Patient mismatch between Ticket and Tour`)
|
||||
// }
|
||||
|
||||
try {
|
||||
ticket.assignTour(tour)
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error.message)
|
||||
}
|
||||
|
||||
await this.ticketRepo.save(ticket)
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
TICKET_REPOSITORY,
|
||||
TicketRepository,
|
||||
} from '../domain/ticket.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class CloseInvoicedTickets {
|
||||
private readonly logger = new Logger(CloseInvoicedTickets.name);
|
||||
|
||||
constructor(
|
||||
@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository,
|
||||
) {}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
this.logger.log(`archiving invoiced tickets`);
|
||||
|
||||
await this.ticketRepo.closeSettledTickets();
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
PATIENT_PROVIDER,
|
||||
PatientProvider,
|
||||
} from '../domain/ports/patient-provider.interface';
|
||||
import {
|
||||
TOUR_PROVIDER,
|
||||
TourProvider,
|
||||
} from '../domain/ports/tour-provider.interface';
|
||||
import { Ticket, TicketId } from '../domain/ticket.entity';
|
||||
import {
|
||||
TICKET_REPOSITORY,
|
||||
TicketRepository,
|
||||
} from '../domain/ticket.repository.interface';
|
||||
|
||||
export interface CreateTicketCommand {
|
||||
patientId: string;
|
||||
initialTourId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CreateTicketUseCase {
|
||||
private readonly logger = new Logger(CreateTicketUseCase.name);
|
||||
|
||||
constructor(
|
||||
@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository,
|
||||
@Inject(TOUR_PROVIDER) private readonly tourProvider: TourProvider,
|
||||
@Inject(PATIENT_PROVIDER) private readonly patientProvider: PatientProvider,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateTicketCommand): Promise<string> {
|
||||
const newTicketId = new TicketId(uuidv4());
|
||||
|
||||
const ticket = Ticket.create(newTicketId, command.patientId);
|
||||
|
||||
if (command.patientId) {
|
||||
const patient = await this.patientProvider.findPatientById(
|
||||
command.patientId,
|
||||
);
|
||||
if (patient) {
|
||||
ticket.updatePatientData(patient);
|
||||
}
|
||||
}
|
||||
|
||||
if (command.initialTourId) {
|
||||
const tour = await this.tourProvider.findTourById(command.initialTourId);
|
||||
if (!tour)
|
||||
throw new NotFoundException(
|
||||
`initial tour ${command.initialTourId} not found`,
|
||||
);
|
||||
|
||||
if (tour.patientId !== command.patientId) {
|
||||
throw new BadRequestException(`Patient mismatch`);
|
||||
}
|
||||
|
||||
ticket.assignTour(tour);
|
||||
}
|
||||
|
||||
await this.ticketRepo.save(ticket);
|
||||
|
||||
return newTicketId.value;
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { Inject, Injectable, Logger, NotFoundException, NotImplementedException } from "@nestjs/common";
|
||||
import { TicketId } from "../domain/ticket.entity";
|
||||
import { TICKET_REPOSITORY, TicketRepository } from "../domain/ticket.repository.interface";
|
||||
|
||||
@Injectable()
|
||||
export class MarkAsApprovedUseCase {
|
||||
private readonly logger = new Logger(MarkAsApprovedUseCase.name)
|
||||
|
||||
constructor(@Inject(TICKET_REPOSITORY) private ticketRepo: TicketRepository) {}
|
||||
|
||||
async execute(ticketIdRaw: string): Promise<void> {
|
||||
this.logger.log(`approving Ticket ${ticketIdRaw}`)
|
||||
const ticketId = new TicketId(ticketIdRaw)
|
||||
const ticket = await this.ticketRepo.findById(ticketId);
|
||||
|
||||
if(!ticket) {
|
||||
throw new NotFoundException(`Ticket ${ticketIdRaw} not found`)
|
||||
}
|
||||
|
||||
ticket.grantApproval()
|
||||
|
||||
await this.ticketRepo.save(ticket)
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
NotImplementedException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
TICKET_REPOSITORY,
|
||||
TicketRepository,
|
||||
} from '../domain/ticket.repository.interface';
|
||||
import { ApprovalRequirement } from '@avicenna/shared-dtos';
|
||||
import { TicketId } from '../domain/ticket.entity';
|
||||
|
||||
@Injectable()
|
||||
export class OverrideApprovalRequirementUseCase {
|
||||
private readonly logger = new Logger(OverrideApprovalRequirementUseCase.name);
|
||||
|
||||
constructor(
|
||||
@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository,
|
||||
) {}
|
||||
|
||||
async execute(ticketIdRaw: string) {
|
||||
this.logger.log(
|
||||
`overriding approval requirement for Ticket ${ticketIdRaw}`,
|
||||
);
|
||||
|
||||
const ticketId = new TicketId(ticketIdRaw);
|
||||
const ticket = await this.ticketRepo.findById(ticketId);
|
||||
|
||||
if (!ticket) {
|
||||
throw new NotFoundException(`Ticket ${ticketIdRaw} not found`);
|
||||
}
|
||||
|
||||
const newRequirement = this.determineNewRequirement(
|
||||
ticket.approval.requirement,
|
||||
);
|
||||
ticket.overrideApprovalRequirement(newRequirement);
|
||||
console.log(ticket.approval.requirement, newRequirement)
|
||||
|
||||
await this.ticketRepo.save(ticket)
|
||||
}
|
||||
|
||||
private determineNewRequirement(
|
||||
requirement: ApprovalRequirement,
|
||||
): ApprovalRequirement {
|
||||
if (
|
||||
requirement === ApprovalRequirement.UNKOWN ||
|
||||
requirement === ApprovalRequirement.NOT_REQUIRED
|
||||
) {
|
||||
return ApprovalRequirement.REQUIRED;
|
||||
}
|
||||
|
||||
return ApprovalRequirement.NOT_REQUIRED;
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { LegacyTourAdapter } from '../infrastructure/legacy-tour.adapter';
|
||||
import { TicketMatchingService } from '../domain/services/ticket-matching.service';
|
||||
import { AssignTourToTicketUseCase } from './assign-tour.usecase';
|
||||
import { TourCandidate } from '../domain/interfaces/tour-candidate.interface';
|
||||
import { CreateTicketUseCase } from './create-ticket.usecase';
|
||||
import { TOUR_PROVIDER } from '../domain/ports/tour-provider.interface';
|
||||
|
||||
@Injectable()
|
||||
export class ProcessUnticketedToursUseCase {
|
||||
private readonly logger = new Logger(ProcessUnticketedToursUseCase.name);
|
||||
|
||||
constructor(
|
||||
@Inject(TOUR_PROVIDER) private readonly tourAdapter: LegacyTourAdapter,
|
||||
private readonly matcher: TicketMatchingService,
|
||||
private createTicketUseCase: CreateTicketUseCase,
|
||||
private assignUseCase: AssignTourToTicketUseCase,
|
||||
) {}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
const orphanedTours = await this.tourAdapter.findUnticketedTours();
|
||||
this.logger.log(`processing ${orphanedTours.length} orphaned tours...`);
|
||||
|
||||
|
||||
for (const tour of orphanedTours) {
|
||||
try {
|
||||
await this.processSingleTour(tour);
|
||||
} catch (error) {
|
||||
this.logger.error(`error processing tour ${tour.id.value}:`, error);
|
||||
this.logger.error(error)
|
||||
}
|
||||
}
|
||||
this.logger.log(`count patientids`, {
|
||||
id_count: orphanedTours.filter(({patientId}) => !!patientId).length,
|
||||
tours: orphanedTours.length
|
||||
})
|
||||
}
|
||||
|
||||
private async processSingleTour(tour: TourCandidate): Promise<void> {
|
||||
const match = await this.matcher.findMatchingTicket(tour);
|
||||
|
||||
if (match) {
|
||||
await this.assignUseCase.execute(match.id.value, tour.id.value);
|
||||
this.logger.log(
|
||||
`Tour ${tour.id.value} assigned to existing Ticket ${match.id.value}`,
|
||||
);
|
||||
} else {
|
||||
await this.createTicketUseCase.execute({
|
||||
initialTourId: tour.id.value,
|
||||
patientId: tour.patientId,
|
||||
});
|
||||
this.logger.log(`created new Ticket for Tour ${tour.id.value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
TICKET_REPOSITORY,
|
||||
TicketRepository,
|
||||
} from '../domain/ticket.repository.interface';
|
||||
import { TicketId } from '../domain/ticket.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ReceivePhysicalTransportDocumentUseCase {
|
||||
private readonly logger = new Logger(
|
||||
ReceivePhysicalTransportDocumentUseCase.name,
|
||||
);
|
||||
|
||||
constructor(
|
||||
@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository,
|
||||
) {}
|
||||
|
||||
async execute(ticketIdRaw: string): Promise<void> {
|
||||
this.logger.log(
|
||||
`receiving physical transport document for Ticket ${ticketIdRaw}`,
|
||||
);
|
||||
|
||||
const ticket = await this.ticketRepo.findById(new TicketId(ticketIdRaw));
|
||||
if (!ticket) throw new NotFoundException(`Ticket ${ticketIdRaw} not found`);
|
||||
|
||||
ticket.receivePhysicalDocument();
|
||||
|
||||
await this.ticketRepo.save(ticket);
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
|
||||
import { DB } from 'src/core/database/types';
|
||||
import { Ticket } from '../domain/ticket.entity';
|
||||
import {
|
||||
TICKET_REPOSITORY,
|
||||
TicketRepository,
|
||||
} from '../domain/ticket.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class RefreshTicketDataUseCase {
|
||||
private readonly logger = new Logger(RefreshTicketDataUseCase.name);
|
||||
|
||||
constructor(
|
||||
@Inject(TICKET_REPOSITORY) private readonly repo: TicketRepository,
|
||||
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
|
||||
) {}
|
||||
|
||||
async execute() {
|
||||
this.logger.log(`starting Ticket refresh cycle ...`);
|
||||
|
||||
for await (const batch of this.repo.streamAllActive(50)) {
|
||||
await Promise.all(batch.map((ticket) => this.processTicket(ticket)));
|
||||
}
|
||||
}
|
||||
|
||||
private async processTicket(ticket: Ticket) {
|
||||
const savedStage = await this.getCurrentStage(ticket.id.value);
|
||||
|
||||
if (savedStage !== ticket.currentStage) {
|
||||
this.logger.log(
|
||||
`Ticket ${ticket.id.value} stage changed from ${savedStage} to ${ticket.currentStage}`,
|
||||
);
|
||||
await this.repo.save(ticket);
|
||||
}
|
||||
}
|
||||
|
||||
private async getCurrentStage(ticketId: string): Promise<string> {
|
||||
const currentStage = await this.db
|
||||
.selectFrom('Ticket')
|
||||
.select('Ticket.stage')
|
||||
.where('Ticket.id', '=', ticketId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!currentStage?.stage) {
|
||||
throw new NotFoundException(`Ticket ${ticketId} not found`);
|
||||
}
|
||||
|
||||
return currentStage.stage;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { TicketId } from '../domain/ticket.entity';
|
||||
import {
|
||||
TICKET_REPOSITORY,
|
||||
TicketRepository,
|
||||
} from '../domain/ticket.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class RegisterExternalApprovalRequestUseCase {
|
||||
private readonly logger = new Logger(
|
||||
RegisterExternalApprovalRequestUseCase.name,
|
||||
);
|
||||
|
||||
constructor(
|
||||
@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository,
|
||||
) {}
|
||||
|
||||
async execute(ticketIdRaw: string) {
|
||||
this.logger.log(
|
||||
`Registering external approval request for Ticket ${ticketIdRaw}`,
|
||||
);
|
||||
|
||||
const ticketId = new TicketId(ticketIdRaw);
|
||||
const ticket = await this.ticketRepo.findById(ticketId);
|
||||
|
||||
if (!ticket) {
|
||||
throw new NotFoundException(`Ticket ${ticketIdRaw} not found`);
|
||||
}
|
||||
|
||||
ticket.registerExternalRequest();
|
||||
|
||||
await this.ticketRepo.save(ticket);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { Injectable, Logger, Inject, NotFoundException } from "@nestjs/common";
|
||||
import { TicketId } from "../domain/ticket.entity";
|
||||
import { TICKET_REPOSITORY, TicketRepository } from "../domain/ticket.repository.interface";
|
||||
|
||||
@Injectable()
|
||||
export class RegisterSentEmailUseCase {
|
||||
private readonly logger = new Logger(RegisterSentEmailUseCase.name);
|
||||
|
||||
constructor(@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository) {}
|
||||
|
||||
async execute(ticketIdRaw: string, type: 'PRE' | 'FULL', messageId: string): Promise<void> {
|
||||
this.logger.log(`Registering Message-ID for Ticket ${ticketIdRaw} (${type}): ${messageId}`);
|
||||
|
||||
const ticket = await this.ticketRepo.findById(new TicketId(ticketIdRaw));
|
||||
if (!ticket) throw new NotFoundException(`Ticket ${ticketIdRaw} not found`);
|
||||
|
||||
ticket.registerSentEmailInfo(type, messageId);
|
||||
|
||||
await this.ticketRepo.save(ticket);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException
|
||||
} from '@nestjs/common';
|
||||
import { TicketId } from '../domain/ticket.entity';
|
||||
import { TICKET_REPOSITORY, TicketRepository } from '../domain/ticket.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class RequestTicketApprovalUseCase {
|
||||
private readonly logger = new Logger(RequestTicketApprovalUseCase.name);
|
||||
|
||||
constructor(@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository) {}
|
||||
|
||||
async execute(ticketIdRaw: string, recipient: string) {
|
||||
this.logger.log(`requesting ticket approval`);
|
||||
const ticketId = new TicketId(ticketIdRaw);
|
||||
const ticket = await this.ticketRepo.findById(ticketId);
|
||||
|
||||
if (!ticket) {
|
||||
throw new NotFoundException(`Ticket ${ticketIdRaw} not found`);
|
||||
}
|
||||
|
||||
ticket.requestApproval(recipient);
|
||||
|
||||
await this.ticketRepo.save(ticket);
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { TicketId } from "../domain/ticket.entity";
|
||||
import { TICKET_REPOSITORY, TicketRepository } from "../domain/ticket.repository.interface";
|
||||
|
||||
@Injectable()
|
||||
export class SendApprovalReminderUseCase {
|
||||
private readonly logger = new Logger(SendApprovalReminderUseCase.name);
|
||||
|
||||
constructor(
|
||||
@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository,
|
||||
) {}
|
||||
|
||||
async execute(ticketIdRaw: string) {
|
||||
this.logger.log(`sending approval reminder for ticket ${ticketIdRaw}`);
|
||||
|
||||
const ticketId = new TicketId(ticketIdRaw);
|
||||
const ticket = await this.ticketRepo.findById(ticketId);
|
||||
|
||||
if (!ticket) {
|
||||
throw new NotFoundException(`Ticket ${ticketIdRaw} not found`);
|
||||
}
|
||||
|
||||
ticket.sendApprovalReminder();
|
||||
|
||||
await this.ticketRepo.save(ticket);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { TicketId } from '../domain/ticket.entity';
|
||||
import {
|
||||
TICKET_REPOSITORY,
|
||||
TicketRepository,
|
||||
} from '../domain/ticket.repository.interface';
|
||||
import { TransportDocument } from '../domain/value-objects/transport-document.vo';
|
||||
|
||||
@Injectable()
|
||||
export class UploadTransportDocumentUseCase {
|
||||
private readonly logger = new Logger(UploadTransportDocumentUseCase.name);
|
||||
|
||||
constructor(
|
||||
@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository,
|
||||
) {}
|
||||
|
||||
async execute(ticketIdRaw: string, file: Express.Multer.File) {
|
||||
this.logger.log(
|
||||
`adding uploaded TransportDocument to Ticket ${ticketIdRaw}`,
|
||||
);
|
||||
const ticketId = new TicketId(ticketIdRaw);
|
||||
const ticket = await this.ticketRepo.findById(ticketId);
|
||||
|
||||
if (!ticket) {
|
||||
throw new NotFoundException(`Ticket ${ticketIdRaw} not found`);
|
||||
}
|
||||
|
||||
const doc = new TransportDocument(
|
||||
file.path,
|
||||
file.originalname,
|
||||
file.mimetype,
|
||||
file.size,
|
||||
);
|
||||
|
||||
ticket.registerDigitalDocumentUpload(doc);
|
||||
|
||||
await this.ticketRepo.save(ticket);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export abstract class AggregateRoot {
|
||||
private _domainEvents: any[] = [];
|
||||
|
||||
protected addDomainEvent(event: any): void {
|
||||
this._domainEvents.push(event);
|
||||
}
|
||||
|
||||
public get domainEvents(): any[] {
|
||||
return [...this._domainEvents];
|
||||
}
|
||||
|
||||
public clearDomainEvents(): void {
|
||||
this._domainEvents = [];
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
export class PatientId {
|
||||
constructor(public readonly value: string) {}
|
||||
}
|
||||
|
||||
export interface PatientCandidate {
|
||||
id: PatientId;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
birthday: Date | null;
|
||||
healthInsurance: string | null;
|
||||
insuranceNo: string | null;
|
||||
careDegree: string | null;
|
||||
street: string | null,
|
||||
city: string | null,
|
||||
zip: string | null,
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { StringifyOptions } from 'querystring';
|
||||
import { TicketId } from '../ticket.entity';
|
||||
|
||||
export class TourId {
|
||||
constructor(public readonly value: string) {}
|
||||
}
|
||||
|
||||
export interface TourCandidate {
|
||||
id: TourId;
|
||||
operationId: string;
|
||||
category: string;
|
||||
ordinanceType: string;
|
||||
transportType: string;
|
||||
patientId: string;
|
||||
patientName: string;
|
||||
patientSurname: string;
|
||||
patientStreet: string;
|
||||
patientCity: string;
|
||||
patientZip: string;
|
||||
healthInsurance: string;
|
||||
insuranceNo: string;
|
||||
hasInfection: boolean;
|
||||
infectionName: string;
|
||||
seriesEndDate: Date | null;
|
||||
|
||||
direction: string;
|
||||
occupiedKm: number;
|
||||
targetStreet: string;
|
||||
targetCity: string;
|
||||
targetZip: string;
|
||||
startStreet: string;
|
||||
startCity: string;
|
||||
startZip: string;
|
||||
|
||||
startDate: Date;
|
||||
rangeEndDate: Date;
|
||||
|
||||
ticketId: TicketId;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { PatientCandidate } from '../interfaces/patient-candidate.interface';
|
||||
|
||||
export const PATIENT_PROVIDER = 'PatientProvider';
|
||||
|
||||
export interface PatientProvider {
|
||||
findPatientById(id: string): Promise<PatientCandidate | null>;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { TourCandidate } from "../interfaces/tour-candidate.interface";
|
||||
|
||||
|
||||
export const TOUR_PROVIDER = 'TourProvider';
|
||||
|
||||
export interface TourProvider {
|
||||
findUnticketedTours(): Promise<TourCandidate[]>;
|
||||
findTourById(id: string): Promise<TourCandidate | null>
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { TICKET_REPOSITORY, TicketRepository } from "../ticket.repository.interface";
|
||||
import { TourCandidate } from "../interfaces/tour-candidate.interface";
|
||||
import { Ticket } from "../ticket.entity";
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class TicketMatchingService {
|
||||
constructor(@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository) {}
|
||||
|
||||
async findMatchingTicket(tour: TourCandidate): Promise<Ticket | null> {
|
||||
const returnTripTicket = await this.ticketRepo.findTicketForTripInOppositeDirection(tour);
|
||||
if(returnTripTicket) return returnTripTicket;
|
||||
|
||||
const seriesTicket = await this.ticketRepo.findTicketForSeries(tour);
|
||||
if (seriesTicket) return seriesTicket;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
import 'reflect-metadata';
|
||||
import {
|
||||
ApprovalRequestStatus,
|
||||
ApprovalRequirement,
|
||||
TicketStage,
|
||||
} from '@avicenna/shared-dtos';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Ticket, TicketId } from '../ticket.entity';
|
||||
import { TicketPatientData } from '../value-objects/ticket-patient-data.vo';
|
||||
import { TicketTourData } from '../value-objects/ticket-tour-data.vo';
|
||||
import { TransportDocument } from '../value-objects/transport-document.vo';
|
||||
import { PatientId } from '../interfaces/patient-candidate.interface';
|
||||
import { TourId } from '../interfaces/tour-candidate.interface';
|
||||
|
||||
export class TicketBuilder {
|
||||
private props: any;
|
||||
|
||||
constructor() {
|
||||
// STANDARD: Ein valides Ticket, das "READY" sein sollte.
|
||||
// Wir setzen hier Werte, die validation checks bestehen.
|
||||
this.props = {
|
||||
id: uuidv4(),
|
||||
stage: TicketStage.DRAFT,
|
||||
patient: this.createValidPatientData(),
|
||||
approval: {
|
||||
req: ApprovalRequirement.NOT_REQUIRED,
|
||||
status: ApprovalRequestStatus.NOT_STARTED,
|
||||
requestedAt: null,
|
||||
lastInteractionAt: null,
|
||||
reminderCount: 0,
|
||||
recipient: null,
|
||||
preInquiryMessageId: null,
|
||||
fullRequestMessageId: null,
|
||||
isOverriden: false,
|
||||
},
|
||||
hasPhysicalTD: false,
|
||||
hasDigitalTD: false,
|
||||
tours: [this.createValidTourData()], // Mindestens eine Tour für Validität
|
||||
transportDocument: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// --- MODIFIERS ---
|
||||
|
||||
public withId(id: string): this {
|
||||
this.props.id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withStage(stage: TicketStage): this {
|
||||
this.props.stage = stage;
|
||||
return this;
|
||||
}
|
||||
|
||||
// --- PATIENT ---
|
||||
|
||||
public withPatient(data: Partial<TicketPatientData>): this {
|
||||
// Merge mit validen Daten, um nicht alles angeben zu müssen
|
||||
const base = this.createValidPatientData();
|
||||
// Achtung: Da TicketPatientData keine Setter hat, bauen wir ein Objekt,
|
||||
// das der Struktur entspricht oder nutzen Reflection/Mocking.
|
||||
// Sauberer Weg: Ein neues TicketPatientData instanziieren.
|
||||
|
||||
this.props.patient = new TicketPatientData(
|
||||
// @ts-ignore - Zugriff auf private props simulation
|
||||
data['sourceId'] || base['sourceId'],
|
||||
data['sourceType'] || base['sourceType'],
|
||||
data.firstName !== undefined ? data.firstName : base.firstName,
|
||||
data.lastName !== undefined ? data.lastName : base.lastName,
|
||||
data.birthday || base.birthday,
|
||||
data.healthInsurance !== undefined
|
||||
? data.healthInsurance
|
||||
: base.healthInsurance,
|
||||
data.insuranceNo || base.insuranceNo,
|
||||
data.street || base.street,
|
||||
data.city || base.city,
|
||||
data.zip || base.zip,
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
public withMissingPatientData(): this {
|
||||
// Erzeugt invalides PatientData VO
|
||||
this.props.patient = new TicketPatientData(
|
||||
new PatientId('p-1'),
|
||||
'PATIENT',
|
||||
null, // Invalid
|
||||
null, // Invalid
|
||||
new Date(),
|
||||
null, // Invalid
|
||||
null,
|
||||
'Musterstr.',
|
||||
'Musterstadt',
|
||||
'12345',
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
public withoutPatient(): this {
|
||||
this.props.patient = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
// --- TOURS ---
|
||||
|
||||
public withTours(tours: TicketTourData[]): this {
|
||||
this.props.tours = tours;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withInvalidTour(): this {
|
||||
// Erzeugt eine Tour mit fehlenden Pflichtfeldern
|
||||
const invalidTour = new TicketTourData(
|
||||
new TourId('t-invalid'),
|
||||
'op-1',
|
||||
'', // Category missing
|
||||
'', // Ordinance missing
|
||||
'', // Transport missing
|
||||
'p-1',
|
||||
'Max',
|
||||
'Mustermann',
|
||||
'Str',
|
||||
'City',
|
||||
'12345',
|
||||
'AOK',
|
||||
'123',
|
||||
'Hinfahrt',
|
||||
10,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
'',
|
||||
'',
|
||||
'', // Target missing
|
||||
'',
|
||||
'',
|
||||
'', // Start missing
|
||||
new Date(),
|
||||
);
|
||||
this.props.tours = [invalidTour];
|
||||
return this;
|
||||
}
|
||||
|
||||
public withoutTours(): this {
|
||||
this.props.tours = [];
|
||||
return this;
|
||||
}
|
||||
|
||||
// --- APPROVAL ---
|
||||
|
||||
public withApprovalRequirement(req: ApprovalRequirement): this {
|
||||
this.props.approval.req = req;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withApprovalStatus(status: ApprovalRequestStatus): this {
|
||||
this.props.approval.status = status;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withApprovalOverride(isOverriden: boolean): this {
|
||||
this.props.approval.isOverriden = isOverriden;
|
||||
return this;
|
||||
}
|
||||
|
||||
// --- DOCUMENTS ---
|
||||
|
||||
public withDigitalTransportDocument(): this {
|
||||
this.props.hasDigitalTD = true;
|
||||
this.props.transportDocument = new TransportDocument(
|
||||
'path/to/file',
|
||||
'file.pdf',
|
||||
'application/pdf',
|
||||
1024,
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
public withPhysicalTransportDocument(): this {
|
||||
this.props.hasPhysicalTD = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
// --- BUILD ---
|
||||
|
||||
public build(): Ticket {
|
||||
// Wir nutzen fromStorage, um den internen State exakt so zu setzen, wie wir wollen.
|
||||
// WICHTIG: fromStorage ruft am Ende `reassessState()` auf.
|
||||
// Das bedeutet, der Stage, den wir oben im Konstruktor setzen, wird sofort
|
||||
// durch die echte Logik überschrieben. Das ist genau das, was wir testen wollen!
|
||||
return Ticket.fromStorage(this.props);
|
||||
}
|
||||
|
||||
// --- HELPERS ---
|
||||
|
||||
private createValidPatientData(): TicketPatientData {
|
||||
return new TicketPatientData(
|
||||
new PatientId('p-1'),
|
||||
'PATIENT',
|
||||
'Max',
|
||||
'Mustermann',
|
||||
new Date('1990-01-01'),
|
||||
'AOK',
|
||||
'V123456',
|
||||
'Musterstraße 1',
|
||||
'Musterstadt',
|
||||
'12345',
|
||||
);
|
||||
}
|
||||
|
||||
private createValidTourData(): TicketTourData {
|
||||
return new TicketTourData(
|
||||
new TourId('t-1'),
|
||||
'OP-123',
|
||||
'Dialyse', // Category
|
||||
'Verordnung', // Ordinance
|
||||
'KTW', // TransportType
|
||||
'p-1',
|
||||
'Max',
|
||||
'Mustermann',
|
||||
'Str 1',
|
||||
'City',
|
||||
'12345',
|
||||
'AOK',
|
||||
'123',
|
||||
'Hinfahrt',
|
||||
15, // km
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
'Zielstr 1',
|
||||
'Zielstadt',
|
||||
'54321', // Target
|
||||
'Startstr 1',
|
||||
'Startstadt',
|
||||
'12345', // Start
|
||||
new Date(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import 'reflect-metadata';
|
||||
import {
|
||||
TicketStage,
|
||||
ApprovalRequirement,
|
||||
ApprovalRequestStatus,
|
||||
} from '@avicenna/shared-dtos';
|
||||
import { TicketBuilder } from './testing/ticket.builder';
|
||||
|
||||
describe('Ticket Entity - Validation & Lifecycle', () => {
|
||||
describe('Stage: DATA_INCOMPLETE', () => {
|
||||
it('should be DATA_INCOMPLETE if patient data is missing', () => {
|
||||
const ticket = new TicketBuilder()
|
||||
.withMissingPatientData() // Vorname, Nachname, Kasse fehlen
|
||||
.build();
|
||||
|
||||
expect(ticket.currentStage).toBe(TicketStage.DATA_INCOMPLETE);
|
||||
expect(ticket.validationErrors.length).toBeGreaterThan(0);
|
||||
expect(ticket.validationErrors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ field: 'firstName' }),
|
||||
expect.objectContaining({ field: 'lastName' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should be DATA_INCOMPLETE if tour data is invalid (e.g. missing addresses)', () => {
|
||||
const ticket = new TicketBuilder().withInvalidTour().build();
|
||||
|
||||
expect(ticket.currentStage).toBe(TicketStage.DATA_INCOMPLETE);
|
||||
expect(ticket.validationErrors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ field: 'startAddress' }),
|
||||
expect.objectContaining({ field: 'category' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stage: DRAFT', () => {
|
||||
it('should be DRAFT if no tours are assigned', () => {
|
||||
const ticket = new TicketBuilder().withoutTours().build();
|
||||
|
||||
expect(ticket.currentStage).toBe(TicketStage.DRAFT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stage: ASSESSMENT_PENDING (Approval Logic)', () => {
|
||||
it('should be ASSESSMENT_PENDING if approval is REQUIRED but not requested yet', () => {
|
||||
const ticket = new TicketBuilder()
|
||||
.withApprovalRequirement(ApprovalRequirement.REQUIRED)
|
||||
.withApprovalStatus(ApprovalRequestStatus.NOT_STARTED)
|
||||
.build();
|
||||
|
||||
expect(ticket.validationErrors).toHaveLength(0); // Daten sind valide
|
||||
expect(ticket.currentStage).toBe(TicketStage.ASSESSMENT_PENDING);
|
||||
});
|
||||
|
||||
it('should transition to APPROVAL_IN_PROGRESS after full request sent', () => {
|
||||
// Wir simulieren hier den State NACHDEM angefragt wurde
|
||||
const ticket = new TicketBuilder()
|
||||
.withApprovalRequirement(ApprovalRequirement.REQUIRED)
|
||||
.withApprovalStatus(ApprovalRequestStatus.FULL_REQUEST_SENT)
|
||||
.build();
|
||||
|
||||
expect(ticket.currentStage).toBe(TicketStage.APPROVAL_IN_PROGRESS);
|
||||
});
|
||||
|
||||
it('should NOT be pending if approval is NOT_REQUIRED', () => {
|
||||
const ticket = new TicketBuilder()
|
||||
.withApprovalRequirement(ApprovalRequirement.NOT_REQUIRED)
|
||||
.build();
|
||||
|
||||
// Sollte weiter zum nächsten Check (Documents) gehen
|
||||
// Da im Builder default keine Docs da sind -> DOCUMENTS_PENDING
|
||||
expect(ticket.currentStage).toBe(TicketStage.DOCUMENTS_PENDING);
|
||||
});
|
||||
|
||||
it('should NOT be pending if approval is GRANTED', () => {
|
||||
const ticket = new TicketBuilder()
|
||||
.withApprovalRequirement(ApprovalRequirement.REQUIRED)
|
||||
.withApprovalStatus(ApprovalRequestStatus.GRANTED)
|
||||
.build();
|
||||
|
||||
// Genehmigt -> Weiter zu Documents
|
||||
expect(ticket.currentStage).toBe(TicketStage.DOCUMENTS_PENDING);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stage: DOCUMENTS_PENDING vs READY', () => {
|
||||
it('should be DOCUMENTS_PENDING if valid, approval clear, but no doc uploaded', () => {
|
||||
const ticket = new TicketBuilder()
|
||||
.withApprovalRequirement(ApprovalRequirement.NOT_REQUIRED)
|
||||
// Kein Dokument gesetzt
|
||||
.build();
|
||||
|
||||
expect(ticket.currentStage).toBe(TicketStage.DOCUMENTS_PENDING);
|
||||
});
|
||||
|
||||
it('should be READY if digital document is present', () => {
|
||||
const ticket = new TicketBuilder()
|
||||
.withApprovalRequirement(ApprovalRequirement.NOT_REQUIRED)
|
||||
.withDigitalTransportDocument()
|
||||
.build();
|
||||
|
||||
expect(ticket.currentStage).toBe(TicketStage.READY);
|
||||
});
|
||||
|
||||
it('should be READY if physical document is present', () => {
|
||||
const ticket = new TicketBuilder()
|
||||
.withApprovalRequirement(ApprovalRequirement.NOT_REQUIRED)
|
||||
.withPhysicalTransportDocument()
|
||||
.build();
|
||||
|
||||
expect(ticket.currentStage).toBe(TicketStage.READY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Approval Override Logic', () => {
|
||||
it('should respect manual override to NOT_REQUIRED even if logic says REQUIRED', () => {
|
||||
// Zuerst ein Ticket, das theoretisch Genehmigung bräuchte (wird durch reassessState ermittelt)
|
||||
// Aber: Wir setzen isOverridden = true
|
||||
const ticket = new TicketBuilder()
|
||||
.withApprovalRequirement(ApprovalRequirement.NOT_REQUIRED) // Manuell gesetzt
|
||||
.withApprovalOverride(true)
|
||||
.build();
|
||||
|
||||
// Da Override true ist, darf determineApprovalRequirement() im Entity NICHT laufen
|
||||
// und den Wert überschreiben.
|
||||
expect(ticket.approval.requirement).toBe(
|
||||
ApprovalRequirement.NOT_REQUIRED,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,640 @@
|
||||
import {
|
||||
ApprovalRequestStatus,
|
||||
ApprovalRequirement,
|
||||
TicketStage,
|
||||
ValidationError,
|
||||
} from '@avicenna/shared-dtos';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { AggregateRoot } from './aggregate-root';
|
||||
import {
|
||||
PatientCandidate,
|
||||
PatientId,
|
||||
} from './interfaces/patient-candidate.interface';
|
||||
import { TourCandidate } from './interfaces/tour-candidate.interface';
|
||||
import {
|
||||
TicketApprovalGrantedEvent,
|
||||
TicketApprovalReminderSentEvent,
|
||||
TicketApprovalRequestedEvent,
|
||||
TicketApprovalRequirementOverriddenEvent,
|
||||
TicketCreatedEvent,
|
||||
TicketPhysicalTransportDocumentReceivedEvent,
|
||||
TicketTransportDocumentUploadedEvent,
|
||||
TourAssignedToTicketEvent,
|
||||
} from './ticket.events';
|
||||
import { TicketPatientData } from './value-objects/ticket-patient-data.vo';
|
||||
import { TicketTourData } from './value-objects/ticket-tour-data.vo';
|
||||
import { TransportDocument } from './value-objects/transport-document.vo';
|
||||
|
||||
class DomainError {
|
||||
constructor(public readonly message: string) {}
|
||||
}
|
||||
|
||||
export class ApprovalWorkflow {
|
||||
constructor(
|
||||
public readonly requirement: ApprovalRequirement,
|
||||
public readonly status: ApprovalRequestStatus,
|
||||
public readonly requestedAt: Date | null,
|
||||
public readonly lastInteractionAt: Date | null,
|
||||
public readonly reminderCount: number,
|
||||
public readonly recipientEmail: string | null,
|
||||
public readonly preInquiryMessageId: string | null = null,
|
||||
public readonly fullRequestMessageId: string | null = null,
|
||||
public readonly isOverridden: boolean = false,
|
||||
) {}
|
||||
|
||||
static initial(): ApprovalWorkflow {
|
||||
return new ApprovalWorkflow(
|
||||
ApprovalRequirement.UNKOWN,
|
||||
ApprovalRequestStatus.NOT_STARTED,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
isOverdue(): boolean {
|
||||
if (!this.lastInteractionAt) return false;
|
||||
|
||||
return differenceInDays(new Date(), this.lastInteractionAt) >= 3;
|
||||
}
|
||||
|
||||
hasRemindersLeft(): boolean {
|
||||
return this.reminderCount < 3;
|
||||
}
|
||||
|
||||
setRequirement(req: ApprovalRequirement): ApprovalWorkflow {
|
||||
if (this.status === ApprovalRequestStatus.GRANTED) return this;
|
||||
|
||||
return new ApprovalWorkflow(
|
||||
req,
|
||||
this.status,
|
||||
this.requestedAt,
|
||||
new Date(),
|
||||
this.reminderCount,
|
||||
this.recipientEmail,
|
||||
this.preInquiryMessageId,
|
||||
this.fullRequestMessageId,
|
||||
this.isOverridden,
|
||||
);
|
||||
}
|
||||
|
||||
overrideRequirement(newReq: ApprovalRequirement): ApprovalWorkflow {
|
||||
return new ApprovalWorkflow(
|
||||
newReq,
|
||||
this.status,
|
||||
this.requestedAt,
|
||||
this.lastInteractionAt,
|
||||
this.reminderCount,
|
||||
this.recipientEmail,
|
||||
this.preInquiryMessageId,
|
||||
this.fullRequestMessageId,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
markAsRequested(
|
||||
hasDigitalDoc: boolean,
|
||||
recipient: string | undefined,
|
||||
isExternal = false,
|
||||
): ApprovalWorkflow {
|
||||
if (this.status === ApprovalRequestStatus.FULL_REQUEST_SENT) return this;
|
||||
if (this.status === ApprovalRequestStatus.GRANTED) return this;
|
||||
|
||||
let newStatus: ApprovalRequestStatus;
|
||||
if (isExternal) {
|
||||
newStatus = ApprovalRequestStatus.EXTERNAL_REQUEST;
|
||||
} else if (hasDigitalDoc) {
|
||||
newStatus = ApprovalRequestStatus.FULL_REQUEST_SENT;
|
||||
} else {
|
||||
newStatus = ApprovalRequestStatus.PRE_INQUIRY_SENT;
|
||||
}
|
||||
|
||||
return new ApprovalWorkflow(
|
||||
this.requirement,
|
||||
newStatus,
|
||||
this.requestedAt ?? new Date(),
|
||||
new Date(),
|
||||
this.reminderCount,
|
||||
recipient,
|
||||
this.preInquiryMessageId,
|
||||
this.fullRequestMessageId,
|
||||
this.isOverridden,
|
||||
);
|
||||
}
|
||||
|
||||
grantApproval(): ApprovalWorkflow {
|
||||
return new ApprovalWorkflow(
|
||||
this.requirement,
|
||||
ApprovalRequestStatus.GRANTED,
|
||||
this.requestedAt,
|
||||
new Date(),
|
||||
this.reminderCount,
|
||||
this.recipientEmail,
|
||||
this.preInquiryMessageId,
|
||||
this.fullRequestMessageId,
|
||||
this.isOverridden,
|
||||
);
|
||||
}
|
||||
|
||||
recordReminder(): ApprovalWorkflow {
|
||||
return new ApprovalWorkflow(
|
||||
this.requirement,
|
||||
this.status,
|
||||
this.requestedAt,
|
||||
new Date(),
|
||||
this.reminderCount + 1,
|
||||
this.recipientEmail,
|
||||
this.preInquiryMessageId,
|
||||
this.fullRequestMessageId,
|
||||
this.isOverridden,
|
||||
);
|
||||
}
|
||||
|
||||
registerSentEmail(type: 'PRE' | 'FULL', messageId: string): ApprovalWorkflow {
|
||||
return new ApprovalWorkflow(
|
||||
this.requirement,
|
||||
this.status,
|
||||
this.requestedAt,
|
||||
this.lastInteractionAt,
|
||||
this.reminderCount,
|
||||
this.recipientEmail,
|
||||
type === 'PRE' ? messageId : this.preInquiryMessageId,
|
||||
type === 'FULL' ? messageId : this.fullRequestMessageId,
|
||||
this.isOverridden,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class TicketId {
|
||||
constructor(public readonly value: string) {}
|
||||
}
|
||||
|
||||
export class Ticket extends AggregateRoot {
|
||||
private _stage: TicketStage;
|
||||
private _tours: Map<string, TicketTourData> = new Map();
|
||||
private _patientId: PatientId | null;
|
||||
private _patientData: TicketPatientData | null = null;
|
||||
private _validationErrors: ValidationError[] = [];
|
||||
private _approval: ApprovalWorkflow;
|
||||
private _hasDigitalTransportDocument: boolean = false;
|
||||
private _hasPhysicalTransportDocument: boolean = false;
|
||||
private _createdAt: Date;
|
||||
private _updatedAt: Date;
|
||||
private _transportDocument: TransportDocument | null = null;
|
||||
|
||||
get patientId(): PatientId | null {
|
||||
return this._patientId;
|
||||
}
|
||||
get transportDocument(): TransportDocument | null {
|
||||
return this._transportDocument;
|
||||
}
|
||||
get hasDigitalTransportDocument(): boolean {
|
||||
return this._hasDigitalTransportDocument;
|
||||
}
|
||||
get hasPhysicalTransportDocument(): boolean {
|
||||
return this._hasPhysicalTransportDocument;
|
||||
}
|
||||
get validationErrors(): ValidationError[] {
|
||||
return [...this._validationErrors];
|
||||
}
|
||||
get assignedTourIds(): string[] {
|
||||
return [...this._tours.keys()];
|
||||
}
|
||||
get currentStage(): TicketStage {
|
||||
return this._stage;
|
||||
}
|
||||
get createdAt(): Date {
|
||||
return this._createdAt;
|
||||
}
|
||||
get updatedAt(): Date {
|
||||
return this._updatedAt;
|
||||
}
|
||||
get approval(): ApprovalWorkflow {
|
||||
return new ApprovalWorkflow(
|
||||
this._approval.requirement,
|
||||
this._approval.status,
|
||||
this._approval.requestedAt,
|
||||
this._approval.lastInteractionAt,
|
||||
this._approval.reminderCount,
|
||||
this._approval.recipientEmail,
|
||||
this._approval.preInquiryMessageId,
|
||||
this._approval.fullRequestMessageId,
|
||||
this._approval.isOverridden,
|
||||
);
|
||||
}
|
||||
get patient(): TicketPatientData {
|
||||
return this._patientData;
|
||||
}
|
||||
get tours(): TicketTourData[] {
|
||||
return Array.from(this._tours.values()).sort(
|
||||
(a, b) => b.startDate.getTime() - a.startDate.getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
public readonly id: TicketId,
|
||||
patientId: PatientId,
|
||||
) {
|
||||
super();
|
||||
this._stage = TicketStage.DRAFT;
|
||||
this._approval = ApprovalWorkflow.initial();
|
||||
this._patientId = patientId;
|
||||
|
||||
this._createdAt = new Date();
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
public updatePatientData(masterData?: PatientCandidate): void {
|
||||
if (masterData) {
|
||||
// if (masterData.id.value !== this.patientId.value)
|
||||
// throw new DomainError('Patient ID mismatch');
|
||||
this._patientData = TicketPatientData.fromMasterData(masterData);
|
||||
this._patientId = masterData.id;
|
||||
} else if (this._tours.size > 0) {
|
||||
const firstTour = this._tours.values().next().value;
|
||||
this._patientData = TicketPatientData.fromTourSnapshot(firstTour);
|
||||
}
|
||||
|
||||
this.reassessState();
|
||||
}
|
||||
|
||||
public assignTour(tour: TourCandidate): void {
|
||||
if (this._tours.has(tour.id.value)) {
|
||||
return;
|
||||
}
|
||||
this.ensureTicketIsOpen();
|
||||
// this.ensurePatientMatches(tour.patientId);
|
||||
|
||||
const tourData = TicketTourData.fromCandidate(tour);
|
||||
|
||||
if (!this._tours.has(tourData.id.value)) {
|
||||
this._tours.set(tourData.id.value, tourData);
|
||||
|
||||
this.addDomainEvent(
|
||||
new TourAssignedToTicketEvent(this.id.value, tour.id.value),
|
||||
);
|
||||
} else {
|
||||
this._tours.set(tourData.id.value, tourData);
|
||||
}
|
||||
|
||||
this.reassessState();
|
||||
}
|
||||
|
||||
public registerDigitalDocumentUpload(doc: TransportDocument): void {
|
||||
this._transportDocument = doc;
|
||||
|
||||
if (!this._hasDigitalTransportDocument) {
|
||||
this._hasDigitalTransportDocument = true;
|
||||
this.addDomainEvent(
|
||||
new TicketTransportDocumentUploadedEvent(this.id.value),
|
||||
);
|
||||
this.reassessState();
|
||||
}
|
||||
}
|
||||
|
||||
public registerSentEmailInfo(type: 'PRE' | 'FULL', messageId: string): void {
|
||||
this._approval = this._approval.registerSentEmail(type, messageId);
|
||||
|
||||
this.reassessState();
|
||||
}
|
||||
|
||||
public receivePhysicalDocument(): void {
|
||||
const oldState = this._hasPhysicalTransportDocument;
|
||||
if (!this._hasPhysicalTransportDocument) {
|
||||
this._hasPhysicalTransportDocument = true;
|
||||
|
||||
if (oldState != true) {
|
||||
this.addDomainEvent(
|
||||
new TicketPhysicalTransportDocumentReceivedEvent(this.id.value),
|
||||
);
|
||||
}
|
||||
this.reassessState();
|
||||
}
|
||||
}
|
||||
|
||||
public overrideApprovalRequirement(newReq: ApprovalRequirement): void {
|
||||
this.ensureTicketIsOpen();
|
||||
|
||||
this._approval = this._approval.overrideRequirement(newReq);
|
||||
|
||||
this.addDomainEvent(
|
||||
new TicketApprovalRequirementOverriddenEvent(this.id.value, newReq),
|
||||
);
|
||||
|
||||
this.reassessState();
|
||||
}
|
||||
|
||||
public requestApproval(recipient: string): void {
|
||||
if (this._approval.requirement !== ApprovalRequirement.REQUIRED) {
|
||||
throw new DomainError(
|
||||
`Genehmigung wird nicht benötigt, Anfrage abgebrochen`,
|
||||
);
|
||||
}
|
||||
|
||||
const prevStatus = this._approval.status;
|
||||
|
||||
this._approval = this._approval.markAsRequested(
|
||||
this._hasDigitalTransportDocument,
|
||||
recipient,
|
||||
);
|
||||
|
||||
if (this._approval.status !== prevStatus) {
|
||||
this.addDomainEvent(
|
||||
new TicketApprovalRequestedEvent(
|
||||
this.id.value,
|
||||
this._approval.status,
|
||||
recipient,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
this.reassessState();
|
||||
}
|
||||
|
||||
public registerExternalRequest(): void {
|
||||
if (this._approval.requirement !== ApprovalRequirement.REQUIRED) {
|
||||
throw new DomainError(
|
||||
`Genehmigung wird nicht benötigt, Anfrage abgebrochen`,
|
||||
);
|
||||
}
|
||||
|
||||
const prevStatus = this._approval.status;
|
||||
|
||||
this._approval = this._approval.markAsRequested(
|
||||
this._hasDigitalTransportDocument,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
if (this._approval.status !== prevStatus) {
|
||||
this.addDomainEvent(
|
||||
new TicketApprovalRequestedEvent(
|
||||
this.id.value,
|
||||
this._approval.status,
|
||||
this._approval.recipientEmail,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
this.reassessState();
|
||||
}
|
||||
|
||||
public sendApprovalReminder(): void {
|
||||
this.ensureTicketIsOpen();
|
||||
|
||||
if (
|
||||
this._approval.status !== ApprovalRequestStatus.PRE_INQUIRY_SENT &&
|
||||
this._approval.status !== ApprovalRequestStatus.FULL_REQUEST_SENT
|
||||
) {
|
||||
throw new DomainError(
|
||||
'cannot send reminder: Approval is not in a requested state.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!this._approval.hasRemindersLeft()) {
|
||||
throw new DomainError(
|
||||
'cannot send reminder: Max reminder count reached.',
|
||||
);
|
||||
}
|
||||
|
||||
this._approval = this._approval.recordReminder();
|
||||
|
||||
this.addDomainEvent(
|
||||
new TicketApprovalReminderSentEvent(
|
||||
this.id.value,
|
||||
this._approval.reminderCount,
|
||||
this._approval.recipientEmail,
|
||||
),
|
||||
);
|
||||
|
||||
this.reassessState();
|
||||
}
|
||||
|
||||
public grantApproval(): void {
|
||||
this.ensureTicketIsOpen();
|
||||
if (this._approval.status === ApprovalRequestStatus.GRANTED) return;
|
||||
|
||||
this._approval = this._approval.grantApproval();
|
||||
|
||||
this.addDomainEvent(new TicketApprovalGrantedEvent(this.id.value));
|
||||
|
||||
this.reassessState();
|
||||
}
|
||||
|
||||
public toPrimitives() {
|
||||
return {
|
||||
id: this.id.value,
|
||||
patientId: this._patientId.value,
|
||||
stage: this._stage,
|
||||
approvalRequirement: this._approval.requirement,
|
||||
approvalStatus: this._approval.status,
|
||||
approvalRequestedAt: this._approval.requestedAt,
|
||||
approvalLastInteractionAt: this._approval.lastInteractionAt,
|
||||
approvalReminderCount: this._approval.reminderCount,
|
||||
approvalRecipient: this._approval.recipientEmail,
|
||||
approvalPreInquiryMessageId: this._approval.preInquiryMessageId,
|
||||
approvalFullRequestMessageId: this._approval.fullRequestMessageId,
|
||||
approvalIsOverriden: this._approval.isOverridden,
|
||||
hasDigitalTransportDocument: this._hasDigitalTransportDocument,
|
||||
hasPhysicalTransportDocument: this._hasPhysicalTransportDocument,
|
||||
|
||||
transportDocument: this._transportDocument
|
||||
? {
|
||||
...this.transportDocument,
|
||||
}
|
||||
: null,
|
||||
|
||||
assignedTourIds: [...this._tours.keys()],
|
||||
createdAt: this._createdAt,
|
||||
updateAt: this._updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
static create(id: TicketId, patientId: string): Ticket {
|
||||
const ticket = new Ticket(id, new PatientId(patientId));
|
||||
ticket.addDomainEvent(new TicketCreatedEvent(id.value, patientId));
|
||||
return ticket;
|
||||
}
|
||||
|
||||
static fromStorage(props: {
|
||||
id: string;
|
||||
patient: TicketPatientData | null;
|
||||
stage: string;
|
||||
approval: {
|
||||
req: string;
|
||||
status: string;
|
||||
requestedAt: Date | null;
|
||||
lastInteractionAt: Date | null;
|
||||
reminderCount: number;
|
||||
recipient: string | null;
|
||||
preInquiryMessageId: string | null;
|
||||
fullRequestMessageId: string | null;
|
||||
isOverriden: boolean;
|
||||
};
|
||||
hasPhysicalTD: boolean;
|
||||
hasDigitalTD: boolean;
|
||||
tours: TicketTourData[];
|
||||
transportDocument: TransportDocument | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
// validationErrors: ValidationError[] // TODO: In DB persistieren, damit Frontend guenstig laden kann
|
||||
}): Ticket {
|
||||
const ticket = new Ticket(
|
||||
new TicketId(props.id),
|
||||
new PatientId(props.patient.patientId),
|
||||
);
|
||||
|
||||
ticket._stage = props.stage as TicketStage;
|
||||
|
||||
ticket._approval = new ApprovalWorkflow(
|
||||
props.approval.req as ApprovalRequirement,
|
||||
props.approval.status as ApprovalRequestStatus,
|
||||
props.approval.requestedAt,
|
||||
props.approval.lastInteractionAt,
|
||||
props.approval.reminderCount,
|
||||
props.approval.recipient,
|
||||
props.approval.preInquiryMessageId,
|
||||
props.approval.fullRequestMessageId,
|
||||
props.approval.isOverriden,
|
||||
);
|
||||
|
||||
ticket._hasDigitalTransportDocument = props.hasDigitalTD;
|
||||
ticket._hasPhysicalTransportDocument = props.hasPhysicalTD;
|
||||
ticket._transportDocument = props.transportDocument;
|
||||
|
||||
ticket._patientData = props.patient;
|
||||
|
||||
for (const tourData of props.tours) {
|
||||
ticket._tours.set(tourData.id.value, tourData);
|
||||
}
|
||||
|
||||
ticket._createdAt = props.createdAt;
|
||||
ticket._updatedAt = props.updatedAt;
|
||||
|
||||
ticket.reassessState();
|
||||
|
||||
return ticket;
|
||||
}
|
||||
|
||||
private reassessState(): void {
|
||||
this._updatedAt = new Date();
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
// Ticket Validierung
|
||||
for (const tour of this._tours.values()) {
|
||||
errors.push(...tour.validate());
|
||||
}
|
||||
|
||||
if (this._patientData) {
|
||||
errors.push(...this._patientData.validate());
|
||||
} else {
|
||||
// if (this._tours.size > 0) {
|
||||
// errors.push({
|
||||
// field: 'patient',
|
||||
// message: 'Keine Patientendaten verfügbar.',
|
||||
// source,
|
||||
// });
|
||||
// }
|
||||
}
|
||||
|
||||
this._validationErrors = errors;
|
||||
|
||||
if (this._tours.size === 0) {
|
||||
this._stage = TicketStage.DRAFT;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._validationErrors.length > 0) {
|
||||
this._stage = TicketStage.DATA_INCOMPLETE;
|
||||
return;
|
||||
}
|
||||
|
||||
// Genehmigungsverfahren
|
||||
if (!this._approval.isOverridden) {
|
||||
this.determineApprovalRequirement();
|
||||
}
|
||||
|
||||
if (
|
||||
this._approval.requirement === ApprovalRequirement.REQUIRED &&
|
||||
this._approval.status !== ApprovalRequestStatus.GRANTED
|
||||
) {
|
||||
if (
|
||||
this._approval.status === ApprovalRequestStatus.FULL_REQUEST_SENT ||
|
||||
this._approval.status === ApprovalRequestStatus.EXTERNAL_REQUEST
|
||||
) {
|
||||
this._stage = TicketStage.APPROVAL_IN_PROGRESS;
|
||||
} else {
|
||||
this._stage = TicketStage.ASSESSMENT_PENDING;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Dokumente fehlen
|
||||
if (
|
||||
!this._hasPhysicalTransportDocument &&
|
||||
!this._hasDigitalTransportDocument
|
||||
) {
|
||||
if (this._approval.requirement === ApprovalRequirement.REQUIRED) {
|
||||
this._stage = TicketStage.APPROVAL_IN_PROGRESS;
|
||||
}
|
||||
this._stage = TicketStage.DOCUMENTS_PENDING;
|
||||
return;
|
||||
}
|
||||
|
||||
// Alles ready
|
||||
this._stage = TicketStage.READY;
|
||||
}
|
||||
|
||||
private ensureTicketIsOpen(): void {
|
||||
if (this._stage === TicketStage.ARCHIVED) {
|
||||
throw new DomainError('Cannot modify archived ticket.');
|
||||
}
|
||||
}
|
||||
|
||||
private ensurePatientMatches(tourPatientId: string): void {
|
||||
if (this.patientId && this.patientId.value !== tourPatientId) {
|
||||
throw new DomainError('Patient mismatch.');
|
||||
}
|
||||
}
|
||||
|
||||
private determineApprovalRequirement(): void {
|
||||
const tour = this._tours.values().next().value;
|
||||
|
||||
let req = ApprovalRequirement.UNKOWN;
|
||||
|
||||
if (tour) {
|
||||
if (
|
||||
tour.ordinanceType.indexOf('BTW') > -1 ||
|
||||
tour.ordinanceType.indexOf('TSW') > -1
|
||||
) {
|
||||
req = ApprovalRequirement.NOT_REQUIRED;
|
||||
} else if (
|
||||
[
|
||||
'1. Entlassung',
|
||||
'2. Entlassung Vorbestellung',
|
||||
'3. Einweisung',
|
||||
'5. Verlegung',
|
||||
'6. Barzahlung',
|
||||
'Privatfahrt Rechnung',
|
||||
'Reha',
|
||||
'Tagesklinik - AKW',
|
||||
'Tagesklinik - ASK',
|
||||
'Tagesklinik - MKH',
|
||||
'Transportschein abholen',
|
||||
].includes(tour.category) ||
|
||||
tour.healthInsurance === 'KOSTENTRÄGER PATIENT'
|
||||
) {
|
||||
req = ApprovalRequirement.NOT_REQUIRED;
|
||||
} else {
|
||||
req = ApprovalRequirement.REQUIRED;
|
||||
}
|
||||
}
|
||||
|
||||
this._approval = this._approval.setRequirement(req);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
ApprovalRequestStatus,
|
||||
ApprovalRequirement,
|
||||
} from '@avicenna/shared-dtos';
|
||||
|
||||
export class TicketCreatedEvent {
|
||||
constructor(
|
||||
public readonly ticketId: string,
|
||||
public readonly patientId: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class TourAssignedToTicketEvent {
|
||||
constructor(
|
||||
public readonly ticketId: string,
|
||||
public readonly tourId: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class TicketStatusChangedEvent {
|
||||
constructor(
|
||||
public readonly ticketId: string,
|
||||
public readonly oldStage: string,
|
||||
public readonly newStage: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class TicketTransportDocumentUploadedEvent {
|
||||
constructor(public readonly ticketId: string) {}
|
||||
}
|
||||
|
||||
export class TicketPhysicalTransportDocumentReceivedEvent {
|
||||
constructor(public readonly ticketId: string) {}
|
||||
}
|
||||
|
||||
export class TicketApprovalRequirementOverriddenEvent {
|
||||
constructor(
|
||||
public readonly ticketId: string,
|
||||
public readonly newRequirement: ApprovalRequirement,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class TicketApprovalRequestedEvent {
|
||||
constructor(
|
||||
public readonly ticketId: string,
|
||||
public readonly approvalStatus: ApprovalRequestStatus,
|
||||
public readonly recipientEmail: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class TicketApprovalReminderSentEvent {
|
||||
constructor(
|
||||
public readonly ticketId: string,
|
||||
public readonly reminderCount: number,
|
||||
public readonly recipientEmail: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class TicketApprovalGrantedEvent {
|
||||
constructor(public readonly ticketId: string) {}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { TourFile } from '@prisma/client';
|
||||
import { TourCandidate } from './interfaces/tour-candidate.interface';
|
||||
import { Ticket, TicketId } from './ticket.entity';
|
||||
|
||||
export const TICKET_REPOSITORY = 'TicketRepository';
|
||||
|
||||
export interface TicketRepository {
|
||||
findById(id: TicketId): Promise<Ticket | null>;
|
||||
save(ticket: Ticket): Promise<void>;
|
||||
findTicketForTripInOppositeDirection(
|
||||
tour: TourCandidate,
|
||||
): Promise<Ticket | null>;
|
||||
findTicketForSeries(tour: TourCandidate): Promise<Ticket | null>;
|
||||
findPendingFilesForTicket(ticketId: TicketId): Promise<string[]>;
|
||||
streamAllActive(batchSize: number): AsyncGenerator<Ticket[]>;
|
||||
/**
|
||||
* BATCH OPERATION: Bypasses Ticket aggregate for performance.
|
||||
* Does NOT trigger Domain Events.
|
||||
* Use only for background maintenance.
|
||||
*/
|
||||
closeSettledTickets(): Promise<void>;
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import { ValidationError } from '@avicenna/shared-dtos';
|
||||
import {
|
||||
PatientCandidate,
|
||||
PatientId,
|
||||
} from '../interfaces/patient-candidate.interface';
|
||||
import { TourCandidate, TourId } from '../interfaces/tour-candidate.interface';
|
||||
import { TicketTourData } from './ticket-tour-data.vo';
|
||||
|
||||
export class TicketPatientData {
|
||||
get patientId(): string | null {
|
||||
return this.sourceType === 'PATIENT' ? this.sourceId.value : null;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly sourceId: PatientId | TourId,
|
||||
private readonly sourceType: 'PATIENT' | 'TOUR',
|
||||
public readonly firstName: string | null,
|
||||
public readonly lastName: string | null,
|
||||
public readonly birthday: Date | null,
|
||||
public readonly healthInsurance: string | null,
|
||||
public readonly insuranceNo: string | null,
|
||||
public readonly street: string | null,
|
||||
public readonly city: string | null,
|
||||
public readonly zip: string | null,
|
||||
) {}
|
||||
|
||||
static fromMasterData(patient: PatientCandidate): TicketPatientData {
|
||||
return new TicketPatientData(
|
||||
patient.id,
|
||||
'PATIENT',
|
||||
patient.firstName,
|
||||
patient.lastName,
|
||||
patient.birthday,
|
||||
patient.healthInsurance,
|
||||
patient.insuranceNo,
|
||||
patient.street,
|
||||
patient.city,
|
||||
patient.zip,
|
||||
);
|
||||
}
|
||||
|
||||
static fromTour(tour: TourCandidate): TicketPatientData {
|
||||
return new TicketPatientData(
|
||||
tour.id,
|
||||
'TOUR',
|
||||
tour.patientName,
|
||||
tour.patientSurname,
|
||||
null,
|
||||
tour.healthInsurance,
|
||||
tour.insuranceNo,
|
||||
tour.patientStreet,
|
||||
tour.patientCity,
|
||||
tour.patientZip,
|
||||
);
|
||||
}
|
||||
|
||||
static fromTourSnapshot(tour: TicketTourData): TicketPatientData {
|
||||
return new TicketPatientData(
|
||||
tour.id,
|
||||
'TOUR',
|
||||
tour.patientName,
|
||||
tour.patientSurname,
|
||||
null,
|
||||
tour.healthInsurance,
|
||||
tour.insuranceNo,
|
||||
tour.patientStreet,
|
||||
tour.patientCity,
|
||||
tour.patientZip,
|
||||
);
|
||||
}
|
||||
|
||||
public validate(): ValidationError[] {
|
||||
const errors: ValidationError[] = [];
|
||||
const source = { type: this.sourceType, id: this.sourceId.value };
|
||||
|
||||
if (!this.firstName) {
|
||||
errors.push({ field: 'firstName', message: 'Vorname fehlt', source });
|
||||
}
|
||||
if (!this.lastName) {
|
||||
errors.push({ field: 'lastName', message: 'Nachname fehlt', source });
|
||||
}
|
||||
if (!this.healthInsurance) {
|
||||
errors.push({
|
||||
field: 'healthInsurance',
|
||||
message: 'Krankenkasse fehlt',
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { ValidationError } from '@avicenna/shared-dtos';
|
||||
import { TourCandidate, TourId } from '../interfaces/tour-candidate.interface';
|
||||
|
||||
export class TicketTourData {
|
||||
constructor(
|
||||
public readonly id: TourId,
|
||||
public readonly operationId: string,
|
||||
public readonly category: string,
|
||||
public readonly ordinanceType: string,
|
||||
public readonly transportType: string,
|
||||
public readonly patientId: string,
|
||||
public readonly patientName: string,
|
||||
public readonly patientSurname: string,
|
||||
public readonly patientStreet: string,
|
||||
public readonly patientCity: string,
|
||||
public readonly patientZip: string,
|
||||
public readonly healthInsurance: string,
|
||||
public readonly insuranceNo: string,
|
||||
public readonly direction: string,
|
||||
public readonly occupiedKm: number,
|
||||
public readonly hasInfection: boolean,
|
||||
public readonly infectionName: string | null,
|
||||
public readonly seriesEndDate: Date | null,
|
||||
public readonly targetStreet: string,
|
||||
public readonly targetCity: string,
|
||||
public readonly targetZip: string,
|
||||
public readonly startStreet: string,
|
||||
public readonly startCity: string,
|
||||
public readonly startZip: string,
|
||||
public readonly startDate: Date,
|
||||
) {}
|
||||
|
||||
static fromCandidate(candidate: TourCandidate): TicketTourData {
|
||||
return new TicketTourData(
|
||||
candidate.id,
|
||||
candidate.operationId,
|
||||
candidate.category,
|
||||
candidate.ordinanceType,
|
||||
candidate.transportType,
|
||||
candidate.patientId,
|
||||
candidate.patientName,
|
||||
candidate.patientSurname,
|
||||
candidate.patientStreet,
|
||||
candidate.patientCity,
|
||||
candidate.patientZip,
|
||||
candidate.healthInsurance,
|
||||
candidate.insuranceNo,
|
||||
candidate.direction,
|
||||
candidate.occupiedKm,
|
||||
candidate.hasInfection,
|
||||
candidate.infectionName,
|
||||
candidate.seriesEndDate,
|
||||
candidate.targetStreet,
|
||||
candidate.targetCity,
|
||||
candidate.targetZip,
|
||||
candidate.startStreet,
|
||||
candidate.startCity,
|
||||
candidate.startZip,
|
||||
candidate.startDate,
|
||||
);
|
||||
}
|
||||
|
||||
public validate(): ValidationError[] {
|
||||
const errors: ValidationError[] = [];
|
||||
const source = { type: 'TOUR' as const, id: this.id.value };
|
||||
|
||||
if (!this.ordinanceType) {
|
||||
errors.push({
|
||||
field: 'ordinanceType',
|
||||
message: `Verordnungsart ist nicht gefüllt`,
|
||||
source,
|
||||
});
|
||||
}
|
||||
if (!this.transportType) {
|
||||
errors.push({
|
||||
field: 'transportType',
|
||||
message: `Transportart ist nicht gefüllt`,
|
||||
source,
|
||||
});
|
||||
}
|
||||
// REASON: Dispolive struggles to populate this field with it's value.
|
||||
// if (!this.category) {
|
||||
// errors.push({
|
||||
// field: 'category',
|
||||
// message: `Kategorie ist nicht gefüllt`,
|
||||
// source,
|
||||
// });
|
||||
// }
|
||||
if (!this.startDate) {
|
||||
errors.push({
|
||||
field: 'startDate',
|
||||
message: `Startzeit ist nicht gefüllt`,
|
||||
source,
|
||||
});
|
||||
}
|
||||
if (!this.startStreet || !this.startZip || !this.startCity) {
|
||||
errors.push({
|
||||
field: 'startAddress',
|
||||
message: `Start Adresse ist nicht gefüllt`,
|
||||
source,
|
||||
});
|
||||
}
|
||||
if (!this.targetStreet || !this.targetZip || !this.targetCity) {
|
||||
errors.push({
|
||||
field: 'targetAddress',
|
||||
message: `Ziel Adresse ist nicht gefüllt`,
|
||||
source,
|
||||
});
|
||||
}
|
||||
if (this.occupiedKm > 9990) {
|
||||
errors.push({
|
||||
field: 'occupiedKm',
|
||||
message: `Besetzte Kilometer sind zu hoch`,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export class TransportDocument {
|
||||
constructor(
|
||||
public readonly storagePath: string,
|
||||
public readonly originalFilename: string,
|
||||
public readonly mimeType: string,
|
||||
public readonly sizeBytes: number,
|
||||
) {}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
export const TICKET_EMAIL_QUEUE = 'ticket-email-queue';
|
||||
|
||||
export type TicketEmailJob =
|
||||
| { type: 'send-pre-inquiry'; data: PreInquiryJobData }
|
||||
| { type: 'send-full-request'; data: FullRequestJobData }
|
||||
| { type: 'send-reminder'; data: ReminderJobData };
|
||||
|
||||
export interface BaseEmailJobData {
|
||||
ticketId: string;
|
||||
recipientEmail: string;
|
||||
// Für Threading: Auf welche ID soll geantwortet werden?
|
||||
replyToMessageId?: string;
|
||||
}
|
||||
|
||||
export interface PreInquiryJobData extends BaseEmailJobData {
|
||||
params: {
|
||||
patientName: string;
|
||||
birthday: string;
|
||||
insuranceNo: string;
|
||||
healthInsurance: string;
|
||||
startAddressLine: string;
|
||||
targetAddressLine: string;
|
||||
transportDate: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FullRequestJobData extends BaseEmailJobData {
|
||||
// Wir mappen das Ticket-VO auf eine flache Struktur für den Worker
|
||||
attachment: {
|
||||
storagePath: string;
|
||||
originalFilename: string;
|
||||
mimeType: string;
|
||||
};
|
||||
patientName: string; // Für Betreff/Body
|
||||
}
|
||||
|
||||
export interface ReminderJobData extends BaseEmailJobData {
|
||||
patientName: string;
|
||||
transportDate: Date;
|
||||
reminderCount: number;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user