diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.ar.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.ar.resx
index 0a189877..6cef3c2a 100644
--- a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.ar.resx
@@ -554,4 +554,13 @@
فقط نماذج F-42 ومطالبات المصروفات تُقدَّم إلى MARS.
السجل مدفوع أو مغلق؛ بنوده نهائية.
السجل ليس من النوع المتوقع.
+ إنشاء من بيانات أجور القوى العاملة
+ استبدال المعدلين العادي والإضافي لكل بند تصنيف مطابق بالمتوسطات؟
+ تم إنشاء {0} بند تصنيف من {1} موظف؛ {2} تصنيف غير مطابق؛ {3} تصنيف بموظف واحد للمراجعة.
+ المرحلة E: متوسط المعدلات الساعية الفردية المعتمدة الحالية لكل تصنيف MARS (بالإضافة إلى المكوّنات المدفوعة لكل ساعة إضافية) يملأ المعدلين العادي والإضافي. مجاميع فقط — ليس منتصف نطاق ولا فردًا. أداة مراجعة؛ الممثل المفوض لا يزال يوقع في MARS.
+ لا يوجد ملف تعويض معتمد يحمل تصنيف MARS لملف السلطة هذا.
+ فقط ملف Salary Survey أو Attachment A يقبل مسودة من بيانات الأجور.
+ ملف المعدلات لم يعد قابلًا للتعديل.
+ بيانات الأجور غير متاحة لهذا القسم.
+ بيانات الأجور غير مفعّلة لهذا القسم.
diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.de.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.de.resx
index de30b487..6352bf04 100644
--- a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.de.resx
@@ -554,4 +554,13 @@
Nur F-42 und Spesenanträge werden bei MARS eingereicht.
Der Datensatz ist bezahlt oder geschlossen; die Zeilen sind endgültig.
Der Datensatz hat nicht den erwarteten Typ.
+ Aus Lohndaten erstellen
+ Reguläre und Überstundensätze jeder passenden Klassifikationszeile durch die Mittelwerte ersetzen?
+ {0} Klassifikationszeile(n) aus {1} Beschäftigten erstellt; {2} nicht zugeordnete Klassifikation(en); {3} Einzelpersonen-Klassifikation(en) zu prüfen.
+ Phase E: Der Mittelwert der aktuell genehmigten individuellen Stundensätze je MARS-Klassifikation (plus Zulagen pro Überstunde) füllt die regulären und Überstundensätze. Nur Aggregate – nie ein Bandmittelwert, nie eine Einzelperson. Eine Prüfhilfe; der Bevollmächtigte unterzeichnet weiterhin in MARS.
+ Kein genehmigtes Vergütungsprofil trägt eine MARS-Klassifikation für dieses Autoritätsprofil.
+ Nur ein Salary-Survey- oder Attachment-A-Profil nimmt einen Lohndaten-Entwurf an.
+ Das Ratenprofil ist nicht mehr bearbeitbar.
+ Lohndaten stehen dieser Organisation nicht zur Verfügung.
+ Lohndaten sind für diese Organisation nicht aktiviert.
diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.el.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.el.resx
index c4bdb8cb..6b533304 100644
--- a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.el.resx
@@ -554,4 +554,13 @@
Μόνο F-42 και αιτήματα δαπανών υποβάλλονται στο MARS.
Η εγγραφή είναι πληρωμένη ή κλειστή· οι γραμμές της είναι οριστικές.
Η εγγραφή δεν είναι του αναμενόμενου τύπου.
+ Δημιουργία από δεδομένα αμοιβών
+ Αντικατάσταση κανονικών και υπερωριακών αμοιβών κάθε γραμμής ταξινόμησης με τους μέσους όρους;
+ {0} γραμμή(ές) ταξινόμησης από {1} εργαζόμενο(ους)· {2} μη αντιστοιχισμένη(ες)· {3} ταξινόμηση(εις) ενός εργαζομένου προς έλεγχο.
+ Φάση E: ο μέσος όρος των τρεχουσών εγκεκριμένων ατομικών ωριαίων αμοιβών ανά ταξινόμηση MARS (συν στοιχεία που πληρώνονται ανά ώρα υπερωρίας) συμπληρώνει τις κανονικές και υπερωριακές αμοιβές. Μόνο σύνολα — ποτέ μέσο εύρους, ποτέ άτομο. Βοήθημα ελέγχου· ο εξουσιοδοτημένος εκπρόσωπος υπογράφει στο MARS.
+ Κανένα εγκεκριμένο προφίλ αμοιβής δεν φέρει ταξινόμηση MARS για αυτό το προφίλ αρχής.
+ Μόνο προφίλ Salary Survey ή Attachment A δέχεται προσχέδιο από δεδομένα αμοιβών.
+ Το προφίλ αμοιβών δεν είναι πλέον επεξεργάσιμο.
+ Τα δεδομένα αμοιβών δεν είναι διαθέσιμα σε αυτό το τμήμα.
+ Τα δεδομένα αμοιβών δεν είναι ενεργοποιημένα για αυτό το τμήμα.
diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.en.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.en.resx
index 172d91ab..f4a7fa25 100644
--- a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.en.resx
@@ -554,4 +554,13 @@
Only F-42 and expense claims are submitted to MARS.
The record is paid or closed; its lines are final.
The record is not of the expected type.
+ Build from workforce pay data
+ Replace the straight and overtime rates of every matching classification line with the workforce means?
+ {0} classification line(s) drafted from {1} employee(s); {2} unmapped classification(s); {3} single-employee classification(s) to review.
+ Phase E: the mean of the current approved individual hourly rates per MARS classification (plus components paid for each overtime hour) fills the straight and overtime rates. Aggregates only — never a pay-range midpoint, never an individual. A review aid; the authorized representative still signs in MARS.
+ No approved employee compensation profile carries a MARS classification for this authority profile.
+ Only a Salary Survey or Attachment A profile takes a workforce draft.
+ The rate profile is no longer editable.
+ Workforce pay data is not available to this department.
+ Workforce pay data is not enabled for this department.
diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.es.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.es.resx
index 3bb623f2..0048ddd6 100644
--- a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.es.resx
@@ -554,4 +554,13 @@
Solo los F-42 y las reclamaciones de gastos se presentan a MARS.
El registro está pagado o cerrado; sus líneas son definitivas.
El registro no es del tipo esperado.
+ Generar desde datos salariales
+ ¿Reemplazar las tarifas normal y extra de cada línea de clasificación coincidente por las medias?
+ {0} línea(s) de clasificación generada(s) de {1} empleado(s); {2} clasificación(es) sin asignar; {3} clasificación(es) de un solo empleado por revisar.
+ Fase E: la media de las tarifas horarias individuales aprobadas por clasificación MARS (más componentes pagados por hora extra) rellena las tarifas normal y extra. Solo agregados — nunca un punto medio de rango ni una persona. Es una ayuda de revisión; el representante autorizado sigue firmando en MARS.
+ Ningún perfil de compensación aprobado lleva una clasificación MARS para este perfil de autoridad.
+ Solo un perfil Salary Survey o Attachment A admite un borrador desde datos salariales.
+ El perfil de tarifas ya no es editable.
+ Los datos salariales no están disponibles para este departamento.
+ Los datos salariales no están habilitados para este departamento.
diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.fr.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.fr.resx
index 6613113e..96e8bb54 100644
--- a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.fr.resx
@@ -554,4 +554,13 @@
Seuls les F-42 et demandes de dépenses sont soumis à MARS.
L'enregistrement est payé ou clôturé ; ses lignes sont définitives.
L'enregistrement n'est pas du type attendu.
+ Générer depuis les données de paie
+ Remplacer les taux normal et sup. de chaque ligne de classification correspondante par les moyennes ?
+ {0} ligne(s) de classification générée(s) à partir de {1} employé(s) ; {2} classification(s) non associée(s) ; {3} classification(s) à un seul employé à vérifier.
+ Phase E : la moyenne des taux horaires individuels approuvés par classification MARS (plus les composantes payées par heure sup.) remplit les taux normal et sup. Agrégats seulement — jamais un milieu de fourchette ni un individu. Aide à la revue ; le représentant autorisé signe toujours dans MARS.
+ Aucun profil de rémunération approuvé ne porte une classification MARS pour ce profil d'autorité.
+ Seul un profil Salary Survey ou Attachment A accepte un brouillon issu des données de paie.
+ Le profil de taux n'est plus modifiable.
+ Les données de paie ne sont pas disponibles pour ce service.
+ Les données de paie ne sont pas activées pour ce service.
diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.it.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.it.resx
index 396798b8..77b4da5e 100644
--- a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.it.resx
@@ -554,4 +554,13 @@
Solo F-42 e richieste spese vengono inviati a MARS.
Il record è pagato o chiuso; le sue righe sono definitive.
Il record non è del tipo atteso.
+ Genera dai dati retributivi
+ Sostituire le tariffe ordinaria e straordinaria di ogni riga di classificazione corrispondente con le medie?
+ {0} riga/righe di classificazione generate da {1} dipendenti; {2} classificazioni non mappate; {3} classificazioni con un solo dipendente da rivedere.
+ Fase E: la media delle tariffe orarie individuali approvate per classificazione MARS (più i componenti pagati per ora di straordinario) compila le tariffe ordinaria e straordinaria. Solo aggregati — mai un punto medio di fascia né un individuo. Un ausilio alla revisione; il rappresentante autorizzato firma comunque in MARS.
+ Nessun profilo retributivo approvato reca una classificazione MARS per questo profilo di autorità.
+ Solo un profilo Salary Survey o Attachment A accetta una bozza dai dati retributivi.
+ Il profilo tariffario non è più modificabile.
+ I dati retributivi non sono disponibili per questo dipartimento.
+ I dati retributivi non sono abilitati per questo dipartimento.
diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.pl.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.pl.resx
index ff0e123c..5ffdf6c9 100644
--- a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.pl.resx
@@ -554,4 +554,13 @@
Tylko F-42 i roszczenia wydatków są zgłaszane do MARS.
Rekord jest zapłacony lub zamknięty; jego pozycje są ostateczne.
Rekord nie jest oczekiwanego typu.
+ Utwórz z danych płacowych
+ Zastąpić stawki zwykłą i nadliczbową każdej pasującej pozycji klasyfikacji średnimi?
+ {0} pozycji klasyfikacji utworzono z {1} pracowników; {2} niezmapowanych klasyfikacji; {3} klasyfikacji jednoosobowych do przeglądu.
+ Faza E: średnia bieżących zatwierdzonych indywidualnych stawek godzinowych na klasyfikację MARS (plus składniki płacone za godzinę nadliczbową) wypełnia stawki zwykłą i nadliczbową. Tylko wartości zbiorcze — nigdy środek przedziału ani osoba. Pomoc w przeglądzie; upoważniony przedstawiciel nadal podpisuje w MARS.
+ Żaden zatwierdzony profil wynagrodzenia nie ma klasyfikacji MARS dla tego profilu autorytetu.
+ Tylko profil Salary Survey lub Attachment A przyjmuje szkic z danych płacowych.
+ Profil stawek nie jest już edytowalny.
+ Dane płacowe nie są dostępne dla tej jednostki.
+ Dane płacowe nie są włączone dla tej jednostki.
diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.resx
index 172d91ab..f4a7fa25 100644
--- a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.resx
+++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.resx
@@ -554,4 +554,13 @@
Only F-42 and expense claims are submitted to MARS.
The record is paid or closed; its lines are final.
The record is not of the expected type.
+ Build from workforce pay data
+ Replace the straight and overtime rates of every matching classification line with the workforce means?
+ {0} classification line(s) drafted from {1} employee(s); {2} unmapped classification(s); {3} single-employee classification(s) to review.
+ Phase E: the mean of the current approved individual hourly rates per MARS classification (plus components paid for each overtime hour) fills the straight and overtime rates. Aggregates only — never a pay-range midpoint, never an individual. A review aid; the authorized representative still signs in MARS.
+ No approved employee compensation profile carries a MARS classification for this authority profile.
+ Only a Salary Survey or Attachment A profile takes a workforce draft.
+ The rate profile is no longer editable.
+ Workforce pay data is not available to this department.
+ Workforce pay data is not enabled for this department.
diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.sv.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.sv.resx
index 9dc65d8d..96225b72 100644
--- a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.sv.resx
@@ -554,4 +554,13 @@
Endast F-42 och utgiftsanspråk lämnas in till MARS.
Posten är betald eller stängd; dess rader är slutgiltiga.
Posten är inte av förväntad typ.
+ Bygg från lönedata
+ Ersätt ordinarie och övertidssats för varje matchande klassificeringsrad med medelvärdena?
+ {0} klassificeringsrad(er) byggda från {1} anställd(a); {2} omappade klassificering(ar); {3} klassificering(ar) med en anställd att granska.
+ Fas E: medelvärdet av de aktuella godkända individuella timlönerna per MARS-klassificering (plus komponenter som betalas per övertidstimme) fyller ordinarie och övertidssats. Endast aggregat — aldrig ett intervallmittvärde, aldrig en individ. Ett granskningsstöd; den behöriga företrädaren signerar fortfarande i MARS.
+ Ingen godkänd ersättningsprofil bär en MARS-klassificering för denna auktoritetsprofil.
+ Endast en Salary Survey- eller Attachment A-profil tar ett lönedatautkast.
+ Satsprofilen kan inte längre redigeras.
+ Lönedata är inte tillgängliga för denna avdelning.
+ Lönedata är inte aktiverade för denna avdelning.
diff --git a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.uk.resx b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.uk.resx
index f004237d..8ef0417b 100644
--- a/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/CalOesMars/CalOesMars.uk.resx
@@ -554,4 +554,13 @@
Лише F-42 та вимоги витрат подаються в MARS.
Запис оплачено або закрито; його рядки остаточні.
Запис не очікуваного типу.
+ Створити з даних оплати
+ Замінити звичайну та понаднормову ставки кожного відповідного рядка класифікації середніми?
+ {0} рядків класифікації створено з {1} працівників; {2} незіставлених класифікацій; {3} класифікацій з одним працівником на перевірку.
+ Фаза E: середнє поточних затверджених індивідуальних погодинних ставок за класифікацією MARS (плюс компоненти за годину понаднормових) заповнює звичайну та понаднормову ставки. Лише агрегати — ніколи середина діапазону чи особа. Допоміжний засіб; уповноважений представник підписує в MARS.
+ Жоден затверджений профіль оплати не має класифікації MARS для цього профілю авторитету.
+ Лише профіль Salary Survey або Attachment A приймає чернетку з даних оплати.
+ Профіль ставок більше не редагується.
+ Дані оплати недоступні для цього підрозділу.
+ Дані оплати не ввімкнено для цього підрозділу.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.ar.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.ar.resx
index 6acf8bd8..4cf5e2fb 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.ar.resx
@@ -710,4 +710,7 @@
لم يتم العثور على العامل.
العامل مطلوب.
سنة الإبلاغ غير صالحة.
+ التكلفة الداخلية
+ هامش المساهمة المقدّر من مدخلات التكلفة الداخلية — ليس ربحًا محاسبيًا؛ المدخلات المفقودة مدرجة في العملية.
+ لا توجد عملية حساب تكلفة لهذا السجل بعد.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.de.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.de.resx
index d3c17600..1806923a 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.de.resx
@@ -710,4 +710,7 @@
Die Person wurde nicht gefunden.
Eine Person ist erforderlich.
Das Meldejahr ist ungültig.
+ Interne Kosten
+ Geschätzter Deckungsbeitrag aus internen Kosteneingaben – kein buchhalterischer Gewinn; fehlende Eingaben stehen im Lauf.
+ Noch kein Kostenlauf für diesen Datensatz.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.el.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.el.resx
index 4949390c..fd574c33 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.el.resx
@@ -710,4 +710,7 @@
Ο εργαζόμενος δεν βρέθηκε.
Απαιτείται εργαζόμενος.
Το έτος αναφοράς δεν είναι έγκυρο.
+ Εσωτερικό κόστος
+ Εκτιμώμενο περιθώριο συνεισφοράς από εσωτερικά κόστη — όχι λογιστικό κέρδος· οι ελλείπουσες είσοδοι αναγράφονται στην εκτέλεση.
+ Δεν υπάρχει ακόμη υπολογισμός κόστους για αυτή την εγγραφή.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx
index 6d5a27c6..d9ebd6db 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx
@@ -710,4 +710,7 @@
The worker was not found.
A worker is required.
The reporting year is not valid.
+ Internal cost
+ Estimated contribution margin from internal cost inputs — not accounting profit; missing inputs are listed on the run.
+ No cost run yet for this record.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.es.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.es.resx
index c6697853..ea1c6087 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.es.resx
@@ -710,4 +710,7 @@
No se encontró el trabajador.
Se requiere un trabajador.
El año del informe no es válido.
+ Costo interno
+ Margen de contribución estimado a partir de costos internos — no es beneficio contable; las entradas faltantes se listan en el cálculo.
+ Aún no hay cálculo de costos para este registro.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.fr.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.fr.resx
index 34caa85c..9eeda2cf 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.fr.resx
@@ -710,4 +710,7 @@
Le travailleur est introuvable.
Un travailleur est requis.
L'année déclarée n'est pas valide.
+ Coût interne
+ Marge sur coûts estimée à partir des coûts internes — pas un bénéfice comptable ; les données manquantes sont listées sur le calcul.
+ Pas encore de calcul de coûts pour cet enregistrement.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.it.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.it.resx
index 030f0ed8..daf3960f 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.it.resx
@@ -710,4 +710,7 @@
Il lavoratore non è stato trovato.
È richiesto un lavoratore.
L'anno di riferimento non è valido.
+ Costo interno
+ Margine di contribuzione stimato dai costi interni — non un utile contabile; gli input mancanti sono elencati nel calcolo.
+ Nessun calcolo dei costi per questo record.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.pl.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.pl.resx
index 580dc290..4ff8f06b 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.pl.resx
@@ -710,4 +710,7 @@
Nie znaleziono pracownika.
Wymagany jest pracownik.
Rok sprawozdawczy jest nieprawidłowy.
+ Koszt wewnętrzny
+ Szacowana marża pokrycia z kosztów wewnętrznych — nie zysk księgowy; brakujące dane są wymienione w przebiegu.
+ Brak przebiegu kosztów dla tego rekordu.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx
index 6d5a27c6..d9ebd6db 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx
@@ -710,4 +710,7 @@
The worker was not found.
A worker is required.
The reporting year is not valid.
+ Internal cost
+ Estimated contribution margin from internal cost inputs — not accounting profit; missing inputs are listed on the run.
+ No cost run yet for this record.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.sv.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.sv.resx
index 3a947c0a..2c9612ce 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.sv.resx
@@ -710,4 +710,7 @@
Arbetstagaren hittades inte.
En arbetstagare krävs.
Rapportåret är ogiltigt.
+ Intern kostnad
+ Uppskattat täckningsbidrag från interna kostnadsindata — inte bokföringsmässig vinst; saknade indata listas på körningen.
+ Ingen kostnadskörning för denna post ännu.
diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.uk.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.uk.resx
index e47ea2a7..ea186bec 100644
--- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.uk.resx
@@ -710,4 +710,7 @@
Працівника не знайдено.
Працівник обов'язковий.
Звітний рік недійсний.
+ Внутрішня вартість
+ Оцінений маржинальний дохід із внутрішніх витрат — не бухгалтерський прибуток; відсутні дані перелічено в розрахунку.
+ Розрахунку витрат для цього запису ще немає.
diff --git a/Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsEntities.cs b/Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsEntities.cs
index 4bc2dc1d..b2642691 100644
--- a/Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsEntities.cs
+++ b/Core/Resgrid.Model/CostRecovery/CalOesMars/CalOesMarsEntities.cs
@@ -10,8 +10,8 @@ namespace Resgrid.Model.CostRecovery.CalOesMars
// are local mirrors of an external system of record — Resgrid prepares, validates, estimates and reconciles; Cal OES
// MARS accepts, invoices and pays. No portal credential, MFA token or browser session material is stored anywhere here.
// Nothing in these tables is under Advanced Data Protection (decision 44): every value is either printed on a claim
- // the paying entity reads or is the department's own public agency information. The reserved IsProtected /
- // ProtectedCatalogVersion columns are never set.
+ // the paying entity reads or is the department's own public agency information (the marker columns were dropped
+ // before release on 2026-09-20).
/// The department's MARS agency record (one per department).
public class CalOesMarsAgencyProfile : IEntity
@@ -53,8 +53,6 @@ public class CalOesMarsAgencyProfile : IEntity
public string AddedByUserId { get; set; }
public DateTime? EditedOn { get; set; }
public string EditedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int? ProtectedCatalogVersion { get; set; }
[NotMapped] public string TableName => "CalOesMarsAgencyProfiles";
[NotMapped] public string IdName => "CalOesMarsAgencyProfileId";
@@ -101,8 +99,6 @@ public class CalOesMarsResourceProfile : IEntity
public string AddedByUserId { get; set; }
public DateTime? EditedOn { get; set; }
public string EditedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int? ProtectedCatalogVersion { get; set; }
[NotMapped] public string SubjectName { get; set; }
public bool IsCurrent(DateTime asOf) => (!EffectiveOn.HasValue || EffectiveOn.Value.Date <= asOf.Date) && (!ExpiresOn.HasValue || ExpiresOn.Value.Date >= asOf.Date);
@@ -149,8 +145,6 @@ public class CalOesMarsRateProfile : IEntity
public string AddedByUserId { get; set; }
public DateTime? EditedOn { get; set; }
public string EditedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int? ProtectedCatalogVersion { get; set; }
[NotMapped] public List Lines { get; set; } = new List();
[NotMapped] public List AdministrativeInputs { get; set; } = new List();
@@ -201,8 +195,6 @@ public class CalOesMarsRateLine : IEntity
public string AddedByUserId { get; set; }
public DateTime? EditedOn { get; set; }
public string EditedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int? ProtectedCatalogVersion { get; set; }
[NotMapped] public string TableName => "CalOesMarsRateLines";
[NotMapped] public string IdName => "CalOesMarsRateLineId";
@@ -245,8 +237,6 @@ public class CalOesMarsAdministrativeRateInput : IEntity
public string AddedByUserId { get; set; }
public DateTime? EditedOn { get; set; }
public string EditedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int? ProtectedCatalogVersion { get; set; }
[NotMapped] public decimal Amount => decimal.TryParse(ActualAmount, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var value) ? value : 0m;
@@ -287,8 +277,6 @@ public class CalOesMarsAgreementSnapshot : IEntity
public string AddedByUserId { get; set; }
public DateTime? EditedOn { get; set; }
public string EditedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int? ProtectedCatalogVersion { get; set; }
public bool CoversDate(DateTime asOf) => (!StartOn.HasValue || StartOn.Value.Date <= asOf.Date) && (!EndOn.HasValue || EndOn.Value.Date >= asOf.Date);
@@ -351,8 +339,6 @@ public class CalOesMarsWorkItem : IEntity
public string AddedByUserId { get; set; }
public DateTime? EditedOn { get; set; }
public string EditedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int? ProtectedCatalogVersion { get; set; }
[NotMapped] public List Lines { get; set; } = new List();
[NotMapped] public string DeploymentName { get; set; }
diff --git a/Core/Resgrid.Model/Invoicing/ContractorBillingModels.cs b/Core/Resgrid.Model/Invoicing/ContractorBillingModels.cs
index b30f11ce..397d1f67 100644
--- a/Core/Resgrid.Model/Invoicing/ContractorBillingModels.cs
+++ b/Core/Resgrid.Model/Invoicing/ContractorBillingModels.cs
@@ -186,8 +186,6 @@ public class RateSchedule : IEntity
public string AddedByUserId { get; set; }
public DateTime? EditedOn { get; set; }
public string EditedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int? ProtectedCatalogVersion { get; set; }
[NotMapped] public List Entries { get; set; } = new List();
[NotMapped] public List Premiums { get; set; } = new List();
@@ -230,8 +228,6 @@ public class RateScheduleEntry : IEntity
public string AddedByUserId { get; set; }
public DateTime? EditedOn { get; set; }
public string EditedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int? ProtectedCatalogVersion { get; set; }
[NotMapped] public List Bands { get; set; } = new List();
[NotMapped] public List RequiredCertifications => RequiredCertification.Parse(RequiredCertificationsJson);
@@ -291,8 +287,6 @@ public class RatePremium : IEntity
public string AddedByUserId { get; set; }
public DateTime? EditedOn { get; set; }
public string EditedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int? ProtectedCatalogVersion { get; set; }
public decimal AdderFor(RateBandTypes band) => band switch
{
@@ -344,8 +338,6 @@ public class ServiceContract : IEntity
public string AddedByUserId { get; set; }
public DateTime? EditedOn { get; set; }
public string EditedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int? ProtectedCatalogVersion { get; set; }
[NotMapped] public List Requirements { get; set; } = new List();
public bool IsLive(DateTime onUtc) => Status == (int)ServiceContractStatuses.Active && !IsDeleted && StartOn <= onUtc && (!EndOn.HasValue || EndOn.Value >= onUtc);
@@ -405,8 +397,6 @@ public class DepartmentComplianceDocument : IEntity
public string AddedByUserId { get; set; }
public DateTime? EditedOn { get; set; }
public string EditedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int? ProtectedCatalogVersion { get; set; }
public bool IsCurrent(DateTime onUtc) => !IsDeleted && (!EffectiveOn.HasValue || EffectiveOn.Value.Date <= onUtc.Date) && (!ExpiresOn.HasValue || ExpiresOn.Value.Date >= onUtc.Date);
@@ -458,8 +448,6 @@ public class Bid : IEntity
public string AddedByUserId { get; set; }
public DateTime? EditedOn { get; set; }
public string EditedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int? ProtectedCatalogVersion { get; set; }
[NotMapped] public List LineItems { get; set; } = new List();
[NotMapped] public bool IsEditable => Status is (int)BidStatuses.Draft or (int)BidStatuses.Submitted;
diff --git a/Core/Resgrid.Model/Invoicing/CustomerBillingProfile.cs b/Core/Resgrid.Model/Invoicing/CustomerBillingProfile.cs
index c95fb294..c08a2e9b 100644
--- a/Core/Resgrid.Model/Invoicing/CustomerBillingProfile.cs
+++ b/Core/Resgrid.Model/Invoicing/CustomerBillingProfile.cs
@@ -48,8 +48,6 @@ public class CustomerBillingProfile : IEntity
public string AddedByUserId { get; set; }
public DateTime? EditedOn { get; set; }
public string EditedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int ProtectedCatalogVersion { get; set; }
[NotMapped]
public string TableName => "CustomerBillingProfiles";
diff --git a/Core/Resgrid.Model/Invoicing/DepartmentBillingIdentity.cs b/Core/Resgrid.Model/Invoicing/DepartmentBillingIdentity.cs
index bac6d20f..decc9a71 100644
--- a/Core/Resgrid.Model/Invoicing/DepartmentBillingIdentity.cs
+++ b/Core/Resgrid.Model/Invoicing/DepartmentBillingIdentity.cs
@@ -38,9 +38,6 @@ public class DepartmentBillingIdentity : IEntity
public bool ShowPayOnlineOnDocuments { get; set; } = true;
public DateTime UpdatedOn { get; set; }
public string UpdatedByUserId { get; set; }
- /// Reserved marker columns (never set): the registrations print on every invoice, so they are not under ADP.
- public bool IsProtected { get; set; }
- public int? ProtectedCatalogVersion { get; set; }
[NotMapped]
public string TableName => "DepartmentBillingIdentities";
diff --git a/Core/Resgrid.Model/Invoicing/DeploymentModels.cs b/Core/Resgrid.Model/Invoicing/DeploymentModels.cs
index 06a32369..343ce028 100644
--- a/Core/Resgrid.Model/Invoicing/DeploymentModels.cs
+++ b/Core/Resgrid.Model/Invoicing/DeploymentModels.cs
@@ -269,8 +269,6 @@ public class DeploymentTimeReport : IEntity
public string AddedByUserId { get; set; }
public DateTime? EditedOn { get; set; }
public string EditedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int? ProtectedCatalogVersion { get; set; }
[NotMapped]
public List Entries { get; set; } = new List();
@@ -349,8 +347,6 @@ public class DeploymentExpense : IEntity
public string AddedByUserId { get; set; }
public DateTime? EditedOn { get; set; }
public string EditedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int? ProtectedCatalogVersion { get; set; }
[NotMapped] public string TableName => "DeploymentExpenses";
[NotMapped] public string IdName => "DeploymentExpenseId";
@@ -378,8 +374,6 @@ public class DeploymentAttachment : IEntity
public bool IsDeleted { get; set; }
public DateTime AddedOn { get; set; }
public string AddedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int? ProtectedCatalogVersion { get; set; }
[NotMapped] public string TableName => "DeploymentAttachments";
[NotMapped] public string IdName => "DeploymentAttachmentId";
diff --git a/Core/Resgrid.Model/Invoicing/Invoice.cs b/Core/Resgrid.Model/Invoicing/Invoice.cs
index c7fa723b..84eb8e86 100644
--- a/Core/Resgrid.Model/Invoicing/Invoice.cs
+++ b/Core/Resgrid.Model/Invoicing/Invoice.cs
@@ -63,8 +63,6 @@ public class Invoice : IEntity
public string AddedByUserId { get; set; }
public DateTime? EditedOn { get; set; }
public string EditedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int ProtectedCatalogVersion { get; set; }
[NotMapped]
public List LineItems { get; set; }
diff --git a/Core/Resgrid.Model/Invoicing/InvoiceLineItem.cs b/Core/Resgrid.Model/Invoicing/InvoiceLineItem.cs
index 8b40c4b5..93e7b362 100644
--- a/Core/Resgrid.Model/Invoicing/InvoiceLineItem.cs
+++ b/Core/Resgrid.Model/Invoicing/InvoiceLineItem.cs
@@ -29,8 +29,6 @@ public class InvoiceLineItem : IEntity
/// Phase C provenance (M0219): the approved daily time report this line was generated from.
public string DeploymentTimeReportId { get; set; }
public int SortOrder { get; set; }
- public bool IsProtected { get; set; }
- public int ProtectedCatalogVersion { get; set; }
[NotMapped]
public string TableName => "InvoiceLineItems";
diff --git a/Core/Resgrid.Model/Invoicing/InvoicePayment.cs b/Core/Resgrid.Model/Invoicing/InvoicePayment.cs
index 39d1a79c..e5cdb54b 100644
--- a/Core/Resgrid.Model/Invoicing/InvoicePayment.cs
+++ b/Core/Resgrid.Model/Invoicing/InvoicePayment.cs
@@ -57,8 +57,6 @@ public class InvoicePayment : IEntity
/// Null when a provider webhook recorded the payment.
public string RecordedByUserId { get; set; }
public DateTime AddedOn { get; set; }
- public bool IsProtected { get; set; }
- public int ProtectedCatalogVersion { get; set; }
/// The amount still counting toward the invoice after refunds and lost disputes.
[NotMapped]
diff --git a/Core/Resgrid.Model/Invoicing/RateCard.cs b/Core/Resgrid.Model/Invoicing/RateCard.cs
index a08e57d5..f975a10c 100644
--- a/Core/Resgrid.Model/Invoicing/RateCard.cs
+++ b/Core/Resgrid.Model/Invoicing/RateCard.cs
@@ -25,8 +25,6 @@ public class RateCard : IEntity
public string AddedByUserId { get; set; }
public DateTime? EditedOn { get; set; }
public string EditedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int ProtectedCatalogVersion { get; set; }
[NotMapped]
public List Items { get; set; }
diff --git a/Core/Resgrid.Model/Invoicing/RateCardItem.cs b/Core/Resgrid.Model/Invoicing/RateCardItem.cs
index a6941fb7..a1ac9f23 100644
--- a/Core/Resgrid.Model/Invoicing/RateCardItem.cs
+++ b/Core/Resgrid.Model/Invoicing/RateCardItem.cs
@@ -49,8 +49,6 @@ public class RateCardItem : IEntity
public string AddedByUserId { get; set; }
public DateTime? EditedOn { get; set; }
public string EditedByUserId { get; set; }
- public bool IsProtected { get; set; }
- public int ProtectedCatalogVersion { get; set; }
[NotMapped]
public string TableName => "RateCardItems";
diff --git a/Core/Resgrid.Model/Repositories/ICalOesMarsRepositories.cs b/Core/Resgrid.Model/Repositories/ICalOesMarsRepositories.cs
index 0dbc38ec..8d0f043b 100644
--- a/Core/Resgrid.Model/Repositories/ICalOesMarsRepositories.cs
+++ b/Core/Resgrid.Model/Repositories/ICalOesMarsRepositories.cs
@@ -55,6 +55,8 @@ public interface ICalOesMarsWorkItemRepository : IRepository
Task> GetByExternalIdAsync(int departmentId, string marsRecordId);
/// Open items (every state but Closed) for the department queue.
Task> GetActionQueueAsync(int departmentId, int? recordType = null);
+ /// Every non-deleted item (closed ones included) that references the agreement snapshot — the immutability / in-use check.
+ Task> GetByAgreementSnapshotAsync(int departmentId, string agreementSnapshotId);
/// Items whose external state is not settled (submitted, returned, approved without an invoice, invoices not paid).
Task> GetUnreconciledAsync(int departmentId);
/// Departments with any open item (the worker's sweep scope).
diff --git a/Core/Resgrid.Model/Search/SearchContracts.cs b/Core/Resgrid.Model/Search/SearchContracts.cs
index 879f4869..ee8b28fb 100644
--- a/Core/Resgrid.Model/Search/SearchContracts.cs
+++ b/Core/Resgrid.Model/Search/SearchContracts.cs
@@ -22,8 +22,16 @@ public static class SearchEntityTypes
public const string Note = "Note";
public const string Record = "Record";
public const string Action = "Action";
-
- public static readonly IReadOnlyList Indexed = new[] { Call, Unit, Personnel, Contact, Message, Document, Note };
+ // Workforce & Business Operations plan (decision 41): allowlisted identifier / title / status rows only. Rate schedules,
+ // DTRs, expenses, compliance documents, certification records, Cal OES MARS and every Phase E table are never projected.
+ public const string Invoice = "Invoice";
+ public const string RateCard = "RateCard";
+ public const string Bid = "Bid";
+ public const string ServiceContract = "ServiceContract";
+ public const string Deployment = "Deployment";
+ public const string CertificationType = "CertificationType";
+
+ public static readonly IReadOnlyList Indexed = new[] { Call, Unit, Personnel, Contact, Message, Document, Note, Invoice, RateCard, Bid, ServiceContract, Deployment, CertificationType };
}
/// Index state values stored on SearchIndexState.State (same numbering as the RMS records index).
diff --git a/Core/Resgrid.Model/Services/ICalOesMarsService.cs b/Core/Resgrid.Model/Services/ICalOesMarsService.cs
index 85c2c6bb..baa44946 100644
--- a/Core/Resgrid.Model/Services/ICalOesMarsService.cs
+++ b/Core/Resgrid.Model/Services/ICalOesMarsService.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Resgrid.Model.CostRecovery.CalOesMars;
@@ -47,6 +48,8 @@ public interface ICalOesMarsService
Task SaveAdministrativeInputsAsync(string rateProfileId, int departmentId, List inputs, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default);
/// Allowable indirect ÷ allowable direct from the reviewed inputs, compared with the de-minimis option; records the method chosen. Blocks on unresolved double-count flags.
Task BuildAdministrativeRateDraftAsync(string rateProfileId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default);
+ /// Salary Survey / Attachment A draft from Phase E pay data (plan C-M3 §"Salary Survey/Attachment A draft"): the mean of the current approved individual hourly rates per reviewed MARS classification (plus components paid for each overtime hour) becomes the straight / overtime rate of the matching line. Aggregates only — never a pay-range midpoint, never an individual; a review aid the authorized representative still signs in MARS.
+ Task BuildSalarySurveyDraftAsync(string rateProfileId, int departmentId, DateTime asOf, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default);
Task SetRateProfileStatusAsync(string rateProfileId, int departmentId, CalOesMarsRateProfileStatuses status, string signedByName, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default);
Task RecordRateProfileObservationAsync(string rateProfileId, int departmentId, CalOesMarsExternalObservation observation, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default);
Task DeleteRateProfileAsync(string rateProfileId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default);
@@ -128,6 +131,31 @@ public sealed class CalOesMarsAdministrativeRateDraft
public bool IsReady => Blockers.Count == 0;
}
+ /// The Salary Survey draft result (counts and classification means only).
+ public sealed class CalOesMarsSalarySurveyDraft
+ {
+ public string RateProfileId { get; set; }
+ public DateTime AsOf { get; set; }
+ public List Classifications { get; set; } = new List();
+ public int EmployeesIncluded => Classifications.Sum(c => c.Count);
+ public int LinesWritten { get; set; }
+ public List UnknownClassifications { get; set; } = new List();
+ public List Blockers { get; set; } = new List();
+ public bool IsReady => Blockers.Count == 0;
+ }
+
+ public sealed class CalOesMarsSalarySurveyDraftLine
+ {
+ public string ClassificationCode { get; set; }
+ public int Count { get; set; }
+ public decimal MeanRate { get; set; }
+ public decimal MeanOvertimeAdder { get; set; }
+ public decimal StraightRate { get; set; }
+ public decimal OvertimeRate { get; set; }
+ /// A single-employee classification exposes that person's rate as the mean; the reviewer decides whether to keep it.
+ public bool SingleEmployee => Count == 1;
+ }
+
public sealed class CalOesMarsInvoiceReconciliation
{
public CalOesMarsWorkItem Invoice { get; set; }
diff --git a/Core/Resgrid.Model/Services/ISearchServices.cs b/Core/Resgrid.Model/Services/ISearchServices.cs
index 659fd534..eac32c30 100644
--- a/Core/Resgrid.Model/Services/ISearchServices.cs
+++ b/Core/Resgrid.Model/Services/ISearchServices.cs
@@ -28,6 +28,14 @@ public interface ISearchProjectionService
Task ProjectNoteAsync(Note note, CancellationToken cancellationToken = default);
+ // Workforce & Business Operations families (decision 41): identifier / title / status only; a deleted row removes the projection.
+ Task ProjectInvoiceAsync(Invoicing.Invoice invoice, CancellationToken cancellationToken = default);
+ Task ProjectRateCardAsync(Invoicing.RateCard rateCard, CancellationToken cancellationToken = default);
+ Task ProjectBidAsync(Invoicing.Bid bid, CancellationToken cancellationToken = default);
+ Task ProjectServiceContractAsync(Invoicing.ServiceContract contract, CancellationToken cancellationToken = default);
+ Task ProjectDeploymentAsync(Invoicing.Deployment deployment, CancellationToken cancellationToken = default);
+ Task ProjectCertificationTypeAsync(DepartmentCertificationType type, CancellationToken cancellationToken = default);
+
Task RemoveAsync(int departmentId, string entityType, string entityId, CancellationToken cancellationToken = default);
/// Builds the projection row without saving it (used by rebuilds and tests). Null when nothing safe can be indexed.
@@ -38,6 +46,12 @@ public interface ISearchProjectionService
Task BuildMessageAsync(Message message);
Task BuildDocumentAsync(Document document);
Task BuildNoteAsync(Note note);
+ Task BuildInvoiceAsync(Invoicing.Invoice invoice);
+ Task BuildRateCardAsync(Invoicing.RateCard rateCard);
+ Task BuildBidAsync(Invoicing.Bid bid);
+ Task BuildServiceContractAsync(Invoicing.ServiceContract contract);
+ Task BuildDeploymentAsync(Invoicing.Deployment deployment);
+ Task BuildCertificationTypeAsync(DepartmentCertificationType type);
/// Upserts a prebuilt row (rebuild path).
Task UpsertAsync(SearchProjection projection, CancellationToken cancellationToken = default);
diff --git a/Core/Resgrid.Services/CertificationService.cs b/Core/Resgrid.Services/CertificationService.cs
index 45ea32f1..17b11a1d 100644
--- a/Core/Resgrid.Services/CertificationService.cs
+++ b/Core/Resgrid.Services/CertificationService.cs
@@ -27,6 +27,7 @@ public partial class CertificationService : ICertificationService
private readonly IDepartmentCertificationTypeRepository _departmentCertificationTypeRepository;
private readonly IPersonnelCertificationRepository _personnelCertificationRepository;
private readonly Lazy _protectedWriteService;
+ private readonly Lazy _searchProjections;
private readonly IPersonnelRoleCertificationRequirementRepository _requirements;
private readonly IDepartmentCertificationSettingsRepository _settings;
private readonly IPersonnelCertificationCreditsRepository _credits;
@@ -60,12 +61,14 @@ public CertificationService(IDepartmentCertificationTypeRepository departmentCer
Lazy departmentSettings,
Lazy communication,
Lazy protectedRead = null,
- IProtectedGrantContext grant = null)
+ IProtectedGrantContext grant = null,
+ Lazy searchProjections = null)
{
_grant = grant;
_departmentCertificationTypeRepository = departmentCertificationTypeRepository;
_personnelCertificationRepository = personnelCertificationRepository;
_protectedWriteService = protectedWriteService;
+ _searchProjections = searchProjections;
_requirements = requirements;
_settings = settings;
_credits = credits;
@@ -109,6 +112,7 @@ public async Task GetCertificationTypeByIdAsync(int
type.IsActive = false;
type.EditedOn = DateTime.UtcNow;
await _departmentCertificationTypeRepository.SaveOrUpdateAsync(type, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectCertificationTypeAsync(type, cancellationToken);
Audit(type.DepartmentId, null, AuditLogTypes.CertificationTypeRemoved, before, Snapshot(type));
return true;
}
@@ -324,6 +328,7 @@ public async Task SaveCertificationTypeAsync(Depart
}
var saved = await _departmentCertificationTypeRepository.SaveOrUpdateAsync(type, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectCertificationTypeAsync(type, cancellationToken);
Audit(saved.DepartmentId, userId, existing == null ? AuditLogTypes.CertificationTypeAdded : AuditLogTypes.CertificationTypeEdited, existing == null ? null : Snapshot(existing), Snapshot(saved));
return saved;
}
diff --git a/Core/Resgrid.Services/CostRecovery/CalOesMarsReimbursementCalculator.cs b/Core/Resgrid.Services/CostRecovery/CalOesMarsReimbursementCalculator.cs
index afbff191..cd6c4b3e 100644
--- a/Core/Resgrid.Services/CostRecovery/CalOesMarsReimbursementCalculator.cs
+++ b/Core/Resgrid.Services/CostRecovery/CalOesMarsReimbursementCalculator.cs
@@ -43,8 +43,10 @@ public CalOesMarsReimbursementResult Calculate(CalOesMarsReimbursementInput inpu
result.Exceptions.Add(Exception(CalOesMarsExceptionCodes.NoSalaryRate, $"No Salary Survey / Attachment A line for classification '{person.ClassificationCode ?? "(none)"}' ({person.Name})."));
continue;
}
- var (straight, ot) = Hours(person, portalToPortal && rate.PortalToPortalEligible, overtime, rate.OvertimeEligible);
- if (straight == 0 && ot == 0 && !portalToPortal)
+ // A portal-to-portal agreement only pays committed hours on rate lines eligible for it; every other line falls back to the DTR hours.
+ var effectivePortalToPortal = portalToPortal && rate.PortalToPortalEligible;
+ var (straight, ot) = Hours(person, effectivePortalToPortal, overtime, rate.OvertimeEligible);
+ if (straight == 0 && ot == 0 && !effectivePortalToPortal)
result.Exceptions.Add(Exception(CalOesMarsExceptionCodes.NoActualHours, $"{person.Name}: no daily time report hours; nothing to reimburse under an actual-hours agreement."));
var straightRate = rate.StraightRate ?? 0m;
var overtimeRate = rate.OvertimeRate ?? (straightRate * 1.5m);
diff --git a/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs b/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs
index d97897e0..8a725171 100644
--- a/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs
+++ b/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs
@@ -23,6 +23,8 @@ namespace Resgrid.Services.CostRecovery
///
public partial class CalOesMarsService
{
+ private const int ReminderSweepPageSize = 200;
+ private const int ReminderSweepMaxDeployments = 5_000;
private static readonly int[] F42AttachmentTypes = { (int)DeploymentAttachmentTypes.SignedF42, (int)DeploymentAttachmentTypes.PaperF42, (int)DeploymentAttachmentTypes.CrewRotationApproval, (int)DeploymentAttachmentTypes.LossDamage, (int)DeploymentAttachmentTypes.ExternalOrder, (int)DeploymentAttachmentTypes.SignedServiceRequest };
#region Queue and reads
@@ -859,7 +861,15 @@ public async Task RunReminderSweepAsync(DateTime asOfUtc, Func w.RecordType == (int)CalOesMarsRecordTypes.GeneratedInvoice && w.LocalState == (int)CalOesMarsLocalStates.PendingLocalAgencyApproval);
if (invoices > 0) lines.Add($"{invoices} MARS invoice(s) awaiting local approval.");
- var deployments = await _deploymentService.GetDeploymentsForDepartmentAsync(departmentId, false, 0, 200);
+ // Every deployment, newest first, in pages: the overdue ones are not the newest, so a single capped page would skip them.
+ var deployments = new List();
+ for (var skip = 0; skip < ReminderSweepMaxDeployments; skip += ReminderSweepPageSize)
+ {
+ var page = await _deploymentService.GetDeploymentsForDepartmentAsync(departmentId, false, skip, ReminderSweepPageSize);
+ if (page == null || page.Count == 0) break;
+ deployments.AddRange(page);
+ if (page.Count < ReminderSweepPageSize) break;
+ }
var due = asOfUtc.AddDays(-Config.CostRecoveryConfig.F42DueDaysAfterRelease);
foreach (var deployment in deployments.Where(d => d.FinanceMode == (int)DeploymentFinanceModes.CostRecovery && d.Status is (int)DeploymentStatuses.Demobilizing or (int)DeploymentStatuses.Completed && (d.StatusChangedOn ?? d.EndOn ?? d.AddedOn) <= due))
{
@@ -875,7 +885,8 @@ public async Task RunReminderSweepAsync(DateTime asOfUtc, Func 50_000) RemindedToday.Clear(); }
+ // Claim the department/day atomically: an overlapping sweep that passed the check above must not send a second digest.
+ lock (RemindedToday) { if (!RemindedToday.Add(key)) continue; if (RemindedToday.Count > 50_000) RemindedToday.Clear(); }
await NotifyManagersAsync(departmentId, "Cal OES MARS: " + string.Join(" ", lines));
notified++;
}
diff --git a/Core/Resgrid.Services/CostRecovery/CalOesMarsService.cs b/Core/Resgrid.Services/CostRecovery/CalOesMarsService.cs
index 5689971c..5d05945c 100644
--- a/Core/Resgrid.Services/CostRecovery/CalOesMarsService.cs
+++ b/Core/Resgrid.Services/CostRecovery/CalOesMarsService.cs
@@ -55,13 +55,14 @@ public partial class CalOesMarsService : ICalOesMarsService
private readonly ICalOesMarsExternalGateway _gateway;
private readonly Lazy _communication;
private readonly Lazy _departmentSettings;
+ private readonly Lazy _compensation;
public CalOesMarsService(ICalOesMarsAgencyProfileRepository agencies, ICalOesMarsResourceProfileRepository resources, ICalOesMarsRateProfileRepository rateProfiles,
ICalOesMarsRateLineRepository rateLines, ICalOesMarsAdministrativeRateInputRepository adminInputs, ICalOesMarsAgreementSnapshotRepository agreements,
ICalOesMarsWorkItemRepository workItems, ICalOesMarsReimbursementLineRepository lines, IDeploymentService deploymentService, ITimeTrackingService timeTracking,
IDeploymentTimeEntryRepository timeEntries, IUnitsService unitsService, IUserProfileService userProfileService, IDepartmentsService departmentsService,
IEventAggregator eventAggregator, IUnitOfWork unitOfWork, ICalOesMarsReimbursementCalculator calculator, ICalOesMarsExternalGateway gateway,
- Lazy communication = null, Lazy departmentSettings = null)
+ Lazy communication = null, Lazy departmentSettings = null, Lazy compensation = null)
{
_agencies = agencies;
_resources = resources;
@@ -83,6 +84,7 @@ public CalOesMarsService(ICalOesMarsAgencyProfileRepository agencies, ICalOesMar
_gateway = gateway;
_communication = communication;
_departmentSettings = departmentSettings;
+ _compensation = compensation;
}
#region Readiness and agency
@@ -514,6 +516,44 @@ public async Task BuildAdministrativeRateDraf
return draft;
}
+ public async Task BuildSalarySurveyDraftAsync(string rateProfileId, int departmentId, DateTime asOf, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default)
+ {
+ var profile = await GetRateProfileAsync(rateProfileId, departmentId) ?? throw new InvalidOperationException("calmars_rate_not_found");
+ var draft = new CalOesMarsSalarySurveyDraft { RateProfileId = profile.CalOesMarsRateProfileId, AsOf = asOf.Date };
+ if (profile.SubmissionType != (int)CalOesMarsSubmissionTypes.SalarySurvey && profile.SubmissionType != (int)CalOesMarsSubmissionTypes.AttachmentA) { draft.Blockers.Add("profile_type"); return draft; }
+ if (!profile.IsEditable) { draft.Blockers.Add("rate_locked"); return draft; }
+ if (_compensation?.Value == null) { draft.Blockers.Add("workforce_unavailable"); return draft; }
+ var authority = CalOesMarsAuthorityProfile.Get(profile.AuthorityProfileCode) ?? CalOesMarsAuthorityProfile.Current;
+ // Phase E returns classification means only (decrypted through the workforce-costing purpose) — no individual reaches this service.
+ var aggregate = await _compensation.Value.GetClassificationRateAggregateAsync(departmentId, asOf.Date, authority.Code);
+ if (aggregate.Count == 0) { draft.Blockers.Add("no_classifications"); return draft; }
+ var lines = profile.Lines.Where(l => !l.IsDeleted).Select(l => l.CloneJson()).ToList();
+ foreach (var (classification, count, meanRate, meanAdder) in aggregate.OrderBy(a => a.ClassificationCode))
+ {
+ if (!authority.SalaryClassifications.Contains(classification, StringComparer.OrdinalIgnoreCase)) { draft.UnknownClassifications.Add(classification); continue; }
+ var straight = Math.Round(meanRate, 2, MidpointRounding.AwayFromZero);
+ // CFAA salary survey overtime: time-and-a-half on the straight rate plus the components paid for each overtime hour.
+ var overtime = Math.Round(meanRate * 1.5m + meanAdder, 2, MidpointRounding.AwayFromZero);
+ draft.Classifications.Add(new CalOesMarsSalarySurveyDraftLine { ClassificationCode = classification, Count = count, MeanRate = straight, MeanOvertimeAdder = Math.Round(meanAdder, 2, MidpointRounding.AwayFromZero), StraightRate = straight, OvertimeRate = overtime });
+ var kind = string.Equals(classification, "Non-suppression", StringComparison.OrdinalIgnoreCase) || profile.SubmissionType == (int)CalOesMarsSubmissionTypes.AttachmentA ? (int)CalOesMarsRateLineKinds.AttachmentANonSuppression : (int)CalOesMarsRateLineKinds.SalarySurvey;
+ var line = lines.FirstOrDefault(l => string.Equals(l.ClassificationCode, classification, StringComparison.OrdinalIgnoreCase) && (l.LineKind == (int)CalOesMarsRateLineKinds.SalarySurvey || l.LineKind == (int)CalOesMarsRateLineKinds.AttachmentANonSuppression));
+ if (line == null)
+ {
+ line = new CalOesMarsRateLine { LineKind = kind, ClassificationCode = classification, Basis = (int)CalOesMarsRateBases.Hourly, Authority = (int)CalOesMarsRateAuthorities.AgencySubmitted, OvertimeEligible = true, PortalToPortalEligible = true, SortOrder = lines.Count };
+ lines.Add(line);
+ }
+ line.StraightRate = straight; line.OvertimeRate = overtime;
+ line.SourceInputVersions = JsonConvert.SerializeObject(new { Source = "workforce-aggregate", AsOf = asOf.Date, Employees = count });
+ draft.LinesWritten++;
+ }
+ if (draft.LinesWritten == 0) { draft.Blockers.Add("no_classifications"); return draft; }
+ await SaveRateLinesAsync(profile.CalOesMarsRateProfileId, departmentId, lines, userId, ipAddress, userAgent, cancellationToken);
+ var audit = Invoicing.DeploymentService.NewAuditEvent(departmentId, userId, AuditLogTypes.CalOesMarsRateDraftBuilt, ipAddress, userAgent);
+ audit.After = JsonConvert.SerializeObject(new { profile.CalOesMarsRateProfileId, draft.AsOf, draft.LinesWritten, draft.EmployeesIncluded, Classifications = draft.Classifications.Select(c => new { c.ClassificationCode, c.Count }).ToList(), draft.UnknownClassifications });
+ _eventAggregator.SendMessage(audit);
+ return draft;
+ }
+
/// Allowable indirect ÷ allowable direct from the reviewed inputs (pure; the de-minimis option comes from the authority profile).
public static CalOesMarsAdministrativeRateDraft BuildAdministrativeRateDraft(CalOesMarsRateProfile profile, CalOesMarsAuthorityProfile authority)
{
@@ -631,8 +671,8 @@ public async Task SaveAgreementAsync(CalOesMarsAgre
var existing = string.IsNullOrWhiteSpace(agreement.CalOesMarsAgreementSnapshotId) ? null : await _agreements.GetByIdForDepartmentAsync(agreement.CalOesMarsAgreementSnapshotId, agreement.DepartmentId);
if (existing != null && existing.IsDeleted) throw new InvalidOperationException("calmars_agreement_not_found");
var before = existing == null ? null : Snapshot(existing);
- // A snapshot referenced by an external work item is immutable: the edit becomes a new version.
- var referenced = existing != null && (await _workItems.GetActionQueueAsync(agreement.DepartmentId))?.Any(w => w.AgreementSnapshotId == existing.CalOesMarsAgreementSnapshotId && w.IsExternal) == true;
+ // A snapshot referenced by an external work item is immutable: the edit becomes a new version. Closed (paid / documentation-only) items count.
+ var referenced = existing != null && (await _workItems.GetByAgreementSnapshotAsync(agreement.DepartmentId, existing.CalOesMarsAgreementSnapshotId))?.Any(w => w.IsExternal) == true;
var target = existing == null || referenced ? new CalOesMarsAgreementSnapshot { DepartmentId = agreement.DepartmentId, AddedOn = now, AddedByUserId = userId, RowVersion = referenced ? existing.RowVersion + 1 : 1 } : existing;
target.ClassificationCode = Trim(agreement.ClassificationCode);
target.ClassificationTitle = Trim(agreement.ClassificationTitle);
@@ -674,7 +714,7 @@ public async Task DeleteAgreementAsync(string agreementSnapshotId, int dep
{
var row = await GetAgreementAsync(agreementSnapshotId, departmentId);
if (row == null) return false;
- if ((await _workItems.GetActionQueueAsync(departmentId))?.Any(w => w.AgreementSnapshotId == row.CalOesMarsAgreementSnapshotId) == true) throw new InvalidOperationException("calmars_agreement_in_use");
+ if ((await _workItems.GetByAgreementSnapshotAsync(departmentId, row.CalOesMarsAgreementSnapshotId))?.Any() == true) throw new InvalidOperationException("calmars_agreement_in_use");
var before = Snapshot(row);
row.IsDeleted = true; row.EditedOn = DateTime.UtcNow; row.EditedByUserId = userId;
await _agreements.SaveOrUpdateAsync(row, cancellationToken);
diff --git a/Core/Resgrid.Services/Invoicing/BidsService.cs b/Core/Resgrid.Services/Invoicing/BidsService.cs
index 05947406..a4a329a3 100644
--- a/Core/Resgrid.Services/Invoicing/BidsService.cs
+++ b/Core/Resgrid.Services/Invoicing/BidsService.cs
@@ -46,12 +46,13 @@ public class BidsService : IBidsService
private readonly IEventAggregator _eventAggregator;
private readonly IUnitOfWork _unitOfWork;
private readonly Lazy _protectedRead;
+ private readonly Lazy _searchProjections;
public BidsService(IBidRepository bids, IBidLineItemRepository lines, IBidNumberSequenceRepository sequence, IServiceContractRepository contracts,
ICustomerBillingProfileRepository profiles, IDepartmentBillingIdentityRepository identities, IRateScheduleService rateSchedules, IDeploymentService deployments,
IContactsService contactsService, IDepartmentsService departmentsService, ICallsService callsService, ICalendarService calendarService, IEmailService emailService,
IPdfProvider pdfProvider, IDomainEventOutboxService outbox, IEventAggregator eventAggregator, IUnitOfWork unitOfWork,
- Lazy protectedRead = null)
+ Lazy protectedRead = null, Lazy searchProjections = null)
{
_bids = bids;
_lines = lines;
@@ -71,6 +72,7 @@ public BidsService(IBidRepository bids, IBidLineItemRepository lines, IBidNumber
_eventAggregator = eventAggregator;
_unitOfWork = unitOfWork;
_protectedRead = protectedRead;
+ _searchProjections = searchProjections;
}
#region Reads
@@ -151,6 +153,7 @@ public async Task CreateDraftBidAsync(int departmentId, string contactId, s
AddedByUserId = userId
};
var saved = await _bids.SaveOrUpdateAsync(bid, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectBidAsync(bid, cancellationToken);
Audit(departmentId, userId, AuditLogTypes.BidCreated, ipAddress, userAgent, null, saved);
await PublishAsync(saved, WorkflowTriggerEventType.BidCreated, null, cancellationToken);
return await GetBidByIdAsync(saved.BidId, departmentId);
@@ -191,6 +194,7 @@ public async Task SaveBidAsync(Bid bid, string userId, string ipAddress, st
existing.EditedByUserId = userId;
var saved = await _bids.SaveOrUpdateAsync(existing, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectBidAsync(existing, cancellationToken);
var recalculated = await RecalculateEstimatesAsync(saved.BidId, saved.DepartmentId, cancellationToken);
Audit(existing.DepartmentId, userId, AuditLogTypes.BidUpdated, ipAddress, userAgent, before, recalculated);
return recalculated;
@@ -279,6 +283,7 @@ public async Task RecalculateEstimatesAsync(string bidId, int departmentId,
bid.EstimatedTotal = shadow.Total;
var lineItems = bid.LineItems;
var saved = await _bids.SaveOrUpdateAsync(bid, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectBidAsync(bid, cancellationToken);
saved.LineItems = lineItems;
return saved;
}
@@ -314,6 +319,7 @@ private async Task TransitionAsync(string bidId, int departmentId, BidStatu
bid.EditedOn = DateTime.UtcNow;
bid.EditedByUserId = userId;
var saved = await _bids.SaveOrUpdateAsync(bid, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectBidAsync(bid, cancellationToken);
Audit(departmentId, userId, auditType, ipAddress, userAgent, before, saved);
if (trigger.HasValue) await PublishAsync(saved, trigger.Value, (int)from, cancellationToken);
return await GetBidByIdAsync(bidId, departmentId);
@@ -364,6 +370,7 @@ public async Task SendBidAsync(string bidId, int departmentId, string toEma
bid.EditedOn = bid.SentOn;
bid.EditedByUserId = userId;
var saved = await _bids.SaveOrUpdateAsync(bid, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectBidAsync(bid, cancellationToken);
Audit(departmentId, userId, AuditLogTypes.BidSent, ipAddress, userAgent, before, saved);
await PublishAsync(saved, WorkflowTriggerEventType.BidSent, (int)BidStatuses.Submitted, cancellationToken);
return await GetBidByIdAsync(bidId, departmentId);
@@ -391,6 +398,7 @@ public async Task DeleteBidAsync(string bidId, int departmentId, string us
bid.EditedOn = DateTime.UtcNow;
bid.EditedByUserId = userId;
await _bids.SaveOrUpdateAsync(bid, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectBidAsync(bid, cancellationToken);
Audit(departmentId, userId, AuditLogTypes.BidDeleted, ipAddress, userAgent, before, bid);
return true;
}
@@ -655,6 +663,7 @@ public async Task ConvertBidToDeploymentAsync(BidConversion
bid.EditedByUserId = userId;
var lineItems = bid.LineItems;
var savedBid = await _bids.SaveOrUpdateAsync(bid, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectBidAsync(bid, cancellationToken);
savedBid.LineItems = lineItems;
Audit(departmentId, userId, AuditLogTypes.BidConverted, ipAddress, userAgent, before, savedBid);
result.Bid = await GetBidByIdAsync(bid.BidId, departmentId);
diff --git a/Core/Resgrid.Services/Invoicing/DeploymentService.cs b/Core/Resgrid.Services/Invoicing/DeploymentService.cs
index e3bc34d5..5f17fc21 100644
--- a/Core/Resgrid.Services/Invoicing/DeploymentService.cs
+++ b/Core/Resgrid.Services/Invoicing/DeploymentService.cs
@@ -43,6 +43,7 @@ public partial class DeploymentService : IDeploymentService
private readonly Lazy _inventoryIssuance;
private readonly Lazy _protectedWrite;
private readonly Lazy _protectedRead;
+ private readonly Lazy _searchProjections;
/// The caller's Protected Data Grant (request-bound in the web hosts, workload elsewhere); a grant holder reads decrypted values.
private readonly IProtectedGrantContext _grant;
@@ -52,7 +53,7 @@ public DeploymentService(IDeploymentRepository deployments, IDeploymentUnitRepos
IPersonnelRolesService personnelRolesService, ICertificationService certificationService, IContactsService contactsService,
ICallsService callsService, IRecordDeploymentsService recordDeployments, IDomainEventOutboxService outbox, IEventAggregator eventAggregator,
IPdfProvider pdfProvider, IUnitOfWork unitOfWork,
- Lazy inventoryIssuance = null, Lazy protectedWrite = null, Lazy protectedRead = null, IProtectedGrantContext grant = null)
+ Lazy inventoryIssuance = null, Lazy protectedWrite = null, Lazy protectedRead = null, IProtectedGrantContext grant = null, Lazy searchProjections = null)
{
_grant = grant;
_deployments = deployments;
@@ -75,6 +76,7 @@ public DeploymentService(IDeploymentRepository deployments, IDeploymentUnitRepos
_inventoryIssuance = inventoryIssuance;
_protectedWrite = protectedWrite;
_protectedRead = protectedRead;
+ _searchProjections = searchProjections;
}
#region Reads
@@ -260,6 +262,7 @@ public async Task SetDeploymentStatusAsync(string deploymentId, int
if (status == DeploymentStatuses.Active && !deployment.StartOn.HasValue) deployment.StartOn = DateTime.UtcNow;
if ((status == DeploymentStatuses.Completed || status == DeploymentStatuses.Cancelled) && !deployment.EndOn.HasValue) deployment.EndOn = DateTime.UtcNow;
var saved = await _deployments.SaveOrUpdateAsync(deployment, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectDeploymentAsync(deployment, cancellationToken);
audit.After = Snapshot(saved);
_eventAggregator.SendMessage(audit);
@@ -289,6 +292,7 @@ public async Task DeleteDeploymentAsync(string deploymentId, int departmen
deployment.EditedOn = DateTime.UtcNow;
deployment.EditedByUserId = userId;
await _deployments.SaveOrUpdateAsync(deployment, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectDeploymentAsync(deployment, cancellationToken);
audit.After = Snapshot(deployment);
_eventAggregator.SendMessage(audit);
return true;
diff --git a/Core/Resgrid.Services/Invoicing/InvoicingService.cs b/Core/Resgrid.Services/Invoicing/InvoicingService.cs
index 4bebcc6b..6297ce6d 100644
--- a/Core/Resgrid.Services/Invoicing/InvoicingService.cs
+++ b/Core/Resgrid.Services/Invoicing/InvoicingService.cs
@@ -33,6 +33,7 @@ public partial class InvoicingService : IInvoicingService
private readonly IDepartmentBillingIdentityRepository _identities;
/// Contractor billing (C-M2): the linked contract's terms override the profile's net days when the invoice issues.
private readonly IServiceContractRepository _serviceContracts;
+ private readonly Lazy _searchProjections;
private readonly IContactsService _contactsService;
private readonly ICallsService _callsService;
private readonly IUnitsService _unitsService;
@@ -51,9 +52,10 @@ public InvoicingService(ICustomerBillingProfileRepository profiles, IRateCardRep
IDomainEventOutboxService outbox, IEventAggregator eventAggregator,
IPdfProvider pdfProvider, IEmailService emailService, IDepartmentsService departmentsService, IAddressService addressService, IUnitOfWork unitOfWork,
Lazy paymentsService = null, Lazy protectedRead = null,
- IServiceContractRepository serviceContracts = null)
+ IServiceContractRepository serviceContracts = null, Lazy searchProjections = null)
{
_serviceContracts = serviceContracts;
+ _searchProjections = searchProjections;
_unitOfWork = unitOfWork;
_paymentsService = paymentsService;
_protectedRead = protectedRead;
@@ -198,8 +200,6 @@ public async Task SaveRateCardAsync(RateCard rateCard, string userId,
audit.Before = Snapshot(existing);
rateCard.AddedOn = existing.AddedOn;
rateCard.AddedByUserId = existing.AddedByUserId;
- rateCard.IsProtected = existing.IsProtected;
- rateCard.ProtectedCatalogVersion = existing.ProtectedCatalogVersion;
rateCard.EditedOn = now;
rateCard.EditedByUserId = userId;
}
@@ -212,6 +212,7 @@ public async Task SaveRateCardAsync(RateCard rateCard, string userId,
rateCard.IsDeleted = false;
var saved = await _rateCards.SaveOrUpdateAsync(rateCard, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectRateCardAsync(rateCard, cancellationToken);
if (saved.IsDefault)
await _rateCards.ClearDefaultAsync(saved.DepartmentId, saved.RateCardId, cancellationToken);
@@ -241,6 +242,7 @@ public async Task DeleteRateCardAsync(string rateCardId, int departmentId,
card.EditedOn = now;
card.EditedByUserId = userId;
await _rateCards.SaveOrUpdateAsync(card, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectRateCardAsync(card, cancellationToken);
audit.After = Snapshot(card);
_eventAggregator.SendMessage(audit);
return true;
@@ -263,8 +265,6 @@ public async Task SaveRateCardItemAsync(RateCardItem item, string
audit.Before = Snapshot(existing);
item.AddedOn = existing.AddedOn;
item.AddedByUserId = existing.AddedByUserId;
- item.IsProtected = existing.IsProtected;
- item.ProtectedCatalogVersion = existing.ProtectedCatalogVersion;
item.EditedOn = now;
item.EditedByUserId = userId;
}
@@ -358,6 +358,7 @@ public async Task CreateDraftInvoiceAsync(int departmentId, string cont
};
var saved = await _invoices.SaveOrUpdateAsync(invoice, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectInvoiceAsync(invoice, cancellationToken);
var audit = NewAuditEvent(departmentId, userId, AuditLogTypes.InvoiceCreated, ipAddress, userAgent);
audit.After = Snapshot(saved);
_eventAggregator.SendMessage(audit);
@@ -387,6 +388,7 @@ public async Task SaveInvoiceAsync(Invoice invoice, string userId, stri
existing.EditedByUserId = userId;
await _invoices.SaveOrUpdateAsync(existing, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectInvoiceAsync(existing, cancellationToken);
var recalculated = await RecalculateTotalsAsync(existing.InvoiceId, existing.DepartmentId, cancellationToken);
audit.After = Snapshot(recalculated);
_eventAggregator.SendMessage(audit);
@@ -405,6 +407,7 @@ public async Task LinkInvoiceToDeploymentAsync(string invoiceId, int de
existing.EditedOn = DateTime.UtcNow;
existing.EditedByUserId = userId;
await _invoices.SaveOrUpdateAsync(existing, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectInvoiceAsync(existing, cancellationToken);
var result = await GetInvoiceByIdAsync(invoiceId, departmentId);
audit.After = Snapshot(result);
_eventAggregator.SendMessage(audit);
@@ -548,6 +551,7 @@ public async Task RecalculateTotalsAsync(string invoiceId, int departme
ComputeTotals(invoice, lines, profile);
await _invoices.SaveOrUpdateAsync(invoice, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectInvoiceAsync(invoice, cancellationToken);
invoice.LineItems = lines;
invoice.Payments = (await _payments.GetByInvoiceIdAsync(invoiceId, departmentId))?.ToList() ?? new List();
return invoice;
@@ -615,6 +619,7 @@ public async Task MarkSentAsync(string invoiceId, int departmentId, str
invoice.EditedByUserId = userId;
await _invoices.SaveOrUpdateAsync(invoice, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectInvoiceAsync(invoice, cancellationToken);
audit.After = Snapshot(invoice);
_eventAggregator.SendMessage(audit);
await PublishAsync(invoice, WorkflowTriggerEventType.InvoiceSent, oldStatus: (int)InvoiceStatus.Draft, cancellationToken: cancellationToken);
@@ -640,6 +645,7 @@ public async Task VoidInvoiceAsync(string invoiceId, int departmentId,
invoice.EditedOn = now;
invoice.EditedByUserId = userId;
await _invoices.SaveOrUpdateAsync(invoice, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectInvoiceAsync(invoice, cancellationToken);
audit.After = Snapshot(invoice);
_eventAggregator.SendMessage(audit);
await PublishAsync(invoice, WorkflowTriggerEventType.InvoiceVoided, oldStatus: oldStatus, cancellationToken: cancellationToken);
@@ -749,6 +755,7 @@ public async Task MarkOverdueInvoicesAsync(DateTime asOfUtc, Func(audit);
await PublishAsync(invoice, WorkflowTriggerEventType.InvoiceOverdue, oldStatus: oldStatus, cancellationToken: cancellationToken);
@@ -884,6 +891,7 @@ private async Task ApplyPaymentStateAsync(Invoice invoice, string userId, DateTi
invoice.EditedOn = now;
invoice.EditedByUserId = userId;
await _invoices.SaveOrUpdateAsync(invoice, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectInvoiceAsync(invoice, cancellationToken);
}
/// Paid when the balance is settled; PartiallyPaid when something is paid; otherwise Overdue if past due, else Sent. Void and Draft never change here.
diff --git a/Core/Resgrid.Services/Invoicing/ServiceContractService.cs b/Core/Resgrid.Services/Invoicing/ServiceContractService.cs
index 6147aefb..79d010ed 100644
--- a/Core/Resgrid.Services/Invoicing/ServiceContractService.cs
+++ b/Core/Resgrid.Services/Invoicing/ServiceContractService.cs
@@ -40,11 +40,12 @@ public class ServiceContractService : IServiceContractService
private readonly IUnitOfWork _unitOfWork;
private readonly Lazy _communication;
private readonly Lazy _departmentSettings;
+ private readonly Lazy _searchProjections;
public ServiceContractService(IServiceContractRepository contracts, IServiceContractDocumentRequirementRepository requirements, IDepartmentComplianceDocumentRepository documents,
IDeploymentRepository deployments, IDeploymentAttachmentRepository attachments, ICustomerBillingProfileRepository profiles, IContactsService contactsService,
IDepartmentsService departmentsService, IDomainEventOutboxService outbox, IEventAggregator eventAggregator, IUnitOfWork unitOfWork,
- Lazy communication = null, Lazy departmentSettings = null)
+ Lazy communication = null, Lazy departmentSettings = null, Lazy searchProjections = null)
{
_contracts = contracts;
_requirements = requirements;
@@ -59,6 +60,7 @@ public ServiceContractService(IServiceContractRepository contracts, IServiceCont
_unitOfWork = unitOfWork;
_communication = communication;
_departmentSettings = departmentSettings;
+ _searchProjections = searchProjections;
}
#region Contracts
@@ -122,6 +124,7 @@ public async Task SaveContractAsync(ServiceContract contract, s
if (existing != null) { target.EditedOn = now; target.EditedByUserId = userId; }
var saved = await _contracts.SaveOrUpdateAsync(target, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectServiceContractAsync(target, cancellationToken);
Audit(contract.DepartmentId, userId, existing == null ? AuditLogTypes.ServiceContractCreated : AuditLogTypes.ServiceContractUpdated, ipAddress, userAgent, before, saved);
if (existing == null && saved.Status == (int)ServiceContractStatuses.Active)
await PublishAsync(saved, WorkflowTriggerEventType.ContractStatusChanged, (int)ServiceContractStatuses.Draft, cancellationToken);
@@ -155,6 +158,7 @@ public async Task SetContractStatusAsync(string serviceContract
contract.EditedOn = DateTime.UtcNow;
contract.EditedByUserId = userId;
var saved = await _contracts.SaveOrUpdateAsync(contract, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectServiceContractAsync(contract, cancellationToken);
Audit(departmentId, userId, AuditLogTypes.ServiceContractStatusChanged, ipAddress, userAgent, before, saved);
await PublishAsync(saved, WorkflowTriggerEventType.ContractStatusChanged, (int)from, cancellationToken);
return await GetContractByIdAsync(serviceContractId, departmentId);
@@ -170,6 +174,7 @@ public async Task DeleteContractAsync(string serviceContractId, int depart
contract.EditedOn = DateTime.UtcNow;
contract.EditedByUserId = userId;
await _contracts.SaveOrUpdateAsync(contract, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectServiceContractAsync(contract, cancellationToken);
Audit(departmentId, userId, AuditLogTypes.ServiceContractDeleted, ipAddress, userAgent, before, contract);
return true;
}
@@ -194,6 +199,7 @@ public async Task> SaveRequirementsAsyn
contract.EditedOn = DateTime.UtcNow;
contract.EditedByUserId = userId;
await _contracts.SaveOrUpdateAsync(contract, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectServiceContractAsync(contract, cancellationToken);
var audit = DeploymentService.NewAuditEvent(departmentId, userId, AuditLogTypes.ServiceContractUpdated, ipAddress, userAgent);
audit.Before = before;
audit.After = saved.CloneJsonToString();
@@ -362,6 +368,7 @@ async Task Enabled(int departmentId)
contract.Status = (int)ServiceContractStatuses.Expired;
contract.EditedOn = asOfUtc;
var saved = await _contracts.SaveOrUpdateAsync(contract, cancellationToken);
+ if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectServiceContractAsync(contract, cancellationToken);
Audit(contract.DepartmentId, null, AuditLogTypes.ServiceContractStatusChanged, null, null, before, saved);
await PublishAsync(saved, WorkflowTriggerEventType.ContractStatusChanged, (int)ServiceContractStatuses.Active, cancellationToken);
touched++;
diff --git a/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs b/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs
index 0679a9ab..a7ad7301 100644
--- a/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs
+++ b/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs
@@ -548,8 +548,6 @@ public async Task SaveExpenseAsync(DeploymentExpense expense,
expense.ReceiptAttachmentId ??= existing.ReceiptAttachmentId;
expense.AddedOn = existing.AddedOn;
expense.AddedByUserId = existing.AddedByUserId;
- expense.IsProtected = existing.IsProtected;
- expense.ProtectedCatalogVersion = existing.ProtectedCatalogVersion;
expense.EditedOn = DateTime.UtcNow;
expense.EditedByUserId = userId;
}
diff --git a/Core/Resgrid.Services/Search/SearchIndexMaintenanceService.cs b/Core/Resgrid.Services/Search/SearchIndexMaintenanceService.cs
index af6922fc..f08a11bf 100644
--- a/Core/Resgrid.Services/Search/SearchIndexMaintenanceService.cs
+++ b/Core/Resgrid.Services/Search/SearchIndexMaintenanceService.cs
@@ -6,6 +6,7 @@
using Resgrid.Config;
using Resgrid.Framework;
using Resgrid.Model;
+using Resgrid.Model.Invoicing;
using Resgrid.Model.Repositories;
using Resgrid.Model.Search;
using Resgrid.Model.Services;
@@ -38,12 +39,24 @@ public class SearchIndexMaintenanceService : ISearchIndexMaintenanceService
private readonly IMessageService _messages;
private readonly IDocumentsService _documents;
private readonly INotesService _notes;
+ private readonly Lazy _invoicing;
+ private readonly Lazy _bids;
+ private readonly Lazy _contracts;
+ private readonly Lazy _deploymentsService;
+ private readonly Lazy _certifications;
public SearchIndexMaintenanceService(ISearchIndexStatesRepository states, ISearchProjectionsRepository projections, IGlobalSearchIndexer indexer,
IDepartmentDataProtectionService dataProtection, IFeatureToggleService featureToggles, ISearchProjectionService projectionService,
ICallsService calls, IUnitsService units, IUserProfileService profiles, IDepartmentsService departments, IDepartmentGroupsService groups,
- IContactsService contacts, IMessageService messages, IDocumentsService documents, INotesService notes)
+ IContactsService contacts, IMessageService messages, IDocumentsService documents, INotesService notes,
+ Lazy invoicing = null, Lazy bids = null, Lazy contracts = null,
+ Lazy deploymentsService = null, Lazy certifications = null)
{
+ _invoicing = invoicing;
+ _bids = bids;
+ _contracts = contracts;
+ _deploymentsService = deploymentsService;
+ _certifications = certifications;
_states = states;
_projections = projections;
_indexer = indexer;
@@ -349,6 +362,89 @@ private async Task RebuildProjectionsAsync(int departmentId, CancellationTo
return n;
}, started, cancellationToken);
+ // Workforce & Business Operations families (decision 41). Each service is Lazy so the search worker never forms a construction cycle with them.
+ if (_invoicing?.Value != null)
+ {
+ count += await Family(departmentId, SearchEntityTypes.Invoice, async () =>
+ {
+ var n = 0;
+ foreach (var invoice in await _invoicing.Value.GetInvoicesForDepartmentAsync(departmentId, new InvoiceListFilter { Skip = 0, Take = 5000 }) ?? new List())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var p = await _projectionService.BuildInvoiceAsync(invoice);
+ if (p != null) { await _projectionService.UpsertAsync(p, cancellationToken); n++; }
+ }
+ return n;
+ }, started, cancellationToken);
+ count += await Family(departmentId, SearchEntityTypes.RateCard, async () =>
+ {
+ var n = 0;
+ foreach (var card in await _invoicing.Value.GetRateCardsForDepartmentAsync(departmentId) ?? new List())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var p = await _projectionService.BuildRateCardAsync(card);
+ if (p != null) { await _projectionService.UpsertAsync(p, cancellationToken); n++; }
+ }
+ return n;
+ }, started, cancellationToken);
+ }
+ if (_bids?.Value != null)
+ {
+ count += await Family(departmentId, SearchEntityTypes.Bid, async () =>
+ {
+ var n = 0;
+ foreach (var bid in await _bids.Value.GetBidsForDepartmentAsync(departmentId, null, 0, 5000) ?? new List())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var p = await _projectionService.BuildBidAsync(bid);
+ if (p != null) { await _projectionService.UpsertAsync(p, cancellationToken); n++; }
+ }
+ return n;
+ }, started, cancellationToken);
+ }
+ if (_contracts?.Value != null)
+ {
+ count += await Family(departmentId, SearchEntityTypes.ServiceContract, async () =>
+ {
+ var n = 0;
+ foreach (var contract in await _contracts.Value.GetContractsForDepartmentAsync(departmentId) ?? new List())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var p = await _projectionService.BuildServiceContractAsync(contract);
+ if (p != null) { await _projectionService.UpsertAsync(p, cancellationToken); n++; }
+ }
+ return n;
+ }, started, cancellationToken);
+ }
+ if (_deploymentsService?.Value != null)
+ {
+ count += await Family(departmentId, SearchEntityTypes.Deployment, async () =>
+ {
+ var n = 0;
+ foreach (var deployment in await _deploymentsService.Value.GetDeploymentsForDepartmentAsync(departmentId, false, 0, 5000) ?? new List())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var p = await _projectionService.BuildDeploymentAsync(deployment);
+ if (p != null) { await _projectionService.UpsertAsync(p, cancellationToken); n++; }
+ }
+ return n;
+ }, started, cancellationToken);
+ }
+ if (_certifications?.Value != null)
+ {
+ count += await Family(departmentId, SearchEntityTypes.CertificationType, async () =>
+ {
+ var n = 0;
+ foreach (var type in await _certifications.Value.GetAllCertificationTypesByDepartmentAsync(departmentId) ?? new List())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var p = await _projectionService.BuildCertificationTypeAsync(type);
+ if (p != null) { await _projectionService.UpsertAsync(p, cancellationToken); n++; }
+ }
+ return n;
+ }, started, cancellationToken);
+ }
+
count += await Family(departmentId, SearchEntityTypes.Message, async () =>
{
// One department-scoped read (M0137 owner column) instead of two folder queries per member. A message
diff --git a/Core/Resgrid.Services/Search/SearchProjectionService.cs b/Core/Resgrid.Services/Search/SearchProjectionService.cs
index e1deccb8..26f584d1 100644
--- a/Core/Resgrid.Services/Search/SearchProjectionService.cs
+++ b/Core/Resgrid.Services/Search/SearchProjectionService.cs
@@ -7,6 +7,7 @@
using Newtonsoft.Json;
using Resgrid.Framework;
using Resgrid.Model;
+using Resgrid.Model.Invoicing;
using Resgrid.Model.Repositories;
using Resgrid.Model.Search;
using Resgrid.Model.Services;
@@ -115,6 +116,24 @@ private async Task Guarded(string entityType, int departmentId, string entityId,
}
}
+ public Task ProjectInvoiceAsync(Invoice invoice, CancellationToken cancellationToken = default)
+ => Guarded(SearchEntityTypes.Invoice, invoice?.DepartmentId ?? 0, invoice?.InvoiceId, invoice != null && invoice.IsDeleted, () => BuildInvoiceAsync(invoice), cancellationToken);
+
+ public Task ProjectRateCardAsync(RateCard rateCard, CancellationToken cancellationToken = default)
+ => Guarded(SearchEntityTypes.RateCard, rateCard?.DepartmentId ?? 0, rateCard?.RateCardId, rateCard != null && rateCard.IsDeleted, () => BuildRateCardAsync(rateCard), cancellationToken);
+
+ public Task ProjectBidAsync(Bid bid, CancellationToken cancellationToken = default)
+ => Guarded(SearchEntityTypes.Bid, bid?.DepartmentId ?? 0, bid?.BidId, bid != null && bid.IsDeleted, () => BuildBidAsync(bid), cancellationToken);
+
+ public Task ProjectServiceContractAsync(ServiceContract contract, CancellationToken cancellationToken = default)
+ => Guarded(SearchEntityTypes.ServiceContract, contract?.DepartmentId ?? 0, contract?.ServiceContractId, contract != null && contract.IsDeleted, () => BuildServiceContractAsync(contract), cancellationToken);
+
+ public Task ProjectDeploymentAsync(Deployment deployment, CancellationToken cancellationToken = default)
+ => Guarded(SearchEntityTypes.Deployment, deployment?.DepartmentId ?? 0, deployment?.DeploymentId, deployment != null && deployment.IsDeleted, () => BuildDeploymentAsync(deployment), cancellationToken);
+
+ public Task ProjectCertificationTypeAsync(DepartmentCertificationType type, CancellationToken cancellationToken = default)
+ => Guarded(SearchEntityTypes.CertificationType, type?.DepartmentId ?? 0, type?.DepartmentCertificationTypeId.ToString(), false, () => BuildCertificationTypeAsync(type), cancellationToken);
+
// ---- builders --------------------------------------------------------------------------------------------
public async Task BuildCallAsync(Call call)
@@ -318,6 +337,122 @@ public async Task BuildNoteAsync(Note note)
return p;
}
+ // ---- Workforce & Business Operations families (decision 41): identifier, title, status. Never an amount, a line, a note, an e-mail or a person. ----
+
+ public async Task BuildInvoiceAsync(Invoice invoice)
+ {
+ if (invoice == null || invoice.DepartmentId <= 0 || string.IsNullOrWhiteSpace(invoice.InvoiceId) || invoice.IsDeleted)
+ return null;
+ var ctx = await ContextAsync(invoice.DepartmentId);
+ var status = Enum.IsDefined(typeof(InvoiceStatus), invoice.Status) ? ((InvoiceStatus)invoice.Status).ToString() : invoice.Status.ToString();
+ var p = New(invoice.DepartmentId, SearchEntityTypes.Invoice, invoice.InvoiceId, ctx);
+ p.Title = Cap("Invoice #" + invoice.InvoiceNumber, TitleMax);
+ p.Summary = Cap(Join(" · ", status, invoice.IssuedOn?.ToString("yyyy-MM-dd"), Safe(invoice.Currency)), SummaryMax);
+ p.Keywords = Cap(Join(" ", invoice.InvoiceNumber.ToString(), Safe(invoice.DeploymentId), Safe(invoice.ServiceContractId)), KeywordsMax);
+ p.Category = status;
+ p.IsActive = invoice.Status != (int)InvoiceStatus.Void;
+ p.OccurredOn = invoice.IssuedOn ?? (invoice.AddedOn == default ? DateTime.UtcNow : invoice.AddedOn);
+ p.Url = $"/User/Invoicing/View?id={Uri.EscapeDataString(invoice.InvoiceId)}";
+ p.MetadataJson = Json(new Dictionary { ["Status"] = status, ["ContactId"] = Safe(invoice.ContactId), ["DeploymentId"] = Safe(invoice.DeploymentId) });
+ return p;
+ }
+
+ public async Task BuildRateCardAsync(RateCard rateCard)
+ {
+ if (rateCard == null || rateCard.DepartmentId <= 0 || string.IsNullOrWhiteSpace(rateCard.RateCardId) || rateCard.IsDeleted)
+ return null;
+ var ctx = await ContextAsync(rateCard.DepartmentId);
+ var name = Safe(rateCard.Name);
+ if (name == null) return null;
+ var p = New(rateCard.DepartmentId, SearchEntityTypes.RateCard, rateCard.RateCardId, ctx);
+ p.Title = Cap(name, TitleMax);
+ p.Summary = Cap(Join(" · ", rateCard.IsDefault ? "Default" : null, rateCard.Active ? "Active" : "Inactive", Safe(rateCard.Description)), SummaryMax);
+ p.Category = rateCard.Active ? "Active" : "Inactive";
+ p.IsActive = rateCard.Active;
+ p.OccurredOn = rateCard.AddedOn == default ? DateTime.UtcNow : rateCard.AddedOn;
+ p.Url = $"/User/Invoicing/EditRateCard?id={Uri.EscapeDataString(rateCard.RateCardId)}";
+ p.MetadataJson = Json(new Dictionary { ["IsDefault"] = rateCard.IsDefault.ToString() });
+ return p;
+ }
+
+ public async Task BuildBidAsync(Bid bid)
+ {
+ if (bid == null || bid.DepartmentId <= 0 || string.IsNullOrWhiteSpace(bid.BidId) || bid.IsDeleted)
+ return null;
+ var ctx = await ContextAsync(bid.DepartmentId);
+ var status = Enum.IsDefined(typeof(BidStatuses), bid.Status) ? ((BidStatuses)bid.Status).ToString() : bid.Status.ToString();
+ var p = New(bid.DepartmentId, SearchEntityTypes.Bid, bid.BidId, ctx);
+ p.Title = Cap(Join(" ", "Bid #" + bid.BidNumber, Safe(bid.Title)), TitleMax);
+ p.Summary = Cap(Join(" · ", status, Safe(bid.IncidentNumber), bid.RequestedStartOn?.ToString("yyyy-MM-dd")), SummaryMax);
+ p.Keywords = Cap(Join(" ", bid.BidNumber.ToString(), Safe(bid.IncidentNumber), Safe(bid.ConvertedDeploymentId)), KeywordsMax);
+ p.Category = status;
+ p.IsActive = bid.Status != (int)BidStatuses.Declined && bid.Status != (int)BidStatuses.Expired;
+ p.OccurredOn = bid.AddedOn == default ? DateTime.UtcNow : bid.AddedOn;
+ p.Url = $"/User/Bids/View?id={Uri.EscapeDataString(bid.BidId)}";
+ p.MetadataJson = Json(new Dictionary { ["Status"] = status, ["ContactId"] = Safe(bid.ContactId), ["DeploymentId"] = Safe(bid.ConvertedDeploymentId) });
+ return p;
+ }
+
+ public async Task BuildServiceContractAsync(ServiceContract contract)
+ {
+ if (contract == null || contract.DepartmentId <= 0 || string.IsNullOrWhiteSpace(contract.ServiceContractId) || contract.IsDeleted)
+ return null;
+ var ctx = await ContextAsync(contract.DepartmentId);
+ var name = Safe(contract.Name);
+ if (name == null) return null;
+ var status = Enum.IsDefined(typeof(ServiceContractStatuses), contract.Status) ? ((ServiceContractStatuses)contract.Status).ToString() : contract.Status.ToString();
+ var p = New(contract.DepartmentId, SearchEntityTypes.ServiceContract, contract.ServiceContractId, ctx);
+ p.Title = Cap(Join(" ", Safe(contract.ContractNumber), name), TitleMax);
+ p.Summary = Cap(Join(" · ", status, contract.StartOn.ToString("yyyy-MM-dd") + " – " + (contract.EndOn?.ToString("yyyy-MM-dd") ?? "…")), SummaryMax);
+ p.Keywords = Cap(Safe(contract.ContractNumber), KeywordsMax);
+ p.Category = status;
+ p.IsActive = contract.Status == (int)ServiceContractStatuses.Active;
+ p.OccurredOn = contract.StartOn == default ? DateTime.UtcNow : contract.StartOn;
+ p.Url = $"/User/Contracts/View?id={Uri.EscapeDataString(contract.ServiceContractId)}";
+ p.MetadataJson = Json(new Dictionary { ["Status"] = status, ["ContactId"] = Safe(contract.ContactId) });
+ return p;
+ }
+
+ public async Task BuildDeploymentAsync(Deployment deployment)
+ {
+ if (deployment == null || deployment.DepartmentId <= 0 || string.IsNullOrWhiteSpace(deployment.DeploymentId) || deployment.IsDeleted)
+ return null;
+ var ctx = await ContextAsync(deployment.DepartmentId);
+ var name = Safe(deployment.Name);
+ if (name == null) return null;
+ var status = Enum.IsDefined(typeof(DeploymentStatuses), deployment.Status) ? ((DeploymentStatuses)deployment.Status).ToString() : deployment.Status.ToString();
+ // Deployments.Notes is ADP catalog 27 and never indexed; the order / request / incident identifiers print on every claim and are plain.
+ var p = New(deployment.DepartmentId, SearchEntityTypes.Deployment, deployment.DeploymentId, ctx);
+ p.Title = Cap(name, TitleMax);
+ p.Summary = Cap(Join(" · ", status, Safe(deployment.IncidentNumber), Safe(deployment.ResourceOrderNumber), deployment.StartOn?.ToString("yyyy-MM-dd")), SummaryMax);
+ p.Keywords = Cap(Join(" ", Safe(deployment.IncidentNumber), Safe(deployment.ResourceOrderNumber), Safe(deployment.RequestNumber), Safe(deployment.CostCode), deployment.CallId?.ToString()), KeywordsMax);
+ p.Category = status;
+ p.IsActive = deployment.IsOpen;
+ p.OccurredOn = deployment.StartOn ?? (deployment.AddedOn == default ? DateTime.UtcNow : deployment.AddedOn);
+ p.Url = $"/User/Deployments/View?id={Uri.EscapeDataString(deployment.DeploymentId)}";
+ p.MetadataJson = Json(new Dictionary { ["Status"] = status, ["FinanceMode"] = deployment.FinanceMode.ToString(), ["CallId"] = deployment.CallId?.ToString() });
+ return p;
+ }
+
+ public async Task BuildCertificationTypeAsync(DepartmentCertificationType type)
+ {
+ if (type == null || type.DepartmentId <= 0 || type.DepartmentCertificationTypeId <= 0)
+ return null;
+ var ctx = await ContextAsync(type.DepartmentId);
+ var name = Safe(type.Type);
+ if (name == null) return null;
+ var p = New(type.DepartmentId, SearchEntityTypes.CertificationType, type.DepartmentCertificationTypeId.ToString(), ctx);
+ p.Title = Cap(name, TitleMax);
+ p.Summary = Cap(Join(" · ", Safe(type.Code), Safe(type.IssuingAuthority), Safe(type.Description)), SummaryMax);
+ p.Keywords = Cap(Join(" ", Safe(type.Code), Safe(type.IssuingAuthority)), KeywordsMax);
+ p.Category = type.IsActive ? "Active" : "Inactive";
+ p.IsActive = type.IsActive;
+ p.OccurredOn = DateTime.UtcNow;
+ p.Url = $"/User/Certifications/EditType?id={type.DepartmentCertificationTypeId}";
+ p.MetadataJson = Json(new Dictionary { ["Code"] = Safe(type.Code), ["AppliesTo"] = type.AppliesTo.ToString() });
+ return p;
+ }
+
// ---- helpers ---------------------------------------------------------------------------------------------
private sealed class ProjectionContext
diff --git a/Core/Resgrid.Services/Search/UnifiedSearchService.Authorization.cs b/Core/Resgrid.Services/Search/UnifiedSearchService.Authorization.cs
index d1670557..1c4a7417 100644
--- a/Core/Resgrid.Services/Search/UnifiedSearchService.Authorization.cs
+++ b/Core/Resgrid.Services/Search/UnifiedSearchService.Authorization.cs
@@ -150,6 +150,33 @@ private async Task AuthorizeAsync(GlobalSearchHit hit, SearchAccess access
return note != null && note.DepartmentId == departmentId &&
(!note.IsAdminOnly || principal.IsDepartmentAdmin) &&
(!note.ExpiresOn.HasValue || note.ExpiresOn > DateTime.UtcNow);
+ // Workforce & Business Operations families: the row must still exist in the department and the caller must hold the page's view claim.
+ case SearchEntityTypes.Invoice:
+ if (_invoicing?.Value == null || !principal.HasResourceClaim("Invoicing", "View") && !principal.IsDepartmentAdmin) return false;
+ var invoice = await _invoicing.Value.GetInvoiceByIdAsync(hit.EntityId, departmentId);
+ return invoice != null && invoice.DepartmentId == departmentId && !invoice.IsDeleted;
+ case SearchEntityTypes.RateCard:
+ if (_invoicing?.Value == null || !principal.HasResourceClaim("Invoicing", "View") && !principal.IsDepartmentAdmin) return false;
+ var rateCard = await _invoicing.Value.GetRateCardByIdAsync(hit.EntityId, departmentId, true);
+ return rateCard != null && rateCard.DepartmentId == departmentId && !rateCard.IsDeleted;
+ case SearchEntityTypes.Bid:
+ if (_bids?.Value == null || !principal.HasResourceClaim("Bids", "View") && !principal.IsDepartmentAdmin) return false;
+ var bid = await _bids.Value.GetBidByIdAsync(hit.EntityId, departmentId);
+ return bid != null && bid.DepartmentId == departmentId && !bid.IsDeleted;
+ case SearchEntityTypes.ServiceContract:
+ if (_contracts?.Value == null || !principal.HasResourceClaim("ServiceContracts", "View") && !principal.IsDepartmentAdmin) return false;
+ var contract = await _contracts.Value.GetContractByIdAsync(hit.EntityId, departmentId);
+ return contract != null && contract.DepartmentId == departmentId && !contract.IsDeleted;
+ case SearchEntityTypes.Deployment:
+ if (_deploymentsService?.Value == null) return false;
+ var deployment = await _deploymentsService.Value.GetDeploymentByIdAsync(hit.EntityId, departmentId);
+ if (deployment == null || deployment.DepartmentId != departmentId || deployment.IsDeleted) return false;
+ // Rostered members reach their own deployments without the claim, exactly as the deployment page does.
+ return principal.IsDepartmentAdmin || principal.HasResourceClaim("Deployments", "View") || deployment.Personnel?.Any(p => p.UserId == userId && !p.RemovedOn.HasValue) == true;
+ case SearchEntityTypes.CertificationType:
+ if (_certifications?.Value == null || !int.TryParse(hit.EntityId, out var typeId) || !principal.HasResourceClaim("Certifications", "View") && !principal.IsDepartmentAdmin) return false;
+ var certificationType = await _certifications.Value.GetCertificationTypeByIdAsync(typeId);
+ return certificationType != null && certificationType.DepartmentId == departmentId;
default:
return false;
}
diff --git a/Core/Resgrid.Services/Search/UnifiedSearchService.cs b/Core/Resgrid.Services/Search/UnifiedSearchService.cs
index 316d5999..7e4ff138 100644
--- a/Core/Resgrid.Services/Search/UnifiedSearchService.cs
+++ b/Core/Resgrid.Services/Search/UnifiedSearchService.cs
@@ -7,6 +7,7 @@
using Newtonsoft.Json;
using Resgrid.Framework;
using Resgrid.Model;
+using Resgrid.Model.Invoicing;
using Resgrid.Model.Repositories;
using Resgrid.Model.Search;
using Resgrid.Model.Services;
@@ -25,6 +26,11 @@ public partial class UnifiedSearchService : IUnifiedSearchService
private const int CandidateWindow = 200;
private readonly IGlobalSearchService _global;
+ private readonly Lazy _invoicing;
+ private readonly Lazy _bids;
+ private readonly Lazy _contracts;
+ private readonly Lazy _deploymentsService;
+ private readonly Lazy _certifications;
private readonly ISystemActionsService _actions;
private readonly IFeatureToggleService _featureToggles;
private readonly IAuthorizationService _authorization;
@@ -41,8 +47,15 @@ public UnifiedSearchService(IGlobalSearchService global, ISystemActionsService a
IPersonnelRolesService roles, ICallsService calls, IUnitsService units, IMessageService messages,
IDocumentsService documents, INotesService notes, IContactsService contacts,
IDepartmentDataProtectionService dataProtection, IDepartmentSettingsService departmentSettings,
- ISearchProjectionsRepository projections)
+ ISearchProjectionsRepository projections,
+ Lazy invoicing = null, Lazy bids = null, Lazy contracts = null,
+ Lazy deployments = null, Lazy certifications = null)
{
+ _invoicing = invoicing;
+ _bids = bids;
+ _contracts = contracts;
+ _deploymentsService = deployments;
+ _certifications = certifications;
_global = global;
_actions = actions;
_featureToggles = featureToggles;
@@ -253,6 +266,13 @@ void Add(string type, string resource, string module = null)
Add(SearchEntityTypes.Message, "Messages", SystemActionModules.Messaging);
Add(SearchEntityTypes.Document, "Documents", SystemActionModules.Documents);
Add(SearchEntityTypes.Note, "Notes", SystemActionModules.Notes);
+ // Workforce & Business Operations families (decision 41): the same claims as their pages; the paid ones behind the Business Ops module switch.
+ Add(SearchEntityTypes.Invoice, "Invoicing", SystemActionModules.BusinessOperations);
+ Add(SearchEntityTypes.RateCard, "Invoicing", SystemActionModules.BusinessOperations);
+ Add(SearchEntityTypes.Bid, "Bids", SystemActionModules.BusinessOperations);
+ Add(SearchEntityTypes.ServiceContract, "ServiceContracts", SystemActionModules.BusinessOperations);
+ Add(SearchEntityTypes.Deployment, "Deployments");
+ Add(SearchEntityTypes.CertificationType, "Certifications");
return allowed;
}
diff --git a/Core/Resgrid.Services/Workforce/FieldCostingService.cs b/Core/Resgrid.Services/Workforce/FieldCostingService.cs
index 11e49505..f8048699 100644
--- a/Core/Resgrid.Services/Workforce/FieldCostingService.cs
+++ b/Core/Resgrid.Services/Workforce/FieldCostingService.cs
@@ -332,7 +332,7 @@ public async Task EstimateBidCostAsync(string bidId, int departmen
{
var usage = new ResourceUsageQuantity { SubjectLabel = line.Description, UsageDate = asOf, Days = days * quantity, OperatingHours = hoursPerDay * days * quantity, Deployments = quantity, SourceType = "BidLineItem", SourceId = line.BidLineItemId };
var profile = profiles.FirstOrDefault(p => p.SubjectType == (int)ResourceSubjectTypes.External && !string.IsNullOrWhiteSpace(line.RateScheduleEntryId) && string.Equals(p.ExternalResourceKey, line.RateScheduleEntryId, StringComparison.OrdinalIgnoreCase));
- builder.AddResource(FieldCostCalculator.CalculateResource(new ResourceCostInput { Usage = usage, Profile = profile, IsFallback = profile != null, AsOf = asOf }), usage, "BidLineItem", line.BidLineItemId, line.Description, true);
+ builder.AddResource(FieldCostCalculator.CalculateResource(new ResourceCostInput { Usage = usage, Profile = profile, IsFallback = false, AsOf = asOf }), usage, "BidLineItem", line.BidLineItemId, line.Description, true);
break;
}
default:
diff --git a/Core/Resgrid.Services/Workforce/PayDataAggregator.cs b/Core/Resgrid.Services/Workforce/PayDataAggregator.cs
index 0ba90d92..100f2ff3 100644
--- a/Core/Resgrid.Services/Workforce/PayDataAggregator.cs
+++ b/Core/Resgrid.Services/Workforce/PayDataAggregator.cs
@@ -152,8 +152,8 @@ public static byte[] RenderXlsx(IReadOnlyList columns, IEnumera
var sheet = new StringBuilder();
sheet.Append("");
var r = 1;
- sheet.Append(XlsxRow(r++, columns.Select(c => c.Header).ToList()));
- foreach (var row in rows) sheet.Append(XlsxRow(r++, row));
+ sheet.Append(XlsxRow(r++, columns.Select(c => c.Header).ToList(), null));
+ foreach (var row in rows) sheet.Append(XlsxRow(r++, row, columns));
sheet.Append("");
using var stream = new MemoryStream();
using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, true))
@@ -167,14 +167,17 @@ public static byte[] RenderXlsx(IReadOnlyList columns, IEnumera
return stream.ToArray();
}
- private static string XlsxRow(int index, IReadOnlyList cells)
+ /// Cell type follows the schema, not the text: only integer / decimal columns become numeric cells, so zero-padded ZIP, NAICS and code values keep their leading zeros and a rate such as 0.75 stays a number. The header row (no schema) is all text.
+ private static string XlsxRow(int index, IReadOnlyList cells, IReadOnlyList columns)
{
var sb = new StringBuilder("");
for (var c = 0; c < cells.Count; c++)
{
var value = cells[c] ?? string.Empty;
var reference = ColumnName(c) + index;
- if (decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out _) && value.Trim().Length > 0 && value == value.Trim() && !value.StartsWith("0") || value == "0")
+ var type = columns != null && c < columns.Count ? columns[c].Type : null;
+ var numeric = (type == "integer" || type == "decimal") && decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out _) && value == value.Trim() && value.Length > 0;
+ if (numeric)
sb.Append("").Append(value).Append("");
else
sb.Append("").Append(Xml(value)).Append("");
diff --git a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs
index 1dc986a6..1291c4e0 100644
--- a/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs
+++ b/Providers/Resgrid.Providers.Claims/ClaimsLogic.cs
@@ -1818,11 +1818,11 @@ public static RecordClaimGrant[] RecordClaimGrants(PermissionTypes type)
new RecordClaimGrant(ResgridClaimTypes.Resources.Workforce, ResgridClaimTypes.Actions.View),
new RecordClaimGrant(ResgridClaimTypes.Resources.WorkforceCompensation, ResgridClaimTypes.Actions.View)
};
+ // The report wizard reads the workforce roster (workers, establishments, contractors); editing it is permission 75.
case PermissionTypes.ManagePayDataReporting:
return new[]
{
new RecordClaimGrant(ResgridClaimTypes.Resources.Workforce, ResgridClaimTypes.Actions.View),
- new RecordClaimGrant(ResgridClaimTypes.Resources.Workforce, ResgridClaimTypes.Actions.Update),
new RecordClaimGrant(ResgridClaimTypes.Resources.PayDataReporting, ResgridClaimTypes.Actions.View),
new RecordClaimGrant(ResgridClaimTypes.Resources.PayDataReporting, ResgridClaimTypes.Actions.Update)
};
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0209_AddCustomerBillingAndRateCards.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0209_AddCustomerBillingAndRateCards.cs
index 339de0ec..1e90e7f0 100644
--- a/Providers/Resgrid.Providers.Migrations/Migrations/M0209_AddCustomerBillingAndRateCards.cs
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0209_AddCustomerBillingAndRateCards.cs
@@ -5,8 +5,8 @@ namespace Resgrid.Providers.Migrations.Migrations
///
/// Workforce & Business Operations plan, Phase B (B1): customer billing profiles (a Contact becomes billable),
/// simple per-call rate cards, and the department's own billing identity that prints on every invoice.
- /// Registry M0209, the next physical number under the no-gaps rule. Every table carries the ADP row marker
- /// (IsProtected / ProtectedCatalogVersion) from creation; the Phase B catalog (26) is registered by M0212.
+ /// Registry M0209, the next physical number under the no-gaps rule. No table carries an ADP row marker: nothing here is under Advanced Data Protection (plan decision 44) — customers read invoices and pay pages without a login
+ /// (the marker columns were dropped before release on 2026-09-20). The customer Contact row stays under ADP.
/// Guarded for safe retry.
///
[Migration(209)]
@@ -37,9 +37,7 @@ public override void Up()
.WithColumn("AddedOn").AsDateTime2().NotNullable()
.WithColumn("AddedByUserId").AsString(128).Nullable()
.WithColumn("EditedOn").AsDateTime2().Nullable()
- .WithColumn("EditedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0);
+ .WithColumn("EditedByUserId").AsString(128).Nullable();
Create.Index("IX_CustomerBillingProfiles_Department").OnTable("CustomerBillingProfiles")
.OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending();
@@ -61,9 +59,7 @@ public override void Up()
.WithColumn("AddedOn").AsDateTime2().NotNullable()
.WithColumn("AddedByUserId").AsString(128).Nullable()
.WithColumn("EditedOn").AsDateTime2().Nullable()
- .WithColumn("EditedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0);
+ .WithColumn("EditedByUserId").AsString(128).Nullable();
Create.Index("IX_RateCards_Department").OnTable("RateCards")
.OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending();
@@ -92,9 +88,7 @@ public override void Up()
.WithColumn("AddedOn").AsDateTime2().NotNullable()
.WithColumn("AddedByUserId").AsString(128).Nullable()
.WithColumn("EditedOn").AsDateTime2().Nullable()
- .WithColumn("EditedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0);
+ .WithColumn("EditedByUserId").AsString(128).Nullable();
Create.Index("IX_RateCardItems_RateCard").OnTable("RateCardItems")
.OnColumn("RateCardId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("SortOrder").Ascending();
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0210_AddInvoices.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0210_AddInvoices.cs
index ae1ff5c7..86e9bdd6 100644
--- a/Providers/Resgrid.Providers.Migrations/Migrations/M0210_AddInvoices.cs
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0210_AddInvoices.cs
@@ -7,7 +7,7 @@ namespace Resgrid.Providers.Migrations.Migrations
/// invoice number sequence. Registry M0210. Money is decimal(18,2) for totals and decimal(18,4) for rates.
/// InvoicePayments already carries the Phase B2 online-payment columns (request id, provider, status, refund,
/// fee/net, payer e-mail, method summary, receipt URL) so M0212 adds no ALTER; Invoices.PlatformFeeAmount is
- /// reserved and always null in v1. Every table carries the ADP row marker from creation. Guarded for safe retry.
+ /// reserved and always null in v1. Nothing here is under Advanced Data Protection (decision 44; marker columns dropped before release). Guarded for safe retry.
///
[Migration(210)]
public class M0210_AddInvoices : Migration
@@ -45,9 +45,7 @@ public override void Up()
.WithColumn("AddedOn").AsDateTime2().NotNullable()
.WithColumn("AddedByUserId").AsString(128).Nullable()
.WithColumn("EditedOn").AsDateTime2().Nullable()
- .WithColumn("EditedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0);
+ .WithColumn("EditedByUserId").AsString(128).Nullable();
// The sequence table is the authority; this unique index is the backstop (decision 7).
Create.Index("UX_Invoices_Department_Number").OnTable("Invoices")
@@ -73,9 +71,7 @@ public override void Up()
.WithColumn("UnitRate").AsDecimal(18, 4).NotNullable().WithDefaultValue(0)
.WithColumn("Amount").AsDecimal(18, 2).NotNullable().WithDefaultValue(0)
.WithColumn("Taxable").AsBoolean().NotNullable().WithDefaultValue(true)
- .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0)
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0);
+ .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0);
Create.Index("IX_InvoiceLineItems_Invoice").OnTable("InvoiceLineItems")
.OnColumn("InvoiceId").Ascending().OnColumn("SortOrder").Ascending();
@@ -105,9 +101,7 @@ public override void Up()
.WithColumn("Notes").AsString(int.MaxValue).Nullable()
.WithColumn("PaidOn").AsDateTime2().NotNullable()
.WithColumn("RecordedByUserId").AsString(128).Nullable()
- .WithColumn("AddedOn").AsDateTime2().NotNullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().NotNullable().WithDefaultValue(0);
+ .WithColumn("AddedOn").AsDateTime2().NotNullable();
Create.Index("IX_InvoicePayments_Invoice").OnTable("InvoicePayments")
.OnColumn("InvoiceId").Ascending().OnColumn("PaidOn").Ascending();
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0215_AddRateSchedules.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0215_AddRateSchedules.cs
index e9f55144..c0bce596 100644
--- a/Providers/Resgrid.Providers.Migrations/Migrations/M0215_AddRateSchedules.cs
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0215_AddRateSchedules.cs
@@ -3,7 +3,7 @@
namespace Resgrid.Providers.Migrations.Migrations
{
///
- /// Workforce & Business Operations plan, Phase C (C1): contractor rate schedules with typed entries (per-certification, crew size families, vehicle, equipment, service), explicit rate bands (standby, deployment, overtime thresholds, daily tiers, out-of-province, mileage, per-diem) and stacking premiums. Rates are explicit numbers (decision 16); thresholds are data, not code. Every table carries the ADP row marker from creation. Registry M0215. Guarded for safe retry.
+ /// Workforce & Business Operations plan, Phase C (C1): contractor rate schedules with typed entries (per-certification, crew size families, vehicle, equipment, service), explicit rate bands (standby, deployment, overtime thresholds, daily tiers, out-of-province, mileage, per-diem) and stacking premiums. Rates are explicit numbers (decision 16); thresholds are data, not code. Nothing here is under Advanced Data Protection (decision 44; marker columns dropped before release). Registry M0215. Guarded for safe retry.
///
[Migration(215)]
public class M0215_AddRateSchedules : Migration
@@ -26,9 +26,7 @@ public override void Up()
.WithColumn("AddedOn").AsDateTime2().NotNullable()
.WithColumn("AddedByUserId").AsString(128).Nullable()
.WithColumn("EditedOn").AsDateTime2().Nullable()
- .WithColumn("EditedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable();
+ .WithColumn("EditedByUserId").AsString(128).Nullable();
Create.Index("IX_RateSchedules_Department").OnTable("RateSchedules").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending();
}
if (!Schema.Table("RateScheduleEntries").Exists())
@@ -54,9 +52,7 @@ public override void Up()
.WithColumn("AddedOn").AsDateTime2().NotNullable()
.WithColumn("AddedByUserId").AsString(128).Nullable()
.WithColumn("EditedOn").AsDateTime2().Nullable()
- .WithColumn("EditedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable();
+ .WithColumn("EditedByUserId").AsString(128).Nullable();
Create.Index("IX_RateScheduleEntries_Schedule").OnTable("RateScheduleEntries").OnColumn("RateScheduleId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("SortOrder").Ascending();
Create.Index("IX_RateScheduleEntries_Group").OnTable("RateScheduleEntries").OnColumn("RateScheduleId").Ascending().OnColumn("GroupKey").Ascending();
Create.ForeignKey("FK_RateScheduleEntries_Schedule").FromTable("RateScheduleEntries").ForeignColumn("RateScheduleId").ToTable("RateSchedules").PrimaryColumn("RateScheduleId");
@@ -97,9 +93,7 @@ public override void Up()
.WithColumn("AddedOn").AsDateTime2().NotNullable()
.WithColumn("AddedByUserId").AsString(128).Nullable()
.WithColumn("EditedOn").AsDateTime2().Nullable()
- .WithColumn("EditedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable();
+ .WithColumn("EditedByUserId").AsString(128).Nullable();
Create.Index("IX_RatePremiums_Schedule").OnTable("RatePremiums").OnColumn("RateScheduleId").Ascending().OnColumn("IsDeleted").Ascending();
Create.ForeignKey("FK_RatePremiums_Schedule").FromTable("RatePremiums").ForeignColumn("RateScheduleId").ToTable("RateSchedules").PrimaryColumn("RateScheduleId");
}
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0216_AddServiceContracts.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0216_AddServiceContracts.cs
index 55d851ec..821d6f1a 100644
--- a/Providers/Resgrid.Providers.Migrations/Migrations/M0216_AddServiceContracts.cs
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0216_AddServiceContracts.cs
@@ -36,9 +36,7 @@ public override void Up()
.WithColumn("AddedOn").AsDateTime2().NotNullable()
.WithColumn("AddedByUserId").AsString(128).Nullable()
.WithColumn("EditedOn").AsDateTime2().Nullable()
- .WithColumn("EditedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable();
+ .WithColumn("EditedByUserId").AsString(128).Nullable();
Create.Index("IX_ServiceContracts_Department").OnTable("ServiceContracts").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("Status").Ascending();
Create.Index("IX_ServiceContracts_Contact").OnTable("ServiceContracts").OnColumn("ContactId").Ascending();
}
@@ -75,9 +73,7 @@ public override void Up()
.WithColumn("AddedOn").AsDateTime2().NotNullable()
.WithColumn("AddedByUserId").AsString(128).Nullable()
.WithColumn("EditedOn").AsDateTime2().Nullable()
- .WithColumn("EditedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable();
+ .WithColumn("EditedByUserId").AsString(128).Nullable();
Create.Index("IX_DepartmentComplianceDocuments_Department").OnTable("DepartmentComplianceDocuments").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("ExpiresOn").Ascending();
}
}
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0217_AddBids.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0217_AddBids.cs
index 775e2eb8..b2dcdd94 100644
--- a/Providers/Resgrid.Providers.Migrations/Migrations/M0217_AddBids.cs
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0217_AddBids.cs
@@ -46,9 +46,7 @@ public override void Up()
.WithColumn("AddedOn").AsDateTime2().NotNullable()
.WithColumn("AddedByUserId").AsString(128).Nullable()
.WithColumn("EditedOn").AsDateTime2().Nullable()
- .WithColumn("EditedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable();
+ .WithColumn("EditedByUserId").AsString(128).Nullable();
Create.Index("IX_Bids_Department").OnTable("Bids").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("Status").Ascending();
Create.Index("IX_Bids_Contact").OnTable("Bids").OnColumn("ContactId").Ascending();
Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_Bids_Number' AND object_id = OBJECT_ID('Bids')) CREATE UNIQUE INDEX [UX_Bids_Number] ON [Bids] ([DepartmentId], [BidNumber]);");
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0218_AddDeploymentsAndTimeTracking.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0218_AddDeploymentsAndTimeTracking.cs
index efa1323a..f57028fe 100644
--- a/Providers/Resgrid.Providers.Migrations/Migrations/M0218_AddDeploymentsAndTimeTracking.cs
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0218_AddDeploymentsAndTimeTracking.cs
@@ -143,9 +143,7 @@ public override void Up()
.WithColumn("AddedOn").AsDateTime2().NotNullable()
.WithColumn("AddedByUserId").AsString(128).Nullable()
.WithColumn("EditedOn").AsDateTime2().Nullable()
- .WithColumn("EditedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable();
+ .WithColumn("EditedByUserId").AsString(128).Nullable();
Create.Index("IX_DeploymentTimeReports_Deployment").OnTable("DeploymentTimeReports").OnColumn("DeploymentId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("ReportDate").Ascending();
Create.Index("IX_DeploymentTimeReports_Department").OnTable("DeploymentTimeReports").OnColumn("DepartmentId").Ascending().OnColumn("Status").Ascending();
Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_DeploymentTimeReports_Number' AND object_id = OBJECT_ID('DeploymentTimeReports')) CREATE UNIQUE INDEX [UX_DeploymentTimeReports_Number] ON [DeploymentTimeReports] ([DepartmentId], [ReportNumber]);");
@@ -202,9 +200,7 @@ public override void Up()
.WithColumn("AddedOn").AsDateTime2().NotNullable()
.WithColumn("AddedByUserId").AsString(128).Nullable()
.WithColumn("EditedOn").AsDateTime2().Nullable()
- .WithColumn("EditedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable();
+ .WithColumn("EditedByUserId").AsString(128).Nullable();
Create.Index("IX_DeploymentExpenses_Deployment").OnTable("DeploymentExpenses").OnColumn("DeploymentId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("ExpenseDate").Ascending();
Create.ForeignKey("FK_DeploymentExpenses_Deployment").FromTable("DeploymentExpenses").ForeignColumn("DeploymentId").ToTable("Deployments").PrimaryColumn("DeploymentId");
}
@@ -222,9 +218,7 @@ public override void Up()
.WithColumn("Data").AsBinary(int.MaxValue).Nullable()
.WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false)
.WithColumn("AddedOn").AsDateTime2().NotNullable()
- .WithColumn("AddedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable();
+ .WithColumn("AddedByUserId").AsString(128).Nullable();
Create.Index("IX_DeploymentAttachments_Deployment").OnTable("DeploymentAttachments").OnColumn("DeploymentId").Ascending().OnColumn("IsDeleted").Ascending();
Create.ForeignKey("FK_DeploymentAttachments_Deployment").FromTable("DeploymentAttachments").ForeignColumn("DeploymentId").ToTable("Deployments").PrimaryColumn("DeploymentId");
}
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfiles.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfiles.cs
index 0aee6e30..292d979f 100644
--- a/Providers/Resgrid.Providers.Migrations/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfiles.cs
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfiles.cs
@@ -16,12 +16,7 @@ public override void Up()
Alter.Table("Invoices").AddColumn("DeploymentId").AsString(36).Nullable();
if (!Schema.Table("InvoiceLineItems").Column("DeploymentTimeReportId").Exists())
Alter.Table("InvoiceLineItems").AddColumn("DeploymentTimeReportId").AsString(36).Nullable();
- // ADP catalog 28 marker for the department billing identity (tax registrations, SAM/CAGE, workers' comp account).
- if (!Schema.Table("DepartmentBillingIdentities").Column("IsProtected").Exists())
- Alter.Table("DepartmentBillingIdentities").AddColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false);
- if (!Schema.Table("DepartmentBillingIdentities").Column("ProtectedCatalogVersion").Exists())
- Alter.Table("DepartmentBillingIdentities").AddColumn("ProtectedCatalogVersion").AsInt32().Nullable();
- Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Invoices_Deployment' AND object_id = OBJECT_ID('Invoices')) CREATE INDEX [IX_Invoices_Deployment] ON [Invoices] ([DeploymentId]) WHERE [DeploymentId] IS NOT NULL;");
+ Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Invoices_Deployment' AND object_id = OBJECT_ID('Invoices')) CREATE INDEX [IX_Invoices_Deployment] ON [Invoices] ([DeploymentId]) WHERE [DeploymentId] IS NOT NULL;");
if (!Schema.Table("CalOesMarsAgencyProfiles").Exists())
{
@@ -52,9 +47,7 @@ public override void Up()
.WithColumn("AddedOn").AsDateTime2().NotNullable()
.WithColumn("AddedByUserId").AsString(128).Nullable()
.WithColumn("EditedOn").AsDateTime2().Nullable()
- .WithColumn("EditedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable();
+ .WithColumn("EditedByUserId").AsString(128).Nullable();
Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_CalOesMarsAgencyProfiles_Department' AND object_id = OBJECT_ID('CalOesMarsAgencyProfiles')) CREATE UNIQUE INDEX [UX_CalOesMarsAgencyProfiles_Department] ON [CalOesMarsAgencyProfiles] ([DepartmentId]) WHERE [IsDeleted] = 0;");
}
if (!Schema.Table("CalOesMarsResourceProfiles").Exists())
@@ -87,9 +80,7 @@ public override void Up()
.WithColumn("AddedOn").AsDateTime2().NotNullable()
.WithColumn("AddedByUserId").AsString(128).Nullable()
.WithColumn("EditedOn").AsDateTime2().Nullable()
- .WithColumn("EditedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable();
+ .WithColumn("EditedByUserId").AsString(128).Nullable();
Create.Index("IX_CalOesMarsResourceProfiles_Department").OnTable("CalOesMarsResourceProfiles").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending();
Create.Index("IX_CalOesMarsResourceProfiles_Unit").OnTable("CalOesMarsResourceProfiles").OnColumn("UnitId").Ascending();
}
@@ -120,9 +111,7 @@ public override void Up()
.WithColumn("AddedOn").AsDateTime2().NotNullable()
.WithColumn("AddedByUserId").AsString(128).Nullable()
.WithColumn("EditedOn").AsDateTime2().Nullable()
- .WithColumn("EditedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable();
+ .WithColumn("EditedByUserId").AsString(128).Nullable();
Create.Index("IX_CalOesMarsRateProfiles_Department").OnTable("CalOesMarsRateProfiles").OnColumn("DepartmentId").Ascending().OnColumn("SubmissionYear").Ascending().OnColumn("IsDeleted").Ascending();
}
if (!Schema.Table("CalOesMarsRateLines").Exists())
@@ -154,9 +143,7 @@ public override void Up()
.WithColumn("AddedOn").AsDateTime2().NotNullable()
.WithColumn("AddedByUserId").AsString(128).Nullable()
.WithColumn("EditedOn").AsDateTime2().Nullable()
- .WithColumn("EditedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable();
+ .WithColumn("EditedByUserId").AsString(128).Nullable();
Create.Index("IX_CalOesMarsRateLines_Profile").OnTable("CalOesMarsRateLines").OnColumn("CalOesMarsRateProfileId").Ascending().OnColumn("SortOrder").Ascending();
Create.ForeignKey("FK_CalOesMarsRateLines_Profile").FromTable("CalOesMarsRateLines").ForeignColumn("CalOesMarsRateProfileId").ToTable("CalOesMarsRateProfiles").PrimaryColumn("CalOesMarsRateProfileId");
}
@@ -186,9 +173,7 @@ public override void Up()
.WithColumn("AddedOn").AsDateTime2().NotNullable()
.WithColumn("AddedByUserId").AsString(128).Nullable()
.WithColumn("EditedOn").AsDateTime2().Nullable()
- .WithColumn("EditedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable();
+ .WithColumn("EditedByUserId").AsString(128).Nullable();
Create.Index("IX_CalOesMarsAdministrativeRateInputs_Profile").OnTable("CalOesMarsAdministrativeRateInputs").OnColumn("CalOesMarsRateProfileId").Ascending();
Create.ForeignKey("FK_CalOesMarsAdministrativeRateInputs_Profile").FromTable("CalOesMarsAdministrativeRateInputs").ForeignColumn("CalOesMarsRateProfileId").ToTable("CalOesMarsRateProfiles").PrimaryColumn("CalOesMarsRateProfileId");
}
@@ -215,9 +200,7 @@ public override void Up()
.WithColumn("AddedOn").AsDateTime2().NotNullable()
.WithColumn("AddedByUserId").AsString(128).Nullable()
.WithColumn("EditedOn").AsDateTime2().Nullable()
- .WithColumn("EditedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable();
+ .WithColumn("EditedByUserId").AsString(128).Nullable();
Create.Index("IX_CalOesMarsAgreementSnapshots_Department").OnTable("CalOesMarsAgreementSnapshots").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("StartOn").Ascending();
}
if (!Schema.Table("CalOesMarsWorkItems").Exists())
@@ -260,9 +243,7 @@ public override void Up()
.WithColumn("AddedOn").AsDateTime2().NotNullable()
.WithColumn("AddedByUserId").AsString(128).Nullable()
.WithColumn("EditedOn").AsDateTime2().Nullable()
- .WithColumn("EditedByUserId").AsString(128).Nullable()
- .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable();
+ .WithColumn("EditedByUserId").AsString(128).Nullable();
Create.Index("IX_CalOesMarsWorkItems_Department").OnTable("CalOesMarsWorkItems").OnColumn("DepartmentId").Ascending().OnColumn("IsDeleted").Ascending().OnColumn("LocalState").Ascending();
Create.Index("IX_CalOesMarsWorkItems_Deployment").OnTable("CalOesMarsWorkItems").OnColumn("DeploymentId").Ascending();
Create.Index("IX_CalOesMarsWorkItems_External").OnTable("CalOesMarsWorkItems").OnColumn("MarsRecordId").Ascending();
@@ -315,8 +296,6 @@ public override void Down()
if (Schema.Table("CalOesMarsRateProfiles").Exists()) Delete.Table("CalOesMarsRateProfiles");
if (Schema.Table("CalOesMarsResourceProfiles").Exists()) Delete.Table("CalOesMarsResourceProfiles");
if (Schema.Table("CalOesMarsAgencyProfiles").Exists()) Delete.Table("CalOesMarsAgencyProfiles");
- if (Schema.Table("DepartmentBillingIdentities").Column("ProtectedCatalogVersion").Exists()) Delete.Column("ProtectedCatalogVersion").FromTable("DepartmentBillingIdentities");
- if (Schema.Table("DepartmentBillingIdentities").Column("IsProtected").Exists()) Delete.Column("IsProtected").FromTable("DepartmentBillingIdentities");
if (Schema.Table("InvoiceLineItems").Column("DeploymentTimeReportId").Exists()) Delete.Column("DeploymentTimeReportId").FromTable("InvoiceLineItems");
if (Schema.Table("Invoices").Column("DeploymentId").Exists()) Delete.Column("DeploymentId").FromTable("Invoices");
if (Schema.Table("Invoices").Column("ServiceContractId").Exists()) Delete.Column("ServiceContractId").FromTable("Invoices");
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0209_AddCustomerBillingAndRateCardsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0209_AddCustomerBillingAndRateCardsPg.cs
index eaa39edd..794e1a27 100644
--- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0209_AddCustomerBillingAndRateCardsPg.cs
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0209_AddCustomerBillingAndRateCardsPg.cs
@@ -34,9 +34,7 @@ public override void Up()
.WithColumn("addedon").AsDateTime2().NotNullable()
.WithColumn("addedbyuserid").AsString(128).Nullable()
.WithColumn("editedon").AsDateTime2().Nullable()
- .WithColumn("editedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0);
+ .WithColumn("editedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_customerbillingprofiles_department ON customerbillingprofiles (departmentid, isdeleted);");
Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_customerbillingprofiles_contact_live ON customerbillingprofiles (contactid) WHERE isdeleted = FALSE;");
@@ -55,9 +53,7 @@ public override void Up()
.WithColumn("addedon").AsDateTime2().NotNullable()
.WithColumn("addedbyuserid").AsString(128).Nullable()
.WithColumn("editedon").AsDateTime2().Nullable()
- .WithColumn("editedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0);
+ .WithColumn("editedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_ratecards_department ON ratecards (departmentid, isdeleted);");
}
@@ -85,9 +81,7 @@ public override void Up()
.WithColumn("addedon").AsDateTime2().NotNullable()
.WithColumn("addedbyuserid").AsString(128).Nullable()
.WithColumn("editedon").AsDateTime2().Nullable()
- .WithColumn("editedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0);
+ .WithColumn("editedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_ratecarditems_ratecard ON ratecarditems (ratecardid, isdeleted, sortorder);");
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_ratecarditems_department ON ratecarditems (departmentid);");
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0210_AddInvoicesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0210_AddInvoicesPg.cs
index 08c46d7f..eeff5f50 100644
--- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0210_AddInvoicesPg.cs
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0210_AddInvoicesPg.cs
@@ -42,9 +42,7 @@ public override void Up()
.WithColumn("addedon").AsDateTime2().NotNullable()
.WithColumn("addedbyuserid").AsString(128).Nullable()
.WithColumn("editedon").AsDateTime2().Nullable()
- .WithColumn("editedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0);
+ .WithColumn("editedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_invoices_department_number ON invoices (departmentid, invoicenumber);");
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_invoices_department_status ON invoices (departmentid, status, isdeleted);");
@@ -65,9 +63,7 @@ public override void Up()
.WithColumn("unitrate").AsDecimal(18, 4).NotNullable().WithDefaultValue(0)
.WithColumn("amount").AsDecimal(18, 2).NotNullable().WithDefaultValue(0)
.WithColumn("taxable").AsBoolean().NotNullable().WithDefaultValue(true)
- .WithColumn("sortorder").AsInt32().NotNullable().WithDefaultValue(0)
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0);
+ .WithColumn("sortorder").AsInt32().NotNullable().WithDefaultValue(0);
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_invoicelineitems_invoice ON invoicelineitems (invoiceid, sortorder);");
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_invoicelineitems_department_call ON invoicelineitems (departmentid, callid);");
@@ -95,9 +91,7 @@ public override void Up()
.WithColumn("notes").AsString(int.MaxValue).Nullable()
.WithColumn("paidon").AsDateTime2().NotNullable()
.WithColumn("recordedbyuserid").AsString(128).Nullable()
- .WithColumn("addedon").AsDateTime2().NotNullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().NotNullable().WithDefaultValue(0);
+ .WithColumn("addedon").AsDateTime2().NotNullable();
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_invoicepayments_invoice ON invoicepayments (invoiceid, paidon);");
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_invoicepayments_department_paid ON invoicepayments (departmentid, paidon);");
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0215_AddRateSchedulesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0215_AddRateSchedulesPg.cs
index 6e3a7804..f2e6e0cb 100644
--- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0215_AddRateSchedulesPg.cs
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0215_AddRateSchedulesPg.cs
@@ -26,9 +26,7 @@ public override void Up()
.WithColumn("addedon").AsDateTime2().NotNullable()
.WithColumn("addedbyuserid").AsString(128).Nullable()
.WithColumn("editedon").AsDateTime2().Nullable()
- .WithColumn("editedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().Nullable();
+ .WithColumn("editedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_rateschedules_department ON rateschedules (departmentid, isdeleted);");
}
if (!Schema.Table("ratescheduleentries").Exists())
@@ -54,9 +52,7 @@ public override void Up()
.WithColumn("addedon").AsDateTime2().NotNullable()
.WithColumn("addedbyuserid").AsString(128).Nullable()
.WithColumn("editedon").AsDateTime2().Nullable()
- .WithColumn("editedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().Nullable();
+ .WithColumn("editedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_ratescheduleentries_schedule ON ratescheduleentries (ratescheduleid, isdeleted, sortorder);");
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_ratescheduleentries_group ON ratescheduleentries (ratescheduleid, groupkey);");
Create.ForeignKey("fk_ratescheduleentries_schedule").FromTable("ratescheduleentries").ForeignColumn("ratescheduleid").ToTable("rateschedules").PrimaryColumn("ratescheduleid");
@@ -97,9 +93,7 @@ public override void Up()
.WithColumn("addedon").AsDateTime2().NotNullable()
.WithColumn("addedbyuserid").AsString(128).Nullable()
.WithColumn("editedon").AsDateTime2().Nullable()
- .WithColumn("editedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().Nullable();
+ .WithColumn("editedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_ratepremiums_schedule ON ratepremiums (ratescheduleid, isdeleted);");
Create.ForeignKey("fk_ratepremiums_schedule").FromTable("ratepremiums").ForeignColumn("ratescheduleid").ToTable("rateschedules").PrimaryColumn("ratescheduleid");
}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0216_AddServiceContractsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0216_AddServiceContractsPg.cs
index 37018db3..1fb1d307 100644
--- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0216_AddServiceContractsPg.cs
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0216_AddServiceContractsPg.cs
@@ -36,9 +36,7 @@ public override void Up()
.WithColumn("addedon").AsDateTime2().NotNullable()
.WithColumn("addedbyuserid").AsString(128).Nullable()
.WithColumn("editedon").AsDateTime2().Nullable()
- .WithColumn("editedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().Nullable();
+ .WithColumn("editedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_servicecontracts_department ON servicecontracts (departmentid, isdeleted, status);");
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_servicecontracts_contact ON servicecontracts (contactid);");
}
@@ -75,9 +73,7 @@ public override void Up()
.WithColumn("addedon").AsDateTime2().NotNullable()
.WithColumn("addedbyuserid").AsString(128).Nullable()
.WithColumn("editedon").AsDateTime2().Nullable()
- .WithColumn("editedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().Nullable();
+ .WithColumn("editedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_departmentcompliancedocuments_department ON departmentcompliancedocuments (departmentid, isdeleted, expireson);");
}
}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0217_AddBidsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0217_AddBidsPg.cs
index 6ba67f1a..06033e04 100644
--- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0217_AddBidsPg.cs
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0217_AddBidsPg.cs
@@ -46,9 +46,7 @@ public override void Up()
.WithColumn("addedon").AsDateTime2().NotNullable()
.WithColumn("addedbyuserid").AsString(128).Nullable()
.WithColumn("editedon").AsDateTime2().Nullable()
- .WithColumn("editedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().Nullable();
+ .WithColumn("editedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_bids_department ON bids (departmentid, isdeleted, status);");
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_bids_contact ON bids (contactid);");
Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_bids_number ON bids (departmentid, bidnumber);");
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0218_AddDeploymentsAndTimeTrackingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0218_AddDeploymentsAndTimeTrackingPg.cs
index d3deaee8..33e1fbbd 100644
--- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0218_AddDeploymentsAndTimeTrackingPg.cs
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0218_AddDeploymentsAndTimeTrackingPg.cs
@@ -143,9 +143,7 @@ public override void Up()
.WithColumn("addedon").AsDateTime2().NotNullable()
.WithColumn("addedbyuserid").AsString(128).Nullable()
.WithColumn("editedon").AsDateTime2().Nullable()
- .WithColumn("editedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().Nullable();
+ .WithColumn("editedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_deploymenttimereports_deployment ON deploymenttimereports (deploymentid, isdeleted, reportdate);");
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_deploymenttimereports_department ON deploymenttimereports (departmentid, status);");
Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_deploymenttimereports_number ON deploymenttimereports (departmentid, reportnumber);");
@@ -202,9 +200,7 @@ public override void Up()
.WithColumn("addedon").AsDateTime2().NotNullable()
.WithColumn("addedbyuserid").AsString(128).Nullable()
.WithColumn("editedon").AsDateTime2().Nullable()
- .WithColumn("editedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().Nullable();
+ .WithColumn("editedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_deploymentexpenses_deployment ON deploymentexpenses (deploymentid, isdeleted, expensedate);");
Create.ForeignKey("fk_deploymentexpenses_deployment").FromTable("deploymentexpenses").ForeignColumn("deploymentid").ToTable("deployments").PrimaryColumn("deploymentid");
}
@@ -222,9 +218,7 @@ public override void Up()
.WithColumn("data").AsCustom("bytea").Nullable()
.WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false)
.WithColumn("addedon").AsDateTime2().NotNullable()
- .WithColumn("addedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().Nullable();
+ .WithColumn("addedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_deploymentattachments_deployment ON deploymentattachments (deploymentid, isdeleted);");
Create.ForeignKey("fk_deploymentattachments_deployment").FromTable("deploymentattachments").ForeignColumn("deploymentid").ToTable("deployments").PrimaryColumn("deploymentid");
}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfilesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfilesPg.cs
index 1d7ef5de..f3fcf033 100644
--- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfilesPg.cs
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0219_ExtendInvoicingAndAddCostRecoveryProfilesPg.cs
@@ -16,12 +16,7 @@ public override void Up()
Alter.Table("invoices").AddColumn("deploymentid").AsString(36).Nullable();
if (!Schema.Table("invoicelineitems").Column("deploymenttimereportid").Exists())
Alter.Table("invoicelineitems").AddColumn("deploymenttimereportid").AsString(36).Nullable();
- // ADP catalog 28 marker for the department billing identity (tax registrations, SAM/CAGE, workers' comp account).
- if (!Schema.Table("departmentbillingidentities").Column("isprotected").Exists())
- Alter.Table("departmentbillingidentities").AddColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false);
- if (!Schema.Table("departmentbillingidentities").Column("protectedcatalogversion").Exists())
- Alter.Table("departmentbillingidentities").AddColumn("protectedcatalogversion").AsInt32().Nullable();
- Execute.Sql("CREATE INDEX IF NOT EXISTS ix_invoices_deployment ON invoices (deploymentid) WHERE deploymentid IS NOT NULL;");
+ Execute.Sql("CREATE INDEX IF NOT EXISTS ix_invoices_deployment ON invoices (deploymentid) WHERE deploymentid IS NOT NULL;");
if (!Schema.Table("caloesmarsagencyprofiles").Exists())
{
@@ -52,9 +47,7 @@ public override void Up()
.WithColumn("addedon").AsDateTime2().NotNullable()
.WithColumn("addedbyuserid").AsString(128).Nullable()
.WithColumn("editedon").AsDateTime2().Nullable()
- .WithColumn("editedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().Nullable();
+ .WithColumn("editedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_caloesmarsagencyprofiles_department ON caloesmarsagencyprofiles (departmentid) WHERE isdeleted = FALSE;");
}
if (!Schema.Table("caloesmarsresourceprofiles").Exists())
@@ -87,9 +80,7 @@ public override void Up()
.WithColumn("addedon").AsDateTime2().NotNullable()
.WithColumn("addedbyuserid").AsString(128).Nullable()
.WithColumn("editedon").AsDateTime2().Nullable()
- .WithColumn("editedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().Nullable();
+ .WithColumn("editedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_caloesmarsresourceprofiles_department ON caloesmarsresourceprofiles (departmentid, isdeleted);");
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_caloesmarsresourceprofiles_unit ON caloesmarsresourceprofiles (unitid);");
}
@@ -120,9 +111,7 @@ public override void Up()
.WithColumn("addedon").AsDateTime2().NotNullable()
.WithColumn("addedbyuserid").AsString(128).Nullable()
.WithColumn("editedon").AsDateTime2().Nullable()
- .WithColumn("editedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().Nullable();
+ .WithColumn("editedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_caloesmarsrateprofiles_department ON caloesmarsrateprofiles (departmentid, submissionyear, isdeleted);");
}
if (!Schema.Table("caloesmarsratelines").Exists())
@@ -154,9 +143,7 @@ public override void Up()
.WithColumn("addedon").AsDateTime2().NotNullable()
.WithColumn("addedbyuserid").AsString(128).Nullable()
.WithColumn("editedon").AsDateTime2().Nullable()
- .WithColumn("editedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().Nullable();
+ .WithColumn("editedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_caloesmarsratelines_profile ON caloesmarsratelines (caloesmarsrateprofileid, sortorder);");
Create.ForeignKey("fk_caloesmarsratelines_profile").FromTable("caloesmarsratelines").ForeignColumn("caloesmarsrateprofileid").ToTable("caloesmarsrateprofiles").PrimaryColumn("caloesmarsrateprofileid");
}
@@ -186,9 +173,7 @@ public override void Up()
.WithColumn("addedon").AsDateTime2().NotNullable()
.WithColumn("addedbyuserid").AsString(128).Nullable()
.WithColumn("editedon").AsDateTime2().Nullable()
- .WithColumn("editedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().Nullable();
+ .WithColumn("editedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_caloesmarsadministrativerateinputs_profile ON caloesmarsadministrativerateinputs (caloesmarsrateprofileid);");
Create.ForeignKey("fk_caloesmarsadministrativerateinputs_profile").FromTable("caloesmarsadministrativerateinputs").ForeignColumn("caloesmarsrateprofileid").ToTable("caloesmarsrateprofiles").PrimaryColumn("caloesmarsrateprofileid");
}
@@ -215,9 +200,7 @@ public override void Up()
.WithColumn("addedon").AsDateTime2().NotNullable()
.WithColumn("addedbyuserid").AsString(128).Nullable()
.WithColumn("editedon").AsDateTime2().Nullable()
- .WithColumn("editedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().Nullable();
+ .WithColumn("editedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_caloesmarsagreementsnapshots_department ON caloesmarsagreementsnapshots (departmentid, isdeleted, starton);");
}
if (!Schema.Table("caloesmarsworkitems").Exists())
@@ -260,9 +243,7 @@ public override void Up()
.WithColumn("addedon").AsDateTime2().NotNullable()
.WithColumn("addedbyuserid").AsString(128).Nullable()
.WithColumn("editedon").AsDateTime2().Nullable()
- .WithColumn("editedbyuserid").AsString(128).Nullable()
- .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false)
- .WithColumn("protectedcatalogversion").AsInt32().Nullable();
+ .WithColumn("editedbyuserid").AsString(128).Nullable();
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_caloesmarsworkitems_department ON caloesmarsworkitems (departmentid, isdeleted, localstate);");
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_caloesmarsworkitems_deployment ON caloesmarsworkitems (deploymentid);");
Execute.Sql("CREATE INDEX IF NOT EXISTS ix_caloesmarsworkitems_external ON caloesmarsworkitems (marsrecordid);");
@@ -315,8 +296,6 @@ public override void Down()
Execute.Sql("DROP TABLE IF EXISTS caloesmarsrateprofiles;");
Execute.Sql("DROP TABLE IF EXISTS caloesmarsresourceprofiles;");
Execute.Sql("DROP TABLE IF EXISTS caloesmarsagencyprofiles;");
- Execute.Sql("ALTER TABLE departmentbillingidentities DROP COLUMN IF EXISTS protectedcatalogversion;");
- Execute.Sql("ALTER TABLE departmentbillingidentities DROP COLUMN IF EXISTS isprotected;");
Execute.Sql("ALTER TABLE invoicelineitems DROP COLUMN IF EXISTS deploymenttimereportid;");
Execute.Sql("ALTER TABLE invoices DROP COLUMN IF EXISTS deploymentid;");
Execute.Sql("ALTER TABLE invoices DROP COLUMN IF EXISTS servicecontractid;");
diff --git a/Repositories/Resgrid.Repositories.DataRepository/CalOesMarsRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/CalOesMarsRepositories.cs
index d0726ea9..e72e9558 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/CalOesMarsRepositories.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/CalOesMarsRepositories.cs
@@ -125,6 +125,9 @@ public Task> GetActionQueueAsync(int departmentI
$"SELECT * FROM {Tbl("CalOesMarsWorkItems")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False} AND {Col("LocalState")} <> {P}Closed" + (recordType.HasValue ? $" AND {Col("RecordType")} = {P}RecordType" : string.Empty) +
$" ORDER BY {Col("LocalState")}, {Col("AddedOn")}", new { DepartmentId = departmentId, Closed = (int)CalOesMarsLocalStates.Closed, RecordType = recordType });
+ public Task> GetByAgreementSnapshotAsync(int departmentId, string agreementSnapshotId) =>
+ QueryAsync($"SELECT * FROM {Tbl("CalOesMarsWorkItems")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("AgreementSnapshotId")} = {P}AgreementSnapshotId AND {Col("IsDeleted")} = {False} ORDER BY {Col("AddedOn")}", new { DepartmentId = departmentId, AgreementSnapshotId = agreementSnapshotId });
+
public Task> GetUnreconciledAsync(int departmentId) =>
QueryAsync(
$"SELECT * FROM {Tbl("CalOesMarsWorkItems")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False} AND {Col("LocalState")} IN ({P}Submitted, {P}Returned, {P}Approved, {P}PendingLocal, {P}PendingPaying, {P}Rejected) ORDER BY {Col("AddedOn")}",
diff --git a/Tests/Resgrid.Tests/Search/BusinessOperationsProjectionTests.cs b/Tests/Resgrid.Tests/Search/BusinessOperationsProjectionTests.cs
new file mode 100644
index 00000000..8532126f
--- /dev/null
+++ b/Tests/Resgrid.Tests/Search/BusinessOperationsProjectionTests.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Moq;
+using NUnit.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Invoicing;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Search;
+using Resgrid.Model.Services;
+using Resgrid.Services.Search;
+
+namespace Resgrid.Tests.Search
+{
+ ///
+ /// Workforce & Business Operations plan decision 41: invoices, rate cards, bids, service contracts, deployments and
+ /// certification types project allowlisted identifier / title / status rows only — never an amount, a line, a note,
+ /// an e-mail or a person — and a deleted row retires its projection.
+ ///
+ [TestFixture]
+ public class BusinessOperationsProjectionTests
+ {
+ private Mock _repository;
+ private List _upserted;
+ private List<(string Type, string Id)> _removed;
+ private SearchProjectionService _service;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _upserted = new List(); _removed = new List<(string, string)>();
+ _repository = new Mock();
+ _repository.Setup(r => r.UpsertAsync(It.IsAny(), It.IsAny())).ReturnsAsync((SearchProjection p, CancellationToken _) => { _upserted.Add(p); return p; });
+ _repository.Setup(r => r.SoftDeleteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((int _, string t, string id, CancellationToken __) => { _removed.Add((t, id)); return true; });
+ var protection = new Mock();
+ protection.Setup(p => p.IsProtectionEnforcedAsync(It.IsAny())).ReturnsAsync(false);
+ protection.Setup(p => p.GetPinnedCatalogVersionAsync(It.IsAny())).ReturnsAsync(28);
+ _service = new SearchProjectionService(_repository.Object, protection.Object);
+ }
+
+ [Test]
+ public async Task Invoice_projects_number_status_and_dates_but_never_amounts_lines_or_emails()
+ {
+ var invoice = new Invoice { InvoiceId = "inv-1", DepartmentId = 5, InvoiceNumber = 1042, Status = (int)InvoiceStatus.Sent, IssuedOn = new DateTime(2026, 9, 1), Currency = "USD", Total = 1234.56m, ContactId = "c-1", DeploymentId = "dep-1", Notes = "Net 30 — call Jane at 555-0100" };
+ var p = await _service.BuildInvoiceAsync(invoice);
+ p.EntityType.Should().Be(SearchEntityTypes.Invoice);
+ p.Title.Should().Be("Invoice #1042");
+ p.Summary.Should().Be("Sent · 2026-09-01 · USD");
+ p.Keywords.Should().Contain("1042").And.Contain("dep-1");
+ p.Url.Should().Be("/User/Invoicing/View?id=inv-1");
+ string.Join(" ", p.Title, p.Summary, p.Keywords, p.SearchText, p.MetadataJson).Should().NotContain("1234").And.NotContain("Jane").And.NotContain("555");
+ p.IsActive.Should().BeTrue();
+
+ invoice.Status = (int)InvoiceStatus.Void;
+ (await _service.BuildInvoiceAsync(invoice)).IsActive.Should().BeFalse();
+ invoice.IsDeleted = true;
+ await _service.ProjectInvoiceAsync(invoice);
+ _removed.Should().Contain((SearchEntityTypes.Invoice, "inv-1"));
+ _upserted.Should().BeEmpty();
+ }
+
+ [Test]
+ public async Task Bid_contract_deployment_rate_card_and_certification_type_project_identifiers_titles_and_statuses_only()
+ {
+ var bid = await _service.BuildBidAsync(new Bid { BidId = "b-1", DepartmentId = 5, BidNumber = 7, Title = "Type 3 engine, 14 days", Status = (int)BidStatuses.Submitted, IncidentNumber = "CA-LNU-001234", EstimatedTotal = 88000m, Notes = "Customer asked for a discount", SentToEmail = "buyer@example.org" });
+ bid.Title.Should().Be("Bid #7 Type 3 engine, 14 days");
+ bid.Summary.Should().StartWith("Submitted · CA-LNU-001234");
+ string.Join(" ", bid.Summary, bid.Keywords, bid.SearchText, bid.MetadataJson).Should().NotContain("88000").And.NotContain("discount").And.NotContain("buyer@");
+ bid.Url.Should().Be("/User/Bids/View?id=b-1");
+
+ var contract = await _service.BuildServiceContractAsync(new ServiceContract { ServiceContractId = "sc-1", DepartmentId = 5, ContractNumber = "2026-014", Name = "County MOU", Status = (int)ServiceContractStatuses.Active, StartOn = new DateTime(2026, 1, 1), InvoiceSubmissionEmail = "ap@county.example" });
+ contract.Title.Should().Be("2026-014 County MOU");
+ contract.Summary.Should().StartWith("Active · 2026-01-01 – …");
+ contract.IsActive.Should().BeTrue();
+ string.Join(" ", contract.Summary, contract.Keywords, contract.MetadataJson).Should().NotContain("ap@county");
+
+ var deployment = await _service.BuildDeploymentAsync(new Deployment { DeploymentId = "dep-1", DepartmentId = 5, Name = "LNU Lightning Complex", Status = (int)DeploymentStatuses.Active, IncidentNumber = "CA-LNU-001234", ResourceOrderNumber = "O-1", RequestNumber = "E-12", StartOn = new DateTime(2026, 8, 1), Notes = "rgdp:1:28:protected-envelope", CallId = 99 });
+ deployment.Title.Should().Be("LNU Lightning Complex");
+ deployment.Keywords.Should().Contain("O-1").And.Contain("E-12").And.Contain("99");
+ deployment.IsActive.Should().BeTrue();
+ string.Join(" ", deployment.Summary, deployment.Keywords, deployment.SearchText, deployment.MetadataJson).Should().NotContain("rgdp", "the wrapper's notes are ADP catalog 27 and never leave the row");
+
+ var card = await _service.BuildRateCardAsync(new RateCard { RateCardId = "rc-1", DepartmentId = 5, Name = "Standard 2026", IsDefault = true, Active = true });
+ card.Title.Should().Be("Standard 2026"); card.Summary.Should().Be("Default · Active"); card.Url.Should().Be("/User/Invoicing/EditRateCard?id=rc-1");
+
+ var type = await _service.BuildCertificationTypeAsync(new DepartmentCertificationType { DepartmentCertificationTypeId = 31, DepartmentId = 5, Type = "Firefighter I", Code = "FF1", IssuingAuthority = "State Fire Training", IsActive = true });
+ type.Title.Should().Be("Firefighter I"); type.Keywords.Should().Contain("FF1"); type.Url.Should().Be("/User/Certifications/EditType?id=31");
+
+ (await _service.BuildBidAsync(new Bid { BidId = "b-2", DepartmentId = 5, IsDeleted = true })).Should().BeNull();
+ (await _service.BuildDeploymentAsync(new Deployment { DeploymentId = "dep-2", DepartmentId = 5, Name = " " })).Should().BeNull("a nameless deployment has nothing safe to index");
+ }
+
+ [Test]
+ public void Indexed_entity_types_include_the_business_operations_families_and_never_the_excluded_ones()
+ {
+ SearchEntityTypes.Indexed.Should().Contain(new[] { SearchEntityTypes.Invoice, SearchEntityTypes.RateCard, SearchEntityTypes.Bid, SearchEntityTypes.ServiceContract, SearchEntityTypes.Deployment, SearchEntityTypes.CertificationType });
+ SearchEntityTypes.Indexed.Should().NotContain(t => t.Contains("TimeReport") || t.Contains("Expense") || t.Contains("RateSchedule") || t.Contains("Compliance") || t.Contains("CalOes") || t.Contains("Workforce") || t.Contains("PayData") || t.Contains("Certification") && t != SearchEntityTypes.CertificationType);
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Services/CalOesMarsCalculatorTests.cs b/Tests/Resgrid.Tests/Services/CalOesMarsCalculatorTests.cs
index dca7ceb8..34e97052 100644
--- a/Tests/Resgrid.Tests/Services/CalOesMarsCalculatorTests.cs
+++ b/Tests/Resgrid.Tests/Services/CalOesMarsCalculatorTests.cs
@@ -98,6 +98,21 @@ public void Portal_to_portal_pays_every_committed_hour_and_flags_missing_rates_a
var noAgreement = new CalOesMarsReimbursementCalculator().Calculate(new CalOesMarsReimbursementInput { F42 = Engine(), RateLines = Rates() });
noAgreement.Exceptions.Should().Contain(e => e.Code == CalOesMarsExceptionCodes.NoAgreement);
noAgreement.Lines.Where(l => l.SubjectId == "p1").Should().ContainSingle("without an agreement every DTR hour is straight time");
+
+ // A rate line that is not portal-to-portal eligible falls back to DTR hours even under a portal-to-portal agreement, so a
+ // person without DTR hours is reported (NoActualHours) instead of producing a silent zero-amount line.
+ var ineligible = Rates();
+ ineligible.Single(r => r.CalOesMarsRateLineId == "sal-capt").PortalToPortalEligible = false;
+ var noHours = Engine();
+ noHours.Personnel.RemoveAll(p => p.DeploymentPersonnelId != "p1");
+ noHours.Personnel[0].ActualHours.Clear();
+ var fallback = new CalOesMarsReimbursementCalculator().Calculate(new CalOesMarsReimbursementInput
+ {
+ F42 = noHours, RateLines = ineligible,
+ Agreement = new CalOesMarsAgreementSnapshot { CompensationMethod = (int)CalOesMarsCompensationMethods.PortalToPortal, OvertimeMethod = (int)CalOesMarsOvertimeMethods.AfterTwelveHoursPerDay }
+ });
+ fallback.Exceptions.Should().Contain(e => e.Code == CalOesMarsExceptionCodes.NoActualHours);
+ fallback.Lines.Where(l => l.SubjectId == "p1").Sum(l => l.ExpectedAmount).Should().Be(0);
}
[Test]
diff --git a/Tests/Resgrid.Tests/Services/CalOesMarsServiceTests.cs b/Tests/Resgrid.Tests/Services/CalOesMarsServiceTests.cs
index afec681b..fe853dfd 100644
--- a/Tests/Resgrid.Tests/Services/CalOesMarsServiceTests.cs
+++ b/Tests/Resgrid.Tests/Services/CalOesMarsServiceTests.cs
@@ -50,6 +50,7 @@ public class CalOesMarsServiceTests
private List _reports;
private List _expenses;
private Mock _deployments;
+ private Mock _compensation;
private CalOesMarsService _service;
[SetUp]
@@ -83,6 +84,7 @@ public void SetUp()
items.Setup(r => r.GetByDeploymentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _items.Where(x => x.DeploymentId == id && !x.IsDeleted).ToList());
items.Setup(r => r.GetByExternalIdAsync(DeptId, It.IsAny())).ReturnsAsync((int _, string id) => _items.Where(x => !x.IsDeleted && (x.MarsRecordId == id || x.MarsInvoiceId == id)).ToList());
items.Setup(r => r.GetActionQueueAsync(DeptId, It.IsAny())).ReturnsAsync((int _, int? type) => _items.Where(x => !x.IsDeleted && x.LocalState != (int)CalOesMarsLocalStates.Closed && (!type.HasValue || x.RecordType == type)).ToList());
+ items.Setup(r => r.GetByAgreementSnapshotAsync(DeptId, It.IsAny())).ReturnsAsync((int _, string id) => _items.Where(x => !x.IsDeleted && x.AgreementSnapshotId == id).ToList());
items.Setup(r => r.GetDepartmentsWithOpenItemsAsync()).ReturnsAsync(() => _items.Where(x => !x.IsDeleted && x.LocalState != (int)CalOesMarsLocalStates.Closed).Select(x => x.DepartmentId).Distinct().ToList());
var lines = Repo(_lines, l => l.CalOesMarsReimbursementLineId, (l, id) => l.CalOesMarsReimbursementLineId = id);
lines.Setup(r => r.GetByWorkItemAsync(It.IsAny())).ReturnsAsync((string id) => _lines.Where(x => x.CalOesMarsWorkItemId == id).ToList());
@@ -123,6 +125,7 @@ public void SetUp()
new DeploymentExpense { DeploymentExpenseId = "ex-2", DeploymentId = "dep-1", DepartmentId = DeptId, ExpenseDate = Dispatch.Date, ExpenseType = (int)DeploymentExpenseTypes.Accommodation, City = "Napa", Amount = 140 }
};
_deployments = new Mock();
+ _compensation = new Mock();
_deployments.Setup(d => d.GetDeploymentByIdAsync("dep-1", DeptId)).ReturnsAsync(() => _deployment);
_deployments.Setup(d => d.GetExternalContextAsync("dep-1", DeptId, It.IsAny())).ReturnsAsync(() => _context);
_deployments.Setup(d => d.GetAttachmentsAsync("dep-1", DeptId)).ReturnsAsync(() => _attachments.ToList());
@@ -150,7 +153,7 @@ public void SetUp()
_service = new CalOesMarsService(agencies.Object, resources.Object, rateProfiles.Object, rateLines.Object, inputs.Object, agreements.Object, items.Object, lines.Object,
_deployments.Object, timeTracking.Object, entries.Object, units.Object, profiles.Object, departments.Object, events.Object, null,
- new CalOesMarsReimbursementCalculator(), new ManualCalOesMarsGateway(), new Lazy(() => communication.Object));
+ new CalOesMarsReimbursementCalculator(), new ManualCalOesMarsGateway(), new Lazy(() => communication.Object), null, new Lazy(() => _compensation.Object));
}
private static Mock Repo(List store, Func id, Action setId) where TRepo : class, IRepository where T : class, IEntity
@@ -300,6 +303,23 @@ public async Task F42_draft_projects_the_fill_roster_and_dtr_facts_then_the_chec
(await _service.IsRosteredForWorkItemAsync(revision.CalOesMarsWorkItemId, DeptId, "stranger")).Should().BeFalse();
}
+ [Test]
+ public async Task Closed_work_items_still_protect_their_agreement_snapshot()
+ {
+ await SeedReadyDepartmentAsync();
+ var item = await _service.BuildF42DraftAsync("dep-1", DeptId, "fill-e12", User, null, null);
+ var agreement = await _service.GetAgreementAsync(item.AgreementSnapshotId, DeptId);
+ agreement.Should().NotBeNull();
+ // The claim was submitted, paid and closed; it left the action queue but its terms are still the ones on record.
+ _items.Single(w => w.CalOesMarsWorkItemId == item.CalOesMarsWorkItemId).LocalState = (int)CalOesMarsLocalStates.Closed;
+
+ await FluentActions.Awaiting(() => _service.DeleteAgreementAsync(agreement.CalOesMarsAgreementSnapshotId, DeptId, User, null, null)).Should().ThrowAsync().WithMessage("calmars_agreement_in_use");
+ var edit = new CalOesMarsAgreementSnapshot { CalOesMarsAgreementSnapshotId = agreement.CalOesMarsAgreementSnapshotId, DepartmentId = DeptId, DocumentKind = agreement.DocumentKind, CompensationMethod = agreement.CompensationMethod, OvertimeMethod = (int)CalOesMarsOvertimeMethods.AfterTwelveHoursPerDay, StartOn = agreement.StartOn };
+ var saved = await _service.SaveAgreementAsync(edit, User, null, null);
+ saved.CalOesMarsAgreementSnapshotId.Should().NotBe(item.AgreementSnapshotId, "an edit to a referenced snapshot becomes a new version");
+ (await _service.GetAgreementAsync(item.AgreementSnapshotId, DeptId)).OvertimeMethod.Should().Be((int)CalOesMarsOvertimeMethods.AfterEightHoursPerDay, "the closed claim's terms are unchanged");
+ }
+
[Test]
public async Task Redispatch_closes_the_first_interval_and_opens_a_superseding_f42()
{
@@ -401,6 +421,35 @@ public async Task Mars_invoice_is_a_work_item_reconciled_against_expected_lines_
typeof(CalOesMarsService).GetConstructors().Single().GetParameters().Select(p => p.ParameterType).Should().NotContain(new[] { typeof(IInvoicingService), typeof(IInvoicePaymentsService) });
}
+ [Test]
+ public async Task Salary_survey_draft_writes_classification_means_from_phase_e_and_never_an_individual()
+ {
+ _compensation.Setup(c => c.GetClassificationRateAggregateAsync(DeptId, It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new List<(string, int, decimal, decimal)> { ("Captain", 3, 61.3333m, 2m), ("Firefighter", 1, 40m, 0m), ("Dog Handler", 2, 30m, 0m) });
+ var survey = await _service.SaveRateProfileAsync(new CalOesMarsRateProfile { DepartmentId = DeptId, SubmissionYear = 2026, SubmissionType = (int)CalOesMarsSubmissionTypes.SalarySurvey, EffectiveOn = new DateTime(2026, 1, 1) }, User, null, null);
+ await _service.SaveRateLinesAsync(survey.CalOesMarsRateProfileId, DeptId, new List { new CalOesMarsRateLine { LineKind = (int)CalOesMarsRateLineKinds.SalarySurvey, ClassificationCode = "Captain", StraightRate = 50, OvertimeRate = 75, IncludesWorkersComp = true, OvertimeEligible = true } }, User, null, null);
+
+ var draft = await _service.BuildSalarySurveyDraftAsync(survey.CalOesMarsRateProfileId, DeptId, new DateTime(2026, 6, 1), User, null, null);
+ draft.IsReady.Should().BeTrue();
+ draft.LinesWritten.Should().Be(2);
+ draft.EmployeesIncluded.Should().Be(4);
+ draft.UnknownClassifications.Should().Equal("Dog Handler");
+ draft.Classifications.Single(c => c.ClassificationCode == "Captain").StraightRate.Should().Be(61.33m);
+ draft.Classifications.Single(c => c.ClassificationCode == "Captain").OvertimeRate.Should().Be(94m, "1.5 × 61.3333 + the $2 per-overtime-hour adder");
+ draft.Classifications.Single(c => c.ClassificationCode == "Firefighter").SingleEmployee.Should().BeTrue();
+
+ var profile = await _service.GetRateProfileAsync(survey.CalOesMarsRateProfileId, DeptId);
+ var captain = profile.Lines.Single(l => l.ClassificationCode == "Captain");
+ captain.StraightRate.Should().Be(61.33m); captain.IncludesWorkersComp.Should().BeTrue("existing line flags survive; only the rates are replaced");
+ captain.SourceInputVersions.Should().Contain("workforce-aggregate").And.Contain("\"Employees\":3");
+ profile.Lines.Single(l => l.ClassificationCode == "Firefighter").OvertimeRate.Should().Be(60m);
+ profile.Lines.Should().NotContain(l => l.ClassificationCode == "Dog Handler");
+ _audits.Should().Contain(a => a.Type == AuditLogTypes.CalOesMarsRateDraftBuilt && a.After.Contains("\"LinesWritten\":2") && !a.After.Contains("61.33"), "the audit carries counts, never rates");
+
+ var admin = await _service.SaveRateProfileAsync(new CalOesMarsRateProfile { DepartmentId = DeptId, SubmissionYear = 2026, SubmissionType = (int)CalOesMarsSubmissionTypes.AdministrativeRate, EffectiveOn = new DateTime(2026, 1, 1) }, User, null, null);
+ (await _service.BuildSalarySurveyDraftAsync(admin.CalOesMarsRateProfileId, DeptId, new DateTime(2026, 6, 1), User, null, null)).Blockers.Should().Equal("profile_type");
+ }
+
[Test]
public async Task Reminder_sweep_sends_one_value_minimized_digest_per_department_per_day()
{
diff --git a/Tests/Resgrid.Tests/Services/PayDataAggregatorTests.cs b/Tests/Resgrid.Tests/Services/PayDataAggregatorTests.cs
index ac0e4b51..e197eac6 100644
--- a/Tests/Resgrid.Tests/Services/PayDataAggregatorTests.cs
+++ b/Tests/Resgrid.Tests/Services/PayDataAggregatorTests.cs
@@ -130,6 +130,13 @@ public void Cells_render_in_template_order_and_exports_are_checksum_stable()
var xlsx = PayDataAggregator.RenderXlsx(Profile.PayrollColumns, new[] { (IReadOnlyList)cells });
using var archive = new ZipArchive(new MemoryStream(xlsx), ZipArchiveMode.Read);
archive.Entries.Select(e => e.FullName).Should().Contain("xl/worksheets/sheet1.xml").And.Contain("[Content_Types].xml");
+ using var sheetReader = new StreamReader(archive.GetEntry("xl/worksheets/sheet1.xml").Open());
+ var sheet = sheetReader.ReadToEnd();
+ // Cell types follow the schema: text / code columns stay text even when numeric-looking (ZIP, job category), integer and decimal columns are numbers.
+ sheet.Should().Contain("Establishment Zip");
+ sheet.Should().Contain("95814");
+ sheet.Should().Contain("10");
+ sheet.Should().Contain("2").And.Contain("24.50");
var contractorCells = PayDataAggregator.Cells(rows[0], Profile, (int)PayDataReportTypes.LaborContractorEmployee, establishment, new PayDataAggregator.ExportContractor { Name = "Staffing Co", Fein = "12-3456789" });
contractorCells.Take(2).Should().Equal("Staffing Co", "12-3456789");
diff --git a/Tests/Resgrid.Tests/Services/WorkforceLocalizationTests.cs b/Tests/Resgrid.Tests/Services/WorkforceLocalizationTests.cs
index 98f80115..526b0edf 100644
--- a/Tests/Resgrid.Tests/Services/WorkforceLocalizationTests.cs
+++ b/Tests/Resgrid.Tests/Services/WorkforceLocalizationTests.cs
@@ -33,7 +33,7 @@ public void Workforce_views_controller_services_and_enum_labels_resolve_real_res
var web = Path.Combine(root, "Web", "Resgrid.Web", "Areas", "User");
var own = Directory.GetFiles(Path.Combine(web, "Views", "Workforce"), "*.cshtml")
.Concat(Directory.GetFiles(Path.Combine(root, "Core", "Resgrid.Services", "Workforce"), "*.cs"))
- .Concat(new[] { Path.Combine(web, "Controllers", "WorkforceController.cs"), Path.Combine(web, "Views", "Shared", "_WorkforceShell.cshtml"), Path.Combine(web, "Views", "Shared", "_WorkforceMessage.cshtml") }).ToList();
+ .Concat(new[] { Path.Combine(web, "Controllers", "WorkforceController.cs"), Path.Combine(web, "Views", "Shared", "_WorkforceShell.cshtml"), Path.Combine(web, "Views", "Shared", "_WorkforceMessage.cshtml"), Path.Combine(web, "Views", "Shared", "_FieldCostCard.cshtml") }).ToList();
var shared = new[] { Path.Combine(web, "Views", "Security", "Index.cshtml"), Path.Combine(web, "Views", "Shared", "_Navigation.cshtml") };
var keys = new HashSet(StringComparer.Ordinal);
diff --git a/Tests/Resgrid.Tests/Services/WorkforceProtectionAndEventsTests.cs b/Tests/Resgrid.Tests/Services/WorkforceProtectionAndEventsTests.cs
index 02862242..edadc394 100644
--- a/Tests/Resgrid.Tests/Services/WorkforceProtectionAndEventsTests.cs
+++ b/Tests/Resgrid.Tests/Services/WorkforceProtectionAndEventsTests.cs
@@ -142,12 +142,12 @@ public async Task Line_items_keep_their_ids_and_invoice_text_is_stored_whole_wit
stored.Select(l => l.InvoiceLineItemId).Should().Contain(new[] { "line-a", "line-b" }, "existing lines keep their row keys so provenance links and audit history follow the row");
stored.Should().HaveCount(3);
stored.Select(l => l.Description).Should().BeEquivalentTo(new[] { "Standby crew (day 2)", "Transport for J. Doe", "Mileage" });
- stored.Should().OnlyContain(l => !l.IsProtected && l.ProtectedCatalogVersion == 0, "nothing on an invoice is enveloped");
+ stored.Should().OnlyContain(l => !string.IsNullOrEmpty(l.Description) && !ProtectedDataEnvelope.HasEnvelopePrefix(l.Description), "nothing on an invoice is enveloped");
await FluentActions.Awaiting(() => service.SaveInvoiceLineItemsAsync("inv-1", DeptId, new List { new InvoiceLineItem { Description = " ", Quantity = 1, UnitRate = 1 } }, "clerk", null, null)).Should().ThrowAsync("every line needs a description");
// The department's registrations print on every invoice: saved and read back verbatim, never through the read seam.
var identity = await service.SaveDepartmentBillingIdentityAsync(new DepartmentBillingIdentity { DepartmentId = DeptId, TaxRegistrationNumber = "12-3456789", SamUei = "ABC123DEF456", PayLinkExpiryDays = 30 }, "clerk", null, null);
- identity.IsProtected.Should().BeFalse();
+ identity.TaxRegistrationNumber.Should().Be("12-3456789", "stored whole; the entity no longer carries an ADP marker");
(await service.GetDepartmentBillingIdentityAsync(DeptId)).TaxRegistrationNumber.Should().Be("12-3456789");
(await service.GetInvoiceByIdAsync("inv-1", DeptId)).LineItems.Select(l => l.Description).Should().Contain("Transport for J. Doe");
protectedRead.VerifyNoOtherCalls();
@@ -218,7 +218,7 @@ public async Task Deployment_notes_stay_enveloped_while_attachments_and_time_rep
attachmentPayload["AttachmentType"].Value().Should().Be((int)DeploymentAttachmentTypes.SignedServiceRequest);
attachmentPayload["AttachmentName"].Value().Should().Be("Signed request");
storedAttachments.Single().Name.Should().Be("Signed request");
- storedAttachments.Single().IsProtected.Should().BeFalse();
+ ProtectedDataEnvelope.HasEnvelopePrefix(storedAttachments.Single().FileName).Should().BeFalse();
// Time reports: created / voided publish, the void reason is appended to the note as typed, entries keep their ids.
var storedReports = new List();
@@ -250,7 +250,7 @@ public async Task Deployment_notes_stay_enveloped_while_attachments_and_time_rep
batch.Validation.IsValid.Should().BeTrue();
storedEntries.Select(e => e.DeploymentTimeEntryId).Should().Contain(firstEntryId, "an existing entry is updated in place, never re-inserted under a new key");
storedEntries.Select(e => e.Notes).Should().BeEquivalentTo(new[] { "Relieved by J. Doe", "Relief crew" }, "the customer signs the DTR; its notes are stored as typed");
- storedReports.Single().IsProtected.Should().BeFalse("nothing on a DTR is enveloped");
+ ProtectedDataEnvelope.HasEnvelopePrefix(storedReports.Single().Notes).Should().BeFalse("nothing on a DTR is enveloped");
enveloped.Should().OnlyContain(k => k.StartsWith("deployment:"), "the deployment wrapper's internal notes are the only seam left in Phase C");
storedReports.Single().Notes = "Crew note";
diff --git a/Web/Resgrid.Web.Services/Controllers/v4/FieldCostController.cs b/Web/Resgrid.Web.Services/Controllers/v4/FieldCostController.cs
index 55536330..45777d20 100644
--- a/Web/Resgrid.Web.Services/Controllers/v4/FieldCostController.cs
+++ b/Web/Resgrid.Web.Services/Controllers/v4/FieldCostController.cs
@@ -60,6 +60,13 @@ private async Task IsRosteredAsync(string deploymentId)
return mine?.Any(d => string.Equals(d.DeploymentId, deploymentId, StringComparison.OrdinalIgnoreCase)) == true;
}
+ private async Task IsUnitOnDeploymentAsync(string deploymentId, int unitId)
+ {
+ if (string.IsNullOrWhiteSpace(deploymentId) || unitId <= 0) return false;
+ var deployment = await _deployments.GetDeploymentByIdAsync(deploymentId, DepartmentId);
+ return deployment?.Units?.Any(u => u.IsActive && u.UnitId == unitId) == true;
+ }
+
[HttpGet("GetAccess")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> GetAccess()
@@ -119,6 +126,8 @@ public async Task> AddResourceUsage([FromBody]
if (!await EnabledAsync()) return Failed("workforce_disabled", StatusCodes.Status403Forbidden);
if (input == null) return Failed("workforce_usage_invalid");
if (!CanViewInternalCosts && !await IsRosteredAsync(input.DeploymentId)) return Failed("workforce_not_rostered", StatusCodes.Status403Forbidden);
+ // A rostered member files readings for the units on their own deployment only; any unit needs ViewInternalCosts.
+ if (!CanViewInternalCosts && !await IsUnitOnDeploymentAsync(input.DeploymentId, input.UnitId)) return Failed("workforce_unit_not_on_deployment", StatusCodes.Status403Forbidden);
try
{
var entry = new ResourceUsageEntry
diff --git a/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs
index ca6de31b..7a3cd03a 100644
--- a/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs
+++ b/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs
@@ -38,10 +38,12 @@ public sealed class BidsController : SecureBaseController
private readonly IContactsService _contactsService;
private readonly IBusinessOperationsAccessService _access;
private readonly IStringLocalizer _strings;
+ private readonly Lazy _costing;
public BidsController(IBidsService bids, IServiceContractService contracts, IRateScheduleService rateSchedules, IInvoicingService invoicing, IDeploymentService deployments,
- IContactsService contactsService, IBusinessOperationsAccessService access, IStringLocalizer strings)
+ IContactsService contactsService, IBusinessOperationsAccessService access, IStringLocalizer strings, Lazy costing = null)
{
+ _costing = costing;
_bids = bids;
_contracts = contracts;
_rateSchedules = rateSchedules;
@@ -242,6 +244,12 @@ public async Task