diff --git a/Core/Resgrid.AdminAssist/Catalog/calls.yaml b/Core/Resgrid.AdminAssist/Catalog/calls.yaml index b319f20f5..1a8fffe1d 100644 --- a/Core/Resgrid.AdminAssist/Catalog/calls.yaml +++ b/Core/Resgrid.AdminAssist/Catalog/calls.yaml @@ -762,7 +762,7 @@ "Field": "CallNumberPattern" }, "Owner": "Resgrid.Core", - "ReviewedOn": "2026-10-06T00:00:00Z", + "ReviewedOn": "2026-10-08T00:00:00Z", "Impact": { "Risk": "Medium", "AudienceKey": "Impact.Audience", @@ -850,6 +850,114 @@ "EvidenceSource": "CallNumberingConfig", "Availability": "available" }, + { + "Id": "field.CallNumberingConfig.YearStartMonth", + "AreaId": "calls", + "LabelKey": "Field.CallNumberingConfig.YearStartMonth", + "HelpKey": "FieldHelp.CallNumberingConfig.YearStartMonth", + "Binding": "CallNumberingConfig.YearStartMonth", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "CallSettings", + "Field": "CallNumberYearStartMonth" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.CallNumberingConfig" + ], + "Affects": [ + "field.CallNumberingConfig.YearStartDay", + "field.CallNumberingConfig.YearLabel" + ], + "Conflicts": [], + "DocumentationId": "field.CallNumberingConfig.YearStartMonth", + "DefaultValue": "0 (January)", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "CallNumberingConfig", + "Availability": "available" + }, + { + "Id": "field.CallNumberingConfig.YearStartDay", + "AreaId": "calls", + "LabelKey": "Field.CallNumberingConfig.YearStartDay", + "HelpKey": "FieldHelp.CallNumberingConfig.YearStartDay", + "Binding": "CallNumberingConfig.YearStartDay", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "CallSettings", + "Field": "CallNumberYearStartMonth" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.CallNumberingConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.CallNumberingConfig.YearStartDay", + "DefaultValue": "0 (the 1st)", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "CallNumberingConfig", + "Availability": "available" + }, + { + "Id": "field.CallNumberingConfig.YearLabel", + "AreaId": "calls", + "LabelKey": "Field.CallNumberingConfig.YearLabel", + "HelpKey": "FieldHelp.CallNumberingConfig.YearLabel", + "Binding": "CallNumberingConfig.YearLabel", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "CallSettings", + "Field": "CallNumberYearStartMonth" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.calls", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.CallNumberingConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.CallNumberingConfig.YearLabel", + "DefaultValue": "0 (the year it ends in)", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "CallNumberingConfig", + "Availability": "available" + }, { "Id": "field.UnitTypeCallStatusOverrideSetting.Overrides", "AreaId": "calls", @@ -2462,6 +2570,9 @@ "SettingIds": [ "field.CallNumberingConfig.Pattern", "field.CallNumberingConfig.SequenceWidth", + "field.CallNumberingConfig.YearLabel", + "field.CallNumberingConfig.YearStartDay", + "field.CallNumberingConfig.YearStartMonth", "setting.CallNumberingConfig", "setting.InternalDispatchEmail", "setting.RssFeedKeyForActiveCalls", @@ -2673,12 +2784,12 @@ "Id": "setting.CallNumberingConfig", "Locale": "en", "TitleKey": "Setting.CallNumberingConfig", - "Body": "Call Numbering Config. How new call numbers are written: the call number pattern and sequence digits, plus a raised next number for departments continuing numbers issued before Resgrid. Changing it never renumbers calls that already have a number.", + "Body": "Call Numbering Config. How new call numbers are written: the call number pattern, sequence digits and the day the numbering year starts (for calls numbered by a fiscal year), plus a raised next number for departments continuing numbers issued before Resgrid. Changing it never renumbers calls that already have a number.", "SourcePath": "docs/admin-assist/settings-reference.md", "Anchor": "setting-callnumberingconfig", "PackVersion": "2026.09.25.2", "Owner": "Resgrid.Core", - "ReviewedOn": "2026-10-06T00:00:00Z" + "ReviewedOn": "2026-10-08T00:00:00Z" }, { "Id": "field.CallNumberingConfig.Pattern", @@ -2702,6 +2813,39 @@ "Owner": "Resgrid.Core", "ReviewedOn": "2026-10-06T00:00:00Z" }, + { + "Id": "field.CallNumberingConfig.YearStartMonth", + "Locale": "en", + "TitleKey": "Field.CallNumberingConfig.YearStartMonth", + "Body": "Call Numbering Config / Year Start Month. Month the numbering year starts, for calls numbered by a fiscal year. A yearly pattern starts again at 1 on that month and day, and YYYY and YY write the fiscal year's name; patterns with MM or DD follow the calendar. Unset is January, the calendar year.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-callnumberingconfig-yearstartmonth", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "field.CallNumberingConfig.YearStartDay", + "Locale": "en", + "TitleKey": "Field.CallNumberingConfig.YearStartDay", + "Body": "Call Numbering Config / Year Start Day. Day of the month the numbering year starts, 1 to the month's last day; February 29 is not allowed. Unset is the 1st.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-callnumberingconfig-yearstartday", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "field.CallNumberingConfig.YearLabel", + "Locale": "en", + "TitleKey": "Field.CallNumberingConfig.YearLabel", + "Body": "Call Numbering Config / Year Label. Which year names a numbering year that does not start on January 1: the year it ends in (the usual fiscal-year convention, so a year starting November 1, 2026 is 2027) or the year it starts in.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-callnumberingconfig-yearlabel", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z" + }, { "Id": "setting.RssFeedKeyForActiveCalls", "Locale": "en", @@ -3476,12 +3620,12 @@ "Id": "call-settings", "Locale": "en", "TitleKey": "Feature.call-settings", - "Body": "Call Settings The mailbox and format used to import calls from CAD or paging email, and the call number format and next number. Consistent call types and statuses make dispatching, response tracking and reporting reliable from the first call. A department adds Structure Fire and Medical call types, sets Responding and On Scene statuses and connects its county CAD email feed. Collect your call types, the statuses members report while responding, and the details of any CAD or paging system that should create calls.", + "Body": "Call Settings The mailbox and format used to import calls from CAD or paging email, and the call number format, next number and numbering year start. Consistent call types and statuses make dispatching, response tracking and reporting reliable from the first call. A department adds Structure Fire and Medical call types, sets Responding and On Scene statuses and connects its county CAD email feed. Collect your call types, the statuses members report while responding, and the details of any CAD or paging system that should create calls.", "SourcePath": "docs/admin-assist/settings-reference.md", "Anchor": "call-settings", "PackVersion": "2026.09.25.2", "Owner": "Resgrid.Core", - "ReviewedOn": "2026-09-24T00:00:00Z" + "ReviewedOn": "2026-10-08T00:00:00Z" } ] } diff --git a/Core/Resgrid.AdminAssist/Catalog/records.yaml b/Core/Resgrid.AdminAssist/Catalog/records.yaml index 11949758c..a397c5781 100644 --- a/Core/Resgrid.AdminAssist/Catalog/records.yaml +++ b/Core/Resgrid.AdminAssist/Catalog/records.yaml @@ -100,7 +100,7 @@ "Field": "RecordsNumberingConfig" }, "Owner": "Resgrid.Core", - "ReviewedOn": "2026-10-05T00:00:00Z", + "ReviewedOn": "2026-10-08T00:00:00Z", "Impact": { "Risk": "Medium", "AudienceKey": "Impact.Audience", @@ -502,7 +502,7 @@ "Field": null }, "Owner": "Resgrid.Core", - "ReviewedOn": "2026-09-24T00:00:00Z", + "ReviewedOn": "2026-10-08T00:00:00Z", "Impact": { "Risk": "Medium", "AudienceKey": "Impact.Audience", @@ -735,6 +735,114 @@ "EvidenceSource": "RecordsNumberingConfig", "Availability": "available" }, + { + "Id": "field.RecordsNumberingConfig.YearStartMonth", + "AreaId": "records", + "LabelKey": "Field.RecordsNumberingConfig.YearStartMonth", + "HelpKey": "FieldHelp.RecordsNumberingConfig.YearStartMonth", + "Binding": "RecordsNumberingConfig.YearStartMonth", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": "NumberingYearStartMonth" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsNumberingConfig" + ], + "Affects": [ + "field.RecordsNumberingConfig.YearStartDay", + "field.RecordsNumberingConfig.YearLabel" + ], + "Conflicts": [], + "DocumentationId": "field.RecordsNumberingConfig.YearStartMonth", + "DefaultValue": "0 (January)", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsNumberingConfig", + "Availability": "available" + }, + { + "Id": "field.RecordsNumberingConfig.YearStartDay", + "AreaId": "records", + "LabelKey": "Field.RecordsNumberingConfig.YearStartDay", + "HelpKey": "FieldHelp.RecordsNumberingConfig.YearStartDay", + "Binding": "RecordsNumberingConfig.YearStartDay", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": "NumberingYearStartMonth" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsNumberingConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsNumberingConfig.YearStartDay", + "DefaultValue": "0 (the 1st)", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsNumberingConfig", + "Availability": "available" + }, + { + "Id": "field.RecordsNumberingConfig.YearLabel", + "AreaId": "records", + "LabelKey": "Field.RecordsNumberingConfig.YearLabel", + "HelpKey": "FieldHelp.RecordsNumberingConfig.YearLabel", + "Binding": "RecordsNumberingConfig.YearLabel", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": "NumberingYearStartMonth" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsNumberingConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsNumberingConfig.YearLabel", + "DefaultValue": "0 (the year it ends in)", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsNumberingConfig", + "Availability": "available" + }, { "Id": "field.RecordsNumberingFloor.NextSequence", "AreaId": "records", @@ -1469,6 +1577,41 @@ "AllowedValues": "Validated by the owning configuration workflow", "EvidenceSource": "RecordsDisclosureConfig", "Availability": "available" + }, + { + "Id": "field.RecordsNumberingConfig.DocumentPatterns", + "AreaId": "records", + "LabelKey": "Field.RecordsNumberingConfig.DocumentPatterns", + "HelpKey": "FieldHelp.RecordsNumberingConfig.DocumentPatterns", + "Binding": "RecordsNumberingConfig.DocumentPatterns", + "ValueType": "list", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Records", + "Action": "Settings", + "Field": "OtherNumbers" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.records", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.RecordsNumberingConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.RecordsNumberingConfig.DocumentPatterns", + "DefaultValue": "empty", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "RecordsNumberingConfig", + "Availability": "available" } ], "Capabilities": [ @@ -1701,6 +1844,9 @@ "field.RecordsNumberingConfig.Prefixes", "field.RecordsNumberingConfig.ResetYearly", "field.RecordsNumberingConfig.SequenceWidth", + "field.RecordsNumberingConfig.YearLabel", + "field.RecordsNumberingConfig.YearStartDay", + "field.RecordsNumberingConfig.YearStartMonth", "field.RecordsNumberingFloor.NextSequence", "field.RecordsNumberingFloor.ScopeKey", "field.RecordsNumberingFloor.SetByUserId", @@ -2123,12 +2269,12 @@ "Id": "setting.RecordsNumberingConfig", "Locale": "en", "TitleKey": "Setting.RecordsNumberingConfig", - "Body": "Records Numbering Config. Department numbering for the built-in record types and incident reports: the number pattern, sequence digits, record type prefixes and raised next numbers. Numbers are assigned when a record is finalized. Department definitions with their own numbering keep it.", + "Body": "Records Numbering Config. Department numbering for the built-in record types and incident reports: the number pattern, sequence digits, record type prefixes, the day the numbering year starts (for records numbered by a fiscal year) and raised next numbers. Numbers are assigned when a record is finalized. Department definitions with their own numbering keep it.", "SourcePath": "docs/admin-assist/settings-reference.md", "Anchor": "setting-recordsnumberingconfig", "PackVersion": "2026.09.25.2", "Owner": "Resgrid.Core", - "ReviewedOn": "2026-10-05T00:00:00Z" + "ReviewedOn": "2026-10-08T00:00:00Z" }, { "Id": "setting.RecordsSearchConfig", @@ -2255,12 +2401,12 @@ "Id": "field.RecordsNumberingConfig.ResetYearly", "Locale": "en", "TitleKey": "Field.RecordsNumberingConfig.ResetYearly", - "Body": "Records Numbering Config / Reset Yearly. Restart record sequences each calendar year in the department time zone.", + "Body": "Records Numbering Config / Reset Yearly. Restart record sequences each numbering year (the calendar year unless a fiscal year start is set) in the department time zone.", "SourcePath": "docs/admin-assist/settings-reference.md", "Anchor": "field-recordsnumberingconfig-resetyearly", "PackVersion": "2026.09.25.2", "Owner": "Resgrid.Core", - "ReviewedOn": "2026-09-24T00:00:00Z" + "ReviewedOn": "2026-10-08T00:00:00Z" }, { "Id": "field.RecordsNumberingConfig.SequenceWidth", @@ -2328,6 +2474,39 @@ "Owner": "Resgrid.Core", "ReviewedOn": "2026-10-05T00:00:00Z" }, + { + "Id": "field.RecordsNumberingConfig.YearStartMonth", + "Locale": "en", + "TitleKey": "Field.RecordsNumberingConfig.YearStartMonth", + "Body": "Records Numbering Config / Year Start Month. Month the numbering year starts, for records numbered by a fiscal year such as a county's or state's. Sequences that include the year start again at 1 on that month and day, and YYYY and YY write the fiscal year's name. Unset is January, the calendar year. Separate from call numbering's year start. Records request numbers and the numbers of inspections, permits, occupancies, investigations and evidence follow the same year.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsnumberingconfig-yearstartmonth", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "field.RecordsNumberingConfig.YearStartDay", + "Locale": "en", + "TitleKey": "Field.RecordsNumberingConfig.YearStartDay", + "Body": "Records Numbering Config / Year Start Day. Day of the month the numbering year starts, 1 to the month's last day; February 29 is not allowed. Unset is the 1st.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsnumberingconfig-yearstartday", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "field.RecordsNumberingConfig.YearLabel", + "Locale": "en", + "TitleKey": "Field.RecordsNumberingConfig.YearLabel", + "Body": "Records Numbering Config / Year Label. Which year names a numbering year that does not start on January 1: the year it ends in (the usual fiscal-year convention, so a year starting November 1, 2026 is 2027) or the year it starts in.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsnumberingconfig-yearlabel", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z" + }, { "Id": "field.RecordsNumberingFloor.NextSequence", "Locale": "en", @@ -2640,12 +2819,12 @@ "Id": "records-settings", "Locale": "en", "TitleKey": "Feature.records-settings", - "Body": "Records Settings Numbering, retention, search and visibility rules for records. Keep incident and activity records with the calls they came from, ready for review and reporting. After each call, the officer completes an incident report from the call and a supervisor reviews it. Know which reports your department completes and who reviews them. Records may need to be enabled for your department.", + "Body": "Records Settings Numbering (including records request and prevention numbers and the numbering year start), retention, search and visibility rules for records. Keep incident and activity records with the calls they came from, ready for review and reporting. After each call, the officer completes an incident report from the call and a supervisor reviews it. Know which reports your department completes and who reviews them. Records may need to be enabled for your department.", "SourcePath": "docs/admin-assist/settings-reference.md", "Anchor": "records-settings", "PackVersion": "2026.09.25.2", "Owner": "Resgrid.Core", - "ReviewedOn": "2026-09-24T00:00:00Z" + "ReviewedOn": "2026-10-08T00:00:00Z" }, { "Id": "record-occupancies", @@ -2778,6 +2957,17 @@ "PackVersion": "2026.09.25.2", "Owner": "Resgrid.Core", "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "field.RecordsNumberingConfig.DocumentPatterns", + "Locale": "en", + "TitleKey": "Field.RecordsNumberingConfig.DocumentPatterns", + "Body": "Records Numbering Config / Document Patterns. The department's own patterns for records request, occupancy, inspection, permit, investigation and evidence numbers. They follow this setting's numbering year start; a kind with no pattern keeps its built-in numbers (PRR-2026-0001, INSP-2026-0001 and so on).", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-recordsnumberingconfig-documentpatterns", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z" } ] } diff --git a/Core/Resgrid.AdminAssist/Catalog/security.yaml b/Core/Resgrid.AdminAssist/Catalog/security.yaml index 8ed693202..6afff5324 100644 --- a/Core/Resgrid.AdminAssist/Catalog/security.yaml +++ b/Core/Resgrid.AdminAssist/Catalog/security.yaml @@ -5114,6 +5114,287 @@ "AllowedValues": "Validated by the owning workflow; row IDs, credentials and configuration changes are never accepted by the reference catalog.", "EvidenceSource": "DepartmentSsoConfig", "Availability": "reference-only" + }, + { + "Id": "setting.DocumentNumberingConfig", + "AreaId": "security", + "LabelKey": "Setting.DocumentNumberingConfig", + "HelpKey": "SettingHelp.DocumentNumberingConfig", + "Binding": "DepartmentSettingTypes.DocumentNumberingConfig", + "ValueType": "protobuf", + "Classification": "Restricted", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DocumentNumbering", + "Field": "DocumentNumberingConfig" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [], + "Affects": [], + "Conflicts": [], + "DocumentationId": "setting.DocumentNumberingConfig", + "DefaultValue": "missing = the built-in numbers", + "AllowedValues": "Validated by the owning editor and service; Admin Assist never saves a proposed value.", + "EvidenceSource": "DepartmentSettings", + "Availability": "available" + }, + { + "Id": "field.DocumentNumberingConfig.YearStartMonth", + "AreaId": "security", + "LabelKey": "Field.DocumentNumberingConfig.YearStartMonth", + "HelpKey": "FieldHelp.DocumentNumberingConfig.YearStartMonth", + "Binding": "DocumentNumberingConfig.YearStartMonth", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DocumentNumbering", + "Field": "YearStartMonth" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DocumentNumberingConfig" + ], + "Affects": [ + "field.DocumentNumberingConfig.YearStartDay", + "field.DocumentNumberingConfig.YearLabel" + ], + "Conflicts": [], + "DocumentationId": "field.DocumentNumberingConfig.YearStartMonth", + "DefaultValue": "0 (January)", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "DocumentNumberingConfig", + "Availability": "available" + }, + { + "Id": "field.DocumentNumberingConfig.YearStartDay", + "AreaId": "security", + "LabelKey": "Field.DocumentNumberingConfig.YearStartDay", + "HelpKey": "FieldHelp.DocumentNumberingConfig.YearStartDay", + "Binding": "DocumentNumberingConfig.YearStartDay", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DocumentNumbering", + "Field": "YearStartMonth" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DocumentNumberingConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.DocumentNumberingConfig.YearStartDay", + "DefaultValue": "0 (the 1st)", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "DocumentNumberingConfig", + "Availability": "available" + }, + { + "Id": "field.DocumentNumberingConfig.YearLabel", + "AreaId": "security", + "LabelKey": "Field.DocumentNumberingConfig.YearLabel", + "HelpKey": "FieldHelp.DocumentNumberingConfig.YearLabel", + "Binding": "DocumentNumberingConfig.YearLabel", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DocumentNumbering", + "Field": "YearStartMonth" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DocumentNumberingConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.DocumentNumberingConfig.YearLabel", + "DefaultValue": "0 (the year it ends in)", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "DocumentNumberingConfig", + "Availability": "available" + }, + { + "Id": "field.DocumentNumberingConfig.Patterns", + "AreaId": "security", + "LabelKey": "Field.DocumentNumberingConfig.Patterns", + "HelpKey": "FieldHelp.DocumentNumberingConfig.Patterns", + "Binding": "DocumentNumberingConfig.Patterns", + "ValueType": "list", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DocumentNumbering", + "Field": "Rows" + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DocumentNumberingConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.DocumentNumberingConfig.Patterns", + "DefaultValue": "empty", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "DocumentNumberingConfig", + "Availability": "available" + }, + { + "Id": "field.DocumentNumberPattern.Kind", + "AreaId": "security", + "LabelKey": "Field.DocumentNumberPattern.Kind", + "HelpKey": "FieldHelp.DocumentNumberPattern.Kind", + "Binding": "DocumentNumberPattern.Kind", + "ValueType": "string", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DocumentNumbering", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DocumentNumberingConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.DocumentNumberPattern.Kind", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "DocumentNumberPattern", + "Availability": "reference-only" + }, + { + "Id": "field.DocumentNumberPattern.Pattern", + "AreaId": "security", + "LabelKey": "Field.DocumentNumberPattern.Pattern", + "HelpKey": "FieldHelp.DocumentNumberPattern.Pattern", + "Binding": "DocumentNumberPattern.Pattern", + "ValueType": "string", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DocumentNumbering", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DocumentNumberingConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.DocumentNumberPattern.Pattern", + "DefaultValue": "unset", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "DocumentNumberPattern", + "Availability": "reference-only" + }, + { + "Id": "field.DocumentNumberPattern.SequenceWidth", + "AreaId": "security", + "LabelKey": "Field.DocumentNumberPattern.SequenceWidth", + "HelpKey": "FieldHelp.DocumentNumberPattern.SequenceWidth", + "Binding": "DocumentNumberPattern.SequenceWidth", + "ValueType": "integer", + "Classification": "Internal", + "Secret": false, + "Location": { + "Controller": "Department", + "Action": "DocumentNumbering", + "Field": null + }, + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z", + "Impact": { + "Risk": "Medium", + "AudienceKey": "Impact.Audience", + "OperationKey": "Area.security", + "TimingKey": "Impact.Timing", + "ReversibilityKey": "Impact.Reversal", + "VerificationKey": "Impact.Verify" + }, + "Requires": [ + "setting.DocumentNumberingConfig" + ], + "Affects": [], + "Conflicts": [], + "DocumentationId": "field.DocumentNumberPattern.SequenceWidth", + "DefaultValue": "the kind's built-in width", + "AllowedValues": "Validated by the owning configuration workflow", + "EvidenceSource": "DocumentNumberPattern", + "Availability": "reference-only" } ], "Capabilities": [ @@ -5169,6 +5450,34 @@ "Prominence": "Key", "DocsPath": "/web-app/department-settings/" }, + { + "Id": "document-numbering", + "AreaId": "security", + "LabelKey": "Feature.document-numbering", + "PurposeKey": "FeaturePurpose.document-numbering", + "ValueKey": "AreaValue.security", + "ExampleKey": "AreaExample.security", + "AdoptionKey": "ModuleAdoption.security", + "ReleaseStatus": "available", + "Location": { + "Controller": "Department", + "Action": "DocumentNumbering" + }, + "Requirements": [], + "SettingIds": [ + "field.DocumentNumberPattern.Kind", + "field.DocumentNumberPattern.Pattern", + "field.DocumentNumberPattern.SequenceWidth", + "field.DocumentNumberingConfig.Patterns", + "field.DocumentNumberingConfig.YearLabel", + "field.DocumentNumberingConfig.YearStartDay", + "field.DocumentNumberingConfig.YearStartMonth", + "setting.DocumentNumberingConfig" + ], + "RuleIds": [], + "Prominence": "Detail", + "DocsPath": "/web-app/department-settings/#document-numbering" + }, { "Id": "module-settings", "AreaId": "security", @@ -7294,6 +7603,17 @@ "Owner": "Resgrid.Core", "ReviewedOn": "2026-09-24T00:00:00Z" }, + { + "Id": "document-numbering", + "Locale": "en", + "TitleKey": "Feature.document-numbering", + "Body": "Document Numbering How work order, invoice, bid and daily time report numbers are written, and the day their numbering year starts for fiscal-year numbering. Sets the time zone and address everything else uses, and keeps administrative access limited and recoverable. An administrator sets the department time zone, hides modules the department will not use, requires two-factor sign-in for administrators and adds a second administrator. Know your department address and time zone, and choose at least two people who will administer Resgrid.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "document-numbering", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z" + }, { "Id": "module-settings", "Locale": "en", @@ -7392,6 +7712,94 @@ "PackVersion": "2026.09.25.2", "Owner": "Resgrid.Core", "ReviewedOn": "2026-09-24T00:00:00Z" + }, + { + "Id": "setting.DocumentNumberingConfig", + "Locale": "en", + "TitleKey": "Setting.DocumentNumberingConfig", + "Body": "Document Numbering Config. How work order, invoice, bid and daily time report numbers are written (Department -> Document Numbering): one pattern per document kind and the day their numbering year starts, plus a raised next number per custom sequence. A kind with no pattern keeps its built-in numbers. Changing it never renumbers documents that already have a number.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "setting-documentnumberingconfig", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "field.DocumentNumberingConfig.YearStartMonth", + "Locale": "en", + "TitleKey": "Field.DocumentNumberingConfig.YearStartMonth", + "Body": "Document Numbering Config / Year Start Month. Month the numbering year starts for work orders, invoices, bids and daily time reports numbered by a fiscal year. Work order numbers and patterns that include the year start again at 1 on that month and day, and YYYY and YY write the fiscal year's name. Unset is January, the calendar year.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-documentnumberingconfig-yearstartmonth", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "field.DocumentNumberingConfig.YearStartDay", + "Locale": "en", + "TitleKey": "Field.DocumentNumberingConfig.YearStartDay", + "Body": "Document Numbering Config / Year Start Day. Day of the month the numbering year starts, 1 to the month's last day; February 29 is not allowed. Unset is the 1st.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-documentnumberingconfig-yearstartday", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "field.DocumentNumberingConfig.YearLabel", + "Locale": "en", + "TitleKey": "Field.DocumentNumberingConfig.YearLabel", + "Body": "Document Numbering Config / Year Label. Which year names a numbering year that does not start on January 1: the year it ends in (the usual fiscal-year convention, so a year starting November 1, 2026 is 2027) or the year it starts in.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-documentnumberingconfig-yearlabel", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "field.DocumentNumberingConfig.Patterns", + "Locale": "en", + "TitleKey": "Field.DocumentNumberingConfig.Patterns", + "Body": "Document Numbering Config / Patterns. The department's own number pattern for work orders, invoices, bids and daily time reports, in the call number tokens (YYYY or YY, MM, DD, SEQ). A document kind with no pattern keeps its built-in numbers (WO-2026-000019, #1042). A new pattern carries on after any number it would repeat. Members see the number as issued on documents, PDFs and exports, and for daily time reports in the Responder, Unit, IC and Dispatch apps.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-documentnumberingconfig-patterns", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "field.DocumentNumberPattern.Kind", + "Locale": "en", + "TitleKey": "Field.DocumentNumberPattern.Kind", + "Body": "Document Number Pattern / Kind. The document the pattern numbers: work-order, invoice, bid or time-report, or for Records records-request, occupancy, inspection, permit, investigation or evidence.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-documentnumberpattern-kind", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "field.DocumentNumberPattern.Pattern", + "Locale": "en", + "TitleKey": "Field.DocumentNumberPattern.Pattern", + "Body": "Document Number Pattern / Pattern. Fixed text around the YYYY or YY, MM, DD and SEQ tokens, with SEQ exactly once. Every number it writes must fit the document's number column: 50 characters, or 32 for occupancy, inspection, permit, investigation and evidence numbers.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-documentnumberpattern-pattern", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z" + }, + { + "Id": "field.DocumentNumberPattern.SequenceWidth", + "Locale": "en", + "TitleKey": "Field.DocumentNumberPattern.SequenceWidth", + "Body": "Document Number Pattern / Sequence Width. Digits the sequence is padded to with leading zeros, 1 to 8; the sequence is counted as a number, so changing the width never restarts it.", + "SourcePath": "docs/admin-assist/settings-reference.md", + "Anchor": "field-documentnumberpattern-sequencewidth", + "PackVersion": "2026.09.25.2", + "Owner": "Resgrid.Core", + "ReviewedOn": "2026-10-08T00:00:00Z" } ] } diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx index 9be150572..0fbed4374 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx @@ -1198,7 +1198,7 @@ تقويم القسم للاجتماعات والفعاليات وليالي التدريب. - صندوق البريد والتنسيق المستخدَمان لاستيراد البلاغات من CAD أو بريد الاستدعاء، إضافةً إلى تنسيق أرقام البلاغات والرقم التالي. + صندوق البريد والتنسيق المستخدَمان لاستيراد البلاغات من CAD أو بريد الاستدعاء، إضافةً إلى تنسيق أرقام البلاغات والرقم التالي وبداية سنة الترقيم. قوالب نصوص بلاغات وملاحظات قابلة لإعادة الاستخدام تسرّع إدخال البلاغات. @@ -1522,7 +1522,7 @@ السجلات المستحقة، أو التي تنتظر المراجعة، أو الجاهزة للإرسال. - قواعد الترقيم والاحتفاظ والبحث والرؤية الخاصة بالسجلات. + قواعد الترقيم (بما فيها أرقام طلبات الاطلاع وأرقام الوقاية وبداية سنة الترقيم) والاحتفاظ والبحث والرؤية الخاصة بالسجلات. تقارير القسم عن البلاغات والأفراد والنشاط. @@ -2236,7 +2236,7 @@ استخدم تسلسلات منفصلة لكل محطة/مجموعة. يغيّر هذا الترقيم، وليس رؤية السجلات. - أعد بدء تسلسلات السجلات في كل سنة ميلادية بالمنطقة الزمنية للقسم. + أعد بدء تسلسلات السجلات في كل سنة ترقيم (السنة الميلادية ما لم تُعيَّن بداية سنة مالية) بالمنطقة الزمنية للقسم. عدد أرقام جزء SEQ في نمط الترقيم، محشوًّا بأصفار: 4 يكتب 0184. من 3 إلى 8 أرقام؛ وتغييره يحافظ على الرقم التالي لكل تسلسل. تحتفظ تعريفات القسم ذات الترقيم الخاص بعرضها الخاص. @@ -6786,4 +6786,100 @@ تستمر عمليات التكامل في العمل عند مغادرة الأشخاص، ولا تفعل إلا ما يسمح به مفتاحها، ويمكن إلغاؤها فوراً. + + إعداد ترقيم البلاغات / شهر بداية السنة + + + الشهر الذي تبدأ فيه سنة الترقيم، للبلاغات المرقّمة حسب سنة مالية. يبدأ النمط السنوي من 1 من جديد في ذلك الشهر واليوم، ويكتب YYYY وYY اسم السنة المالية؛ أما الأنماط التي تحتوي على MM أو DD فتتبع التقويم. القيمة غير المعيّنة تعني يناير، أي السنة الميلادية. + + + إعداد ترقيم البلاغات / يوم بداية السنة + + + يوم الشهر الذي تبدأ فيه سنة الترقيم، من 1 إلى آخر يوم في الشهر؛ ولا يُسمح بـ 29 فبراير. القيمة غير المعيّنة تعني اليوم الأول. + + + إعداد ترقيم البلاغات / تسمية السنة + + + السنة التي تُسمّى بها سنة ترقيم لا تبدأ في 1 يناير: السنة التي تنتهي فيها (العُرف المعتاد للسنة المالية، فالسنة التي تبدأ في 1 نوفمبر 2026 هي 2027) أو السنة التي تبدأ فيها. + + + إعداد ترقيم السجلات / شهر بداية السنة + + + الشهر الذي تبدأ فيه سنة الترقيم، للسجلات المرقّمة حسب سنة مالية مثل سنة المقاطعة أو الولاية. تبدأ التسلسلات التي تتضمن السنة من 1 من جديد في ذلك الشهر واليوم، ويكتب YYYY وYY اسم السنة المالية. القيمة غير المعيّنة تعني يناير، أي السنة الميلادية. وهي منفصلة عن بداية سنة ترقيم البلاغات. تتبع أرقام طلبات الاطلاع على السجلات وأرقام عمليات التفتيش والتصاريح والمباني والتحقيقات والأدلة السنة نفسها. + + + إعداد ترقيم السجلات / يوم بداية السنة + + + يوم الشهر الذي تبدأ فيه سنة الترقيم، من 1 إلى آخر يوم في الشهر؛ ولا يُسمح بـ 29 فبراير. القيمة غير المعيّنة تعني اليوم الأول. + + + إعداد ترقيم السجلات / تسمية السنة + + + السنة التي تُسمّى بها سنة ترقيم لا تبدأ في 1 يناير: السنة التي تنتهي فيها (العُرف المعتاد للسنة المالية، فالسنة التي تبدأ في 1 نوفمبر 2026 هي 2027) أو السنة التي تبدأ فيها. + + + إعداد ترقيم المستندات + + + كيف تُكتب أرقام أوامر العمل والفواتير والعروض وتقارير الوقت اليومية، واليوم الذي تبدأ فيه سنة ترقيمها. يحتفظ نوع المستند الذي ليس له نمط بأرقامه المضمّنة. + + + إعداد ترقيم المستندات / شهر بداية السنة + + + الشهر الذي تبدأ فيه سنة الترقيم لأوامر العمل والفواتير والعروض وتقارير الوقت اليومية المرقّمة حسب سنة مالية. تبدأ أرقام أوامر العمل والأنماط التي تتضمن السنة من 1 من جديد في ذلك الشهر واليوم، ويكتب YYYY وYY اسم السنة المالية. القيمة غير المعيّنة تعني يناير، أي السنة الميلادية. + + + إعداد ترقيم المستندات / يوم بداية السنة + + + يوم الشهر الذي تبدأ فيه سنة الترقيم، من 1 إلى آخر يوم في الشهر؛ ولا يُسمح بـ 29 فبراير. القيمة غير المعيّنة تعني اليوم الأول. + + + إعداد ترقيم المستندات / تسمية السنة + + + السنة التي تُسمّى بها سنة ترقيم لا تبدأ في 1 يناير: السنة التي تنتهي فيها (العُرف المعتاد للسنة المالية، فالسنة التي تبدأ في 1 نوفمبر 2026 هي 2027) أو السنة التي تبدأ فيها. + + + إعداد ترقيم المستندات / الأنماط + + + نمط الترقيم الخاص بالقسم لأوامر العمل والفواتير والعروض وتقارير الوقت اليومية، برموز أرقام البلاغات (YYYY أو YY وMM وDD وSEQ). يحتفظ نوع المستند الذي ليس له نمط بأرقامه المضمّنة (WO-2026-000019، #1042). يواصل النمط الجديد بعد أي رقم قد يكرّره. يرى الأعضاء الرقم كما صدر على المستندات وملفات PDF والتصديرات، ولتقارير الوقت اليومية في تطبيقات Responder وUnit وIC وDispatch. + + + نمط رقم المستند / النوع + + + المستند الذي يرقّمه النمط: work-order أو invoice أو bid أو time-report، أو في السجلات records-request أو occupancy أو inspection أو permit أو investigation أو evidence. + + + نمط رقم المستند / النمط + + + نص ثابت حول رموز YYYY أو YY وMM وDD وSEQ، مع ظهور SEQ مرة واحدة فقط. يجب أن يتسع كل رقم يكتبه لعمود رقم المستند: 50 حرفًا، أو 32 لأرقام المباني والتفتيش والتصاريح والتحقيقات والأدلة. + + + نمط رقم المستند / عرض التسلسل + + + عدد الأرقام التي يُملأ إليها التسلسل بالأصفار البادئة، من 1 إلى 8؛ ويُحتسب التسلسل كرقم، لذا لا يعيد تغيير العرض تشغيله أبدًا. + + + إعداد ترقيم السجلات / أنماط المستندات + + + أنماط القسم الخاصة لأرقام طلبات الاطلاع على السجلات والمباني والتفتيش والتصاريح والتحقيقات والأدلة. تتبع بداية سنة الترقيم في هذا الإعداد؛ ويحتفظ النوع الذي ليس له نمط بأرقامه المضمّنة (PRR-2026-0001 وINSP-2026-0001 وغيرها). + + + ترقيم المستندات + + + كيف تُكتب أرقام أوامر العمل والفواتير والعروض وتقارير الوقت اليومية، واليوم الذي تبدأ فيه سنة ترقيمها عند الترقيم حسب سنة مالية. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx index 9f571035b..cc46fd2aa 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx @@ -1198,7 +1198,7 @@ Der Abteilungskalender für Besprechungen, Veranstaltungen und Ausbildungsabende. - Das Postfach und Format zum Importieren von Einsätzen aus CAD- oder Alarmierungs-E-Mails sowie Format und nächste Nummer der Einsatznummern. + Das Postfach und Format zum Importieren von Einsätzen aus CAD- oder Alarmierungs-E-Mails sowie Format, nächste Nummer und Beginn des Nummerierungsjahres der Einsatznummern. Wiederverwendbare Textvorlagen für Einsätze und Notizen, die die Einsatzerfassung beschleunigen. @@ -1522,7 +1522,7 @@ Datensätze, die fällig sind, auf Prüfung warten oder zur Übermittlung bereit sind. - Nummerierungs-, Aufbewahrungs-, Such- und Sichtbarkeitsregeln für Datensätze. + Nummerierungs- (einschließlich Auskunftsersuchen, Brandverhütungsnummern und Beginn des Nummerierungsjahres), Aufbewahrungs-, Such- und Sichtbarkeitsregeln für Datensätze. Abteilungsberichte zu Einsätzen, Personal und Aktivität. @@ -2236,7 +2236,7 @@ Verwendet separate Nummernfolgen je Wache/Gruppe. Dies ändert die Nummerierung, nicht die Sichtbarkeit der Datensätze. - Setzt die Datensatzfolgen in jedem Kalenderjahr in der Zeitzone der Abteilung zurück. + Setzt die Datensatzfolgen in jedem Nummerierungsjahr (dem Kalenderjahr, sofern kein Haushaltsjahresbeginn gesetzt ist) in der Zeitzone der Abteilung zurück. Stellen des SEQ-Teils im Nummernmuster, mit Nullen aufgefüllt: 4 schreibt 0184. 3 bis 8 Stellen; eine Änderung behält die nächste Nummer jeder Nummernfolge bei. Abteilungsdefinitionen mit eigener Nummerierung behalten ihre eigene Stellenanzahl. @@ -6786,4 +6786,100 @@ Integrationen funktionieren weiter, wenn Personen ausscheiden, können nur, was ihr Schlüssel erlaubt, und lassen sich sofort widerrufen. + + Konfiguration der Einsatznummerierung / Monat des Jahresbeginns + + + Monat, in dem das Nummerierungsjahr beginnt, für Einsätze, die nach einem Haushaltsjahr nummeriert werden. Ein jährliches Muster beginnt an diesem Monat und Tag wieder bei 1, und YYYY und YY schreiben den Namen des Haushaltsjahres; Muster mit MM oder DD folgen dem Kalender. Nicht gesetzt bedeutet Januar, also das Kalenderjahr. + + + Konfiguration der Einsatznummerierung / Tag des Jahresbeginns + + + Tag des Monats, an dem das Nummerierungsjahr beginnt, von 1 bis zum letzten Tag des Monats; der 29. Februar ist nicht erlaubt. Nicht gesetzt bedeutet der 1. + + + Konfiguration der Einsatznummerierung / Jahresbezeichnung + + + Welches Jahr ein Nummerierungsjahr benennt, das nicht am 1. Januar beginnt: das Jahr, in dem es endet (die übliche Konvention für Haushaltsjahre, ein am 1. November 2026 beginnendes Jahr ist also 2027), oder das Jahr, in dem es beginnt. + + + Konfiguration der Datensatznummerierung / Monat des Jahresbeginns + + + Monat, in dem das Nummerierungsjahr beginnt, für Datensätze, die nach einem Haushaltsjahr nummeriert werden, etwa dem eines Landkreises oder Landes. Nummernfolgen mit Jahr beginnen an diesem Monat und Tag wieder bei 1, und YYYY und YY schreiben den Namen des Haushaltsjahres. Nicht gesetzt bedeutet Januar, also das Kalenderjahr. Unabhängig vom Jahresbeginn der Einsatznummerierung. Nummern von Auskunftsersuchen sowie von Prüfungen, Genehmigungen, Objekten, Ermittlungen und Nachweisen folgen demselben Jahr. + + + Konfiguration der Datensatznummerierung / Tag des Jahresbeginns + + + Tag des Monats, an dem das Nummerierungsjahr beginnt, von 1 bis zum letzten Tag des Monats; der 29. Februar ist nicht erlaubt. Nicht gesetzt bedeutet der 1. + + + Konfiguration der Datensatznummerierung / Jahresbezeichnung + + + Welches Jahr ein Nummerierungsjahr benennt, das nicht am 1. Januar beginnt: das Jahr, in dem es endet (die übliche Konvention für Haushaltsjahre, ein am 1. November 2026 beginnendes Jahr ist also 2027), oder das Jahr, in dem es beginnt. + + + Konfiguration der Dokumentnummerierung + + + Wie Nummern von Arbeitsaufträgen, Rechnungen, Angeboten und täglichen Zeitberichten geschrieben werden und an welchem Tag ihr Nummerierungsjahr beginnt. Eine Dokumentart ohne Muster behält ihre integrierten Nummern. + + + Konfiguration der Dokumentnummerierung / Monat des Jahresbeginns + + + Monat, in dem das Nummerierungsjahr für Arbeitsaufträge, Rechnungen, Angebote und tägliche Zeitberichte beginnt, die nach einem Haushaltsjahr nummeriert werden. Arbeitsauftragsnummern und Muster mit Jahr beginnen an diesem Monat und Tag wieder bei 1, und YYYY und YY schreiben den Namen des Haushaltsjahres. Nicht gesetzt bedeutet Januar, also das Kalenderjahr. + + + Konfiguration der Dokumentnummerierung / Tag des Jahresbeginns + + + Tag des Monats, an dem das Nummerierungsjahr beginnt, von 1 bis zum letzten Tag des Monats; der 29. Februar ist nicht erlaubt. Nicht gesetzt bedeutet der 1. + + + Konfiguration der Dokumentnummerierung / Jahresbezeichnung + + + Welches Jahr ein Nummerierungsjahr benennt, das nicht am 1. Januar beginnt: das Jahr, in dem es endet (die übliche Konvention für Haushaltsjahre, ein am 1. November 2026 beginnendes Jahr ist also 2027), oder das Jahr, in dem es beginnt. + + + Konfiguration der Dokumentnummerierung / Muster + + + Das eigene Nummernmuster der Abteilung für Arbeitsaufträge, Rechnungen, Angebote und tägliche Zeitberichte, mit den Platzhaltern der Einsatznummern (YYYY oder YY, MM, DD, SEQ). Eine Dokumentart ohne Muster behält ihre integrierten Nummern (WO-2026-000019, #1042). Ein neues Muster setzt nach jeder Nummer fort, die es wiederholen würde. Mitglieder sehen die Nummer wie vergeben auf Dokumenten, PDFs und Exporten und bei täglichen Zeitberichten in den Apps Responder, Unit, IC und Dispatch. + + + Dokumentnummernmuster / Art + + + Das Dokument, das das Muster nummeriert: work-order, invoice, bid oder time-report, bei Akten records-request, occupancy, inspection, permit, investigation oder evidence. + + + Dokumentnummernmuster / Muster + + + Fester Text um die Platzhalter YYYY oder YY, MM, DD und SEQ, SEQ genau einmal. Jede geschriebene Nummer muss in die Nummernspalte des Dokuments passen: 50 Zeichen, bei Objekt-, Prüfungs-, Genehmigungs-, Ermittlungs- und Nachweisnummern 32. + + + Dokumentnummernmuster / Stellenanzahl der Nummernfolge + + + Stellen, auf die die Laufnummer mit führenden Nullen aufgefüllt wird, 1 bis 8; die Laufnummer wird als Zahl gezählt, eine geänderte Breite beginnt sie also nie neu. + + + Konfiguration der Datensatznummerierung / Dokumentmuster + + + Die eigenen Muster der Abteilung für Nummern von Auskunftsersuchen, Objekten, Prüfungen, Genehmigungen, Ermittlungen und Nachweisen. Sie folgen dem Beginn des Nummerierungsjahres dieser Einstellung; eine Art ohne Muster behält ihre integrierten Nummern (PRR-2026-0001, INSP-2026-0001 usw.). + + + Dokumentnummerierung + + + Wie Nummern von Arbeitsaufträgen, Rechnungen, Angeboten und täglichen Zeitberichten geschrieben werden und an welchem Tag ihr Nummerierungsjahr bei einer Nummerierung nach Haushaltsjahr beginnt. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx index aed07908f..9f90660ab 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx @@ -1198,7 +1198,7 @@ Το ημερολόγιο του τμήματος για συναντήσεις, εκδηλώσεις και βραδιές εκπαίδευσης. - Το γραμματοκιβώτιο και η μορφή που χρησιμοποιούνται για την εισαγωγή κλήσεων από CAD ή email ειδοποίησης, καθώς και η μορφή και ο επόμενος αριθμός των αριθμών κλήσεων. + Το γραμματοκιβώτιο και η μορφή που χρησιμοποιούνται για την εισαγωγή κλήσεων από CAD ή email ειδοποίησης, καθώς και η μορφή, ο επόμενος αριθμός και η έναρξη του έτους αρίθμησης των αριθμών κλήσεων. Επαναχρησιμοποιήσιμα πρότυπα κειμένου κλήσης και σημειώσεων που επιταχύνουν την καταχώριση κλήσεων. @@ -1522,7 +1522,7 @@ Εγγραφές που εκκρεμούν, αναμένουν επανεξέταση ή είναι έτοιμες για υποβολή. - Κανόνες αρίθμησης, διατήρησης, αναζήτησης και ορατότητας για τις εγγραφές. + Κανόνες αρίθμησης (μαζί με τους αριθμούς αιτημάτων πρόσβασης, πρόληψης και την έναρξη του έτους αρίθμησης), διατήρησης, αναζήτησης και ορατότητας για τις εγγραφές. Αναφορές τμήματος για κλήσεις, προσωπικό και δραστηριότητα. @@ -2236,7 +2236,7 @@ Χρησιμοποιεί ξεχωριστές ακολουθίες ανά σταθμό/ομάδα. Αυτό αλλάζει την αρίθμηση, όχι την ορατότητα των εγγραφών. - Επανεκκινεί τις ακολουθίες εγγραφών κάθε ημερολογιακό έτος στη ζώνη ώρας του τμήματος. + Επανεκκινεί τις ακολουθίες εγγραφών κάθε έτος αρίθμησης (το ημερολογιακό έτος, εκτός αν έχει οριστεί έναρξη οικονομικού έτους) στη ζώνη ώρας του τμήματος. Ψηφία του τμήματος SEQ στο μοτίβο αρίθμησης, με μηδενικά συμπλήρωσης: το 4 γράφει 0184. Από 3 έως 8 ψηφία· η αλλαγή του διατηρεί τον επόμενο αριθμό κάθε ακολουθίας. Οι ορισμοί του τμήματος με δική τους αρίθμηση διατηρούν το δικό τους πλάτος. @@ -6786,4 +6786,100 @@ Οι ενοποιήσεις συνεχίζουν να λειτουργούν όταν φεύγουν άτομα, κάνουν μόνο ό,τι επιτρέπει το κλειδί τους και ανακαλούνται αμέσως. + + Ρύθμιση αρίθμησης κλήσεων / Μήνας έναρξης έτους + + + Ο μήνας κατά τον οποίο ξεκινά το έτος αρίθμησης, για κλήσεις που αριθμούνται ανά οικονομικό έτος. Ένα ετήσιο μοτίβο ξεκινά ξανά από το 1 σε αυτόν τον μήνα και την ημέρα, και τα YYYY και YY γράφουν το όνομα του οικονομικού έτους· τα μοτίβα με MM ή DD ακολουθούν το ημερολόγιο. Χωρίς τιμή σημαίνει Ιανουάριος, δηλαδή το ημερολογιακό έτος. + + + Ρύθμιση αρίθμησης κλήσεων / Ημέρα έναρξης έτους + + + Η ημέρα του μήνα κατά την οποία ξεκινά το έτος αρίθμησης, από 1 έως την τελευταία ημέρα του μήνα· η 29η Φεβρουαρίου δεν επιτρέπεται. Χωρίς τιμή σημαίνει η 1η. + + + Ρύθμιση αρίθμησης κλήσεων / Ονομασία έτους + + + Ποιο έτος ονομάζει ένα έτος αρίθμησης που δεν ξεκινά την 1η Ιανουαρίου: το έτος στο οποίο λήγει (η συνήθης σύμβαση για οικονομικά έτη, οπότε ένα έτος που ξεκινά την 1η Νοεμβρίου 2026 είναι το 2027) ή το έτος στο οποίο ξεκινά. + + + Διαμόρφωση αρίθμησης εγγραφών / Μήνας έναρξης έτους + + + Ο μήνας κατά τον οποίο ξεκινά το έτος αρίθμησης, για εγγραφές που αριθμούνται ανά οικονομικό έτος, όπως μιας κομητείας ή πολιτείας. Οι ακολουθίες που περιέχουν το έτος ξεκινούν ξανά από το 1 σε αυτόν τον μήνα και την ημέρα, και τα YYYY και YY γράφουν το όνομα του οικονομικού έτους. Χωρίς τιμή σημαίνει Ιανουάριος, δηλαδή το ημερολογιακό έτος. Ανεξάρτητο από την έναρξη έτους της αρίθμησης κλήσεων. Οι αριθμοί αιτημάτων πρόσβασης σε αρχεία και οι αριθμοί επιθεωρήσεων, αδειών, κτιρίων, ερευνών και τεκμηρίων ακολουθούν το ίδιο έτος. + + + Διαμόρφωση αρίθμησης εγγραφών / Ημέρα έναρξης έτους + + + Η ημέρα του μήνα κατά την οποία ξεκινά το έτος αρίθμησης, από 1 έως την τελευταία ημέρα του μήνα· η 29η Φεβρουαρίου δεν επιτρέπεται. Χωρίς τιμή σημαίνει η 1η. + + + Διαμόρφωση αρίθμησης εγγραφών / Ονομασία έτους + + + Ποιο έτος ονομάζει ένα έτος αρίθμησης που δεν ξεκινά την 1η Ιανουαρίου: το έτος στο οποίο λήγει (η συνήθης σύμβαση για οικονομικά έτη, οπότε ένα έτος που ξεκινά την 1η Νοεμβρίου 2026 είναι το 2027) ή το έτος στο οποίο ξεκινά. + + + Ρύθμιση αρίθμησης εγγράφων + + + Πώς γράφονται οι αριθμοί εντολών εργασίας, τιμολογίων, προσφορών και ημερήσιων αναφορών χρόνου, και η ημέρα που ξεκινά το έτος αρίθμησής τους. Ένα είδος εγγράφου χωρίς μοτίβο διατηρεί τους ενσωματωμένους αριθμούς του. + + + Ρύθμιση αρίθμησης εγγράφων / Μήνας έναρξης έτους + + + Ο μήνας κατά τον οποίο ξεκινά το έτος αρίθμησης για εντολές εργασίας, τιμολόγια, προσφορές και ημερήσιες αναφορές χρόνου που αριθμούνται ανά οικονομικό έτος. Οι αριθμοί εντολών εργασίας και τα μοτίβα με έτος ξεκινούν ξανά από το 1 σε αυτόν τον μήνα και την ημέρα, και τα YYYY και YY γράφουν το όνομα του οικονομικού έτους. Χωρίς τιμή σημαίνει Ιανουάριος, δηλαδή το ημερολογιακό έτος. + + + Ρύθμιση αρίθμησης εγγράφων / Ημέρα έναρξης έτους + + + Η ημέρα του μήνα κατά την οποία ξεκινά το έτος αρίθμησης, από 1 έως την τελευταία ημέρα του μήνα· η 29η Φεβρουαρίου δεν επιτρέπεται. Χωρίς τιμή σημαίνει η 1η. + + + Ρύθμιση αρίθμησης εγγράφων / Ονομασία έτους + + + Ποιο έτος ονομάζει ένα έτος αρίθμησης που δεν ξεκινά την 1η Ιανουαρίου: το έτος στο οποίο λήγει (η συνήθης σύμβαση για οικονομικά έτη, οπότε ένα έτος που ξεκινά την 1η Νοεμβρίου 2026 είναι το 2027) ή το έτος στο οποίο ξεκινά. + + + Ρύθμιση αρίθμησης εγγράφων / Μοτίβα + + + Το δικό μοτίβο αρίθμησης του τμήματος για εντολές εργασίας, τιμολόγια, προσφορές και ημερήσιες αναφορές χρόνου, με τα σύμβολα των αριθμών κλήσεων (YYYY ή YY, MM, DD, SEQ). Ένα είδος εγγράφου χωρίς μοτίβο διατηρεί τους ενσωματωμένους αριθμούς του (WO-2026-000019, #1042). Ένα νέο μοτίβο συνεχίζει μετά από κάθε αριθμό που θα επαναλάμβανε. Τα μέλη βλέπουν τον αριθμό όπως εκδόθηκε σε έγγραφα, PDF και εξαγωγές, και για τις ημερήσιες αναφορές χρόνου στις εφαρμογές Responder, Unit, IC και Dispatch. + + + Μοτίβο αριθμού εγγράφου / Είδος + + + Το έγγραφο που αριθμεί το μοτίβο: work-order, invoice, bid ή time-report, ή στα αρχεία records-request, occupancy, inspection, permit, investigation ή evidence. + + + Μοτίβο αριθμού εγγράφου / Μοτίβο + + + Σταθερό κείμενο γύρω από τα σύμβολα YYYY ή YY, MM, DD και SEQ, με το SEQ ακριβώς μία φορά. Κάθε αριθμός που γράφει πρέπει να χωρά στη στήλη αριθμού του εγγράφου: 50 χαρακτήρες, ή 32 για αριθμούς κτιρίων, επιθεωρήσεων, αδειών, ερευνών και τεκμηρίων. + + + Μοτίβο αριθμού εγγράφου / Πλάτος ακολουθίας + + + Ψηφία στα οποία συμπληρώνεται η ακολουθία με μηδενικά μπροστά, από 1 έως 8· η ακολουθία μετριέται ως αριθμός, οπότε η αλλαγή πλάτους δεν την ξεκινά ποτέ από την αρχή. + + + Διαμόρφωση αρίθμησης εγγραφών / Μοτίβα εγγράφων + + + Τα δικά μοτίβα του τμήματος για αριθμούς αιτημάτων πρόσβασης σε αρχεία, κτιρίων, επιθεωρήσεων, αδειών, ερευνών και τεκμηρίων. Ακολουθούν την έναρξη του έτους αρίθμησης αυτής της ρύθμισης· ένα είδος χωρίς μοτίβο διατηρεί τους ενσωματωμένους αριθμούς του (PRR-2026-0001, INSP-2026-0001 κ.λπ.). + + + Αρίθμηση εγγράφων + + + Πώς γράφονται οι αριθμοί εντολών εργασίας, τιμολογίων, προσφορών και ημερήσιων αναφορών χρόνου, και η ημέρα που ξεκινά το έτος αρίθμησής τους για αρίθμηση ανά οικονομικό έτος. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx index 5581f7a2d..80824a111 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx @@ -1198,7 +1198,7 @@ The department calendar for meetings, events and training nights. - The mailbox and format used to import calls from CAD or paging email, and the call number format and next number. + The mailbox and format used to import calls from CAD or paging email, and the call number format, next number and numbering year start. Reusable call text and note templates that speed up call entry. @@ -1522,7 +1522,7 @@ Records that are due, awaiting review or ready to submit. - Numbering, retention, search and visibility rules for records. + Numbering (including records request and prevention numbers and the numbering year start), retention, search and visibility rules for records. Department reports on calls, personnel and activity. @@ -2236,7 +2236,7 @@ Use separate station/group sequences. This changes numbering, not record visibility. - Restart record sequences each calendar year in the department time zone. + Restart record sequences each numbering year (the calendar year unless a fiscal year start is set) in the department time zone. Digits in the SEQ part of the number pattern, zero-padded: 4 writes 0184. 3 to 8 digits; changing it keeps each sequence's next number. Department definitions with their own numbering keep their own width. @@ -6786,4 +6786,100 @@ Integrations keep working when people leave, can only do what their key allows and can be revoked at once. + + Call Numbering Config / Year Start Month + + + Month the numbering year starts, for calls numbered by a fiscal year. A yearly pattern starts again at 1 on that month and day, and YYYY and YY write the fiscal year's name; patterns with MM or DD follow the calendar. Unset is January, the calendar year. + + + Call Numbering Config / Year Start Day + + + Day of the month the numbering year starts, 1 to the month's last day; February 29 is not allowed. Unset is the 1st. + + + Call Numbering Config / Year Label + + + Which year names a numbering year that does not start on January 1: the year it ends in (the usual fiscal-year convention, so a year starting November 1, 2026 is 2027) or the year it starts in. + + + Records Numbering Config / Year Start Month + + + Month the numbering year starts, for records numbered by a fiscal year such as a county's or state's. Sequences that include the year start again at 1 on that month and day, and YYYY and YY write the fiscal year's name. Unset is January, the calendar year. Separate from call numbering's year start. Records request numbers and the numbers of inspections, permits, occupancies, investigations and evidence follow the same year. + + + Records Numbering Config / Year Start Day + + + Day of the month the numbering year starts, 1 to the month's last day; February 29 is not allowed. Unset is the 1st. + + + Records Numbering Config / Year Label + + + Which year names a numbering year that does not start on January 1: the year it ends in (the usual fiscal-year convention, so a year starting November 1, 2026 is 2027) or the year it starts in. + + + Document Numbering Config + + + How work order, invoice, bid and daily time report numbers are written, and the day their numbering year starts. A document kind with no pattern keeps its built-in numbers. + + + Document Numbering Config / Year Start Month + + + Month the numbering year starts for work orders, invoices, bids and daily time reports numbered by a fiscal year. Work order numbers and patterns that include the year start again at 1 on that month and day, and YYYY and YY write the fiscal year's name. Unset is January, the calendar year. + + + Document Numbering Config / Year Start Day + + + Day of the month the numbering year starts, 1 to the month's last day; February 29 is not allowed. Unset is the 1st. + + + Document Numbering Config / Year Label + + + Which year names a numbering year that does not start on January 1: the year it ends in (the usual fiscal-year convention, so a year starting November 1, 2026 is 2027) or the year it starts in. + + + Document Numbering Config / Patterns + + + The department's own number pattern for work orders, invoices, bids and daily time reports, in the call number tokens (YYYY or YY, MM, DD, SEQ). A document kind with no pattern keeps its built-in numbers (WO-2026-000019, #1042). A new pattern carries on after any number it would repeat. Members see the number as issued on documents, PDFs and exports, and for daily time reports in the Responder, Unit, IC and Dispatch apps. + + + Document Number Pattern / Kind + + + The document the pattern numbers: work-order, invoice, bid or time-report, or for Records records-request, occupancy, inspection, permit, investigation or evidence. + + + Document Number Pattern / Pattern + + + Fixed text around the YYYY or YY, MM, DD and SEQ tokens, with SEQ exactly once. Every number it writes must fit the document's number column: 50 characters, or 32 for occupancy, inspection, permit, investigation and evidence numbers. + + + Document Number Pattern / Sequence Width + + + Digits the sequence is padded to with leading zeros, 1 to 8; the sequence is counted as a number, so changing the width never restarts it. + + + Records Numbering Config / Document Patterns + + + The department's own patterns for records request, occupancy, inspection, permit, investigation and evidence numbers. They follow this setting's numbering year start; a kind with no pattern keeps its built-in numbers (PRR-2026-0001, INSP-2026-0001 and so on). + + + Document Numbering + + + How work order, invoice, bid and daily time report numbers are written, and the day their numbering year starts for fiscal-year numbering. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx index c41e9f6b8..1d6675dfa 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx @@ -1198,7 +1198,7 @@ El calendario del departamento para reuniones, eventos y noches de capacitación. - El buzón y el formato usados para importar llamadas desde CAD o correo de aviso, y el formato y el próximo número de los números de llamada. + El buzón y el formato usados para importar llamadas desde CAD o correo de aviso, y el formato, el próximo número y el inicio del año de numeración de los números de llamada. Plantillas reutilizables de texto de llamada y notas que agilizan el registro de llamadas. @@ -1522,7 +1522,7 @@ Registros pendientes, en espera de revisión o listos para enviar. - Reglas de numeración, retención, búsqueda y visibilidad de los registros. + Reglas de numeración (incluidos los números de solicitudes de acceso y de prevención y el inicio del año de numeración), retención, búsqueda y visibilidad de los registros. Informes del departamento sobre llamadas, personal y actividad. @@ -2236,7 +2236,7 @@ Use secuencias separadas por estación o grupo. Esto cambia la numeración, no la visibilidad de los registros. - Reinicia las secuencias de registros cada año calendario según la zona horaria del departamento. + Reinicia las secuencias de registros cada año de numeración (el año calendario salvo que se haya fijado el inicio de un año fiscal) según la zona horaria del departamento. Dígitos de la parte SEQ del patrón de numeración, con ceros a la izquierda: 4 escribe 0184. De 3 a 8 dígitos; al cambiarlo, cada secuencia conserva su siguiente número. Las definiciones del departamento con numeración propia conservan su propia longitud. @@ -6786,4 +6786,100 @@ Las integraciones siguen funcionando cuando alguien se va, solo pueden hacer lo que permite su clave y se pueden revocar al instante. + + Configuración de numeración de llamadas / Mes de inicio del año + + + Mes en que empieza el año de numeración, para llamadas numeradas por un año fiscal. Un patrón anual vuelve a empezar en 1 en ese mes y día, y YYYY y YY escriben el nombre del año fiscal; los patrones con MM o DD siguen el calendario. Sin valor es enero, el año natural. + + + Configuración de numeración de llamadas / Día de inicio del año + + + Día del mes en que empieza el año de numeración, de 1 al último día del mes; no se permite el 29 de febrero. Sin valor es el día 1. + + + Configuración de numeración de llamadas / Nombre del año + + + Qué año da nombre a un año de numeración que no empieza el 1 de enero: el año en que termina (la convención habitual de los años fiscales, así que un año que empieza el 1 de noviembre de 2026 es 2027) o el año en que empieza. + + + Configuración de numeración de registros / Mes de inicio del año + + + Mes en que empieza el año de numeración, para registros numerados por un año fiscal, como el de un condado o estado. Las secuencias que incluyen el año vuelven a empezar en 1 en ese mes y día, y YYYY y YY escriben el nombre del año fiscal. Sin valor es enero, el año natural. Es independiente del inicio de año de la numeración de llamadas. Los números de las solicitudes de acceso a registros y de inspecciones, permisos, ocupaciones, investigaciones y evidencias siguen el mismo año. + + + Configuración de numeración de registros / Día de inicio del año + + + Día del mes en que empieza el año de numeración, de 1 al último día del mes; no se permite el 29 de febrero. Sin valor es el día 1. + + + Configuración de numeración de registros / Nombre del año + + + Qué año da nombre a un año de numeración que no empieza el 1 de enero: el año en que termina (la convención habitual de los años fiscales, así que un año que empieza el 1 de noviembre de 2026 es 2027) o el año en que empieza. + + + Configuración de numeración de documentos + + + Cómo se escriben los números de órdenes de trabajo, facturas, ofertas y partes diarios de horas, y el día en que empieza su año de numeración. Un tipo de documento sin patrón mantiene sus números integrados. + + + Configuración de numeración de documentos / Mes de inicio del año + + + Mes en que empieza el año de numeración de las órdenes de trabajo, facturas, ofertas y partes diarios de horas numerados por un año fiscal. Los números de órdenes de trabajo y los patrones con año vuelven a empezar en 1 en ese mes y día, y YYYY y YY escriben el nombre del año fiscal. Sin valor es enero, el año natural. + + + Configuración de numeración de documentos / Día de inicio del año + + + Día del mes en que empieza el año de numeración, de 1 al último día del mes; no se permite el 29 de febrero. Sin valor es el día 1. + + + Configuración de numeración de documentos / Nombre del año + + + Qué año da nombre a un año de numeración que no empieza el 1 de enero: el año en que termina (la convención habitual de los años fiscales, así que un año que empieza el 1 de noviembre de 2026 es 2027) o el año en que empieza. + + + Configuración de numeración de documentos / Patrones + + + El patrón de numeración propio del departamento para órdenes de trabajo, facturas, ofertas y partes diarios de horas, con los marcadores de los números de llamada (YYYY o YY, MM, DD, SEQ). Un tipo de documento sin patrón mantiene sus números integrados (WO-2026-000019, #1042). Un patrón nuevo continúa después de cualquier número que repetiría. Los miembros ven el número tal como se emitió en documentos, PDF y exportaciones, y en el caso de los partes diarios de horas en las apps Responder, Unit, IC y Dispatch. + + + Patrón de número de documento / Tipo + + + El documento que numera el patrón: work-order, invoice, bid o time-report, o en Registros records-request, occupancy, inspection, permit, investigation o evidence. + + + Patrón de número de documento / Patrón + + + Texto fijo alrededor de los marcadores YYYY o YY, MM, DD y SEQ, con SEQ exactamente una vez. Cada número que escribe debe caber en la columna de número del documento: 50 caracteres, o 32 para los números de ocupaciones, inspecciones, permisos, investigaciones y evidencias. + + + Patrón de número de documento / Ancho de la secuencia + + + Dígitos a los que se rellena la secuencia con ceros a la izquierda, de 1 a 8; la secuencia se cuenta como número, así que cambiar el ancho nunca la reinicia. + + + Configuración de numeración de registros / Patrones de documentos + + + Los patrones propios del departamento para los números de solicitudes de acceso a registros, ocupaciones, inspecciones, permisos, investigaciones y evidencias. Siguen el inicio del año de numeración de este ajuste; un tipo sin patrón mantiene sus números integrados (PRR-2026-0001, INSP-2026-0001, etc.). + + + Numeración de documentos + + + Cómo se escriben los números de órdenes de trabajo, facturas, ofertas y partes diarios de horas, y el día en que empieza su año de numeración cuando se numera por año fiscal. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx index d2ca258a5..b76c7000c 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx @@ -1198,7 +1198,7 @@ Le calendrier du département pour les réunions, événements et soirées de formation. - La boîte aux lettres et le format utilisés pour importer les appels depuis un CAD ou un e-mail de radiomessagerie, ainsi que le format et le prochain numéro des numéros d'appel. + La boîte aux lettres et le format utilisés pour importer les appels depuis un CAD ou un e-mail de radiomessagerie, ainsi que le format, le prochain numéro et le début de l'année de numérotation des numéros d'appel. Modèles de texte d'appel et de notes réutilisables qui accélèrent la saisie des appels. @@ -1522,7 +1522,7 @@ Dossiers à traiter, en attente de vérification ou prêts à être soumis. - Règles de numérotation, de conservation, de recherche et de visibilité des dossiers. + Règles de numérotation (y compris les numéros de demandes d'accès et de prévention et le début de l'année de numérotation), de conservation, de recherche et de visibilité des dossiers. Rapports du département sur les appels, le personnel et l'activité. @@ -2236,7 +2236,7 @@ Utilise des séquences distinctes par caserne/groupe. Ceci modifie la numérotation, et non la visibilité des dossiers. - Réinitialise les séquences de dossiers chaque année civile, selon le fuseau horaire du département. + Réinitialise les séquences de dossiers chaque année de numérotation (l'année civile, sauf si un début d'exercice est défini), selon le fuseau horaire du département. Chiffres de la partie SEQ du modèle de numérotation, complétés par des zéros : 4 écrit 0184. De 3 à 8 chiffres ; le modifier conserve le prochain numéro de chaque séquence. Les définitions du département ayant leur propre numérotation conservent leur propre largeur. @@ -6786,4 +6786,100 @@ Les intégrations continuent de fonctionner quand des personnes partent, ne peuvent faire que ce que leur clé autorise et peuvent être révoquées immédiatement. + + Configuration de la numérotation des appels / Mois de début de l'année + + + Mois où commence l'année de numérotation, pour les appels numérotés par exercice budgétaire. Un modèle annuel recommence à 1 ce mois et ce jour-là, et YYYY et YY écrivent le nom de l'exercice ; les modèles avec MM ou DD suivent le calendrier. Non défini correspond à janvier, l'année civile. + + + Configuration de la numérotation des appels / Jour de début de l'année + + + Jour du mois où commence l'année de numérotation, de 1 au dernier jour du mois ; le 29 février n'est pas autorisé. Non défini correspond au 1er. + + + Configuration de la numérotation des appels / Nom de l'année + + + Année qui donne son nom à une année de numérotation ne commençant pas le 1er janvier : l'année où elle se termine (la convention habituelle des exercices, une année commençant le 1er novembre 2026 est donc 2027) ou l'année où elle commence. + + + Configuration de numérotation des dossiers / Mois de début de l'année + + + Mois où commence l'année de numérotation, pour les dossiers numérotés par exercice budgétaire, comme celui d'un comté ou d'un État. Les séquences qui contiennent l'année recommencent à 1 ce mois et ce jour-là, et YYYY et YY écrivent le nom de l'exercice. Non défini correspond à janvier, l'année civile. Indépendant du début d'année de la numérotation des appels. Les numéros des demandes d'accès aux documents et ceux des inspections, permis, occupations, enquêtes et preuves suivent la même année. + + + Configuration de numérotation des dossiers / Jour de début de l'année + + + Jour du mois où commence l'année de numérotation, de 1 au dernier jour du mois ; le 29 février n'est pas autorisé. Non défini correspond au 1er. + + + Configuration de numérotation des dossiers / Nom de l'année + + + Année qui donne son nom à une année de numérotation ne commençant pas le 1er janvier : l'année où elle se termine (la convention habituelle des exercices, une année commençant le 1er novembre 2026 est donc 2027) ou l'année où elle commence. + + + Configuration de la numérotation des documents + + + Comment s'écrivent les numéros des ordres de travail, factures, offres et rapports de temps quotidiens, et le jour où commence leur année de numérotation. Un type de document sans modèle conserve ses numéros intégrés. + + + Configuration de la numérotation des documents / Mois de début de l'année + + + Mois où commence l'année de numérotation des ordres de travail, factures, offres et rapports de temps quotidiens numérotés par exercice budgétaire. Les numéros d'ordres de travail et les modèles avec une année recommencent à 1 ce mois et ce jour-là, et YYYY et YY écrivent le nom de l'exercice. Non défini correspond à janvier, l'année civile. + + + Configuration de la numérotation des documents / Jour de début de l'année + + + Jour du mois où commence l'année de numérotation, de 1 au dernier jour du mois ; le 29 février n'est pas autorisé. Non défini correspond au 1er. + + + Configuration de la numérotation des documents / Nom de l'année + + + Année qui donne son nom à une année de numérotation ne commençant pas le 1er janvier : l'année où elle se termine (la convention habituelle des exercices, une année commençant le 1er novembre 2026 est donc 2027) ou l'année où elle commence. + + + Configuration de la numérotation des documents / Modèles + + + Le modèle de numérotation propre au service pour les ordres de travail, factures, offres et rapports de temps quotidiens, avec les jetons des numéros d'appel (YYYY ou YY, MM, DD, SEQ). Un type de document sans modèle conserve ses numéros intégrés (WO-2026-000019, #1042). Un nouveau modèle reprend après tout numéro qu'il répéterait. Les membres voient le numéro tel qu'émis sur les documents, les PDF et les exports, et pour les rapports de temps quotidiens dans les applications Responder, Unit, IC et Dispatch. + + + Modèle de numéro de document / Type + + + Le document que le modèle numérote : work-order, invoice, bid ou time-report, ou pour les dossiers records-request, occupancy, inspection, permit, investigation ou evidence. + + + Modèle de numéro de document / Modèle + + + Texte fixe autour des jetons YYYY ou YY, MM, DD et SEQ, avec SEQ exactement une fois. Chaque numéro écrit doit tenir dans la colonne de numéro du document : 50 caractères, ou 32 pour les numéros d'occupation, d'inspection, de permis, d'enquête et de preuve. + + + Modèle de numéro de document / Largeur de la séquence + + + Nombre de chiffres auquel la séquence est complétée par des zéros, de 1 à 8 ; la séquence est comptée comme un nombre, changer la largeur ne la redémarre donc jamais. + + + Configuration de numérotation des dossiers / Modèles de documents + + + Les modèles propres au service pour les numéros de demandes d'accès aux documents, d'occupations, d'inspections, de permis, d'enquêtes et de preuves. Ils suivent le début de l'année de numérotation de ce paramètre ; un type sans modèle conserve ses numéros intégrés (PRR-2026-0001, INSP-2026-0001, etc.). + + + Numérotation des documents + + + Comment s'écrivent les numéros des ordres de travail, factures, offres et rapports de temps quotidiens, et le jour où commence leur année de numérotation pour une numérotation par exercice. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx index 3dff0d267..34f205fc2 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx @@ -1198,7 +1198,7 @@ Il calendario del dipartimento per riunioni, eventi e serate di formazione. - La casella di posta e il formato utilizzati per importare le chiamate da CAD o e-mail di paging, e il formato e il prossimo numero dei numeri di chiamata. + La casella di posta e il formato utilizzati per importare le chiamate da CAD o e-mail di paging, e il formato, il prossimo numero e l'inizio dell'anno di numerazione dei numeri di chiamata. Modelli riutilizzabili di testo e note per la chiamata che velocizzano l'inserimento delle chiamate. @@ -1522,7 +1522,7 @@ Record in scadenza, in attesa di revisione o pronti per l'invio. - Regole di numerazione, conservazione, ricerca e visibilità per i record. + Regole di numerazione (inclusi i numeri delle richieste di accesso e di prevenzione e l'inizio dell'anno di numerazione), conservazione, ricerca e visibilità per i record. Rapporti del dipartimento su chiamate, personale e attività. @@ -2236,7 +2236,7 @@ Utilizza sequenze separate per stazione/gruppo. Questo modifica la numerazione, non la visibilità dei record. - Riavvia le sequenze dei record a ogni anno solare nel fuso orario del dipartimento. + Riavvia le sequenze dei record a ogni anno di numerazione (l'anno solare, salvo che sia impostato l'inizio di un anno fiscale) nel fuso orario del dipartimento. Cifre della parte SEQ dello schema di numerazione, con zeri iniziali: 4 scrive 0184. Da 3 a 8 cifre; modificarlo mantiene il numero successivo di ogni sequenza. Le definizioni del dipartimento con una numerazione propria mantengono la propria larghezza. @@ -6786,4 +6786,100 @@ Le integrazioni continuano a funzionare quando le persone se ne vanno, possono fare solo ciò che la loro chiave consente e possono essere revocate subito. + + Configurazione della numerazione delle chiamate / Mese di inizio anno + + + Mese in cui inizia l'anno di numerazione, per le chiamate numerate per anno fiscale. Uno schema annuale riparte da 1 in quel mese e giorno, e YYYY e YY scrivono il nome dell'anno fiscale; gli schemi con MM o DD seguono il calendario. Se non impostato è gennaio, l'anno solare. + + + Configurazione della numerazione delle chiamate / Giorno di inizio anno + + + Giorno del mese in cui inizia l'anno di numerazione, da 1 all'ultimo giorno del mese; il 29 febbraio non è consentito. Se non impostato è il giorno 1. + + + Configurazione della numerazione delle chiamate / Nome dell'anno + + + Quale anno dà il nome a un anno di numerazione che non inizia il 1° gennaio: l'anno in cui termina (la convenzione abituale degli anni fiscali, quindi un anno che inizia il 1° novembre 2026 è il 2027) o l'anno in cui inizia. + + + Configurazione numerazione record / Mese di inizio anno + + + Mese in cui inizia l'anno di numerazione, per i record numerati per anno fiscale, come quello di una contea o di uno stato. Le sequenze che includono l'anno ripartono da 1 in quel mese e giorno, e YYYY e YY scrivono il nome dell'anno fiscale. Se non impostato è gennaio, l'anno solare. Indipendente dall'inizio d'anno della numerazione delle chiamate. I numeri delle richieste di accesso agli atti e quelli di ispezioni, permessi, occupazioni, indagini e prove seguono lo stesso anno. + + + Configurazione numerazione record / Giorno di inizio anno + + + Giorno del mese in cui inizia l'anno di numerazione, da 1 all'ultimo giorno del mese; il 29 febbraio non è consentito. Se non impostato è il giorno 1. + + + Configurazione numerazione record / Nome dell'anno + + + Quale anno dà il nome a un anno di numerazione che non inizia il 1° gennaio: l'anno in cui termina (la convenzione abituale degli anni fiscali, quindi un anno che inizia il 1° novembre 2026 è il 2027) o l'anno in cui inizia. + + + Configurazione della numerazione dei documenti + + + Come vengono scritti i numeri di ordini di lavoro, fatture, offerte e rapporti giornalieri ore, e il giorno in cui inizia il loro anno di numerazione. Un tipo di documento senza schema mantiene i numeri predefiniti. + + + Configurazione della numerazione dei documenti / Mese di inizio anno + + + Mese in cui inizia l'anno di numerazione per ordini di lavoro, fatture, offerte e rapporti giornalieri ore numerati per anno fiscale. I numeri degli ordini di lavoro e gli schemi con l'anno ripartono da 1 in quel mese e giorno, e YYYY e YY scrivono il nome dell'anno fiscale. Se non impostato è gennaio, l'anno solare. + + + Configurazione della numerazione dei documenti / Giorno di inizio anno + + + Giorno del mese in cui inizia l'anno di numerazione, da 1 all'ultimo giorno del mese; il 29 febbraio non è consentito. Se non impostato è il giorno 1. + + + Configurazione della numerazione dei documenti / Nome dell'anno + + + Quale anno dà il nome a un anno di numerazione che non inizia il 1° gennaio: l'anno in cui termina (la convenzione abituale degli anni fiscali, quindi un anno che inizia il 1° novembre 2026 è il 2027) o l'anno in cui inizia. + + + Configurazione della numerazione dei documenti / Schemi + + + Lo schema di numerazione proprio del dipartimento per ordini di lavoro, fatture, offerte e rapporti giornalieri ore, con i segnaposto dei numeri di chiamata (YYYY o YY, MM, DD, SEQ). Un tipo di documento senza schema mantiene i numeri predefiniti (WO-2026-000019, #1042). Un nuovo schema prosegue dopo qualsiasi numero che ripeterebbe. I membri vedono il numero così come emesso su documenti, PDF ed esportazioni e, per i rapporti giornalieri ore, nelle app Responder, Unit, IC e Dispatch. + + + Schema del numero di documento / Tipo + + + Il documento che lo schema numera: work-order, invoice, bid o time-report, oppure nei registri records-request, occupancy, inspection, permit, investigation o evidence. + + + Schema del numero di documento / Schema + + + Testo fisso attorno ai segnaposto YYYY o YY, MM, DD e SEQ, con SEQ esattamente una volta. Ogni numero scritto deve stare nella colonna del numero del documento: 50 caratteri, o 32 per i numeri di occupazioni, ispezioni, permessi, indagini e prove. + + + Schema del numero di documento / Larghezza sequenza + + + Cifre a cui la sequenza viene riempita con zeri iniziali, da 1 a 8; la sequenza è contata come numero, quindi cambiare la larghezza non la fa mai ripartire. + + + Configurazione numerazione record / Schemi dei documenti + + + Gli schemi propri del dipartimento per i numeri di richieste di accesso agli atti, occupazioni, ispezioni, permessi, indagini e prove. Seguono l'inizio dell'anno di numerazione di questa impostazione; un tipo senza schema mantiene i numeri predefiniti (PRR-2026-0001, INSP-2026-0001 e così via). + + + Numerazione dei documenti + + + Come vengono scritti i numeri di ordini di lavoro, fatture, offerte e rapporti giornalieri ore, e il giorno in cui inizia il loro anno di numerazione per la numerazione per anno fiscale. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx index a4018b37d..e942c7f9d 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx @@ -1198,7 +1198,7 @@ Kalendarz oddziału na potrzeby spotkań, wydarzeń i wieczorów szkoleniowych. - Skrzynka pocztowa i format używane do importowania zgłoszeń z CAD lub e-maila pagera oraz format i następny numer zgłoszeń. + Skrzynka pocztowa i format używane do importowania zgłoszeń z CAD lub e-maila pagera oraz format, następny numer i początek roku numeracji zgłoszeń. Wielokrotnego użytku szablony tekstu zgłoszenia i notatek, które przyspieszają wprowadzanie zgłoszeń. @@ -1522,7 +1522,7 @@ Rekordy, które są zaplanowane, oczekują na przegląd lub są gotowe do przesłania. - Reguły numeracji, retencji, wyszukiwania i widoczności rekordów. + Reguły numeracji (w tym numery wniosków o udostępnienie, numery prewencji i początek roku numeracji), retencji, wyszukiwania i widoczności rekordów. Raporty oddziału dotyczące zgłoszeń, personelu i aktywności. @@ -2236,7 +2236,7 @@ Używa oddzielnych sekwencji dla poszczególnych stacji/grup. Zmienia to numerację, a nie widoczność rekordów. - Resetuje sekwencje rekordów każdego roku kalendarzowego w strefie czasowej oddziału. + Resetuje sekwencje rekordów w każdym roku numeracji (roku kalendarzowym, chyba że ustawiono początek roku budżetowego) w strefie czasowej oddziału. Cyfry części SEQ wzorca numeracji, uzupełniane zerami: 4 daje 0184. Od 3 do 8 cyfr; zmiana zachowuje następny numer każdej sekwencji. Definicje oddziału z własną numeracją zachowują własną szerokość. @@ -6786,4 +6786,100 @@ Integracje działają dalej, gdy ludzie odchodzą, mogą tylko to, na co pozwala ich klucz, i można je natychmiast unieważnić. + + Konfiguracja numeracji zgłoszeń / Miesiąc początku roku + + + Miesiąc, w którym zaczyna się rok numeracji, dla zgłoszeń numerowanych według roku budżetowego. Wzorzec roczny zaczyna się ponownie od 1 w tym miesiącu i dniu, a YYYY i YY wpisują nazwę roku budżetowego; wzorce z MM lub DD podążają za kalendarzem. Brak wartości oznacza styczeń, czyli rok kalendarzowy. + + + Konfiguracja numeracji zgłoszeń / Dzień początku roku + + + Dzień miesiąca, w którym zaczyna się rok numeracji, od 1 do ostatniego dnia miesiąca; 29 lutego jest niedozwolony. Brak wartości oznacza 1. dzień. + + + Konfiguracja numeracji zgłoszeń / Nazwa roku + + + Który rok nadaje nazwę rokowi numeracji, który nie zaczyna się 1 stycznia: rok, w którym się kończy (zwykła konwencja lat budżetowych, więc rok zaczynający się 1 listopada 2026 to 2027), albo rok, w którym się zaczyna. + + + Konfiguracja numeracji rekordów / Miesiąc początku roku + + + Miesiąc, w którym zaczyna się rok numeracji, dla rekordów numerowanych według roku budżetowego, na przykład hrabstwa lub stanu. Sekwencje zawierające rok zaczynają się ponownie od 1 w tym miesiącu i dniu, a YYYY i YY wpisują nazwę roku budżetowego. Brak wartości oznacza styczeń, czyli rok kalendarzowy. Niezależny od początku roku numeracji zgłoszeń. Numery wniosków o udostępnienie akt oraz numery kontroli, zezwoleń, obiektów, dochodzeń i materiału dowodowego podlegają temu samemu rokowi. + + + Konfiguracja numeracji rekordów / Dzień początku roku + + + Dzień miesiąca, w którym zaczyna się rok numeracji, od 1 do ostatniego dnia miesiąca; 29 lutego jest niedozwolony. Brak wartości oznacza 1. dzień. + + + Konfiguracja numeracji rekordów / Nazwa roku + + + Który rok nadaje nazwę rokowi numeracji, który nie zaczyna się 1 stycznia: rok, w którym się kończy (zwykła konwencja lat budżetowych, więc rok zaczynający się 1 listopada 2026 to 2027), albo rok, w którym się zaczyna. + + + Konfiguracja numeracji dokumentów + + + Jak zapisywane są numery zleceń pracy, faktur, ofert i dziennych raportów czasu oraz dzień, w którym zaczyna się ich rok numeracji. Rodzaj dokumentu bez wzorca zachowuje wbudowane numery. + + + Konfiguracja numeracji dokumentów / Miesiąc początku roku + + + Miesiąc, w którym zaczyna się rok numeracji zleceń pracy, faktur, ofert i dziennych raportów czasu numerowanych według roku budżetowego. Numery zleceń pracy i wzorce z rokiem zaczynają się ponownie od 1 w tym miesiącu i dniu, a YYYY i YY wpisują nazwę roku budżetowego. Brak wartości oznacza styczeń, czyli rok kalendarzowy. + + + Konfiguracja numeracji dokumentów / Dzień początku roku + + + Dzień miesiąca, w którym zaczyna się rok numeracji, od 1 do ostatniego dnia miesiąca; 29 lutego jest niedozwolony. Brak wartości oznacza 1. dzień. + + + Konfiguracja numeracji dokumentów / Nazwa roku + + + Który rok nadaje nazwę rokowi numeracji, który nie zaczyna się 1 stycznia: rok, w którym się kończy (zwykła konwencja lat budżetowych, więc rok zaczynający się 1 listopada 2026 to 2027), albo rok, w którym się zaczyna. + + + Konfiguracja numeracji dokumentów / Wzorce + + + Własny wzorzec numeracji jednostki dla zleceń pracy, faktur, ofert i dziennych raportów czasu, z tokenami numerów zgłoszeń (YYYY lub YY, MM, DD, SEQ). Rodzaj dokumentu bez wzorca zachowuje wbudowane numery (WO-2026-000019, #1042). Nowy wzorzec kontynuuje po każdym numerze, który by powtórzył. Członkowie widzą numer w wydanej postaci na dokumentach, w plikach PDF i eksportach, a w przypadku dziennych raportów czasu w aplikacjach Responder, Unit, IC i Dispatch. + + + Wzorzec numeru dokumentu / Rodzaj + + + Dokument numerowany przez wzorzec: work-order, invoice, bid lub time-report, a w rejestrach records-request, occupancy, inspection, permit, investigation lub evidence. + + + Wzorzec numeru dokumentu / Wzorzec + + + Stały tekst wokół tokenów YYYY lub YY, MM, DD i SEQ, z SEQ dokładnie raz. Każdy zapisany numer musi zmieścić się w kolumnie numeru dokumentu: 50 znaków lub 32 dla numerów obiektów, kontroli, zezwoleń, dochodzeń i materiału dowodowego. + + + Wzorzec numeru dokumentu / Szerokość sekwencji + + + Liczba cyfr, do której sekwencja jest uzupełniana zerami wiodącymi, od 1 do 8; sekwencja jest liczona jako liczba, więc zmiana szerokości nigdy jej nie restartuje. + + + Konfiguracja numeracji rekordów / Wzorce dokumentów + + + Własne wzorce jednostki dla numerów wniosków o udostępnienie akt, obiektów, kontroli, zezwoleń, dochodzeń i materiału dowodowego. Podlegają początkowi roku numeracji tego ustawienia; rodzaj bez wzorca zachowuje wbudowane numery (PRR-2026-0001, INSP-2026-0001 itd.). + + + Numeracja dokumentów + + + Jak zapisywane są numery zleceń pracy, faktur, ofert i dziennych raportów czasu oraz dzień, w którym zaczyna się ich rok numeracji przy numeracji według roku budżetowego. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx index 1cd9c9c62..1cd4399c5 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx @@ -1198,7 +1198,7 @@ The department calendar for meetings, events and training nights. - The mailbox and format used to import calls from CAD or paging email, and the call number format and next number. + The mailbox and format used to import calls from CAD or paging email, and the call number format, next number and numbering year start. Reusable call text and note templates that speed up call entry. @@ -1522,7 +1522,7 @@ Records that are due, awaiting review or ready to submit. - Numbering, retention, search and visibility rules for records. + Numbering (including records request and prevention numbers and the numbering year start), retention, search and visibility rules for records. Department reports on calls, personnel and activity. @@ -2209,7 +2209,7 @@ Use separate station/group sequences. This changes numbering, not record visibility. - Restart record sequences each calendar year in the department time zone. + Restart record sequences each numbering year (the calendar year unless a fiscal year start is set) in the department time zone. Zero-padded sequence width for definitions using department defaults. The Records editor validates the supported width. @@ -6732,4 +6732,100 @@ Integrations keep working when people leave, can only do what their key allows and can be revoked at once. + + Call Numbering Config / Year Start Month + + + Month the numbering year starts, for calls numbered by a fiscal year. A yearly pattern starts again at 1 on that month and day, and YYYY and YY write the fiscal year's name; patterns with MM or DD follow the calendar. Unset is January, the calendar year. + + + Call Numbering Config / Year Start Day + + + Day of the month the numbering year starts, 1 to the month's last day; February 29 is not allowed. Unset is the 1st. + + + Call Numbering Config / Year Label + + + Which year names a numbering year that does not start on January 1: the year it ends in (the usual fiscal-year convention, so a year starting November 1, 2026 is 2027) or the year it starts in. + + + Records Numbering Config / Year Start Month + + + Month the numbering year starts, for records numbered by a fiscal year such as a county's or state's. Sequences that include the year start again at 1 on that month and day, and YYYY and YY write the fiscal year's name. Unset is January, the calendar year. Separate from call numbering's year start. Records request numbers and the numbers of inspections, permits, occupancies, investigations and evidence follow the same year. + + + Records Numbering Config / Year Start Day + + + Day of the month the numbering year starts, 1 to the month's last day; February 29 is not allowed. Unset is the 1st. + + + Records Numbering Config / Year Label + + + Which year names a numbering year that does not start on January 1: the year it ends in (the usual fiscal-year convention, so a year starting November 1, 2026 is 2027) or the year it starts in. + + + Document Numbering Config + + + How work order, invoice, bid and daily time report numbers are written, and the day their numbering year starts. A document kind with no pattern keeps its built-in numbers. + + + Document Numbering Config / Year Start Month + + + Month the numbering year starts for work orders, invoices, bids and daily time reports numbered by a fiscal year. Work order numbers and patterns that include the year start again at 1 on that month and day, and YYYY and YY write the fiscal year's name. Unset is January, the calendar year. + + + Document Numbering Config / Year Start Day + + + Day of the month the numbering year starts, 1 to the month's last day; February 29 is not allowed. Unset is the 1st. + + + Document Numbering Config / Year Label + + + Which year names a numbering year that does not start on January 1: the year it ends in (the usual fiscal-year convention, so a year starting November 1, 2026 is 2027) or the year it starts in. + + + Document Numbering Config / Patterns + + + The department's own number pattern for work orders, invoices, bids and daily time reports, in the call number tokens (YYYY or YY, MM, DD, SEQ). A document kind with no pattern keeps its built-in numbers (WO-2026-000019, #1042). A new pattern carries on after any number it would repeat. Members see the number as issued on documents, PDFs and exports, and for daily time reports in the Responder, Unit, IC and Dispatch apps. + + + Document Number Pattern / Kind + + + The document the pattern numbers: work-order, invoice, bid or time-report, or for Records records-request, occupancy, inspection, permit, investigation or evidence. + + + Document Number Pattern / Pattern + + + Fixed text around the YYYY or YY, MM, DD and SEQ tokens, with SEQ exactly once. Every number it writes must fit the document's number column: 50 characters, or 32 for occupancy, inspection, permit, investigation and evidence numbers. + + + Document Number Pattern / Sequence Width + + + Digits the sequence is padded to with leading zeros, 1 to 8; the sequence is counted as a number, so changing the width never restarts it. + + + Records Numbering Config / Document Patterns + + + The department's own patterns for records request, occupancy, inspection, permit, investigation and evidence numbers. They follow this setting's numbering year start; a kind with no pattern keeps its built-in numbers (PRR-2026-0001, INSP-2026-0001 and so on). + + + Document Numbering + + + How work order, invoice, bid and daily time report numbers are written, and the day their numbering year starts for fiscal-year numbering. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx index 316b25987..f0ba7c54c 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx @@ -1198,7 +1198,7 @@ Avdelningens kalender för möten, evenemang och utbildningskvällar. - Brevlådan och formatet som används för att importera larm från CAD eller personsökar-e-post, samt larmnummerformatet och nästa nummer. + Brevlådan och formatet som används för att importera larm från CAD eller personsökar-e-post, samt larmnummerformatet, nästa nummer och numreringsårets början. Återanvändbara text- och anteckningsmallar för larm som snabbar upp registreringen. @@ -1522,7 +1522,7 @@ Poster som ska lämnas, väntar på granskning eller är redo att skickas in. - Regler för numrering, bevarande, sökning och synlighet för poster. + Regler för numrering (inklusive nummer för begäran om handlingar, förebyggande nummer och numreringsårets början), bevarande, sökning och synlighet för poster. Avdelningsrapporter om larm, personal och aktivitet. @@ -2236,7 +2236,7 @@ Använd separata sekvenser per station/grupp. Detta ändrar numreringen, inte posternas synlighet. - Starta om postsekvenserna varje kalenderår i avdelningens tidszon. + Starta om postsekvenserna varje numreringsår (kalenderåret om ingen räkenskapsårsstart har angetts) i avdelningens tidszon. Siffror i SEQ-delen av nummermönstret, nollutfyllda: 4 skriver 0184. 3 till 8 siffror; en ändring behåller nästa nummer för varje sekvens. Avdelningsdefinitioner med egen numrering behåller sin egen bredd. @@ -6786,4 +6786,100 @@ Integrationer fortsätter att fungera när personer slutar, kan bara göra det nyckeln tillåter och kan återkallas direkt. + + Konfiguration för larmnumrering / Månad då året börjar + + + Månaden då numreringsåret börjar, för larm som numreras efter ett räkenskapsår. Ett årligt mönster börjar om på 1 den månaden och dagen, och YYYY och YY skriver räkenskapsårets namn; mönster med MM eller DD följer kalendern. Inget värde betyder januari, alltså kalenderåret. + + + Konfiguration för larmnumrering / Dag då året börjar + + + Dagen i månaden då numreringsåret börjar, från 1 till månadens sista dag; den 29 februari är inte tillåten. Inget värde betyder den 1:a. + + + Konfiguration för larmnumrering / Årets namn + + + Vilket år som namnger ett numreringsår som inte börjar den 1 januari: året det slutar (den vanliga konventionen för räkenskapsår, så ett år som börjar den 1 november 2026 är 2027) eller året det börjar. + + + Inställningar för numrering av poster / Månad då året börjar + + + Månaden då numreringsåret börjar, för poster som numreras efter ett räkenskapsår, till exempel ett läns eller en delstats. Sekvenser som innehåller året börjar om på 1 den månaden och dagen, och YYYY och YY skriver räkenskapsårets namn. Inget värde betyder januari, alltså kalenderåret. Oberoende av larmnumreringens årsstart. Nummer för begäran om allmänna handlingar och nummer för tillsyner, tillstånd, objekt, utredningar och underlag följer samma år. + + + Inställningar för numrering av poster / Dag då året börjar + + + Dagen i månaden då numreringsåret börjar, från 1 till månadens sista dag; den 29 februari är inte tillåten. Inget värde betyder den 1:a. + + + Inställningar för numrering av poster / Årets namn + + + Vilket år som namnger ett numreringsår som inte börjar den 1 januari: året det slutar (den vanliga konventionen för räkenskapsår, så ett år som börjar den 1 november 2026 är 2027) eller året det börjar. + + + Konfiguration för dokumentnumrering + + + Hur nummer för arbetsorder, fakturor, anbud och dagliga tidrapporter skrivs, och dagen deras numreringsår börjar. En dokumenttyp utan mönster behåller sina inbyggda nummer. + + + Konfiguration för dokumentnumrering / Månad då året börjar + + + Månaden då numreringsåret börjar för arbetsorder, fakturor, anbud och dagliga tidrapporter som numreras efter ett räkenskapsår. Arbetsordernummer och mönster med år börjar om på 1 den månaden och dagen, och YYYY och YY skriver räkenskapsårets namn. Inget värde betyder januari, alltså kalenderåret. + + + Konfiguration för dokumentnumrering / Dag då året börjar + + + Dagen i månaden då numreringsåret börjar, från 1 till månadens sista dag; den 29 februari är inte tillåten. Inget värde betyder den 1:a. + + + Konfiguration för dokumentnumrering / Årets namn + + + Vilket år som namnger ett numreringsår som inte börjar den 1 januari: året det slutar (den vanliga konventionen för räkenskapsår, så ett år som börjar den 1 november 2026 är 2027) eller året det börjar. + + + Konfiguration för dokumentnumrering / Mönster + + + Avdelningens eget nummermönster för arbetsorder, fakturor, anbud och dagliga tidrapporter, med larmnummertecknen (YYYY eller YY, MM, DD, SEQ). En dokumenttyp utan mönster behåller sina inbyggda nummer (WO-2026-000019, #1042). Ett nytt mönster fortsätter efter varje nummer det skulle upprepa. Medlemmar ser numret som det utfärdats på dokument, PDF-filer och exporter, och för dagliga tidrapporter i apparna Responder, Unit, IC och Dispatch. + + + Mönster för dokumentnummer / Typ + + + Dokumentet som mönstret numrerar: work-order, invoice, bid eller time-report, eller för journaler records-request, occupancy, inspection, permit, investigation eller evidence. + + + Mönster för dokumentnummer / Mönster + + + Fast text runt tecknen YYYY eller YY, MM, DD och SEQ, med SEQ exakt en gång. Varje nummer det skriver måste rymmas i dokumentets nummerkolumn: 50 tecken, eller 32 för objekt-, tillsyns-, tillstånds-, utrednings- och underlagsnummer. + + + Mönster för dokumentnummer / Sekvensens bredd + + + Siffror som sekvensen fylls ut till med inledande nollor, 1 till 8; sekvensen räknas som ett tal, så en ändrad bredd startar aldrig om den. + + + Inställningar för numrering av poster / Dokumentmönster + + + Avdelningens egna mönster för nummer för begäran om allmänna handlingar, objekt, tillsyner, tillstånd, utredningar och underlag. De följer den här inställningens numreringsårsstart; en typ utan mönster behåller sina inbyggda nummer (PRR-2026-0001, INSP-2026-0001 och så vidare). + + + Dokumentnumrering + + + Hur nummer för arbetsorder, fakturor, anbud och dagliga tidrapporter skrivs, och dagen deras numreringsår börjar vid numrering efter räkenskapsår. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx index 02cf7d561..dc600f6dd 100644 --- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx @@ -1198,7 +1198,7 @@ Календар підрозділу для зустрічей, подій і навчальних вечорів. - Поштова скринька та формат, що використовуються для імпорту викликів із CAD або пейджингової пошти, а також формат і наступний номер викликів. + Поштова скринька та формат, що використовуються для імпорту викликів із CAD або пейджингової пошти, а також формат, наступний номер і початок року нумерації викликів. Багаторазові шаблони тексту виклику й нотаток, що прискорюють введення викликів. @@ -1522,7 +1522,7 @@ Записи, що очікують виконання, перевірки або готові до подання. - Правила нумерації, зберігання, пошуку та видимості для записів. + Правила нумерації (зокрема номери запитів на доступ, номери профілактики та початок року нумерації), зберігання, пошуку та видимості для записів. Звіти підрозділу про виклики, персонал і діяльність. @@ -2236,7 +2236,7 @@ Використовує окремі послідовності для станції/групи. Це змінює нумерацію, а не видимість записів. - Перезапускає послідовності записів щороку за календарем у часовому поясі підрозділу. + Перезапускає послідовності записів щороку нумерації (календарного року, якщо не задано початок фінансового року) у часовому поясі підрозділу. Цифри частини SEQ у шаблоні нумерації, доповнені нулями: 4 дає 0184. Від 3 до 8 цифр; зміна зберігає наступний номер кожної послідовності. Визначення підрозділу з власною нумерацією зберігають власну довжину. @@ -6786,4 +6786,100 @@ Інтеграції продовжують працювати, коли люди йдуть, можуть лише те, що дозволяє їхній ключ, і їх можна миттєво відкликати. + + Конфігурація нумерації викликів / Місяць початку року + + + Місяць, у якому починається рік нумерації, для викликів, що нумеруються за фінансовим роком. Річний шаблон знову починається з 1 у цей місяць і день, а YYYY і YY записують назву фінансового року; шаблони з MM або DD слідують календарю. Без значення — січень, тобто календарний рік. + + + Конфігурація нумерації викликів / День початку року + + + День місяця, у який починається рік нумерації, від 1 до останнього дня місяця; 29 лютого не дозволено. Без значення — 1-ше число. + + + Конфігурація нумерації викликів / Назва року + + + Який рік дає назву року нумерації, що не починається 1 січня: рік, у якому він закінчується (звичайна домовленість для фінансових років, тож рік, що починається 1 листопада 2026, — це 2027), або рік, у якому він починається. + + + Налаштування нумерації записів / Місяць початку року + + + Місяць, у якому починається рік нумерації, для записів, що нумеруються за фінансовим роком, наприклад округу чи штату. Послідовності з роком знову починаються з 1 у цей місяць і день, а YYYY і YY записують назву фінансового року. Без значення — січень, тобто календарний рік. Не залежить від початку року нумерації викликів. Номери запитів на доступ до документів і номери перевірок, дозволів, об’єктів, розслідувань та доказів слідують тому самому року. + + + Налаштування нумерації записів / День початку року + + + День місяця, у який починається рік нумерації, від 1 до останнього дня місяця; 29 лютого не дозволено. Без значення — 1-ше число. + + + Налаштування нумерації записів / Назва року + + + Який рік дає назву року нумерації, що не починається 1 січня: рік, у якому він закінчується (звичайна домовленість для фінансових років, тож рік, що починається 1 листопада 2026, — це 2027), або рік, у якому він починається. + + + Конфігурація нумерації документів + + + Як записуються номери нарядів на роботи, рахунків, пропозицій і щоденних звітів про час, і день, коли починається їхній рік нумерації. Вид документа без шаблону зберігає вбудовані номери. + + + Конфігурація нумерації документів / Місяць початку року + + + Місяць, у якому починається рік нумерації нарядів на роботи, рахунків, пропозицій і щоденних звітів про час, що нумеруються за фінансовим роком. Номери нарядів і шаблони з роком знову починаються з 1 у цей місяць і день, а YYYY і YY записують назву фінансового року. Без значення — січень, тобто календарний рік. + + + Конфігурація нумерації документів / День початку року + + + День місяця, у який починається рік нумерації, від 1 до останнього дня місяця; 29 лютого не дозволено. Без значення — 1-ше число. + + + Конфігурація нумерації документів / Назва року + + + Який рік дає назву року нумерації, що не починається 1 січня: рік, у якому він закінчується (звичайна домовленість для фінансових років, тож рік, що починається 1 листопада 2026, — це 2027), або рік, у якому він починається. + + + Конфігурація нумерації документів / Шаблони + + + Власний шаблон нумерації підрозділу для нарядів на роботи, рахунків, пропозицій і щоденних звітів про час із токенами номерів викликів (YYYY або YY, MM, DD, SEQ). Вид документа без шаблону зберігає вбудовані номери (WO-2026-000019, #1042). Новий шаблон продовжує після будь-якого номера, який він повторив би. Учасники бачать номер у виданому вигляді в документах, PDF і експортах, а для щоденних звітів про час — у застосунках Responder, Unit, IC і Dispatch. + + + Шаблон номера документа / Вид + + + Документ, який нумерує шаблон: work-order, invoice, bid або time-report, а в записах records-request, occupancy, inspection, permit, investigation або evidence. + + + Шаблон номера документа / Шаблон + + + Фіксований текст навколо токенів YYYY або YY, MM, DD і SEQ, де SEQ рівно один раз. Кожен записаний номер має вміщуватися в стовпець номера документа: 50 символів або 32 для номерів об’єктів, перевірок, дозволів, розслідувань і доказів. + + + Шаблон номера документа / Довжина послідовності + + + Кількість цифр, до якої послідовність доповнюється нулями попереду, від 1 до 8; послідовність рахується як число, тож зміна довжини ніколи її не перезапускає. + + + Налаштування нумерації записів / Шаблони документів + + + Власні шаблони підрозділу для номерів запитів на доступ до документів, об’єктів, перевірок, дозволів, розслідувань і доказів. Вони слідують початку року нумерації цього налаштування; вид без шаблону зберігає вбудовані номери (PRR-2026-0001, INSP-2026-0001 тощо). + + + Нумерація документів + + + Як записуються номери нарядів на роботи, рахунків, пропозицій і щоденних звітів про час, і день, коли починається їхній рік нумерації при нумерації за фінансовим роком. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx index 00a070141..65f4d6b88 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx @@ -952,7 +952,7 @@ حقول نموذج البلاغ الجديد - اختر الحقول المدمجة التي تظهر في النموذج وأيها يجب إكماله قبل إنشاء البلاغ وإرساله إلى الميدان. الاسم والطبيعة والأولوية والنوع تظهر دائمًا وهي مطلوبة دائمًا. + اختر الحقول المدمجة التي تظهر في النموذج وأيها يجب إكماله قبل إنشاء البلاغ وإرساله إلى الميدان. الاسم والطبيعة والأولوية والنوع تظهر دائمًا وهي مطلوبة دائمًا. تنطبق القواعد نفسها عند تعديل بلاغ أو إضافة بلاغ مؤرشف. الحقل @@ -985,7 +985,7 @@ ملاحظة - جهة الاتصال الرئيسية + اسم المبلّغ معلومات الاتصال بالمبلّغ @@ -1408,9 +1408,93 @@ لم يُرفع رقم البلاغ التالي: لا يمكن رفعه إلا فوق الرقم الذي سيتلقاه البلاغ التالي بالفعل ({0}). - لم يُرفع رقم البلاغ التالي لأن النمط تغيّر في عملية الحفظ نفسها. ارفعه مرة أخرى للنمط الجديد. + لم يُرفع رقم البلاغ التالي لأن النمط أو بداية سنة الترقيم تغيّرا في عملية الحفظ نفسها. ارفعه مرة أخرى للتسلسل الجديد. + + + تبدأ سنة الترقيم في + + + يوم الشهر + + + تُسمّى بالسنة التي تنتهي فيها + + + تُسمّى بالسنة التي تبدأ فيها + + + للبلاغات المرقّمة حسب سنة مالية، اختر اليوم الذي تبدأ فيه. يبدأ النمط السنوي من 1 من جديد في ذلك اليوم، ويكتب {YYYY} و{YY} اسم السنة المالية. تُسمّى معظم السنوات المالية بالسنة التي تنتهي فيها، لذا فإن السنة التي تبدأ في 1 نوفمبر 2026 هي 2027. يمثّل 1 يناير السنة الميلادية. الأنماط التي تحتوي على {MM} أو {DD} تتبع التقويم دائمًا. + + + يبدأ من 1 من جديد كل سنة في {0}. + + + لم يُحفظ ترقيم البلاغات. يجب أن تبدأ سنة الترقيم في يوم موجود في كل سنة، لذا لا يمكن استخدام 29 فبراير. + + + ترقيم المستندات + + + كيف يرقّم Resgrid أوامر العمل والفواتير والعروض وتقارير الوقت اليومية. اترك النمط فارغًا للإبقاء على الأرقام المضمّنة. لا يؤدي تغيير النمط أبدًا إلى إعادة ترقيم المستندات التي لها رقم بالفعل، ويواصل النمط الجديد بعد أي رقم قد يكرّره. + + + ترقيم السجلات + + + للمستندات المرقّمة حسب سنة مالية، اختر اليوم الذي تبدأ فيه. تبدأ أرقام أوامر العمل والأنماط التي تتضمن السنة من 1 من جديد في ذلك اليوم، ويكتب {YYYY} و{YY} اسم السنة المالية. تُسمّى معظم السنوات المالية بالسنة التي تنتهي فيها، لذا فإن السنة التي تبدأ في 1 نوفمبر 2026 هي 2027. يمثّل 1 يناير السنة الميلادية. لأرقام البلاغات والسجلات بداية سنة خاصة بها. + + + المستند + + + مضمّن، مثل + + + نص ثابت (حروف وأرقام و- _ . /) حول {YYYY} أو {YY} و{MM} و{DD} و{SEQ}، الذي يجب أن يظهر مرة واحدة. يبدأ النمط الذي يتضمن السنة من 1 من جديد في كل سنة ترقيم؛ ومع {MM} أو {DD}، في كل شهر أو يوم. يُعرض الرقم المكوّن من أرقام فقط مع # في بدايته، كما كانت أرقام الفواتير والعروض وتقارير الوقت دائمًا. + + + تم حفظ ترقيم المستندات. + + + تم حفظ بقية ترقيم المستندات. + + + لم يُحفظ ترقيم المستندات. يجب أن تبدأ سنة الترقيم في يوم موجود في كل سنة، لذا لا يمكن استخدام 29 فبراير. + + + لم تُحفظ هذه الأنماط لأنها غير صالحة أو قد تكتب رقمًا أطول من 50 حرفًا؛ واحتفظت تلك المستندات بالترقيم الذي كان لها: + + + يمكن رفع الرقم التالي فقط. تُركت هذه دون تغيير لأن الرقم المُدخل أقل من الرقم الذي ستصدره تاليًا: + + + تغيّر النمط أو بداية سنة الترقيم في عملية الحفظ نفسها، لذلك لم تُطبَّق الأرقام التالية المُدخلة. ارفعها مرة أخرى للتسلسل الجديد. + + + أوامر العمل + + + الفواتير + + + العروض + + + تقارير الوقت اليومية + + + نمط الترقيم + + + الرقم التالي + + + رفع التالي إلى تم حفظ إعدادات البلاغات. + + رمز Plus Code يحدد الموقع فقط ولا يُحفظ في البلاغ، لذا لا يمكن جعله مطلوبًا. اجعل إحداثيات GPS مطلوبة بدلًا منه. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx index 7fc24db5f..e7e22593c 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx @@ -894,7 +894,7 @@ Felder im Einsatzformular - Wählen Sie, welche Standardfelder im Formular für neue Einsätze erscheinen und welche ausgefüllt sein müssen, bevor ein Einsatz angelegt und an die Kräfte gesendet werden kann. Name, Art, Priorität und Typ werden immer angezeigt und sind immer erforderlich. + Wählen Sie, welche Standardfelder im Formular für neue Einsätze erscheinen und welche ausgefüllt sein müssen, bevor ein Einsatz angelegt und an die Kräfte gesendet werden kann. Name, Art, Priorität und Typ werden immer angezeigt und sind immer erforderlich. Dieselben Regeln gelten beim Bearbeiten eines Einsatzes und beim Hinzufügen eines archivierten Einsatzes. Feld @@ -927,7 +927,7 @@ Notiz - Hauptkontakt + Name des Melders Kontaktdaten des Melders @@ -1350,9 +1350,93 @@ Die nächste Einsatznummer wurde nicht angehoben: Sie kann nur über die Nummer angehoben werden, die der nächste Einsatz bereits erhält ({0}). - Die nächste Einsatznummer wurde nicht angehoben, weil sich das Muster beim selben Speichern geändert hat. Heben Sie sie für das neue Muster erneut an. + Die nächste Einsatznummer wurde nicht angehoben, weil sich das Muster oder der Beginn des Nummerierungsjahres beim selben Speichern geändert hat. Heben Sie sie für die neue laufende Nummer erneut an. + + + Nummerierungsjahr beginnt am + + + Tag des Monats + + + Benannt nach dem Jahr, in dem es endet + + + Benannt nach dem Jahr, in dem es beginnt + + + Für Einsätze, die nach einem Haushaltsjahr nummeriert werden, wählen Sie den Tag, an dem es beginnt. Ein jährliches Muster beginnt an diesem Tag wieder bei 1, und {YYYY} und {YY} schreiben den Namen des Haushaltsjahres. Die meisten Haushaltsjahre werden nach dem Jahr benannt, in dem sie enden; eines, das am 1. November 2026 beginnt, ist also 2027. Der 1. Januar ist das Kalenderjahr. Muster mit {MM} oder {DD} folgen immer dem Kalender. + + + Beginnt jedes Jahr am {0} wieder bei 1. + + + Die Einsatznummerierung wurde nicht gespeichert. Das Nummerierungsjahr muss an einem Tag beginnen, den es in jedem Jahr gibt; der 29. Februar ist daher nicht möglich. + + + Dokumentnummerierung + + + Wie Resgrid Arbeitsaufträge, Rechnungen, Angebote und tägliche Zeitberichte nummeriert. Lassen Sie ein Muster leer, um die integrierten Nummern zu behalten. Eine Musteränderung nummeriert Dokumente, die bereits eine Nummer haben, nie neu, und ein neues Muster setzt nach jeder Nummer fort, die es wiederholen würde. + + + Aktennummerierung + + + Für Dokumente, die nach einem Haushaltsjahr nummeriert werden, wählen Sie den Tag, an dem es beginnt. Arbeitsauftragsnummern und Muster mit Jahr beginnen an diesem Tag wieder bei 1, und {YYYY} und {YY} schreiben den Namen des Haushaltsjahres. Die meisten Haushaltsjahre werden nach dem Jahr benannt, in dem sie enden; eines, das am 1. November 2026 beginnt, ist also 2027. Der 1. Januar ist das Kalenderjahr. Einsatz- und Aktennummern haben ihren eigenen Jahresbeginn. + + + Dokument + + + Integriert, z. B. + + + Fester Text (Buchstaben, Ziffern, - _ . /) um {YYYY} oder {YY}, {MM}, {DD} und {SEQ}, das genau einmal vorkommen muss. Ein Muster mit Jahr beginnt in jedem Nummerierungsjahr wieder bei 1, mit {MM} oder {DD} in jedem Monat bzw. an jedem Tag. Eine Nummer nur aus Ziffern wird mit # davor angezeigt, wie Rechnungs-, Angebots- und Zeitberichtsnummern schon immer. + + + Die Dokumentnummerierung wurde gespeichert. + + + Die übrige Dokumentnummerierung wurde gespeichert. + + + Die Dokumentnummerierung wurde nicht gespeichert. Das Nummerierungsjahr muss an einem Tag beginnen, den es in jedem Jahr gibt; der 29. Februar ist daher nicht möglich. + + + Diese Muster wurden nicht gespeichert, weil sie ungültig sind oder eine Nummer mit mehr als 50 Zeichen schreiben könnten; diese Dokumente behielten ihre bisherige Nummerierung: + + + Eine nächste Nummer kann nur angehoben werden. Diese blieben unverändert, weil die eingegebene Nummer unter der lag, die sie als Nächstes vergeben: + + + Das Muster oder der Beginn des Nummerierungsjahres wurde beim selben Speichern geändert, daher wurden die eingegebenen nächsten Nummern nicht übernommen. Heben Sie sie für die neue Laufnummer erneut an. + + + Arbeitsaufträge + + + Rechnungen + + + Angebote + + + Tägliche Zeitberichte + + + Nummernmuster + + + Nächste Nummer + + + Nächste erhöhen auf Die Einsatzeinstellungen wurden gespeichert. + + Ein Plus Code dient nur zum Finden des Ortes und wird nicht im Einsatz gespeichert, daher kann er nicht verpflichtend sein. Machen Sie stattdessen die GPS-Koordinaten verpflichtend. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx index 3743ff4ce..3532feb07 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx @@ -985,7 +985,7 @@ Πεδία φόρμας νέας κλήσης - Επιλέξτε ποια ενσωματωμένα πεδία εμφανίζονται στη φόρμα και ποια πρέπει να συμπληρωθούν πριν η κλήση δημιουργηθεί και σταλεί. Όνομα, φύση, προτεραιότητα και τύπος εμφανίζονται πάντα και είναι πάντα υποχρεωτικά. + Επιλέξτε ποια ενσωματωμένα πεδία εμφανίζονται στη φόρμα και ποια πρέπει να συμπληρωθούν πριν η κλήση δημιουργηθεί και σταλεί. Όνομα, φύση, προτεραιότητα και τύπος εμφανίζονται πάντα και είναι πάντα υποχρεωτικά. Οι ίδιοι κανόνες ισχύουν κατά την επεξεργασία μιας κλήσης ή την προσθήκη αρχειοθετημένης κλήσης. Πεδίο @@ -1018,7 +1018,7 @@ Σημείωση - Κύρια επαφή + Όνομα αναφέροντος Στοιχεία επικοινωνίας αναφέροντος @@ -1408,9 +1408,93 @@ Ο επόμενος αριθμός κλήσης δεν αυξήθηκε: μπορεί να αυξηθεί μόνο πάνω από τον αριθμό που λαμβάνει ήδη η επόμενη κλήση ({0}). - Ο επόμενος αριθμός κλήσης δεν αυξήθηκε επειδή το μοτίβο άλλαξε στην ίδια αποθήκευση. Αυξήστε τον ξανά για το νέο μοτίβο. + Ο επόμενος αριθμός κλήσης δεν αυξήθηκε επειδή το μοτίβο ή η έναρξη του έτους αρίθμησης άλλαξε στην ίδια αποθήκευση. Αυξήστε τον ξανά για τη νέα ακολουθία. + + + Το έτος αρίθμησης ξεκινά + + + Ημέρα του μήνα + + + Ονομάζεται από το έτος στο οποίο λήγει + + + Ονομάζεται από το έτος στο οποίο ξεκινά + + + Για κλήσεις που αριθμούνται ανά οικονομικό έτος, επιλέξτε την ημέρα που ξεκινά. Ένα ετήσιο μοτίβο ξεκινά ξανά από το 1 εκείνη την ημέρα, και τα {YYYY} και {YY} γράφουν το όνομα του οικονομικού έτους. Τα περισσότερα οικονομικά έτη ονομάζονται από το έτος στο οποίο λήγουν, οπότε ένα που ξεκινά την 1η Νοεμβρίου 2026 είναι το 2027. Η 1η Ιανουαρίου είναι το ημερολογιακό έτος. Τα μοτίβα με {MM} ή {DD} ακολουθούν πάντα το ημερολόγιο. + + + Ξεκινά ξανά από το 1 κάθε έτος στις {0}. + + + Η αρίθμηση κλήσεων δεν αποθηκεύτηκε. Το έτος αρίθμησης πρέπει να ξεκινά σε ημέρα που υπάρχει κάθε έτος, οπότε η 29η Φεβρουαρίου δεν μπορεί να χρησιμοποιηθεί. + + + Αρίθμηση εγγράφων + + + Πώς το Resgrid αριθμεί τις εντολές εργασίας, τα τιμολόγια, τις προσφορές και τις ημερήσιες αναφορές χρόνου. Αφήστε ένα μοτίβο κενό για να διατηρηθούν οι ενσωματωμένοι αριθμοί. Η αλλαγή μοτίβου δεν αναριθμεί ποτέ έγγραφα που έχουν ήδη αριθμό, και ένα νέο μοτίβο συνεχίζει μετά από κάθε αριθμό που θα επαναλάμβανε. + + + Αρίθμηση αρχείων + + + Για έγγραφα που αριθμούνται ανά οικονομικό έτος, επιλέξτε την ημέρα που ξεκινά. Οι αριθμοί εντολών εργασίας και τα μοτίβα με έτος ξεκινούν ξανά από το 1 εκείνη την ημέρα, και τα {YYYY} και {YY} γράφουν το όνομα του οικονομικού έτους. Τα περισσότερα οικονομικά έτη ονομάζονται από το έτος στο οποίο λήγουν, οπότε ένα που ξεκινά την 1η Νοεμβρίου 2026 είναι το 2027. Η 1η Ιανουαρίου είναι το ημερολογιακό έτος. Οι αριθμοί κλήσεων και αρχείων έχουν τη δική τους έναρξη έτους. + + + Έγγραφο + + + Ενσωματωμένο, π.χ. + + + Σταθερό κείμενο (γράμματα, ψηφία, - _ . /) γύρω από {YYYY} ή {YY}, {MM}, {DD} και {SEQ}, το οποίο πρέπει να εμφανίζεται μία φορά. Ένα μοτίβο με έτος ξεκινά ξανά από το 1 κάθε έτος αρίθμησης· με {MM} ή {DD}, κάθε μήνα ή ημέρα. Ένας αριθμός μόνο με ψηφία εμφανίζεται με # μπροστά, όπως πάντα οι αριθμοί τιμολογίων, προσφορών και αναφορών χρόνου. + + + Η αρίθμηση εγγράφων αποθηκεύτηκε. + + + Η υπόλοιπη αρίθμηση εγγράφων αποθηκεύτηκε. + + + Η αρίθμηση εγγράφων δεν αποθηκεύτηκε. Το έτος αρίθμησης πρέπει να ξεκινά σε ημέρα που υπάρχει κάθε έτος, οπότε η 29η Φεβρουαρίου δεν μπορεί να χρησιμοποιηθεί. + + + Αυτά τα μοτίβα δεν αποθηκεύτηκαν επειδή δεν είναι έγκυρα ή θα μπορούσαν να γράψουν αριθμό μεγαλύτερο από 50 χαρακτήρες· αυτά τα έγγραφα διατήρησαν την αρίθμηση που είχαν: + + + Ένας επόμενος αριθμός μπορεί μόνο να αυξηθεί. Αυτοί έμειναν αμετάβλητοι επειδή ο αριθμός που εισαγάγατε ήταν μικρότερος από αυτόν που θα εκδώσουν στη συνέχεια: + + + Το μοτίβο ή η έναρξη του έτους αρίθμησης άλλαξε στην ίδια αποθήκευση, οπότε οι επόμενοι αριθμοί που εισαγάγατε δεν εφαρμόστηκαν. Αυξήστε τους ξανά για τη νέα ακολουθία. + + + Εντολές εργασίας + + + Τιμολόγια + + + Προσφορές + + + Ημερήσιες αναφορές χρόνου + + + Μοτίβο αρίθμησης + + + Επόμενος αριθμός + + + Αύξηση επόμενου σε Οι ρυθμίσεις κλήσεων αποθηκεύτηκαν. + + Ο κωδικός Plus Code χρησιμεύει μόνο για τον εντοπισμό της τοποθεσίας και δεν αποθηκεύεται στην κλήση, επομένως δεν μπορεί να είναι υποχρεωτικός. Κάντε υποχρεωτικές τις συντεταγμένες GPS. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx index 6545a2a8a..ecad88249 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx @@ -985,7 +985,7 @@ New Call Form Fields - Choose which built-in fields appear on the new call form and which must be completed before a call can be created and sent to the field. Name, nature, priority and type are always shown and always required. + Choose which built-in fields appear on the new call form and which must be completed before a call can be created and sent to the field. Name, nature, priority and type are always shown and always required. The same rules apply when a call is edited or an archived call is added. Field @@ -1018,7 +1018,7 @@ Note - Primary Contact + Reporter Name Reporter Contact Info @@ -1408,9 +1408,93 @@ The next call number was not raised: it can only be raised above the number the next call already receives ({0}). - The next call number was not raised because the pattern changed in the same save. Raise it again for the new pattern. + The next call number was not raised because the pattern or the numbering year start changed in the same save. Raise it again for the new sequence. + + + Numbering year starts + + + Day of the month + + + Named by the year it ends in + + + Named by the year it starts in + + + For calls numbered by a fiscal year, pick the day it starts. A yearly pattern starts again at 1 that day, and {YYYY} and {YY} write the fiscal year's name. Most fiscal years are named by the year they end in, so one starting November 1, 2026 is 2027. January 1 is the calendar year. Patterns with {MM} or {DD} always follow the calendar. + + + Starts again at 1 each year on {0}. + + + The call numbering was not saved. The numbering year has to start on a day every year has, so February 29 cannot be used. + + + Document Numbering + + + How Resgrid numbers work orders, invoices, bids and daily time reports. Leave a pattern blank to keep the built-in numbers. Changing a pattern never renumbers documents that already have a number, and a new pattern carries on after any number it would repeat. + + + Records numbering + + + For documents numbered by a fiscal year, pick the day it starts. Work order numbers, and patterns with a year, start again at 1 that day, and {YYYY} and {YY} write the fiscal year's name. Most fiscal years are named by the year they end in, so one starting November 1, 2026 is 2027. January 1 is the calendar year. Call and Records numbers have their own year start. + + + Document + + + Built-in, e.g. + + + Fixed text (letters, digits, - _ . /) around {YYYY} or {YY}, {MM}, {DD} and {SEQ}, which must appear once. A pattern with a year starts again at 1 each numbering year; with {MM} or {DD}, each month or day. A number of digits alone is shown with # in front, as invoice, bid and time report numbers always have been. + + + The document numbering was saved. + + + The rest of the document numbering was saved. + + + The document numbering was not saved. The numbering year has to start on a day every year has, so February 29 cannot be used. + + + These patterns were not saved because they are not valid or could write a number longer than 50 characters; those documents kept the numbering they had: + + + A next number can only be raised. These were left unchanged because the number entered was below the one they will issue next: + + + The pattern or the numbering year start changed in the same save, so the next numbers entered were not applied. Raise them again for the new sequence. + + + Work orders + + + Invoices + + + Bids + + + Daily time reports + + + Number pattern + + + Next number + + + Raise next to Call settings were saved. + + A plus code only finds the location and is not saved on the call, so it cannot be required. Require GPS Coordinates instead. + diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx index e6019e269..433d01f6a 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx @@ -985,7 +985,7 @@ Campos del formulario de nueva llamada - Elija qué campos integrados aparecen en el formulario de nueva llamada y cuáles deben completarse antes de crear la llamada y enviarla al terreno. Nombre, naturaleza, prioridad y tipo se muestran siempre y son siempre obligatorios. + Elija qué campos integrados aparecen en el formulario de nueva llamada y cuáles deben completarse antes de crear la llamada y enviarla al terreno. Nombre, naturaleza, prioridad y tipo se muestran siempre y son siempre obligatorios. Las mismas reglas se aplican al editar una llamada o al añadir una llamada archivada. Campo @@ -1018,7 +1018,7 @@ Nota - Contacto principal + Nombre del informante Datos de contacto del informante @@ -1408,9 +1408,93 @@ El próximo número de llamada no se subió: solo se puede subir por encima del número que ya recibe la próxima llamada ({0}). - El próximo número de llamada no se subió porque el patrón cambió en el mismo guardado. Súbalo de nuevo para el nuevo patrón. + El próximo número de llamada no se subió porque el patrón o el inicio del año de numeración cambió en el mismo guardado. Súbalo de nuevo para la nueva secuencia. + + + El año de numeración empieza el + + + Día del mes + + + Se nombra por el año en que termina + + + Se nombra por el año en que empieza + + + Para llamadas numeradas por un año fiscal, elija el día en que empieza. Un patrón anual vuelve a empezar en 1 ese día, y {YYYY} y {YY} escriben el nombre del año fiscal. La mayoría de los años fiscales se nombran por el año en que terminan, así que uno que empieza el 1 de noviembre de 2026 es 2027. El 1 de enero es el año natural. Los patrones con {MM} o {DD} siempre siguen el calendario. + + + Vuelve a empezar en 1 cada año el {0}. + + + La numeración de llamadas no se guardó. El año de numeración tiene que empezar en un día que exista todos los años, así que no se puede usar el 29 de febrero. + + + Numeración de documentos + + + Cómo numera Resgrid las órdenes de trabajo, facturas, ofertas y partes diarios de horas. Deje un patrón en blanco para mantener los números integrados. Cambiar un patrón nunca renumera los documentos que ya tienen número, y un patrón nuevo continúa después de cualquier número que repetiría. + + + Numeración de registros + + + Para documentos numerados por un año fiscal, elija el día en que empieza. Los números de órdenes de trabajo y los patrones con año vuelven a empezar en 1 ese día, y {YYYY} y {YY} escriben el nombre del año fiscal. La mayoría de los años fiscales se nombran por el año en que terminan, así que uno que empieza el 1 de noviembre de 2026 es 2027. El 1 de enero es el año natural. Los números de llamadas y de registros tienen su propio inicio de año. + + + Documento + + + Integrado, p. ej. + + + Texto fijo (letras, dígitos, - _ . /) alrededor de {YYYY} o {YY}, {MM}, {DD} y {SEQ}, que debe aparecer una vez. Un patrón con año vuelve a empezar en 1 cada año de numeración; con {MM} o {DD}, cada mes o día. Un número formado solo por dígitos se muestra con # delante, como siempre los números de factura, oferta y parte de horas. + + + Se guardó la numeración de documentos. + + + Se guardó el resto de la numeración de documentos. + + + La numeración de documentos no se guardó. El año de numeración tiene que empezar en un día que exista todos los años, así que no se puede usar el 29 de febrero. + + + Estos patrones no se guardaron porque no son válidos o podrían escribir un número de más de 50 caracteres; esos documentos conservaron su numeración: + + + Un próximo número solo se puede subir. Estos no se cambiaron porque el número introducido era inferior al que emitirán a continuación: + + + El patrón o el inicio del año de numeración cambió en el mismo guardado, por lo que no se aplicaron los próximos números introducidos. Súbalos de nuevo para la nueva secuencia. + + + Órdenes de trabajo + + + Facturas + + + Ofertas + + + Partes diarios de horas + + + Patrón de numeración + + + Próximo número + + + Aumentar el próximo a La configuración de llamadas se guardó. + + Un Plus Code solo sirve para encontrar la ubicación y no se guarda en la llamada, por lo que no puede ser obligatorio. Haga obligatorias las coordenadas GPS. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx index 88846efbc..7af236781 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx @@ -894,7 +894,7 @@ Champs du formulaire d'intervention - Choisissez les champs intégrés qui apparaissent sur le formulaire et ceux qui doivent être remplis avant qu'une intervention puisse être créée et envoyée sur le terrain. Nom, nature, priorité et type sont toujours affichés et toujours requis. + Choisissez les champs intégrés qui apparaissent sur le formulaire et ceux qui doivent être remplis avant qu'une intervention puisse être créée et envoyée sur le terrain. Nom, nature, priorité et type sont toujours affichés et toujours requis. Les mêmes règles s'appliquent lors de la modification d'une intervention ou de l'ajout d'une intervention archivée. Champ @@ -927,7 +927,7 @@ Note - Contact principal + Nom du déclarant Coordonnées du déclarant @@ -1350,9 +1350,93 @@ Le prochain numéro d'appel n'a pas été relevé : il ne peut être relevé qu'au-dessus du numéro que le prochain appel reçoit déjà ({0}). - Le prochain numéro d'appel n'a pas été relevé car le modèle a changé lors du même enregistrement. Relevez-le à nouveau pour le nouveau modèle. + Le prochain numéro d'appel n'a pas été relevé car le modèle ou le début de l'année de numérotation a changé lors du même enregistrement. Relevez-le à nouveau pour la nouvelle séquence. + + + L'année de numérotation commence le + + + Jour du mois + + + Nommée d'après l'année où elle se termine + + + Nommée d'après l'année où elle commence + + + Pour les appels numérotés par exercice budgétaire, choisissez le jour où il commence. Un modèle annuel recommence à 1 ce jour-là, et {YYYY} et {YY} écrivent le nom de l'exercice. La plupart des exercices sont nommés d'après l'année où ils se terminent : celui qui commence le 1er novembre 2026 est donc 2027. Le 1er janvier correspond à l'année civile. Les modèles avec {MM} ou {DD} suivent toujours le calendrier. + + + Recommence à 1 chaque année le {0}. + + + La numérotation des appels n'a pas été enregistrée. L'année de numérotation doit commencer un jour qui existe chaque année ; le 29 février ne peut donc pas être utilisé. + + + Numérotation des documents + + + Comment Resgrid numérote les ordres de travail, factures, offres et rapports de temps quotidiens. Laissez un modèle vide pour conserver les numéros intégrés. Changer un modèle ne renumérote jamais les documents qui ont déjà un numéro, et un nouveau modèle reprend après tout numéro qu'il répéterait. + + + Numérotation des dossiers + + + Pour les documents numérotés par exercice budgétaire, choisissez le jour où il commence. Les numéros d'ordres de travail, et les modèles avec une année, recommencent à 1 ce jour-là, et {YYYY} et {YY} écrivent le nom de l'exercice. La plupart des exercices sont nommés d'après l'année où ils se terminent : celui qui commence le 1er novembre 2026 est donc 2027. Le 1er janvier correspond à l'année civile. Les numéros d'appel et de dossiers ont leur propre début d'année. + + + Document + + + Intégré, p. ex. + + + Texte fixe (lettres, chiffres, - _ . /) autour de {YYYY} ou {YY}, {MM}, {DD} et {SEQ}, qui doit apparaître une fois. Un modèle avec une année recommence à 1 chaque année de numérotation ; avec {MM} ou {DD}, chaque mois ou jour. Un numéro composé uniquement de chiffres est affiché précédé de #, comme l'ont toujours été les numéros de facture, d'offre et de rapport de temps. + + + La numérotation des documents a été enregistrée. + + + Le reste de la numérotation des documents a été enregistré. + + + La numérotation des documents n'a pas été enregistrée. L'année de numérotation doit commencer un jour qui existe chaque année ; le 29 février ne peut donc pas être utilisé. + + + Ces modèles n'ont pas été enregistrés car ils ne sont pas valides ou pourraient écrire un numéro de plus de 50 caractères ; ces documents ont conservé leur numérotation : + + + Un prochain numéro peut seulement être augmenté. Ceux-ci n'ont pas été modifiés car le numéro saisi était inférieur à celui qu'ils émettront ensuite : + + + Le modèle ou le début de l'année de numérotation a changé lors du même enregistrement ; les prochains numéros saisis n'ont donc pas été appliqués. Relevez-les à nouveau pour la nouvelle séquence. + + + Ordres de travail + + + Factures + + + Offres + + + Rapports de temps quotidiens + + + Modèle de numérotation + + + Prochain numéro + + + Porter le prochain à Les paramètres des appels ont été enregistrés. + + Un Plus Code sert uniquement à trouver le lieu et n'est pas enregistré sur l'intervention ; il ne peut donc pas être obligatoire. Rendez plutôt les coordonnées GPS obligatoires. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx index cc7436ea7..02607c560 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx @@ -894,7 +894,7 @@ Campi del modulo di chiamata - Scegli quali campi predefiniti compaiono nel modulo di nuova chiamata e quali devono essere compilati prima che la chiamata possa essere creata e inviata sul campo. Nome, natura, priorità e tipo sono sempre visibili e sempre obbligatori. + Scegli quali campi predefiniti compaiono nel modulo di nuova chiamata e quali devono essere compilati prima che la chiamata possa essere creata e inviata sul campo. Nome, natura, priorità e tipo sono sempre visibili e sempre obbligatori. Le stesse regole valgono quando si modifica una chiamata o si aggiunge una chiamata archiviata. Campo @@ -927,7 +927,7 @@ Nota - Contatto principale + Nome del segnalante Contatti del segnalante @@ -1350,9 +1350,93 @@ Il prossimo numero di chiamata non è stato alzato: può essere alzato solo sopra il numero che la prossima chiamata riceve già ({0}). - Il prossimo numero di chiamata non è stato alzato perché lo schema è cambiato nello stesso salvataggio. Alzalo di nuovo per il nuovo schema. + Il prossimo numero di chiamata non è stato alzato perché lo schema o l'inizio dell'anno di numerazione è cambiato nello stesso salvataggio. Alzalo di nuovo per la nuova sequenza. + + + L'anno di numerazione inizia il + + + Giorno del mese + + + Prende il nome dall'anno in cui termina + + + Prende il nome dall'anno in cui inizia + + + Per le chiamate numerate per anno fiscale, scegli il giorno in cui inizia. Uno schema annuale riparte da 1 quel giorno, e {YYYY} e {YY} scrivono il nome dell'anno fiscale. La maggior parte degli anni fiscali prende il nome dall'anno in cui termina, quindi uno che inizia il 1° novembre 2026 è il 2027. Il 1° gennaio è l'anno solare. Gli schemi con {MM} o {DD} seguono sempre il calendario. + + + Riparte da 1 ogni anno il {0}. + + + La numerazione delle chiamate non è stata salvata. L'anno di numerazione deve iniziare in un giorno presente in ogni anno, quindi il 29 febbraio non può essere usato. + + + Numerazione dei documenti + + + Come Resgrid numera ordini di lavoro, fatture, offerte e rapporti giornalieri ore. Lascia vuoto uno schema per mantenere i numeri predefiniti. Cambiare uno schema non rinumera mai i documenti che hanno già un numero, e un nuovo schema prosegue dopo qualsiasi numero che ripeterebbe. + + + Numerazione registri + + + Per i documenti numerati per anno fiscale, scegli il giorno in cui inizia. I numeri degli ordini di lavoro, e gli schemi con l'anno, ripartono da 1 quel giorno, e {YYYY} e {YY} scrivono il nome dell'anno fiscale. La maggior parte degli anni fiscali prende il nome dall'anno in cui termina, quindi uno che inizia il 1° novembre 2026 è il 2027. Il 1° gennaio è l'anno solare. I numeri di chiamata e dei registri hanno un proprio inizio d'anno. + + + Documento + + + Predefinito, es. + + + Testo fisso (lettere, cifre, - _ . /) attorno a {YYYY} o {YY}, {MM}, {DD} e {SEQ}, che deve comparire una volta. Uno schema con l'anno riparte da 1 a ogni anno di numerazione; con {MM} o {DD}, ogni mese o giorno. Un numero di sole cifre è mostrato con # davanti, come da sempre i numeri di fatture, offerte e rapporti ore. + + + La numerazione dei documenti è stata salvata. + + + Il resto della numerazione dei documenti è stato salvato. + + + La numerazione dei documenti non è stata salvata. L'anno di numerazione deve iniziare in un giorno presente in ogni anno, quindi il 29 febbraio non può essere usato. + + + Questi schemi non sono stati salvati perché non sono validi o potrebbero scrivere un numero più lungo di 50 caratteri; quei documenti hanno mantenuto la numerazione che avevano: + + + Un prossimo numero può solo essere alzato. Questi sono rimasti invariati perché il numero inserito era inferiore a quello che emetteranno dopo: + + + Lo schema o l'inizio dell'anno di numerazione è cambiato nello stesso salvataggio, quindi i prossimi numeri inseriti non sono stati applicati. Alzali di nuovo per la nuova sequenza. + + + Ordini di lavoro + + + Fatture + + + Offerte + + + Rapporti giornalieri ore + + + Schema di numerazione + + + Prossimo numero + + + Porta il prossimo a Le impostazioni delle chiamate sono state salvate. + + Un Plus Code serve solo a trovare la posizione e non viene salvato nella chiamata, quindi non può essere obbligatorio. Rendi obbligatorie le coordinate GPS. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx index f28dbde39..51a0bf4dd 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx @@ -894,7 +894,7 @@ Pola formularza nowego zdarzenia - Wybierz, które wbudowane pola pojawiają się w formularzu i które muszą zostać wypełnione, zanim zdarzenie będzie można utworzyć i wysłać w teren. Nazwa, charakter, priorytet i typ są zawsze widoczne i zawsze wymagane. + Wybierz, które wbudowane pola pojawiają się w formularzu i które muszą zostać wypełnione, zanim zdarzenie będzie można utworzyć i wysłać w teren. Nazwa, charakter, priorytet i typ są zawsze widoczne i zawsze wymagane. Te same zasady obowiązują przy edycji zdarzenia i dodawaniu zdarzenia archiwalnego. Pole @@ -927,7 +927,7 @@ Notatka - Główny kontakt + Imię i nazwisko zgłaszającego Dane kontaktowe zgłaszającego @@ -1350,9 +1350,93 @@ Następny numer zgłoszenia nie został podniesiony: można go podnieść tylko powyżej numeru, który następne zgłoszenie już otrzyma ({0}). - Następny numer zgłoszenia nie został podniesiony, ponieważ wzorzec zmienił się w tym samym zapisie. Podnieś go ponownie dla nowego wzorca. + Następny numer zgłoszenia nie został podniesiony, ponieważ wzorzec lub początek roku numeracji zmienił się w tym samym zapisie. Podnieś go ponownie dla nowego numeru kolejnego. + + + Rok numeracji zaczyna się + + + Dzień miesiąca + + + Nazwany rokiem, w którym się kończy + + + Nazwany rokiem, w którym się zaczyna + + + Dla zgłoszeń numerowanych według roku budżetowego wybierz dzień, w którym się zaczyna. Wzorzec roczny zaczyna się wtedy ponownie od 1, a {YYYY} i {YY} wpisują nazwę roku budżetowego. Większość lat budżetowych nazywa się rokiem, w którym się kończą, więc rok zaczynający się 1 listopada 2026 to 2027. 1 stycznia oznacza rok kalendarzowy. Wzorce z {MM} lub {DD} zawsze podążają za kalendarzem. + + + Zaczyna się od 1 co roku w dniu {0}. + + + Numeracja zgłoszeń nie została zapisana. Rok numeracji musi zaczynać się w dniu, który występuje w każdym roku, więc nie można użyć 29 lutego. + + + Numeracja dokumentów + + + Jak Resgrid numeruje zlecenia pracy, faktury, oferty i dzienne raporty czasu. Pozostaw wzorzec pusty, aby zachować wbudowane numery. Zmiana wzorca nigdy nie zmienia numerów dokumentów, które już mają numer, a nowy wzorzec kontynuuje po każdym numerze, który by powtórzył. + + + Numeracja rejestrów + + + Dla dokumentów numerowanych według roku budżetowego wybierz dzień, w którym się zaczyna. Numery zleceń pracy i wzorce z rokiem zaczynają się wtedy ponownie od 1, a {YYYY} i {YY} wpisują nazwę roku budżetowego. Większość lat budżetowych nazywa się rokiem, w którym się kończą, więc rok zaczynający się 1 listopada 2026 to 2027. 1 stycznia oznacza rok kalendarzowy. Numery zgłoszeń i rejestrów mają własny początek roku. + + + Dokument + + + Wbudowany, np. + + + Stały tekst (litery, cyfry, - _ . /) wokół {YYYY} lub {YY}, {MM}, {DD} i {SEQ}, który musi wystąpić raz. Wzorzec z rokiem zaczyna się od 1 w każdym roku numeracji; z {MM} lub {DD} — w każdym miesiącu lub dniu. Numer złożony tylko z cyfr jest wyświetlany z # na początku, jak zawsze numery faktur, ofert i raportów czasu. + + + Numeracja dokumentów została zapisana. + + + Pozostała część numeracji dokumentów została zapisana. + + + Numeracja dokumentów nie została zapisana. Rok numeracji musi zaczynać się w dniu, który występuje w każdym roku, więc nie można użyć 29 lutego. + + + Te wzorce nie zostały zapisane, ponieważ są nieprawidłowe lub mogłyby zapisać numer dłuższy niż 50 znaków; te dokumenty zachowały dotychczasową numerację: + + + Następny numer można tylko podnieść. Te pozostały bez zmian, ponieważ wpisany numer był niższy niż ten, który wydadzą jako następny: + + + Wzorzec lub początek roku numeracji zmienił się w tym samym zapisie, więc wpisane następne numery nie zostały zastosowane. Podnieś je ponownie dla nowej sekwencji. + + + Zlecenia pracy + + + Faktury + + + Oferty + + + Dzienne raporty czasu + + + Wzorzec numeracji + + + Następny numer + + + Podnieś następny do Ustawienia zgłoszeń zostały zapisane. + + Plus Code służy tylko do znalezienia lokalizacji i nie jest zapisywany w zdarzeniu, więc nie może być wymagany. Zamiast tego ustaw jako wymagane współrzędne GPS. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.resx index ac892cbd3..baf307416 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.resx @@ -580,7 +580,7 @@ New Call Form Fields - Choose which built-in fields appear on the new call form and which must be completed before a call can be created and sent to the field. Name, nature, priority and type are always shown and always required. + Choose which built-in fields appear on the new call form and which must be completed before a call can be created and sent to the field. Name, nature, priority and type are always shown and always required. The same rules apply when a call is edited or an archived call is added. Field @@ -613,7 +613,7 @@ Note - Primary Contact + Reporter Name Reporter Contact Info @@ -1003,9 +1003,93 @@ The next call number was not raised: it can only be raised above the number the next call already receives ({0}). - The next call number was not raised because the pattern changed in the same save. Raise it again for the new pattern. + The next call number was not raised because the pattern or the numbering year start changed in the same save. Raise it again for the new sequence. + + + Numbering year starts + + + Day of the month + + + Named by the year it ends in + + + Named by the year it starts in + + + For calls numbered by a fiscal year, pick the day it starts. A yearly pattern starts again at 1 that day, and {YYYY} and {YY} write the fiscal year's name. Most fiscal years are named by the year they end in, so one starting November 1, 2026 is 2027. January 1 is the calendar year. Patterns with {MM} or {DD} always follow the calendar. + + + Starts again at 1 each year on {0}. + + + The call numbering was not saved. The numbering year has to start on a day every year has, so February 29 cannot be used. + + + Document Numbering + + + How Resgrid numbers work orders, invoices, bids and daily time reports. Leave a pattern blank to keep the built-in numbers. Changing a pattern never renumbers documents that already have a number, and a new pattern carries on after any number it would repeat. + + + Records numbering + + + For documents numbered by a fiscal year, pick the day it starts. Work order numbers, and patterns with a year, start again at 1 that day, and {YYYY} and {YY} write the fiscal year's name. Most fiscal years are named by the year they end in, so one starting November 1, 2026 is 2027. January 1 is the calendar year. Call and Records numbers have their own year start. + + + Document + + + Built-in, e.g. + + + Fixed text (letters, digits, - _ . /) around {YYYY} or {YY}, {MM}, {DD} and {SEQ}, which must appear once. A pattern with a year starts again at 1 each numbering year; with {MM} or {DD}, each month or day. A number of digits alone is shown with # in front, as invoice, bid and time report numbers always have been. + + + The document numbering was saved. + + + The rest of the document numbering was saved. + + + The document numbering was not saved. The numbering year has to start on a day every year has, so February 29 cannot be used. + + + These patterns were not saved because they are not valid or could write a number longer than 50 characters; those documents kept the numbering they had: + + + A next number can only be raised. These were left unchanged because the number entered was below the one they will issue next: + + + The pattern or the numbering year start changed in the same save, so the next numbers entered were not applied. Raise them again for the new sequence. + + + Work orders + + + Invoices + + + Bids + + + Daily time reports + + + Number pattern + + + Next number + + + Raise next to Call settings were saved. + + A plus code only finds the location and is not saved on the call, so it cannot be required. Require GPS Coordinates instead. + diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx index 94554f637..ed7a97008 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx @@ -894,7 +894,7 @@ Fält i ärendeformuläret - Välj vilka inbyggda fält som visas i formuläret för nya ärenden och vilka som måste fyllas i innan ett ärende kan skapas och skickas ut. Namn, art, prioritet och typ visas alltid och krävs alltid. + Välj vilka inbyggda fält som visas i formuläret för nya ärenden och vilka som måste fyllas i innan ett ärende kan skapas och skickas ut. Namn, art, prioritet och typ visas alltid och krävs alltid. Samma regler gäller när ett ärende redigeras eller ett arkiverat ärende läggs till. Fält @@ -927,7 +927,7 @@ Anteckning - Primär kontakt + Anmälarens namn Anmälarens kontaktuppgifter @@ -1350,9 +1350,93 @@ Nästa larmnummer höjdes inte: det kan bara höjas över det nummer som nästa larm redan får ({0}). - Nästa larmnummer höjdes inte eftersom mönstret ändrades i samma sparning. Höj det igen för det nya mönstret. + Nästa larmnummer höjdes inte eftersom mönstret eller numreringsårets början ändrades i samma sparning. Höj det igen för det nya löpnumret. + + + Numreringsåret börjar + + + Dag i månaden + + + Namnges efter året det slutar + + + Namnges efter året det börjar + + + För larm som numreras efter ett räkenskapsår väljer du dagen det börjar. Ett årligt mönster börjar om på 1 den dagen, och {YYYY} och {YY} skriver räkenskapsårets namn. De flesta räkenskapsår namnges efter året de slutar, så ett som börjar den 1 november 2026 är 2027. Den 1 januari är kalenderåret. Mönster med {MM} eller {DD} följer alltid kalendern. + + + Börjar om på 1 varje år den {0}. + + + Larmnumreringen sparades inte. Numreringsåret måste börja på en dag som finns varje år, så den 29 februari kan inte användas. + + + Dokumentnumrering + + + Hur Resgrid numrerar arbetsorder, fakturor, anbud och dagliga tidrapporter. Lämna ett mönster tomt för att behålla de inbyggda numren. Att ändra ett mönster numrerar aldrig om dokument som redan har ett nummer, och ett nytt mönster fortsätter efter varje nummer det skulle upprepa. + + + Journalnumrering + + + För dokument som numreras efter ett räkenskapsår väljer du dagen det börjar. Arbetsordernummer, och mönster med år, börjar om på 1 den dagen, och {YYYY} och {YY} skriver räkenskapsårets namn. De flesta räkenskapsår namnges efter året de slutar, så ett som börjar den 1 november 2026 är 2027. Den 1 januari är kalenderåret. Larm- och journalnummer har en egen årsstart. + + + Dokument + + + Inbyggt, t.ex. + + + Fast text (bokstäver, siffror, - _ . /) runt {YYYY} eller {YY}, {MM}, {DD} och {SEQ}, som måste förekomma en gång. Ett mönster med år börjar om på 1 varje numreringsår; med {MM} eller {DD} varje månad eller dag. Ett nummer med bara siffror visas med # framför, som faktura-, anbuds- och tidrapportnummer alltid har gjort. + + + Dokumentnumreringen sparades. + + + Resten av dokumentnumreringen sparades. + + + Dokumentnumreringen sparades inte. Numreringsåret måste börja på en dag som finns varje år, så den 29 februari kan inte användas. + + + Dessa mönster sparades inte eftersom de är ogiltiga eller kan skriva ett nummer längre än 50 tecken; dokumenten behöll sin tidigare numrering: + + + Ett nästa nummer kan bara höjas. Dessa lämnades oförändrade eftersom det angivna numret var lägre än det de utfärdar härnäst: + + + Mönstret eller numreringsårets början ändrades i samma sparning, så de angivna nästa numren tillämpades inte. Höj dem igen för den nya sekvensen. + + + Arbetsorder + + + Fakturor + + + Anbud + + + Dagliga tidrapporter + + + Nummermönster + + + Nästa nummer + + + Höj nästa till Larminställningarna sparades. + + En pluskod används bara för att hitta platsen och sparas inte på ärendet, så den kan inte vara obligatorisk. Gör GPS-koordinaterna obligatoriska i stället. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx index 8903d7a39..d9b381cfe 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx @@ -894,7 +894,7 @@ Поля форми нового виклику - Оберіть, які вбудовані поля відображаються у формі та які мають бути заповнені, перш ніж виклик можна створити та надіслати. Назва, характер, пріоритет і тип завжди видимі та обов’язкові. + Оберіть, які вбудовані поля відображаються у формі та які мають бути заповнені, перш ніж виклик можна створити та надіслати. Назва, характер, пріоритет і тип завжди видимі та обов’язкові. Ті самі правила діють під час редагування виклику та додавання архівного виклику. Поле @@ -927,7 +927,7 @@ Примітка - Основний контакт + Ім'я заявника Контакти заявника @@ -1350,9 +1350,93 @@ Наступний номер виклику не піднято: його можна підняти лише вище номера, який наступний виклик уже отримає ({0}). - Наступний номер виклику не піднято, бо шаблон змінився під час того самого збереження. Підніміть його знову для нового шаблону. + Наступний номер виклику не піднято, бо шаблон або початок року нумерації змінився під час того самого збереження. Підніміть його знову для нової послідовності. + + + Рік нумерації починається + + + День місяця + + + Називається за роком, у якому закінчується + + + Називається за роком, у якому починається + + + Для викликів, що нумеруються за фінансовим роком, виберіть день, коли він починається. Річний шаблон знову починається з 1 цього дня, а {YYYY} і {YY} записують назву фінансового року. Більшість фінансових років називають за роком, у якому вони закінчуються, тож рік, що починається 1 листопада 2026, — це 2027. 1 січня — календарний рік. Шаблони з {MM} або {DD} завжди слідують календарю. + + + Знову починається з 1 щороку {0}. + + + Нумерацію викликів не збережено. Рік нумерації має починатися в день, який є в кожному році, тож 29 лютого використати не можна. + + + Нумерація документів + + + Як Resgrid нумерує наряди на роботи, рахунки, пропозиції та щоденні звіти про час. Залиште шаблон порожнім, щоб зберегти вбудовані номери. Зміна шаблону ніколи не перенумеровує документи, які вже мають номер, а новий шаблон продовжує після будь-якого номера, який він повторив би. + + + Нумерація записів + + + Для документів, що нумеруються за фінансовим роком, виберіть день, коли він починається. Номери нарядів на роботи та шаблони з роком знову починаються з 1 цього дня, а {YYYY} і {YY} записують назву фінансового року. Більшість фінансових років називають за роком, у якому вони закінчуються, тож рік, що починається 1 листопада 2026, — це 2027. 1 січня — календарний рік. Номери викликів і записів мають власний початок року. + + + Документ + + + Вбудований, напр. + + + Фіксований текст (літери, цифри, - _ . /) навколо {YYYY} або {YY}, {MM}, {DD} і {SEQ}, який має бути рівно один раз. Шаблон із роком знову починається з 1 кожного року нумерації; з {MM} або {DD} — кожного місяця чи дня. Номер лише з цифр показується з # попереду, як завжди номери рахунків, пропозицій і звітів про час. + + + Нумерацію документів збережено. + + + Решту нумерації документів збережено. + + + Нумерацію документів не збережено. Рік нумерації має починатися в день, який є в кожному році, тож 29 лютого використати не можна. + + + Ці шаблони не збережено, бо вони недійсні або можуть записати номер довший за 50 символів; ці документи зберегли попередню нумерацію: + + + Наступний номер можна лише підняти. Ці залишилися без змін, бо введений номер менший за той, який вони видадуть наступним: + + + Шаблон або початок року нумерації змінився під час того самого збереження, тому введені наступні номери не застосовано. Підніміть їх знову для нової послідовності. + + + Наряди на роботи + + + Рахунки + + + Пропозиції + + + Щоденні звіти про час + + + Шаблон нумерації + + + Наступний номер + + + Підвищити наступний до Налаштування викликів збережено. + + Plus Code лише допомагає знайти місце і не зберігається у виклику, тому його не можна зробити обов'язковим. Натомість зробіть обов'язковими GPS-координати. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx index 6a208febd..03908aab6 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx @@ -507,4 +507,7 @@ يُبلغ كل من أُرسل إلى المكالمة ووحداتها وفريق قيادة الحادث بأن المكالمة أُغلقت. لا تُرفق ملاحظات الإغلاق. + + الحقل {0} مطلوب في هذه الإدارة. + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx index 32e4136e2..838fdfd0d 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx @@ -1065,4 +1065,7 @@ Informiert alle alarmierten Personen, die Einheiten und das Einsatzleitungsteam, dass der Einsatz geschlossen wurde. Die Abschlussnotizen werden nicht mitgesendet. + + {0} ist in dieser Abteilung ein Pflichtfeld. + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx index e1906a0b6..3f87f156f 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx @@ -977,4 +977,7 @@ Ενημερώνει όσους κλήθηκαν στην κλήση, τις μονάδες της και την ομάδα διοίκησης συμβάντος ότι η κλήση έκλεισε. Οι σημειώσεις κλεισίματος δεν περιλαμβάνονται. + + Το πεδίο {0} είναι υποχρεωτικό σε αυτό το τμήμα. + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx index 41fff19ed..ef6a9f84e 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx @@ -977,4 +977,7 @@ Tells everyone dispatched on the call, its units and the incident command team that the call is closed. The closing notes are not included. + + {0} is required by this department. + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx index 4eff74313..e40c947e1 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx @@ -1123,4 +1123,7 @@ Avisa a todas las personas despachadas, a sus unidades y al equipo de mando del incidente de que la llamada se ha cerrado. Las notas de cierre no se incluyen. + + {0} es obligatorio en este departamento. + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx index 87a4eb252..ee6eaeb84 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx @@ -1065,4 +1065,7 @@ Informe toutes les personnes engagées sur l'appel, ses unités et l'équipe de commandement que l'appel est clôturé. Les notes de clôture ne sont pas incluses. + + {0} est obligatoire dans ce service. + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx index f841e5693..f9b251fb0 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx @@ -1065,4 +1065,7 @@ Avvisa tutte le persone inviate alla chiamata, le sue unità e il team di comando dell'incidente che la chiamata è stata chiusa. Le note di chiusura non sono incluse. + + {0} è obbligatorio in questo dipartimento. + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx index bd0fb5979..853c0a320 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx @@ -1065,4 +1065,7 @@ Powiadamia wszystkie osoby zadysponowane do zgłoszenia, jego jednostki i zespół dowodzenia o zamknięciu zgłoszenia. Notatki zamknięcia nie są dołączane. + + Pole {0} jest wymagane w tym wydziale. + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx index afe4a134e..60d1116f0 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx @@ -1065,4 +1065,7 @@ Meddelar alla som larmats till ärendet, dess enheter och insatsledningen att ärendet är stängt. Stängningsanteckningarna skickas inte med. + + {0} är obligatoriskt i den här avdelningen. + diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx index ee738d29b..053195c55 100644 --- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx @@ -1065,4 +1065,7 @@ Повідомляє всіх, кого направлено на виклик, його підрозділи та команду командування інцидентом про закриття виклику. Нотатки закриття не додаються. + + Поле {0} є обов'язковим у цьому підрозділі. + diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx index 04fbf0e7d..23692006c 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.ar.resx @@ -123,7 +123,18 @@ الرقم التالي رفع التالي إلى لا يمكن إلا رفع الرقم التالي. تُركت هذه التسلسلات دون تغيير لأن الرقم المُدخل أقل من الرقم الذي ستصدره تاليًا: - تغيّر نمط الترقيم أو بادئة أحد أنواع السجلات، لذلك لم تُطبَّق الأرقام التالية المُدخلة. راجع التسلسلات الجديدة واحفظ مرة أخرى. + تغيّر نمط الترقيم أو بادئة أحد أنواع السجلات أو بداية سنة الترقيم، لذلك لم تُطبَّق الأرقام التالية المُدخلة. راجع التسلسلات الجديدة واحفظ مرة أخرى. + تبدأ سنة الترقيم في + يوم الشهر + تُسمّى بالسنة التي تنتهي فيها + تُسمّى بالسنة التي تبدأ فيها + للسجلات المرقّمة حسب سنة مالية، سواء كانت سنتك أو سنة المقاطعة أو الولاية التي ترفع إليها التقارير، اختر اليوم الذي تبدأ فيه. تبدأ الأرقام التي تتضمن السنة من 1 من جديد في ذلك اليوم، ويكتب {YYYY} و{YY} اسم السنة المالية. تُسمّى معظم السنوات المالية بالسنة التي تنتهي فيها، لذا فإن السنة التي تبدأ في 1 نوفمبر 2026 هي 2027. يمثّل 1 يناير السنة الميلادية. لأرقام البلاغات بداية سنة خاصة بها في صفحة إعدادات استيراد البلاغات. تتبع أرقام طلبات الاطلاع على السجلات وأرقام عمليات التفتيش والتصاريح والمباني والتحقيقات والأدلة السنة نفسها. + الأرقام التالية لعام {0} (من {1} إلى {2}) + لم يُحفظ ترقيم السجلات. يجب أن تبدأ سنة الترقيم في يوم موجود في كل سنة، لذا لا يمكن استخدام 29 فبراير. + أرقام سجلات أخرى + أرقام طلبات الاطلاع على السجلات والمباني وعمليات التفتيش والتصاريح والتحقيقات والأدلة. اترك النمط فارغًا للإبقاء على الأرقام المضمّنة المعروضة. تستخدم الأنماط {YYYY} أو {YY} و{MM} و{DD} و{SEQ} كما تفعل أرقام البلاغات، وتتبع بداية سنة الترقيم أعلاه، ولا تتجاوز 32 حرفًا (50 لطلبات الاطلاع). + مضمّن، مثل + لم تُحفظ هذه الأنماط لأنها غير صالحة أو قد تكتب رقمًا أطول مما يتسع له السجل؛ واحتفظت تلك الأرقام بما كان لها: لا يمكن خفض الرقم التالي بعد رفعه. هل تريد حفظ هذه التغييرات؟ جميع أنواع السجلات بلا محطة/مجموعة diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx index 0d2a79412..b7500dd2a 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.de.resx @@ -123,7 +123,18 @@ Nächste Nummer Nächste erhöhen auf Eine nächste Nummer kann nur erhöht werden. Diese Laufnummern blieben unverändert, weil die eingegebene Zahl unter der lag, die sie als Nächstes vergeben: - Das Nummernmuster oder ein Präfix eines Aktentyps wurde geändert, daher wurden die eingegebenen nächsten Nummern nicht übernommen. Prüfen Sie die neuen Laufnummern und speichern Sie erneut. + Das Nummernmuster, ein Präfix eines Aktentyps oder der Beginn des Nummerierungsjahres wurde geändert, daher wurden die eingegebenen nächsten Nummern nicht übernommen. Prüfen Sie die neuen Laufnummern und speichern Sie erneut. + Nummerierungsjahr beginnt am + Tag des Monats + Benannt nach dem Jahr, in dem es endet + Benannt nach dem Jahr, in dem es beginnt + Für Akten, die nach einem Haushaltsjahr nummeriert werden – Ihrem eigenen oder dem eines Landkreises oder Landes, an den Sie berichten –, wählen Sie den Tag, an dem es beginnt. Nummern mit Jahr beginnen an diesem Tag wieder bei 1, und {YYYY} und {YY} schreiben den Namen des Haushaltsjahres. Die meisten Haushaltsjahre werden nach dem Jahr benannt, in dem sie enden; eines, das am 1. November 2026 beginnt, ist also 2027. Der 1. Januar ist das Kalenderjahr. Einsatznummern haben ihren eigenen Jahresbeginn auf der Seite Einsatz-Importeinstellungen. Nummern von Auskunftsersuchen sowie von Prüfungen, Genehmigungen, Objekten, Ermittlungen und Nachweisen folgen demselben Jahr. + Nächste Nummern für {0} ({1} bis {2}) + Die Aktennummerierung wurde nicht gespeichert. Das Nummerierungsjahr muss an einem Tag beginnen, den es in jedem Jahr gibt; der 29. Februar ist daher nicht möglich. + Weitere Aktennummern + Nummern von Auskunftsersuchen, Objekten, Prüfungen, Genehmigungen, Ermittlungen und Nachweisen. Lassen Sie ein Muster leer, um die angezeigten integrierten Nummern zu behalten. Muster verwenden {YYYY} oder {YY}, {MM}, {DD} und {SEQ} wie Einsatznummern, folgen dem Beginn des Nummerierungsjahres oben und passen in 32 Zeichen (50 bei Auskunftsersuchen). + Integriert, z. B. + Diese Muster wurden nicht gespeichert, weil sie ungültig sind oder eine für die Akte zu lange Nummer schreiben könnten; diese Nummern behielten ihre bisherige Form: Eine erhöhte nächste Nummer kann nicht wieder gesenkt werden. Änderungen speichern? Alle Aktentypen Keine Wache/Gruppe diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx index 73d1da737..0e7af132e 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.el.resx @@ -123,7 +123,18 @@ Επόμενος αριθμός Αύξηση επόμενου σε Ο επόμενος αριθμός μπορεί μόνο να αυξηθεί. Αυτές οι ακολουθίες δεν άλλαξαν, επειδή ο αριθμός που εισαγάγατε ήταν μικρότερος από αυτόν που θα εκδώσουν στη συνέχεια: - Το μοτίβο αρίθμησης ή το πρόθεμα ενός τύπου αρχείου άλλαξε, οπότε οι επόμενοι αριθμοί που εισαγάγατε δεν εφαρμόστηκαν. Ελέγξτε τις νέες ακολουθίες και αποθηκεύστε ξανά. + Το μοτίβο αρίθμησης, το πρόθεμα ενός τύπου αρχείου ή η έναρξη του έτους αρίθμησης άλλαξε, οπότε οι επόμενοι αριθμοί που εισαγάγατε δεν εφαρμόστηκαν. Ελέγξτε τις νέες ακολουθίες και αποθηκεύστε ξανά. + Το έτος αρίθμησης ξεκινά + Ημέρα του μήνα + Ονομάζεται από το έτος στο οποίο λήγει + Ονομάζεται από το έτος στο οποίο ξεκινά + Για αρχεία που αριθμούνται ανά οικονομικό έτος, το δικό σας ή μιας κομητείας ή πολιτείας στην οποία αναφέρετε, επιλέξτε την ημέρα που ξεκινά. Οι αριθμοί που περιέχουν το έτος ξεκινούν ξανά από το 1 εκείνη την ημέρα, και τα {YYYY} και {YY} γράφουν το όνομα του οικονομικού έτους. Τα περισσότερα οικονομικά έτη ονομάζονται από το έτος στο οποίο λήγουν, οπότε ένα που ξεκινά την 1η Νοεμβρίου 2026 είναι το 2027. Η 1η Ιανουαρίου είναι το ημερολογιακό έτος. Οι αριθμοί κλήσεων έχουν τη δική τους έναρξη έτους στη σελίδα Ρυθμίσεις Εισαγωγής Κλήσεων. Οι αριθμοί αιτημάτων πρόσβασης σε αρχεία και οι αριθμοί επιθεωρήσεων, αδειών, κτιρίων, ερευνών και τεκμηρίων ακολουθούν το ίδιο έτος. + Επόμενοι αριθμοί για το {0} ({1} έως {2}) + Η αρίθμηση αρχείων δεν αποθηκεύτηκε. Το έτος αρίθμησης πρέπει να ξεκινά σε ημέρα που υπάρχει κάθε έτος, οπότε η 29η Φεβρουαρίου δεν μπορεί να χρησιμοποιηθεί. + Άλλοι αριθμοί αρχείων + Αριθμοί αιτημάτων πρόσβασης σε αρχεία, κτιρίων, επιθεωρήσεων, αδειών, ερευνών και τεκμηρίων. Αφήστε ένα μοτίβο κενό για να διατηρηθούν οι ενσωματωμένοι αριθμοί που εμφανίζονται. Τα μοτίβα χρησιμοποιούν {YYYY} ή {YY}, {MM}, {DD} και {SEQ} όπως οι αριθμοί κλήσεων, ακολουθούν την έναρξη του έτους αρίθμησης παραπάνω και χωρούν σε 32 χαρακτήρες (50 για αιτήματα πρόσβασης). + Ενσωματωμένο, π.χ. + Αυτά τα μοτίβα δεν αποθηκεύτηκαν επειδή δεν είναι έγκυρα ή θα μπορούσαν να γράψουν αριθμό πολύ μεγάλο για το αρχείο· αυτοί οι αριθμοί διατήρησαν ό,τι είχαν: Ένας αυξημένος επόμενος αριθμός δεν μπορεί να μειωθεί ξανά. Αποθήκευση αλλαγών; Όλοι οι τύποι αρχείων Χωρίς σταθμό/ομάδα diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx index edf1f57a5..bb9a29c22 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.en.resx @@ -123,7 +123,18 @@ Next number Raise next to A next number can only be raised. These sequences were left unchanged because the number entered was below the one they will issue next: - The number pattern or a record type prefix changed, so the next numbers entered were not applied. Review the new sequences and save again. + The number pattern, a record type prefix or the numbering year start changed, so the next numbers entered were not applied. Review the new sequences and save again. + Numbering year starts + Day of the month + Named by the year it ends in + Named by the year it starts in + For records numbered by a fiscal year, your own or a county's or state's you report to, pick the day it starts. Numbers that include the year start again at 1 that day, and {YYYY} and {YY} write the fiscal year's name. Most fiscal years are named by the year they end in, so one starting November 1, 2026 is 2027. January 1 is the calendar year. Call numbers have their own year start on the Call Import Settings page. Records request numbers and the numbers of inspections, permits, occupancies, investigations and evidence follow the same year. + Next numbers for {0} ({1} to {2}) + The record numbering was not saved. The numbering year has to start on a day every year has, so February 29 cannot be used. + Other record numbers + Records request, occupancy, inspection, permit, investigation and evidence numbers. Leave a pattern blank to keep the built-in numbers shown. Patterns use {YYYY} or {YY}, {MM}, {DD} and {SEQ} as call numbers do, follow the numbering year start above, and fit 32 characters (50 for records requests). + Built-in, e.g. + These patterns were not saved because they are not valid or could write a number too long for the record; those numbers kept what they had: A raised next number cannot be lowered again. Save these changes? All record types No station/group diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx index cc1a60bdc..dd8b4950b 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.es.resx @@ -123,7 +123,18 @@ Próximo número Aumentar el próximo a El próximo número solo se puede aumentar. Estas secuencias no se modificaron porque el número introducido era menor que el que emitirán a continuación: - El patrón de numeración o el prefijo de un tipo de registro cambió, por lo que no se aplicaron los próximos números introducidos. Revise las nuevas secuencias y vuelva a guardar. + El patrón de numeración, el prefijo de un tipo de registro o el inicio del año de numeración cambió, por lo que no se aplicaron los próximos números introducidos. Revise las nuevas secuencias y vuelva a guardar. + El año de numeración empieza el + Día del mes + Se nombra por el año en que termina + Se nombra por el año en que empieza + Para registros numerados por un año fiscal, el suyo o el de un condado o estado al que informa, elija el día en que empieza. Los números que incluyen el año vuelven a empezar en 1 ese día, y {YYYY} y {YY} escriben el nombre del año fiscal. La mayoría de los años fiscales se nombran por el año en que terminan, así que uno que empieza el 1 de noviembre de 2026 es 2027. El 1 de enero es el año natural. Los números de llamada tienen su propio inicio de año en la página Configuración de importación de llamadas. Los números de las solicitudes de acceso a registros y de inspecciones, permisos, ocupaciones, investigaciones y evidencias siguen el mismo año. + Próximos números para {0} ({1} a {2}) + La numeración de registros no se guardó. El año de numeración tiene que empezar en un día que exista todos los años, así que no se puede usar el 29 de febrero. + Otros números de registros + Números de solicitudes de acceso a registros, ocupaciones, inspecciones, permisos, investigaciones y evidencias. Deje un patrón en blanco para mantener los números integrados que se muestran. Los patrones usan {YYYY} o {YY}, {MM}, {DD} y {SEQ} como los números de llamada, siguen el inicio del año de numeración de arriba y caben en 32 caracteres (50 para las solicitudes de acceso). + Integrado, p. ej. + Estos patrones no se guardaron porque no son válidos o podrían escribir un número demasiado largo para el registro; esos números conservaron lo que tenían: Un próximo número aumentado no se puede volver a reducir. ¿Guardar estos cambios? Todos los tipos de registro Sin estación/grupo diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx index ebc8e0269..60ccfc5ff 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.fr.resx @@ -123,7 +123,18 @@ Prochain numéro Porter le prochain à Un prochain numéro ne peut qu'être augmenté. Ces séquences n'ont pas été modifiées, car le nombre saisi était inférieur à celui qu'elles attribueront ensuite : - Le modèle de numérotation ou le préfixe d'un type de dossier a changé ; les prochains numéros saisis n'ont donc pas été appliqués. Vérifiez les nouvelles séquences et enregistrez à nouveau. + Le modèle de numérotation, le préfixe d'un type de dossier ou le début de l'année de numérotation a changé ; les prochains numéros saisis n'ont donc pas été appliqués. Vérifiez les nouvelles séquences et enregistrez à nouveau. + L'année de numérotation commence le + Jour du mois + Nommée d'après l'année où elle se termine + Nommée d'après l'année où elle commence + Pour les dossiers numérotés par exercice budgétaire, le vôtre ou celui d'un comté ou d'un État auquel vous rendez compte, choisissez le jour où il commence. Les numéros qui contiennent l'année recommencent à 1 ce jour-là, et {YYYY} et {YY} écrivent le nom de l'exercice. La plupart des exercices sont nommés d'après l'année où ils se terminent : celui qui commence le 1er novembre 2026 est donc 2027. Le 1er janvier correspond à l'année civile. Les numéros d'appel ont leur propre début d'année sur la page Paramètres d'importation d'appels. Les numéros des demandes d'accès aux documents et ceux des inspections, permis, occupations, enquêtes et preuves suivent la même année. + Prochains numéros pour {0} (du {1} au {2}) + La numérotation des dossiers n'a pas été enregistrée. L'année de numérotation doit commencer un jour qui existe chaque année ; le 29 février ne peut donc pas être utilisé. + Autres numéros de dossiers + Numéros des demandes d'accès aux documents, occupations, inspections, permis, enquêtes et preuves. Laissez un modèle vide pour conserver les numéros intégrés affichés. Les modèles utilisent {YYYY} ou {YY}, {MM}, {DD} et {SEQ} comme les numéros d'appel, suivent le début de l'année de numérotation ci-dessus et tiennent en 32 caractères (50 pour les demandes d'accès). + Intégré, p. ex. + Ces modèles n'ont pas été enregistrés car ils ne sont pas valides ou pourraient écrire un numéro trop long pour le dossier ; ces numéros ont conservé ce qu'ils avaient : Un prochain numéro augmenté ne peut plus être diminué. Enregistrer ces modifications ? Tous les types de dossiers Aucune caserne ni groupe diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx index ded97e624..98cfd9317 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.it.resx @@ -123,7 +123,18 @@ Prossimo numero Porta il prossimo a Il prossimo numero può solo essere aumentato. Queste sequenze non sono state modificate perché il numero inserito era inferiore a quello che assegneranno successivamente: - Lo schema di numerazione o il prefisso di un tipo di registro è cambiato, quindi i prossimi numeri inseriti non sono stati applicati. Controlla le nuove sequenze e salva di nuovo. + Lo schema di numerazione, il prefisso di un tipo di registro o l'inizio dell'anno di numerazione è cambiato, quindi i prossimi numeri inseriti non sono stati applicati. Controlla le nuove sequenze e salva di nuovo. + L'anno di numerazione inizia il + Giorno del mese + Prende il nome dall'anno in cui termina + Prende il nome dall'anno in cui inizia + Per i registri numerati per anno fiscale, il tuo o quello di una contea o di uno stato a cui riferisci, scegli il giorno in cui inizia. I numeri che includono l'anno ripartono da 1 quel giorno, e {YYYY} e {YY} scrivono il nome dell'anno fiscale. La maggior parte degli anni fiscali prende il nome dall'anno in cui termina, quindi uno che inizia il 1° novembre 2026 è il 2027. Il 1° gennaio è l'anno solare. I numeri di chiamata hanno un proprio inizio d'anno nella pagina Impostazioni importazione chiamate. I numeri delle richieste di accesso agli atti e quelli di ispezioni, permessi, occupazioni, indagini e prove seguono lo stesso anno. + Prossimi numeri per il {0} (dal {1} al {2}) + La numerazione dei registri non è stata salvata. L'anno di numerazione deve iniziare in un giorno presente in ogni anno, quindi il 29 febbraio non può essere usato. + Altri numeri dei registri + Numeri delle richieste di accesso agli atti, occupazioni, ispezioni, permessi, indagini e prove. Lascia vuoto uno schema per mantenere i numeri predefiniti mostrati. Gli schemi usano {YYYY} o {YY}, {MM}, {DD} e {SEQ} come i numeri di chiamata, seguono l'inizio dell'anno di numerazione sopra e stanno in 32 caratteri (50 per le richieste di accesso). + Predefinito, es. + Questi schemi non sono stati salvati perché non sono validi o potrebbero scrivere un numero troppo lungo per il registro; quei numeri hanno mantenuto ciò che avevano: Un prossimo numero aumentato non può essere ridotto di nuovo. Salvare queste modifiche? Tutti i tipi di registro Nessuna stazione/gruppo diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx index a9fcdb062..fd4d2abf3 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.pl.resx @@ -123,7 +123,18 @@ Następny numer Podnieś następny do Następny numer można tylko podnieść. Te sekwencje pozostały bez zmian, ponieważ wpisana liczba była niższa niż numer, który nadadzą jako następny: - Wzorzec numeracji lub prefiks typu rejestru się zmienił, więc wpisane następne numery nie zostały zastosowane. Sprawdź nowe sekwencje i zapisz ponownie. + Wzorzec numeracji, prefiks typu rejestru lub początek roku numeracji się zmienił, więc wpisane następne numery nie zostały zastosowane. Sprawdź nowe sekwencje i zapisz ponownie. + Rok numeracji zaczyna się + Dzień miesiąca + Nazwany rokiem, w którym się kończy + Nazwany rokiem, w którym się zaczyna + Dla rejestrów numerowanych według roku budżetowego — własnego albo hrabstwa lub stanu, któremu składasz raporty — wybierz dzień, w którym się zaczyna. Numery zawierające rok zaczynają się wtedy ponownie od 1, a {YYYY} i {YY} wpisują nazwę roku budżetowego. Większość lat budżetowych nazywa się rokiem, w którym się kończą, więc rok zaczynający się 1 listopada 2026 to 2027. 1 stycznia oznacza rok kalendarzowy. Numery zgłoszeń mają własny początek roku na stronie Ustawienia importu zgłoszeń. Numery wniosków o udostępnienie akt oraz numery kontroli, zezwoleń, obiektów, dochodzeń i materiału dowodowego podlegają temu samemu rokowi. + Następne numery na rok {0} (od {1} do {2}) + Numeracja rejestrów nie została zapisana. Rok numeracji musi zaczynać się w dniu, który występuje w każdym roku, więc nie można użyć 29 lutego. + Inne numery rejestrów + Numery wniosków o udostępnienie akt, obiektów, kontroli, zezwoleń, dochodzeń i materiału dowodowego. Pozostaw wzorzec pusty, aby zachować pokazane wbudowane numery. Wzorce używają {YYYY} lub {YY}, {MM}, {DD} i {SEQ} jak numery zgłoszeń, podlegają początkowi roku numeracji powyżej i mieszczą się w 32 znakach (50 dla wniosków o udostępnienie). + Wbudowany, np. + Te wzorce nie zostały zapisane, ponieważ są nieprawidłowe lub mogłyby zapisać numer za długi dla rejestru; te numery zachowały dotychczasową postać: Podniesionego następnego numeru nie można ponownie obniżyć. Zapisać te zmiany? Wszystkie typy rejestrów Brak stacji/grupy diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx index ca3ae2579..852c6913d 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.sv.resx @@ -123,7 +123,18 @@ Nästa nummer Höj nästa till Ett nästa nummer kan bara höjas. Dessa sekvenser lämnades oförändrade eftersom det angivna talet var lägre än det de utfärdar härnäst: - Nummermönstret eller en journaltyps prefix ändrades, så de angivna nästa numren tillämpades inte. Granska de nya sekvenserna och spara igen. + Nummermönstret, en journaltyps prefix eller numreringsårets början ändrades, så de angivna nästa numren tillämpades inte. Granska de nya sekvenserna och spara igen. + Numreringsåret börjar + Dag i månaden + Namnges efter året det slutar + Namnges efter året det börjar + För journaler som numreras efter ett räkenskapsår, ert eget eller ett läns eller en delstats som ni rapporterar till, väljer du dagen det börjar. Nummer som innehåller året börjar om på 1 den dagen, och {YYYY} och {YY} skriver räkenskapsårets namn. De flesta räkenskapsår namnges efter året de slutar, så ett som börjar den 1 november 2026 är 2027. Den 1 januari är kalenderåret. Larmnummer har en egen årsstart på sidan Inställningar för larmimport. Nummer för begäran om allmänna handlingar och nummer för tillsyner, tillstånd, objekt, utredningar och underlag följer samma år. + Nästa nummer för {0} ({1} till {2}) + Journalnumreringen sparades inte. Numreringsåret måste börja på en dag som finns varje år, så den 29 februari kan inte användas. + Andra journalnummer + Nummer för begäran om allmänna handlingar, objekt, tillsyner, tillstånd, utredningar och underlag. Lämna ett mönster tomt för att behålla de inbyggda numren som visas. Mönster använder {YYYY} eller {YY}, {MM}, {DD} och {SEQ} som larmnummer, följer numreringsårets början ovan och ryms i 32 tecken (50 för begäran om allmänna handlingar). + Inbyggt, t.ex. + Dessa mönster sparades inte eftersom de är ogiltiga eller kan skriva ett nummer som är för långt för journalen; numren behöll det de hade: Ett höjt nästa nummer kan inte sänkas igen. Spara ändringarna? Alla journaltyper Ingen station/grupp diff --git a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx index 0132d54f1..5221f7f04 100644 --- a/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Records/Records.uk.resx @@ -123,7 +123,18 @@ Наступний номер Підвищити наступний до Наступний номер можна лише підвищити. Ці послідовності залишено без змін, бо введене число менше за номер, який вони видадуть наступним: - Шаблон нумерації або префікс типу запису змінено, тому введені наступні номери не застосовано. Перегляньте нові послідовності та збережіть ще раз. + Шаблон нумерації, префікс типу запису або початок року нумерації змінено, тому введені наступні номери не застосовано. Перегляньте нові послідовності та збережіть ще раз. + Рік нумерації починається + День місяця + Називається за роком, у якому закінчується + Називається за роком, у якому починається + Для записів, що нумеруються за фінансовим роком — вашим власним або округу чи штату, якому ви звітуєте, — виберіть день, коли він починається. Номери з роком знову починаються з 1 цього дня, а {YYYY} і {YY} записують назву фінансового року. Більшість фінансових років називають за роком, у якому вони закінчуються, тож рік, що починається 1 листопада 2026, — це 2027. 1 січня — календарний рік. Номери викликів мають власний початок року на сторінці «Налаштування імпорту викликів». Номери запитів на доступ до документів і номери перевірок, дозволів, об’єктів, розслідувань та доказів слідують тому самому року. + Наступні номери на {0} рік (з {1} по {2}) + Нумерацію записів не збережено. Рік нумерації має починатися в день, який є в кожному році, тож 29 лютого використати не можна. + Інші номери записів + Номери запитів на доступ до документів, об’єктів, перевірок, дозволів, розслідувань і доказів. Залиште шаблон порожнім, щоб зберегти показані вбудовані номери. Шаблони використовують {YYYY} або {YY}, {MM}, {DD} і {SEQ}, як номери викликів, слідують початку року нумерації вище й вміщуються у 32 символи (50 для запитів на доступ). + Вбудований, напр. + Ці шаблони не збережено, бо вони недійсні або можуть записати номер, задовгий для запису; ці номери зберегли попередній вигляд: Підвищений наступний номер не можна знову знизити. Зберегти ці зміни? Усі типи записів Без станції/групи diff --git a/Core/Resgrid.Localization/Common.ar.resx b/Core/Resgrid.Localization/Common.ar.resx index 8ef6ff499..a7d090dbb 100644 --- a/Core/Resgrid.Localization/Common.ar.resx +++ b/Core/Resgrid.Localization/Common.ar.resx @@ -22,6 +22,7 @@ بلاغ عنوان البلاغ إعدادات استيراد البلاغات + ترقيم المستندات البلاغات طابع وقت البلاغ إلغاء diff --git a/Core/Resgrid.Localization/Common.de.resx b/Core/Resgrid.Localization/Common.de.resx index ec091957f..f0b99742f 100644 --- a/Core/Resgrid.Localization/Common.de.resx +++ b/Core/Resgrid.Localization/Common.de.resx @@ -110,6 +110,9 @@ Einsatz-Importeinstellungen + + Dokumentnummerierung + Einsätze diff --git a/Core/Resgrid.Localization/Common.el.resx b/Core/Resgrid.Localization/Common.el.resx index 57ff22e54..54c0b4b9a 100644 --- a/Core/Resgrid.Localization/Common.el.resx +++ b/Core/Resgrid.Localization/Common.el.resx @@ -168,6 +168,9 @@ Ρυθμίσεις Εισαγωγής Κλήσεων + + Αρίθμηση εγγράφων + Κλήσεις diff --git a/Core/Resgrid.Localization/Common.en.resx b/Core/Resgrid.Localization/Common.en.resx index a5695b8cc..a9dff8a51 100644 --- a/Core/Resgrid.Localization/Common.en.resx +++ b/Core/Resgrid.Localization/Common.en.resx @@ -168,6 +168,9 @@ Call Import Settings + + Document Numbering + Calls diff --git a/Core/Resgrid.Localization/Common.es.resx b/Core/Resgrid.Localization/Common.es.resx index e128a9f14..15f63ab3c 100644 --- a/Core/Resgrid.Localization/Common.es.resx +++ b/Core/Resgrid.Localization/Common.es.resx @@ -168,6 +168,9 @@ Configuración de importación de llamadas + + Numeración de documentos + Llamadas diff --git a/Core/Resgrid.Localization/Common.fr.resx b/Core/Resgrid.Localization/Common.fr.resx index 7feab3be8..a4ab32d76 100644 --- a/Core/Resgrid.Localization/Common.fr.resx +++ b/Core/Resgrid.Localization/Common.fr.resx @@ -110,6 +110,9 @@ Paramètres d'importation d'appels + + Numérotation des documents + Appels diff --git a/Core/Resgrid.Localization/Common.it.resx b/Core/Resgrid.Localization/Common.it.resx index 91bec751d..a1a1e516b 100644 --- a/Core/Resgrid.Localization/Common.it.resx +++ b/Core/Resgrid.Localization/Common.it.resx @@ -110,6 +110,9 @@ Impostazioni importazione chiamate + + Numerazione dei documenti + Chiamate diff --git a/Core/Resgrid.Localization/Common.pl.resx b/Core/Resgrid.Localization/Common.pl.resx index 3b63be82b..9c087768d 100644 --- a/Core/Resgrid.Localization/Common.pl.resx +++ b/Core/Resgrid.Localization/Common.pl.resx @@ -110,6 +110,9 @@ Ustawienia importu zgłoszeń + + Numeracja dokumentów + Zgłoszenia diff --git a/Core/Resgrid.Localization/Common.sv.resx b/Core/Resgrid.Localization/Common.sv.resx index 370f6225a..b68bb5eef 100644 --- a/Core/Resgrid.Localization/Common.sv.resx +++ b/Core/Resgrid.Localization/Common.sv.resx @@ -110,6 +110,9 @@ Inställningar för larmimport + + Dokumentnumrering + Larm diff --git a/Core/Resgrid.Localization/Common.uk.resx b/Core/Resgrid.Localization/Common.uk.resx index cab676a30..23d7a8b5d 100644 --- a/Core/Resgrid.Localization/Common.uk.resx +++ b/Core/Resgrid.Localization/Common.uk.resx @@ -110,6 +110,9 @@ Налаштування імпорту викликів + + Нумерація документів + Виклики diff --git a/Core/Resgrid.Model/CallNumbering.cs b/Core/Resgrid.Model/CallNumbering.cs index c4fd3d775..0d282a154 100644 --- a/Core/Resgrid.Model/CallNumbering.cs +++ b/Core/Resgrid.Model/CallNumbering.cs @@ -22,6 +22,29 @@ public class CallNumberingConfig /// Zero-padded width of the sequence part; 0 or 1 writes it unpadded, as the legacy numbers are. [ProtoMember(2)] public int SequenceWidth { get; set; } + + /// + /// Month (1-12) the numbering year starts, for a department numbering calls by a fiscal year; 0, unset, is January. + /// Only a pattern that restarts yearly uses it: {MM} and {DD} always write the calendar date. + /// + [ProtoMember(3)] + public int YearStartMonth { get; set; } + + /// Day of the numbering year starts; 0, unset, is the 1st. + [ProtoMember(4)] + public int YearStartDay { get; set; } + + /// + /// value: which year {YYYY} and {YY} write for a numbering year that does not start on + /// January 1. 0 is the year it ends in. + /// + [ProtoMember(5)] + public int YearLabel { get; set; } + + public NumberingYearStart YearStart() + { + return new NumberingYearStart(YearStartMonth, YearStartDay, (NumberingYearLabel)YearLabel); + } } /// When a call number pattern starts its sequence again: whenever the date text it writes changes. @@ -39,7 +62,7 @@ public enum CallNumberResetPeriod /// public sealed class CallNumberScope { - public CallNumberScope(string prefix, string suffix, int width, CallNumberResetPeriod period, DateTime? periodStart, DateTime? periodEnd) + public CallNumberScope(string prefix, string suffix, int width, CallNumberResetPeriod period, DateTime? periodStart, DateTime? periodEnd, int? year = null) { Prefix = prefix ?? string.Empty; Suffix = suffix ?? string.Empty; @@ -47,6 +70,7 @@ public CallNumberScope(string prefix, string suffix, int width, CallNumberResetP Period = period; PeriodStart = periodStart; PeriodEnd = periodEnd; + Year = year; } public string Prefix { get; } @@ -63,6 +87,9 @@ public CallNumberScope(string prefix, string suffix, int width, CallNumberResetP /// Department-local, exclusive end of the period; null when the pattern never restarts. public DateTime? PeriodEnd { get; } + /// The year {YYYY} and {YY} write for the scope (a fiscal year's name when the numbering year starts later than January 1); null when the pattern has no year. + public int? Year { get; } + /// Stable identity of the sequence; the counter row is keyed by it. '#' never appears in a pattern literal. public string Key => Prefix + "#" + Suffix; @@ -74,6 +101,7 @@ public CallNumberScope(string prefix, string suffix, int width, CallNumberResetP /// (letters, digits, '-', '_', '.' and '/') around tokens: {YYYY} or {YY} the year, {MM} the month, {DD} the day of the /// month, and {SEQ} the sequence, which must appear exactly once. Dates are the department's local date of the call. /// {MM} needs a year token and {DD} needs {MM}, so a sequence never runs on across the same month of different years. + /// A department numbering by a fiscal year sets the day its numbering year starts (). /// public static class CallNumberFormat { @@ -136,10 +164,18 @@ public static CallNumberResetPeriod ResetPeriod(string pattern) return CallNumberResetPeriod.Never; } - /// The sequence scope a call logged at (department time) falls in. - public static CallNumberScope Resolve(string pattern, int width, DateTime localDate) + /// + /// The sequence scope a call logged at (department time) falls in. A pattern that restarts + /// yearly runs from (January 1 when null) and writes that numbering year's name; one with + /// {MM} or {DD} writes and restarts on the calendar date, so a number such as 20270115-3 always reads as the day it was logged. + /// + public static CallNumberScope Resolve(string pattern, int width, DateTime localDate, NumberingYearStart yearStart = null) { var parts = Parse(pattern) ?? throw new ArgumentException("The call number pattern is not valid.", nameof(pattern)); + var period = ResetPeriod(pattern); + yearStart = period == CallNumberResetPeriod.Yearly ? yearStart ?? NumberingYearStart.Calendar : NumberingYearStart.Calendar; + var year = yearStart.YearOf(localDate); + var prefix = new StringBuilder(); var suffix = new StringBuilder(); var target = prefix; @@ -151,15 +187,14 @@ public static CallNumberScope Resolve(string pattern, int width, DateTime localD continue; } - target.Append(part.Token == null ? part.Literal : Render(part.Token, localDate)); + target.Append(part.Token == null ? part.Literal : Render(part.Token, localDate, year)); } - var period = ResetPeriod(pattern); DateTime? start = null, end = null; switch (period) { case CallNumberResetPeriod.Yearly: - start = new DateTime(localDate.Year, 1, 1, 0, 0, 0, DateTimeKind.Unspecified); + start = yearStart.StartOf(localDate); end = start.Value.AddYears(1); break; case CallNumberResetPeriod.Monthly: @@ -172,15 +207,15 @@ public static CallNumberScope Resolve(string pattern, int width, DateTime localD break; } - return new CallNumberScope(prefix.ToString(), suffix.ToString(), EffectiveWidth(width), period, start, end); + return new CallNumberScope(prefix.ToString(), suffix.ToString(), EffectiveWidth(width), period, start, end, period == CallNumberResetPeriod.Never ? (int?)null : year); } - private static string Render(string token, DateTime localDate) + private static string Render(string token, DateTime localDate, int year) { switch (token) { - case YearToken: return localDate.Year.ToString("D4", CultureInfo.InvariantCulture); - case ShortYearToken: return (localDate.Year % 100).ToString("D2", CultureInfo.InvariantCulture); + case YearToken: return year.ToString("D4", CultureInfo.InvariantCulture); + case ShortYearToken: return (year % 100).ToString("D2", CultureInfo.InvariantCulture); case MonthToken: return localDate.Month.ToString("D2", CultureInfo.InvariantCulture); case DayToken: return localDate.Day.ToString("D2", CultureInfo.InvariantCulture); default: return string.Empty; @@ -280,6 +315,15 @@ public class CallNumberingUpdate /// The sequence to raise the current scope's next number to; null leaves it as it is. public int? NextSequence { get; set; } + + /// Month the numbering year starts (1-12); null leaves the saved one as it is. + public int? YearStartMonth { get; set; } + + /// Day of the month the numbering year starts; null leaves the saved one as it is. + public int? YearStartDay { get; set; } + + /// value; null leaves the saved one as it is. + public int? YearLabel { get; set; } } public class CallNumberingSaveResult @@ -287,6 +331,9 @@ public class CallNumberingSaveResult /// The pattern did not validate; nothing was saved. public bool PatternRejected { get; set; } + /// The year start is not a month and day every year has (February 29 included); nothing was saved. + public bool YearStartRejected { get; set; } + /// Set when the requested next number was below what the sequence already issues next; the sequence was left as it was. public CallNumberSequenceStatus BelowCurrent { get; set; } diff --git a/Core/Resgrid.Model/CallStatusAttribution.cs b/Core/Resgrid.Model/CallStatusAttribution.cs index f24903295..ed759b1db 100644 --- a/Core/Resgrid.Model/CallStatusAttribution.cs +++ b/Core/Resgrid.Model/CallStatusAttribution.cs @@ -12,8 +12,10 @@ namespace Resgrid.Model /// 1. a destination the client sent is kept as is; /// 2. otherwise the call of the unit's/person's previous status is kept when that call is still open and the /// previous status wasn't a clearing one (the new status may itself be clearing: that records the clear time); - /// 3. otherwise, for a non-clearing status, the one open call the unit/person is dispatched to (other than a call - /// their previous, clearing status already cleared); more than one candidate leaves the status unlinked. + /// 3. otherwise, for a non-clearing status, the one open call the unit/person is dispatched to, other than what their + /// previous, clearing status already cleared (): the call it points at, or, when it + /// points at no call (back in quarters, available at a station), every call dispatched to them before it; more than + /// one candidate leaves the status unlinked. /// Rules 2 and 3 read the unit's/person's state now, so they only run for a status set now (); /// an offline status replayed later is left for the read-time walk, which places it by its own time. /// @@ -97,6 +99,49 @@ public static bool CanCarryForward(int? previousCallId, bool previousIsClearing, return candidates.Count == 1 ? candidates[0] : (int?)null; } + /// + /// Rule 3 over the unit's/person's open dispatches with their times: + /// after leaving out what the previous status already cleared (). A call dispatched to + /// them more than once (directly and through a group, say) counts when any of those dispatches is after the clear. + /// + /// The unit's/person's dispatches to open calls. + /// A dispatch's call. + /// When the dispatch went out: its latest redispatch when there was one. + /// The call the previous status cleared, or null. + /// When the previous status cleared every call dispatched before it, or null. + public static int? PickDispatchCall(IEnumerable openDispatches, Func callId, Func dispatchedOn, + int? clearedCallId, DateTime? clearedAllAt) where T : class + { + if (openDispatches == null) + return null; + + return PickDispatchCall(openDispatches + .Where(x => x != null && (!clearedAllAt.HasValue || dispatchedOn(x) > clearedAllAt.Value)) + .Select(callId), clearedCallId); + } + + /// + /// What a unit's/person's previous status already cleared, for rule 3. A clearing status cleared the call it points + /// at. One that points at no call (back in quarters, available at a station, no destination, or an untyped legacy + /// destination that may be a station) cleared every call they had been dispatched to by then, so only a dispatch or + /// redispatch after it is new work. A status that was not clearing, or no previous status, cleared nothing. + /// + /// Whether the previous status was a clearing one. + /// The previous status's destination id. + /// The previous status's destination type (). + /// The previous status's time, or null when there was no previous status. + public static (int? ClearedCallId, DateTime? ClearedAllAt) ClearedByPrevious(bool previousIsClearing, int? destinationId, + int? destinationType, DateTime? timestamp) + { + if (!previousIsClearing) + return (null, null); + + var clearedCallId = CallStatusLinkage.LinkedCallId(destinationId, destinationType); + var pointsAtCall = clearedCallId.HasValue && destinationType == (int)DestinationEntityTypes.Call; + + return (clearedCallId, pointsAtCall ? null : timestamp); + } + /// /// The inferred statuses one dispatched unit or person contributes to a call's record. /// diff --git a/Core/Resgrid.Model/CallStatusLinkage.cs b/Core/Resgrid.Model/CallStatusLinkage.cs index 5c309c74b..f8a64c36f 100644 --- a/Core/Resgrid.Model/CallStatusLinkage.cs +++ b/Core/Resgrid.Model/CallStatusLinkage.cs @@ -113,8 +113,12 @@ public static bool BelongsToCall(this ActionLog actionLog, IReadOnlyDictionary BuildBaseTypeMap(IEnumerable cus } /// - /// True when a unit state ends the unit's involvement in whatever it was working: back in service, out of - /// service, returning, released or cancelled. Custom statuses resolve through their base type. + /// True when a unit state ends the unit's involvement in whatever it was working: back in service (custom base types + /// Available, In Quarters, Completed and On Patrol), out of service, returning, released or cancelled. Custom statuses + /// resolve through their base type. /// public static bool IsClearingUnitState(int rawState, IReadOnlyDictionary customBaseTypes) { @@ -201,7 +206,8 @@ public static bool IsClearingUnitState(int rawState, IReadOnlyDictionary /// True when a personnel status ends the person's involvement: available / standing by, not responding, available - /// at a station, or a custom status whose base type means cleared, returning, unavailable or out of service. + /// at a station, or a custom status whose base type means back in service (available, in quarters, completed, on + /// patrol), cleared, returning, unavailable or out of service. /// Responding to a station is not clearing: volunteers respond to the station to pick up the apparatus. /// public static bool IsClearingPersonnelStatus(int rawStatus, IReadOnlyDictionary customBaseTypes) @@ -216,6 +222,8 @@ public static bool IsClearingPersonnelStatus(int rawStatus, IReadOnlyDictionary< { case ActionBaseTypes.Available: case ActionBaseTypes.InQuarters: + case ActionBaseTypes.Completed: + case ActionBaseTypes.OnPatrol: case ActionBaseTypes.NotResponding: case ActionBaseTypes.Cleared: case ActionBaseTypes.Returning: diff --git a/Core/Resgrid.Model/DepartmentSettingTypes.cs b/Core/Resgrid.Model/DepartmentSettingTypes.cs index 1abc29c4a..bd55321bc 100644 --- a/Core/Resgrid.Model/DepartmentSettingTypes.cs +++ b/Core/Resgrid.Model/DepartmentSettingTypes.cs @@ -157,5 +157,12 @@ public enum DepartmentSettingTypes /// dispatch keeps the call open. Missing = false (dispatchers close calls). /// CloseCallWhenUnitsClear = 116, + + /// + /// ProtoBuf-serialized : the department's own number patterns for work orders, + /// invoices, bids and daily time reports, and the day their numbering year starts (Department -> Document Numbering). + /// Missing = the built-in numbers. Custom sequences are counted in DocumentNumberSequences. + /// + DocumentNumberingConfig = 117, } } diff --git a/Core/Resgrid.Model/DocumentNumberSequence.cs b/Core/Resgrid.Model/DocumentNumberSequence.cs new file mode 100644 index 000000000..7be81d1b2 --- /dev/null +++ b/Core/Resgrid.Model/DocumentNumberSequence.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace Resgrid.Model +{ + /// + /// One department document-number sequence (registry M0268): the last sequence issued for a document kind under a custom + /// pattern in one , and the department's raised starting point for it. Built-in numbers keep + /// their own counters (InvoiceNumberSequences, RmsPreventionSequences ...); only the repository's atomic statements write it. + /// + public class DocumentNumberSequence : IEntity + { + [Required] + public int DepartmentId { get; set; } + + /// A key. + [Required] + public string Kind { get; set; } + + /// , e.g. "INV-2027-#". + [Required] + public string ScopeKey { get; set; } + + /// The last sequence issued; the next document receives one more. + public int LastSequence { get; set; } + + /// The department's raised next number for this scope (0 = none). + public int FloorSequence { get; set; } + + public DateTime? FloorSetOn { get; set; } + + public string FloorSetByUserId { get; set; } + + public DateTime ModifiedOn { get; set; } + + [NotMapped] + public string TableName => "DocumentNumberSequences"; + + [NotMapped] + public string IdName => "DepartmentId"; + + [NotMapped] + public int IdType => 0; + + [NotMapped] + [JsonIgnore] + public object IdValue + { + get { return DepartmentId; } + set { DepartmentId = (int)value; } + } + + [NotMapped] + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/DocumentNumbering.cs b/Core/Resgrid.Model/DocumentNumbering.cs new file mode 100644 index 000000000..e9615e479 --- /dev/null +++ b/Core/Resgrid.Model/DocumentNumbering.cs @@ -0,0 +1,316 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using ProtoBuf; +using Resgrid.Model.Invoicing; +using Resgrid.Model.WorkOrders; + +namespace Resgrid.Model +{ + /// + /// Department setting 117 (DocumentNumberingConfig): how the department's work order, invoice, bid and daily time report + /// numbers are written, and the day the numbering year starts for them (a fiscal year when it is not January 1). A kind + /// with no saved pattern keeps the numbers Resgrid has always issued for it. Records request and prevention numbers are + /// configured with the rest of Records numbering () and follow its + /// year start. The custom sequences are counted in DocumentNumberSequences. + /// + [ProtoContract] + public class DocumentNumberingConfig + { + public DocumentNumberingConfig() + { + Patterns = new List(); + } + + /// Month (1-12) the numbering year starts; 0, unset, is January. + [ProtoMember(1)] + public int YearStartMonth { get; set; } + + /// Day of the numbering year starts; 0, unset, is the 1st. + [ProtoMember(2)] + public int YearStartDay { get; set; } + + /// value: which year names a numbering year that does not start on January 1. 0 is the year it ends in. + [ProtoMember(3)] + public int YearLabel { get; set; } + + /// The department's own pattern per document kind; a kind with no entry keeps its built-in numbers. + [ProtoMember(4)] + public List Patterns { get; set; } + + public NumberingYearStart YearStart() + { + return new NumberingYearStart(YearStartMonth, YearStartDay, (NumberingYearLabel)YearLabel); + } + } + + /// One document kind's number pattern, in the call number grammar (see ). + [ProtoContract] + public class DocumentNumberPattern + { + /// A key, e.g. "work-order". + [ProtoMember(1)] + public string Kind { get; set; } + + /// Fixed text around {YYYY}/{YY}, {MM}, {DD} and {SEQ}, e.g. "MNT-{YY}-{SEQ}". + [ProtoMember(2)] + public string Pattern { get; set; } + + /// Zero-padded width of the sequence, 1 to 8. + [ProtoMember(3)] + public int SequenceWidth { get; set; } + } + + /// A numbered document kind: its key, the numbers Resgrid always issued for it and the room its column has. + public sealed class DocumentNumberKind + { + public DocumentNumberKind(string key, string legacyPattern, int legacyWidth, int maxLength, bool records) + { + Key = key; + LegacyPattern = legacyPattern; + LegacyWidth = legacyWidth; + MaxLength = maxLength; + IsRecords = records; + } + + public string Key { get; } + + /// The built-in numbers in the pattern grammar, e.g. "WO-{YYYY}-{SEQ}" with 6. + public string LegacyPattern { get; } + + public int LegacyWidth { get; } + + /// The longest number the kind's column holds; a pattern that could write longer is refused. + public int MaxLength { get; } + + /// Records kinds are saved with setting 72 and number by its year start; the others by setting 117's. + public bool IsRecords { get; } + } + + /// Every document kind whose number a department can format. + public static class DocumentNumberKinds + { + public const string WorkOrder = "work-order"; + public const string Invoice = "invoice"; + public const string Bid = "bid"; + public const string TimeReport = "time-report"; + public const string RecordsRequest = "records-request"; + public const string Occupancy = "occupancy"; + public const string Inspection = "inspection"; + public const string Permit = "permit"; + public const string Investigation = "investigation"; + public const string Evidence = "evidence"; + + public static readonly IReadOnlyList All = new[] + { + new DocumentNumberKind(WorkOrder, "WO-{YYYY}-{SEQ}", 6, 50, false), + new DocumentNumberKind(Invoice, "{SEQ}", 1, 50, false), + new DocumentNumberKind(Bid, "{SEQ}", 1, 50, false), + new DocumentNumberKind(TimeReport, "{SEQ}", 1, 50, false), + new DocumentNumberKind(RecordsRequest, "PRR-{YYYY}-{SEQ}", 4, 50, true), + new DocumentNumberKind(Occupancy, "OCC-{YYYY}-{SEQ}", 4, 32, true), + new DocumentNumberKind(Inspection, "INSP-{YYYY}-{SEQ}", 4, 32, true), + new DocumentNumberKind(Permit, "PRM-{YYYY}-{SEQ}", 4, 32, true), + new DocumentNumberKind(Investigation, "INV-{YYYY}-{SEQ}", 4, 32, true), + new DocumentNumberKind(Evidence, "EV-{YYYY}-{SEQ}", 4, 32, true) + }; + + public static DocumentNumberKind Find(string key) + { + return All.FirstOrDefault(k => string.Equals(k.Key, key, StringComparison.Ordinal)); + } + + /// The document kind of a sequence name (OCC, INSP, PRM, INV, EV). + public static string ForPreventionKind(string preventionKind) + { + switch (preventionKind) + { + case RmsPreventionNumberKinds.Occupancy: return Occupancy; + case RmsPreventionNumberKinds.Inspection: return Inspection; + case RmsPreventionNumberKinds.Permit: return Permit; + case RmsPreventionNumberKinds.Investigation: return Investigation; + case RmsPreventionNumberKinds.Evidence: return Evidence; + default: return null; + } + } + } + + /// + /// Document number patterns. They use the call number grammar (): fixed text (letters, digits, + /// '-', '_', '.', '/') around {YYYY} or {YY}, {MM}, {DD} and {SEQ}. A yearly pattern restarts on the numbering year start; + /// one with {MM} or {DD} on the calendar date. A pattern saved for a kind that matches its built-in numbers keeps them. + /// + public static class DocumentNumbering + { + private static readonly Regex AllDigits = new Regex("^[0-9]+$", RegexOptions.CultureInvariant); + + /// The longest text can write: date tokens at their width, {SEQ} at the largest sequence. + public static int MaxRenderedLength(string pattern) + { + var normalized = CallNumberFormat.Normalize(pattern); + if (normalized == null) + return int.MaxValue; + return normalized.Replace(CallNumberFormat.YearToken, "0000").Replace(CallNumberFormat.ShortYearToken, "00") + .Replace(CallNumberFormat.MonthToken, "00").Replace(CallNumberFormat.DayToken, "00") + .Replace(CallNumberFormat.SequenceToken, CallNumberFormat.MaxSequence.ToString(System.Globalization.CultureInfo.InvariantCulture)).Length; + } + + /// True when is in the grammar and every number it writes fits the kind's column. + public static bool IsValid(DocumentNumberKind kind, string pattern) + { + return kind != null && CallNumberFormat.IsValid(pattern?.Trim()) && MaxRenderedLength(pattern) <= kind.MaxLength; + } + + /// + /// The department's own pattern for , or null when it keeps the built-in numbers: no entry, an entry + /// that no longer validates, or one that is the built-in pattern and width. + /// + public static DocumentNumberPattern EffectivePattern(IEnumerable patterns, string kind) + { + var info = DocumentNumberKinds.Find(kind); + var saved = patterns?.FirstOrDefault(p => p != null && string.Equals(p.Kind, kind, StringComparison.Ordinal)); + if (info == null || saved == null || !IsValid(info, saved.Pattern)) + return null; + + var pattern = CallNumberFormat.Normalize(saved.Pattern); + var width = CallNumberFormat.EffectiveWidth(saved.SequenceWidth); + if (string.Equals(pattern, info.LegacyPattern, StringComparison.Ordinal) && width == info.LegacyWidth) + return null; + + return new DocumentNumberPattern { Kind = kind, Pattern = pattern, SequenceWidth = width }; + } + + /// Replaces a kind's entry; a null or blank pattern removes it, so the kind goes back to its built-in numbers. + public static void SetPattern(List patterns, string kind, string pattern, int width) + { + patterns.RemoveAll(p => p == null || string.Equals(p.Kind, kind, StringComparison.Ordinal)); + if (!string.IsNullOrWhiteSpace(pattern)) + patterns.Add(new DocumentNumberPattern { Kind = kind, Pattern = CallNumberFormat.Normalize(pattern), SequenceWidth = CallNumberFormat.EffectiveWidth(width) }); + } + + /// + /// How a number reads in a sentence or heading: a number of digits alone is written "#1042", as invoice, bid and time + /// report numbers always have been; any other number is shown as issued, e.g. "INV-2026-0001". + /// + public static string Display(string number) + { + if (string.IsNullOrEmpty(number)) + return number; + return AllDigits.IsMatch(number) ? "#" + number : number; + } + + /// The built-in text of a work order number, for rows issued before numbers were stored as text. + public static string LegacyWorkOrderNumber(int year, int sequence) + { + return "WO-" + year.ToString(System.Globalization.CultureInfo.InvariantCulture) + "-" + sequence.ToString("D6", System.Globalization.CultureInfo.InvariantCulture); + } + + /// A number made safe for a file name: anything other than letters, digits, '-', '_' and '.' becomes '-'. + public static string FileSafe(string number) + { + if (string.IsNullOrEmpty(number)) + return number; + var chars = number.Select(c => char.IsLetterOrDigit(c) || c == '-' || c == '_' || c == '.' ? c : '-').ToArray(); + return new string(chars).Trim('-'); + } + } + + /// + /// The number text of each numbered document (NumberText: as issued, for files, exports and search) and how it reads + /// on screen (NumberLabel: "#1042" for a plain number). Rows written before M0268 by an older server have no + /// DisplayNumber and read as their built-in number. + /// + public static class DocumentNumberExtensions + { + public static string NumberText(this Invoice invoice) => + invoice == null ? null : invoice.DisplayNumber ?? invoice.InvoiceNumber.ToString(System.Globalization.CultureInfo.InvariantCulture); + + public static string NumberText(this Bid bid) => + bid == null ? null : bid.DisplayNumber ?? bid.BidNumber.ToString(System.Globalization.CultureInfo.InvariantCulture); + + public static string NumberText(this DeploymentTimeReport report) => + report == null ? null : report.DisplayNumber ?? report.ReportNumber.ToString(System.Globalization.CultureInfo.InvariantCulture); + + public static string NumberText(this WorkOrder order) => + order == null ? null : order.DisplayNumber ?? DocumentNumbering.LegacyWorkOrderNumber(order.NumberYear, order.NumberSequence); + + public static string NumberLabel(this Invoice invoice) => DocumentNumbering.Display(invoice.NumberText()); + + public static string NumberLabel(this Bid bid) => DocumentNumbering.Display(bid.NumberText()); + + public static string NumberLabel(this DeploymentTimeReport report) => DocumentNumbering.Display(report.NumberText()); + } + + /// One document kind as a numbering screen shows it: its pattern and, for a custom pattern, what it issues next. + public class DocumentNumberStatus + { + public string Kind { get; set; } + + /// True when the department's own pattern numbers the kind; false while it keeps the built-in numbers. + public bool Custom { get; set; } + + public string Pattern { get; set; } + + public int SequenceWidth { get; set; } + + /// of the current sequence; null for the built-in numbers. + public string ScopeKey { get; set; } + + public CallNumberResetPeriod Period { get; set; } + + /// The sequence the next document receives; 0 for the built-in numbers. + public int NextSequence { get; set; } + + /// The full number the next document receives under a custom pattern; null for the built-in numbers. + public string NextNumber { get; set; } + + /// A sample number in the pattern for today (sequence 153), for the built-in numbers too. + public string Example { get; set; } + + public int FloorSequence { get; set; } + } + + /// A request to save one kind's pattern (and optionally raise its current sequence). + public class DocumentNumberPatternUpdate + { + public string Kind { get; set; } + + /// Blank returns the kind to its built-in numbers. + public string Pattern { get; set; } + + public int SequenceWidth { get; set; } + + /// The sequence shown on the screen when the raise was typed; a raise against any other scope is not applied. + public string ScopeKey { get; set; } + + public int? NextSequence { get; set; } + } + + public class DocumentNumberingUpdate + { + /// Null leaves the saved year start as it is (Records kinds take theirs from setting 72 and ignore these). + public int? YearStartMonth { get; set; } + + public int? YearStartDay { get; set; } + + public int? YearLabel { get; set; } + + public List Patterns { get; set; } = new List(); + } + + public class DocumentNumberingSaveResult + { + /// The year start is not a day every year has; nothing was saved. + public bool YearStartRejected { get; set; } + + /// Kinds whose pattern did not validate or does not fit the kind's column; those kinds kept what they had. + public List PatternsRejected { get; set; } = new List(); + + /// Raised next numbers below what the sequence already issues next; left unchanged. + public List BelowCurrent { get; set; } = new List(); + + /// Raised next numbers typed against a sequence the saved settings no longer produce (pattern or year start changed). + public int NotApplied { get; set; } + } +} diff --git a/Core/Resgrid.Model/Helpers/NewCallFieldPolicyValidator.cs b/Core/Resgrid.Model/Helpers/NewCallFieldPolicyValidator.cs index 879c8e965..bd2af1dae 100644 --- a/Core/Resgrid.Model/Helpers/NewCallFieldPolicyValidator.cs +++ b/Core/Resgrid.Model/Helpers/NewCallFieldPolicyValidator.cs @@ -38,6 +38,22 @@ public class NewCallFieldValues /// dispatcher who picks it up, and the dispatch time and dispatch list requirements do not apply. /// public bool IsPending { get; set; } + + /// + /// Fields this surface has no way to collect: their requirement is not enforced here. A requirement nobody can + /// meet does not make the call more complete, it stops calls being created or edited at all -- the protocols and + /// linked call on a client that has no picker for them, or the dispatch time on an edit of a call already sent. + /// + public ISet UnsupportedKeys { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + + /// Marks fields this surface cannot collect; see . + public NewCallFieldValues Unsupported(params string[] keys) + { + foreach (var key in keys) + UnsupportedKeys.Add(key); + + return this; + } } /// @@ -61,15 +77,17 @@ public static List Validate(NewCallFieldPolicy policy, Ne if (policy == null || policy.IsEmpty || values == null) return violations; + bool Enforced(string key) => policy.IsRequired(key) && !values.UnsupportedKeys.Contains(key); + void RequireText(string key, string value) { - if (policy.IsRequired(key) && string.IsNullOrWhiteSpace(value)) + if (Enforced(key) && string.IsNullOrWhiteSpace(value)) violations.Add(new NewCallFieldViolation { Key = key }); } void Require(string key, bool hasValue) { - if (policy.IsRequired(key) && !hasValue) + if (Enforced(key) && !hasValue) violations.Add(new NewCallFieldViolation { Key = key }); } diff --git a/Core/Resgrid.Model/Invoicing/ContractorBillingModels.cs b/Core/Resgrid.Model/Invoicing/ContractorBillingModels.cs index 2a2e0202a..64f59b7f6 100644 --- a/Core/Resgrid.Model/Invoicing/ContractorBillingModels.cs +++ b/Core/Resgrid.Model/Invoicing/ContractorBillingModels.cs @@ -415,6 +415,8 @@ public class Bid : IEntity [Required] public int DepartmentId { get; set; } public int BidNumber { get; set; } + /// The number as issued: the department's pattern (setting 117) or the built-in plain . Show it through . + public string DisplayNumber { get; set; } [Required] public string ContactId { get; set; } public string CustomerBillingProfileId { get; set; } @@ -514,7 +516,7 @@ public static class ContractorWorkflowPayload public static readonly (string Variable, string Property)[] BidVariables = { - ("id", "BidId"), ("number", "BidNumber"), ("title", "Title"), ("status", "Status"), ("old_status", "OldStatus"), ("contact_id", "ContactId"), ("contact_name", "ContactName"), + ("id", "BidId"), ("number", "BidNumber"), ("display_number", "DisplayNumber"), ("title", "Title"), ("status", "Status"), ("old_status", "OldStatus"), ("contact_id", "ContactId"), ("contact_name", "ContactName"), ("contract_id", "ServiceContractId"), ("incident_number", "IncidentNumber"), ("valid_until", "ValidUntil"), ("requested_start_on", "RequestedStartOn"), ("requested_end_on", "RequestedEndOn"), ("estimated_total", "EstimatedTotal"), ("currency", "Currency"), ("sent_on", "SentOn"), ("accepted_on", "AcceptedOn"), ("declined_on", "DeclinedOn"), ("converted_deployment_id", "ConvertedDeploymentId"), ("converted_call_id", "ConvertedCallId") }; diff --git a/Core/Resgrid.Model/Invoicing/DeploymentModels.cs b/Core/Resgrid.Model/Invoicing/DeploymentModels.cs index 2794acc79..5de01ced5 100644 --- a/Core/Resgrid.Model/Invoicing/DeploymentModels.cs +++ b/Core/Resgrid.Model/Invoicing/DeploymentModels.cs @@ -254,6 +254,8 @@ public class DeploymentTimeReport : IEntity public string DeploymentId { get; set; } public int DepartmentId { get; set; } public int ReportNumber { get; set; } + /// The number as issued: the department's pattern (setting 117) or the built-in plain . Show it through . + public string DisplayNumber { get; set; } public DateTime ReportDate { get; set; } /// Crew time report scope: the deployed unit whose crew and equipment this report covers (M0227). public string DeploymentUnitId { get; set; } @@ -452,7 +454,7 @@ public static readonly (string Variable, string Property)[] Variables = ("call_id", "CallId"), ("incident_number", "IncidentNumber"), ("resource_order_number", "ResourceOrderNumber"), ("request_number", "RequestNumber"), ("cost_code", "CostCode"), ("external_order_id", "RmsExternalOrderId"), ("contact_id", "ContactId"), ("start_on", "StartOn"), ("end_on", "EndOn"), ("subject_type", "SubjectType"), ("subject_id", "SubjectId"), ("subject_name", "SubjectName"), ("roster_action", "RosterAction"), - ("report_number", "ReportNumber"), ("report_date", "ReportDate"), ("report_id", "TimeReportId"), ("report_status", "ReportStatus"), + ("report_number", "ReportNumber"), ("report_display_number", "ReportDisplayNumber"), ("report_date", "ReportDate"), ("report_id", "TimeReportId"), ("report_status", "ReportStatus"), ("expense_type", "ExpenseType"), ("expense_amount", "ExpenseAmount"), ("expense_currency", "ExpenseCurrency"), ("attachment_id", "AttachmentId"), ("attachment_type", "AttachmentType"), ("attachment_name", "AttachmentName") }; diff --git a/Core/Resgrid.Model/Invoicing/Invoice.cs b/Core/Resgrid.Model/Invoicing/Invoice.cs index 84eb8e86e..680dc7595 100644 --- a/Core/Resgrid.Model/Invoicing/Invoice.cs +++ b/Core/Resgrid.Model/Invoicing/Invoice.cs @@ -20,6 +20,9 @@ public class Invoice : IEntity public int DepartmentId { get; set; } public int InvoiceNumber { get; set; } + /// The number as issued: the department's pattern (setting 117) or the built-in plain . Show it through . + public string DisplayNumber { get; set; } + [Required] public string CustomerBillingProfileId { get; set; } diff --git a/Core/Resgrid.Model/Invoicing/InvoiceWorkflowPayload.cs b/Core/Resgrid.Model/Invoicing/InvoiceWorkflowPayload.cs index 09c9f2bf0..71950b51b 100644 --- a/Core/Resgrid.Model/Invoicing/InvoiceWorkflowPayload.cs +++ b/Core/Resgrid.Model/Invoicing/InvoiceWorkflowPayload.cs @@ -12,7 +12,7 @@ public static class InvoiceWorkflowPayload /// (template variable, payload property) pairs; drives the variable catalog, context builder and sample data. public static readonly (string Variable, string Property)[] Variables = { - ("id", "InvoiceId"), ("number", "InvoiceNumber"), ("status", "Status"), ("contact_id", "ContactId"), ("contact_name", "ContactName"), + ("id", "InvoiceId"), ("number", "InvoiceNumber"), ("display_number", "DisplayNumber"), ("status", "Status"), ("contact_id", "ContactId"), ("contact_name", "ContactName"), ("currency", "Currency"), ("sub_total", "SubTotal"), ("discount_amount", "DiscountAmount"), ("tax_amount", "TaxAmount"), ("total", "Total"), ("amount_paid", "AmountPaid"), ("balance", "Balance"), ("issued_on", "IssuedOn"), ("due_on", "DueOn"), ("sent_on", "SentOn"), ("paid_on", "PaidOn"), ("payment_amount", "PaymentAmount"), ("payment_method", "PaymentMethod"), diff --git a/Core/Resgrid.Model/Invoicing/OnlinePaymentModels.cs b/Core/Resgrid.Model/Invoicing/OnlinePaymentModels.cs index ae1f722e7..9dcf7c939 100644 --- a/Core/Resgrid.Model/Invoicing/OnlinePaymentModels.cs +++ b/Core/Resgrid.Model/Invoicing/OnlinePaymentModels.cs @@ -276,6 +276,8 @@ public class PaymentRequestSpec public int DepartmentId { get; set; } public string InvoiceId { get; set; } public int InvoiceNumber { get; set; } + /// The invoice number as issued (the department's pattern or the plain number), for the payer-facing label. + public string InvoiceDisplayNumber { get; set; } public decimal Amount { get; set; } public string Currency { get; set; } public string CustomerEmail { get; set; } @@ -374,6 +376,8 @@ public class PayPageModel public string InvoiceId { get; set; } public int DepartmentId { get; set; } public int InvoiceNumber { get; set; } + /// How the invoice number reads on the page: "#1042", or the department's pattern as issued. + public string InvoiceLabel { get; set; } public string DepartmentName { get; set; } public decimal AmountDue { get; set; } public string Currency { get; set; } diff --git a/Core/Resgrid.Model/NewCallFieldPolicy.cs b/Core/Resgrid.Model/NewCallFieldPolicy.cs index d6cd18514..37e6c52b8 100644 --- a/Core/Resgrid.Model/NewCallFieldPolicy.cs +++ b/Core/Resgrid.Model/NewCallFieldPolicy.cs @@ -57,6 +57,14 @@ public static class NewCallFieldKeys public static bool IsKnown(string key) => !string.IsNullOrWhiteSpace(key) && All.Any(x => string.Equals(x, key, StringComparison.OrdinalIgnoreCase)); + + /// + /// False for a field that can be shown or hidden but never required. A plus code is only a way of finding the + /// location; it is never stored on the call, so requiring it guarantees the crews nothing, and the web form has + /// no plus code input at all -- a requirement there blocked every call. Require GPS coordinates instead. + /// + public static bool CanBeRequired(string key) => + IsKnown(key) && !string.Equals(key, PlusCode, StringComparison.OrdinalIgnoreCase); } /// Visibility and requiredness for one built-in new-call field. @@ -75,7 +83,7 @@ public class NewCallFieldRule [ProtoMember(2)] public bool Visible { get; set; } = true; - /// True blocks call creation until the field has a value. + /// True blocks creating (or saving an edit to) a call until the field has a value. [ProtoMember(3)] public bool Required { get; set; } } @@ -111,8 +119,9 @@ public bool IsVisible(string key) } /// - /// True when the field must have a value. Hidden fields are never required, whatever the stored - /// rule says — otherwise a bad configuration would lock the department out of creating calls. + /// True when the field must have a value. Hidden fields, and fields that can never be required + /// (), are not required whatever the stored rule says — + /// otherwise a bad configuration would lock the department out of creating calls. /// public bool IsRequired(string key) { @@ -121,7 +130,7 @@ public bool IsRequired(string key) if (rule == null) return false; - return rule.Visible && rule.Required; + return rule.Visible && rule.Required && NewCallFieldKeys.CanBeRequired(key); } /// True when nothing is configured, i.e. stock behaviour. @@ -137,6 +146,14 @@ public NewCallFieldPolicy Normalize() .Where(x => NewCallFieldKeys.IsKnown(x?.Key)) .GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase) .Select(g => g.Last()) + .Select(x => + { + // A policy saved before plus code stopped being requirable keeps its visibility but loses the requirement. + if (x.Required && !NewCallFieldKeys.CanBeRequired(x.Key)) + x.Required = false; + + return x; + }) .Where(x => !x.Visible || x.Required) .ToList(); diff --git a/Core/Resgrid.Model/NumberingYear.cs b/Core/Resgrid.Model/NumberingYear.cs new file mode 100644 index 000000000..3b40a9b78 --- /dev/null +++ b/Core/Resgrid.Model/NumberingYear.cs @@ -0,0 +1,83 @@ +using System; + +namespace Resgrid.Model +{ + /// Which calendar year names a numbering year that does not start on January 1. + public enum NumberingYearLabel + { + /// The year it ends in, the usual fiscal-year convention: a year starting 1 November 2026 is 2027. + EndYear = 0, + + /// The year it starts in: a year starting 1 November 2026 is 2026. + StartYear = 1 + } + + /// + /// The day a department's numbering year starts: January 1, the calendar year, unless it numbers by a fiscal year (its own, + /// or a county's or state's it reports to) that starts on another month and day. Call numbering (setting 115) and record + /// numbering (setting 72) each carry their own, so calls can follow the calendar while reports follow a fiscal year. + /// Dates are department-local. + /// + public sealed class NumberingYearStart + { + public static readonly NumberingYearStart Calendar = new NumberingYearStart(1, 1, NumberingYearLabel.EndYear); + + /// + /// An unset (0) month or day reads as January or the 1st, so a department that never saved one numbers by the calendar + /// year; so does a saved date that no longer validates. + /// + public NumberingYearStart(int month, int day, NumberingYearLabel label) + { + month = month <= 0 ? 1 : month; + day = day <= 0 ? 1 : day; + if (!IsValid(month, day)) + { + month = 1; + day = 1; + } + + Month = month; + Day = day; + Label = label == NumberingYearLabel.StartYear ? NumberingYearLabel.StartYear : NumberingYearLabel.EndYear; + } + + public int Month { get; } + + public int Day { get; } + + public NumberingYearLabel Label { get; } + + public bool IsCalendarYear => Month == 1 && Day == 1; + + /// A month and day every year has. February 29 is refused, so the year starts on the same date every year. + public static bool IsValid(int month, int day) + { + return month >= 1 && month <= 12 && day >= 1 && day <= DateTime.DaysInMonth(2001, month); + } + + /// Local midnight of the day the numbering year containing started. + public DateTime StartOf(DateTime localDate) + { + var start = new DateTime(localDate.Year, Month, Day, 0, 0, 0, DateTimeKind.Unspecified); + return localDate < start ? start.AddYears(-1) : start; + } + + /// Local midnight of the day the numbering year named starts. + public DateTime StartOfYear(int year) + { + return new DateTime(NamedByStartYear ? year : year - 1, Month, Day, 0, 0, 0, DateTimeKind.Unspecified); + } + + /// + /// The year naming the numbering year that contains : the calendar year when it starts on + /// January 1, otherwise the year it ends or starts in, as says. + /// + public int YearOf(DateTime localDate) + { + var startYear = StartOf(localDate).Year; + return NamedByStartYear ? startYear : startYear + 1; + } + + private bool NamedByStartYear => IsCalendarYear || Label == NumberingYearLabel.StartYear; + } +} diff --git a/Core/Resgrid.Model/Records/RecordNumberFormat.cs b/Core/Resgrid.Model/Records/RecordNumberFormat.cs index c7ad89a35..3296934d1 100644 --- a/Core/Resgrid.Model/Records/RecordNumberFormat.cs +++ b/Core/Resgrid.Model/Records/RecordNumberFormat.cs @@ -35,8 +35,9 @@ public RecordNumberScope(string prefix, string suffix, int width) /// /// Department record-number patterns (setting 72, ). A pattern is literal /// text (letters, digits, '-', '_' and '.') around tokens: {PREFIX} the record type's prefix (RUN, TRN, INC... or the department's own), - /// {YYYY} or {YY} the year, {GROUP} the station/group as "G" plus its id, and {SEQ} the zero-padded sequence, - /// which must appear exactly once. A department with no saved pattern keeps the one its checkboxes always produced. + /// {YYYY} or {YY} the numbering year (a fiscal year's name when is later than + /// January 1), {GROUP} the station/group as "G" plus its id, and {SEQ} the zero-padded sequence, which must appear exactly + /// once. A department with no saved pattern keeps the one its checkboxes always produced. /// public static class RecordNumberFormat { diff --git a/Core/Resgrid.Model/Records/RecordsDepartmentSettings.cs b/Core/Resgrid.Model/Records/RecordsDepartmentSettings.cs index 107c0b334..981822840 100644 --- a/Core/Resgrid.Model/Records/RecordsDepartmentSettings.cs +++ b/Core/Resgrid.Model/Records/RecordsDepartmentSettings.cs @@ -20,13 +20,14 @@ public RecordsNumberingConfig() IncludeYear = true; Floors = new List(); Prefixes = new List(); + DocumentPatterns = new List(); } /// RmsNumberAssignment value; default OnFinalize so abandoned drafts leave no gaps. [ProtoMember(1)] public int NumberAssignment { get; set; } - /// Restart the sequence each calendar year (department time zone). + /// Restart the sequence each numbering year (department time zone; see ). [ProtoMember(2)] public bool ResetYearly { get; set; } @@ -63,6 +64,37 @@ public RecordsNumberingConfig() [ProtoMember(8)] public List Prefixes { get; set; } + /// + /// Month (1-12) the numbering year starts, for a department whose reports follow a fiscal year (its own, or a county's or + /// state's it reports to); 0, unset, is January. {YYYY}, {YY} and a definition's yearly reset all follow it. + /// + [ProtoMember(9)] + public int YearStartMonth { get; set; } + + /// Day of the numbering year starts; 0, unset, is the 1st. + [ProtoMember(10)] + public int YearStartDay { get; set; } + + /// + /// value: which year {YYYY} and {YY} write for a numbering year that does not start on + /// January 1. 0 is the year it ends in. + /// + [ProtoMember(11)] + public int YearLabel { get; set; } + + /// + /// The department's own patterns for the other numbers Records issues: records requests and the prevention and + /// investigation numbers (see ). A kind with no entry keeps its built-in numbers + /// (PRR-2026-0001, OCC-2026-0001 ...). They number by this setting's year start. + /// + [ProtoMember(12)] + public List DocumentPatterns { get; set; } + + public NumberingYearStart YearStart() + { + return new NumberingYearStart(YearStartMonth, YearStartDay, (NumberingYearLabel)YearLabel); + } + /// What {PREFIX} renders for a record type: the department's prefix, or the shipped default when it has none (or a saved one no longer validates). public string PrefixFor(string definitionKey) { diff --git a/Core/Resgrid.Model/Records/RecordsNumberingContracts.cs b/Core/Resgrid.Model/Records/RecordsNumberingContracts.cs index 348d99778..a42c4a5a1 100644 --- a/Core/Resgrid.Model/Records/RecordsNumberingContracts.cs +++ b/Core/Resgrid.Model/Records/RecordsNumberingContracts.cs @@ -37,13 +37,22 @@ public class RecordsNumberingUpdate public int SequenceWidth { get; set; } - /// The year the requested next numbers were shown for. + /// The numbering year the requested next numbers were shown for (a fiscal year's name when the year starts later than January 1). public int Year { get; set; } public List NextNumbers { get; set; } = new List(); /// Record type prefixes to save; null leaves every type's prefix as it is. A blank prefix returns the type to its default. public List Prefixes { get; set; } + + /// Month the numbering year starts (1-12); null leaves the saved one as it is. + public int? YearStartMonth { get; set; } + + /// Day of the month the numbering year starts; null leaves the saved one as it is. + public int? YearStartDay { get; set; } + + /// value; null leaves the saved one as it is. + public int? YearLabel { get; set; } } /// A request to set one system record type's {PREFIX}. @@ -59,10 +68,13 @@ public class RecordsNumberingSaveResult /// The pattern did not validate; nothing in the numbering setting was saved. public bool PatternRejected { get; set; } + /// The year start is not a month and day every year has (February 29 included); nothing in the numbering setting was saved. + public bool YearStartRejected { get; set; } + /// Requested next numbers below what the sequence would already issue; those sequences were left as they were. public List BelowCurrent { get; set; } = new List(); - /// Requested next numbers for a sequence the saved pattern no longer produces (the pattern or a prefix changed in the same save). + /// Requested next numbers for a sequence the saved pattern no longer produces (the pattern, a prefix or the year start changed in the same save). public int NotApplied { get; set; } /// Definition keys whose requested prefix did not validate; those types kept the prefix they had. diff --git a/Core/Resgrid.Model/Repositories/ICallDispatchUnitRepository.cs b/Core/Resgrid.Model/Repositories/ICallDispatchUnitRepository.cs index 78781b05f..5e3efd59f 100644 --- a/Core/Resgrid.Model/Repositories/ICallDispatchUnitRepository.cs +++ b/Core/Resgrid.Model/Repositories/ICallDispatchUnitRepository.cs @@ -19,12 +19,14 @@ public interface ICallDispatchUnitRepository: IRepository Task> GetCallUnitDispatchesByCallIdAsync(int callId); /// - /// Gets the ids of the department's open (active, not deleted) calls the unit is dispatched to. + /// Gets the unit's dispatches to the department's open (active, not deleted) calls. A scheduled call only counts once it + /// has gone out or its dispatch time has passed. /// - Task> GetOpenCallIdsForUnitAsync(int departmentId, int unitId); + Task> GetOpenCallUnitDispatchesForUnitAsync(int departmentId, int unitId); /// - /// Gets the unit dispatches of every open (active, not deleted) department call. + /// Gets the unit dispatches of every open (active, not deleted) department call. A scheduled call only counts once it + /// has gone out or its dispatch time has passed. /// Task> GetOpenCallUnitDispatchesForDepartmentAsync(int departmentId); diff --git a/Core/Resgrid.Model/Repositories/ICallDispatchesRepository.cs b/Core/Resgrid.Model/Repositories/ICallDispatchesRepository.cs index c0edae8f3..39afc7f41 100644 --- a/Core/Resgrid.Model/Repositories/ICallDispatchesRepository.cs +++ b/Core/Resgrid.Model/Repositories/ICallDispatchesRepository.cs @@ -27,10 +27,12 @@ public interface ICallDispatchesRepository: IRepository Task> GetCallDispatchesByCallIdAsync(int callId); /// - /// Gets the ids of the department's open (active, not deleted) calls the user is dispatched to, directly or through a - /// dispatched group they belong to or a dispatched role they hold. + /// Gets the user's dispatches to the department's open (active, not deleted) calls: direct, through a dispatched group + /// they belong to or a dispatched role they hold (), one row per dispatch, so a call + /// can appear more than once. is the latest redispatch when there was one; + /// the call times are not read. A scheduled call only counts once it has gone out or its dispatch time has passed. /// - Task> GetOpenCallIdsForUserAsync(int departmentId, string userId); + Task> GetOpenCallDispatchesForUserAsync(int departmentId, string userId); /// /// Gets the personnel dispatches of every department call logged in the range (UTC, inclusive). diff --git a/Core/Resgrid.Model/Repositories/ICallNumberSequencesRepository.cs b/Core/Resgrid.Model/Repositories/ICallNumberSequencesRepository.cs index 5700910ba..31e3b9148 100644 --- a/Core/Resgrid.Model/Repositories/ICallNumberSequencesRepository.cs +++ b/Core/Resgrid.Model/Repositories/ICallNumberSequencesRepository.cs @@ -35,6 +35,12 @@ public interface ICallNumberSequencesRepository /// Numbers of the department's deleted calls logged in [fromUtc, toUtc). Deleted calls keep their numbers, so renumbering never reuses them. Task> GetDeletedCallNumbersAsync(int departmentId, DateTime fromUtc, DateTime toUtc); + /// + /// Numbers of every department call logged in [fromUtc, toUtc), deleted calls included. Renumbering a numbering year holds the + /// numbers written around it, which can share its text when the department changed its year start. + /// + Task> GetCallNumbersAsync(int departmentId, DateTime fromUtc, DateTime toUtc); + /// /// Highest sequence written as prefix + digits + suffix into the department's call numbers, deleted calls included, limited to /// calls logged in [fromUtc, toUtc) when given. The sequence is read as a number, so a change of digit width never restarts it. diff --git a/Core/Resgrid.Model/Repositories/IDocumentNumberSequencesRepository.cs b/Core/Resgrid.Model/Repositories/IDocumentNumberSequencesRepository.cs new file mode 100644 index 000000000..5a97510d9 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IDocumentNumberSequencesRepository.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + /// Department document-number sequences for custom patterns (DocumentNumberSequences, M0268). Every write is one atomic statement per dialect. + public interface IDocumentNumberSequencesRepository + { + Task GetSequenceAsync(int departmentId, string kind, string scopeKey); + + /// + /// Takes the next sequence in a scope and returns it. A scope with no row yet starts at + 1, so the + /// caller seeds it with the highest sequence already written into a number of that scope. + /// + Task TakeNextAsync(int departmentId, string kind, string scopeKey, int seed, CancellationToken cancellationToken = default); + + /// Makes the scope issue at least next and records it as the raised starting point. Never lowers either. + Task RaiseFloorAsync(int departmentId, string kind, string scopeKey, int nextSequence, string userId, DateTime now, CancellationToken cancellationToken = default); + + /// + /// Highest sequence written as prefix + digits + suffix into the department's numbers of (the + /// kind's own number column; built-in and custom numbers alike, deleted rows included). The sequence is read as a number, + /// so a change of digit width never restarts it. 0 for an unknown kind. + /// + Task GetHighestIssuedAsync(int departmentId, string kind, string numberPrefix, string numberSuffix); + } +} diff --git a/Core/Resgrid.Model/Repositories/IInvoicingRepositories.cs b/Core/Resgrid.Model/Repositories/IInvoicingRepositories.cs index 431fed585..74074a8ef 100644 --- a/Core/Resgrid.Model/Repositories/IInvoicingRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IInvoicingRepositories.cs @@ -37,6 +37,8 @@ public class InvoiceAgingRow { public string InvoiceId { get; set; } public int InvoiceNumber { get; set; } + /// The number as issued (null on rows written before M0268 by an older server). + public string DisplayNumber { get; set; } public string ContactId { get; set; } public int Status { get; set; } public DateTime? DueOn { get; set; } diff --git a/Core/Resgrid.Model/Services/ICallNumberingService.cs b/Core/Resgrid.Model/Services/ICallNumberingService.cs index a42181d6c..b9d24b245 100644 --- a/Core/Resgrid.Model/Services/ICallNumberingService.cs +++ b/Core/Resgrid.Model/Services/ICallNumberingService.cs @@ -26,11 +26,12 @@ public interface ICallNumberingService Task SaveAsync(int departmentId, string userId, CallNumberingUpdate update, CancellationToken cancellationToken = default(CancellationToken)); /// - /// Renumbers the department's calls logged in a local year in logged order, each sequence starting at its raised starting - /// point and skipping numbers deleted calls still hold. Counters are moved past the year's numbers before any call is - /// rewritten, so a call created meanwhile never shares a number. False, and nothing renumbered, when the pattern never - /// restarts (a year cannot be renumbered on its own). + /// Renumbers the department's calls logged in the year containing in logged order: the numbering + /// year (a fiscal year when the department set its start) for a yearly pattern, the local calendar year for one with {MM} or + /// {DD}. Each sequence starts at its raised starting point and skips numbers deleted calls still hold. Counters are moved past + /// the year's numbers before any call is rewritten, so a call created meanwhile never shares a number. False, and nothing + /// renumbered, when the pattern never restarts (a year cannot be renumbered on its own). /// - Task RenumberCallsForYearAsync(int departmentId, int year, CancellationToken cancellationToken = default(CancellationToken)); + Task RenumberCallsForYearAsync(int departmentId, DateTime inYearUtc, CancellationToken cancellationToken = default(CancellationToken)); } } diff --git a/Core/Resgrid.Model/Services/ICallStatusAttributionService.cs b/Core/Resgrid.Model/Services/ICallStatusAttributionService.cs index 1afb07aa5..e70aba7eb 100644 --- a/Core/Resgrid.Model/Services/ICallStatusAttributionService.cs +++ b/Core/Resgrid.Model/Services/ICallStatusAttributionService.cs @@ -12,7 +12,8 @@ public interface ICallStatusAttributionService { /// /// Before a unit state is saved: marks a sent destination as explicit, or links the state to the previous state's - /// still-open call or to the one open call the unit is dispatched to; a state replayed from an offline queue is left + /// still-open call or to the one open call the unit is dispatched to that its previous, clearing state did not already + /// end (); a state replayed from an offline queue is left /// unlinked for the read-time walk. Never throws; a failure saves the state as sent. /// Task AttributeUnitStateAsync(UnitState state, UnitState previousState, int departmentId); @@ -20,7 +21,8 @@ public interface ICallStatusAttributionService /// /// Before a personnel status is saved: marks a sent destination as explicit, or links the status to the call of the /// unit state that placed the person on a unit, the previous status's still-open call, or the one open call the - /// person is dispatched to; a status replayed from an offline queue is only linked through its unit state. Never + /// person is dispatched to (directly, by group or by role) that their previous, clearing status did not already end; + /// a status replayed from an offline queue is only linked through its unit state. Never /// throws; a failure saves the status as sent. /// Task AttributeActionLogAsync(ActionLog actionLog, ActionLog previousActionLog); diff --git a/Core/Resgrid.Model/Services/ICallsService.cs b/Core/Resgrid.Model/Services/ICallsService.cs index 00ae209e2..10edbe0a8 100644 --- a/Core/Resgrid.Model/Services/ICallsService.cs +++ b/Core/Resgrid.Model/Services/ICallsService.cs @@ -22,14 +22,15 @@ public interface ICallsService Task SaveCallAsync(Call call, CancellationToken cancellationToken = default(CancellationToken)); /// - /// Renumbers the department's calls logged in a local year, in logged order, with the department's call number - /// pattern. False, and nothing renumbered, when the pattern has no year (its sequence spans years). + /// Renumbers the department's calls logged in the numbering year containing (a fiscal year when + /// the department set its start), in logged order, with the department's call number pattern. False, and nothing + /// renumbered, when the pattern has no year (its sequence spans years). /// /// The department identifier. - /// The local year to regenerate call numbers for. + /// A moment in the year to regenerate call numbers for, such as an archived call's logged time. /// The cancellation token that can be used by other objects or threads to receive notice of cancellation. /// Task<System.Boolean>. - Task RegenerateCallNumbersAsync(int departmentId, int year, + Task RegenerateCallNumbersAsync(int departmentId, DateTime inYearUtc, CancellationToken cancellationToken = default(CancellationToken)); /// diff --git a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs index 256e3d1d7..30674354e 100644 --- a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs @@ -122,6 +122,12 @@ Task SaveNewCallFieldPolicyAsync(int departmentId, NewCallFi /// Setting 115: the department's call number pattern and digits; a new (legacy) config when none is saved or the blob is corrupt. Task GetCallNumberingConfigAsync(int departmentId, bool bypassCache = false); + /// Setting 117: work order, invoice, bid and daily time report number patterns and their numbering year start. Missing = the built-in numbers. + Task GetDocumentNumberingConfigAsync(int departmentId, bool bypassCache = false); + + Task SetDocumentNumberingConfigAsync(int departmentId, DocumentNumberingConfig config, + CancellationToken cancellationToken = default(CancellationToken)); + Task SetCallNumberingConfigAsync(int departmentId, CallNumberingConfig config, CancellationToken cancellationToken = default(CancellationToken)); diff --git a/Core/Resgrid.Model/Services/IDocumentNumberingService.cs b/Core/Resgrid.Model/Services/IDocumentNumberingService.cs new file mode 100644 index 000000000..36bccbdc1 --- /dev/null +++ b/Core/Resgrid.Model/Services/IDocumentNumberingService.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Department document numbers (see ): work orders, invoices, bids and daily time reports + /// (setting 117) and the records request and prevention numbers (setting 72's DocumentPatterns). A kind with no pattern of + /// the department's own keeps its built-in numbers, which its service still allocates; a custom pattern takes its numbers + /// from DocumentNumberSequences. Both number by the numbering year (a fiscal year when the department starts one). + /// + public interface IDocumentNumberingService + { + /// + /// The numbering year falls in for , read in the department's time zone: + /// Records kinds by setting 72's year start, the others by setting 117's. The calendar year when none is set. + /// + Task GetNumberingYearAsync(int departmentId, string kind, DateTime utcDate); + + /// The department's own pattern for , or null when the kind keeps its built-in numbers. + Task GetPatternAsync(int departmentId, string kind, bool bypassCache = false); + + /// + /// Takes the next number in the department's pattern for a document dated . Null when the kind + /// keeps its built-in numbers, in which case the caller allocates them as it always has. Each number is issued once. + /// + Task TakeCustomNumberAsync(int departmentId, string kind, DateTime utcDate, CancellationToken cancellationToken = default); + + /// Every kind on one screen (Records kinds or the rest), with its pattern, sample and next number, without taking any. + Task> GetStatusesAsync(int departmentId, bool records, DateTime utcDate); + + /// + /// Saves setting 117: the year start and the work order, invoice, bid and daily time report patterns, then raises any + /// requested next numbers (each may only rise). A refused year start saves nothing; a refused pattern keeps the kind's old one. + /// + Task SaveAsync(int departmentId, string userId, DocumentNumberingUpdate update, CancellationToken cancellationToken = default); + + /// + /// Saves the Records kinds' patterns into setting 72 (records requests, occupancies, inspections, permits, investigations + /// and evidence) and raises any requested next numbers. Their year start is setting 72's own, saved with Records numbering. + /// + Task SaveRecordsPatternsAsync(int departmentId, string userId, List patterns, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IRecordsNumberingService.cs b/Core/Resgrid.Model/Services/IRecordsNumberingService.cs index 186e7c8b5..66d85be25 100644 --- a/Core/Resgrid.Model/Services/IRecordsNumberingService.cs +++ b/Core/Resgrid.Model/Services/IRecordsNumberingService.cs @@ -6,16 +6,21 @@ namespace Resgrid.Model.Services { /// /// Department record numbering (setting 72): the number pattern and the next number of each sequence it produces. - /// Allocation itself stays with the Records and incident report services; this is the administration side. + /// Allocation itself stays with the Records and incident report services; this is the administration side. Records request + /// and prevention number patterns are saved through . /// public interface IRecordsNumberingService { - /// Every sequence the pattern in produces in , with what each issues next. + /// + /// Every sequence the pattern in produces in the numbering year named + /// (see ), with what each issues next. + /// Task> GetSequencesAsync(int departmentId, RecordsNumberingConfig config, int year); /// - /// Saves the pattern and digit width, then raises each requested next number. A next number may only rise: one - /// below what its sequence would already issue is refused, so a department never re-issues a number it has used. + /// Saves the pattern, digit width and year start, then raises each requested next number. A next number may only rise: one + /// below what its sequence would already issue is refused, so a department never re-issues a number it has used. Next + /// numbers are not applied in a save that changes the year start, since they were entered against the old year's sequences. /// Task SaveAsync(int departmentId, string userId, RecordsNumberingUpdate update, CancellationToken cancellationToken = default(CancellationToken)); } diff --git a/Core/Resgrid.Model/UnitCallInvolvement.cs b/Core/Resgrid.Model/UnitCallInvolvement.cs index e49646697..6f763d8c1 100644 --- a/Core/Resgrid.Model/UnitCallInvolvement.cs +++ b/Core/Resgrid.Model/UnitCallInvolvement.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using Resgrid.Model.Reporting; @@ -53,14 +54,16 @@ public static bool HasFinishedCall(int rawState, IReadOnlyDictionary c /// /// The call a unit is working, which a new status sent without a destination is linked to (the same rules as the /// server's write-time attribution, ): the open call its latest status points at - /// unless that status already cleared it, otherwise the one open call it is dispatched to (ignoring the call it just - /// cleared). Null when there is none, or more than one dispatched call to choose from. + /// unless that status already cleared it, otherwise the one open call it is dispatched to, ignoring what its latest, + /// clearing status already cleared (: the call it points at, or + /// every call dispatched before a clear that points at no call). Null when there is none, or more than one dispatched + /// call to choose from. /// /// The unit's latest status, or null. /// Whether that status ended the unit's involvement (). /// The department's open (active) call ids. - /// The open calls the unit is dispatched to. - public static int? ResolveWorkingCallId(UnitState latest, bool latestIsClearing, ICollection openCallIds, IEnumerable openDispatchedCallIds) + /// The unit's dispatches to open calls. + public static int? ResolveWorkingCallId(UnitState latest, bool latestIsClearing, ICollection openCallIds, IEnumerable openDispatches) { var latestCallId = latest != null ? CallStatusLinkage.LinkedCallId(latest.DestinationId, latest.DestinationType) : null; @@ -71,10 +74,21 @@ public static bool HasFinishedCall(int rawState, IReadOnlyDictionary c if (latestCallId.HasValue && !latestIsClearing && openCallIds != null && openCallIds.Contains(latestCallId.Value)) return latestCallId.Value; - var dispatched = (openDispatchedCallIds ?? Enumerable.Empty()) - .Where(x => openCallIds == null || openCallIds.Contains(x)); + var (clearedCallId, clearedAllAt) = CallStatusAttribution.ClearedByPrevious(latestIsClearing && latest != null, + latest?.DestinationId, latest?.DestinationType, latest?.Timestamp); - return CallStatusAttribution.PickDispatchCall(dispatched, latestIsClearing ? latestCallId : null); + var dispatched = (openDispatches ?? Enumerable.Empty()) + .Where(x => x != null && (openCallIds == null || openCallIds.Contains(x.CallId))); + + return CallStatusAttribution.PickDispatchCall(dispatched, x => x.CallId, LatestDispatchTime, clearedCallId, clearedAllAt); + } + + /// + /// When a unit's dispatch to a call last went out: its latest redispatch, else the dispatch itself. + /// + public static DateTime LatestDispatchTime(CallDispatchUnit dispatch) + { + return dispatch.LastDispatchedOn ?? dispatch.DispatchedOn; } } } diff --git a/Core/Resgrid.Model/WorkOrders/WorkOrderModels.cs b/Core/Resgrid.Model/WorkOrders/WorkOrderModels.cs index ef7c72b8b..129b25195 100644 --- a/Core/Resgrid.Model/WorkOrders/WorkOrderModels.cs +++ b/Core/Resgrid.Model/WorkOrders/WorkOrderModels.cs @@ -40,6 +40,8 @@ public sealed class WorkOrder : WorkOrderRow public string RequestId { get; set; } public int NumberYear { get; set; } public int NumberSequence { get; set; } + /// The number as issued: the department's pattern (setting 117) or the built-in WO-{year}-{sequence:D6}; null only on rows written before M0268 by an older server. + public string DisplayNumber { get; set; } public int Type { get; set; } public int Priority { get; set; } public int Status { get; set; } diff --git a/Core/Resgrid.Services/CallAutoCloseService.cs b/Core/Resgrid.Services/CallAutoCloseService.cs index 5eafa6e57..6fa93b523 100644 --- a/Core/Resgrid.Services/CallAutoCloseService.cs +++ b/Core/Resgrid.Services/CallAutoCloseService.cs @@ -88,13 +88,13 @@ public async Task> CloseCallsFinishedByUnitStateAsync(int departmentId UnitCallInvolvement.HasFinishedCall(previous.State, baseTypes)) return closed; - var openDispatched = ((await _callDispatchUnitRepository.GetOpenCallIdsForUnitAsync(departmentId, saved.UnitId)) ?? Enumerable.Empty()) - .Distinct().ToList(); + var openDispatches = ((await _callDispatchUnitRepository.GetOpenCallUnitDispatchesForUnitAsync(departmentId, saved.UnitId)) ?? Enumerable.Empty()) + .Where(x => x != null).ToList(); - if (openDispatched.Count == 0) + if (openDispatches.Count == 0) return closed; - var callId = await ResolveFinishedCallIdAsync(saved, previous, openDispatched, now); + var callId = await ResolveFinishedCallIdAsync(saved, previous, openDispatches, now); if (!callId.HasValue) return closed; @@ -114,8 +114,10 @@ public async Task> CloseCallsFinishedByUnitStateAsync(int departmentId /// call of its latest status that pointed at one of its open dispatches (a transport leg in between may have pointed at /// a hospital). An old call the unit was never cleared from is not closed by a status set for a later call. /// - private async Task ResolveFinishedCallIdAsync(UnitState saved, UnitState previous, List openDispatched, DateTime now) + private async Task ResolveFinishedCallIdAsync(UnitState saved, UnitState previous, List openDispatches, DateTime now) { + var openDispatched = openDispatches.Select(x => x.CallId).Distinct().ToList(); + // A status pointing at a call the unit is not dispatched to (or one already closed) finished that call, not this one. var savedCallId = TypedCallId(saved); if (savedCallId.HasValue) @@ -128,17 +130,7 @@ public async Task> CloseCallsFinishedByUnitStateAsync(int departmentId if (openDispatched.Count == 1) return openDispatched[0]; - var earliestDispatch = now; - foreach (var openCallId in openDispatched) - { - var dispatch = ((await _callDispatchUnitRepository.GetCallUnitDispatchesByCallIdAsync(openCallId)) ?? Enumerable.Empty()) - .Where(x => x.UnitId == saved.UnitId) - .OrderBy(x => x.DispatchedOn) - .FirstOrDefault(); - - if (dispatch != null && dispatch.DispatchedOn < earliestDispatch) - earliestDispatch = dispatch.DispatchedOn; - } + var earliestDispatch = openDispatches.Select(x => x.DispatchedOn).Append(now).Min(); var history = (await _unitStatesRepository.GetAllUnitStatesForUnitInDateRangeAsync(saved.UnitId, earliestDispatch, now)) ?? Enumerable.Empty(); var lastCallId = history @@ -190,36 +182,48 @@ private async Task TryCloseAsync(int departmentId, int callId, UnitState s } // Two units clearing at the same moment would both see the call finished; only the first closes it. - var claim = await _cacheProvider.IncrementAsync(string.Format(CloseClaimCacheKey, callId), CloseClaimLength); + var claimKey = string.Format(CloseClaimCacheKey, callId); + var claim = await _cacheProvider.IncrementAsync(claimKey, CloseClaimLength); if (claim > 1) return false; - call = await _callsService.PopulateCallData(call, true, false, false, true, true, true, false, false, false); - - var closedByUserId = saved.SetByUserId; - if (string.IsNullOrWhiteSpace(closedByUserId)) - closedByUserId = (await _departmentsService.GetDepartmentByIdAsync(departmentId, false))?.ManagingUserId; - - var unit = await _unitsRepository.GetByIdAsync(saved.UnitId); - - call.State = (int)CallStates.Closed; - call.ClosedOn = now; - call.ClosedByUserId = closedByUserId; - call.CompletedNotes = unit != null && !string.IsNullOrWhiteSpace(unit.Name) - ? $"Call closed automatically: {unit.Name} was the last unit to clear." - : "Call closed automatically: the last unit cleared."; + Call savedCall; + try + { + call = await _callsService.PopulateCallData(call, true, false, false, true, true, true, false, false, false); + + var closedByUserId = saved.SetByUserId; + if (string.IsNullOrWhiteSpace(closedByUserId)) + closedByUserId = (await _departmentsService.GetDepartmentByIdAsync(departmentId, false))?.ManagingUserId; + + var unit = await _unitsRepository.GetByIdAsync(saved.UnitId); + + call.State = (int)CallStates.Closed; + call.ClosedOn = now; + call.ClosedByUserId = closedByUserId; + call.CompletedNotes = unit != null && !string.IsNullOrWhiteSpace(unit.Name) + ? $"Call closed automatically: {unit.Name} was the last unit to clear." + : "Call closed automatically: the last unit cleared."; + + // ADP protected write: CompletedNotes is cataloged. A blocked write leaves the call open for the dispatcher. + var protectedWrite = await _protectedWriteService.Value.PrepareCallWriteAsync(departmentId, call, null, null, closedByUserId, + workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + { + Logging.LogError($"Call {callId} not closed automatically: protected write blocked ({protectedWrite.Reason})."); + await _cacheProvider.RemoveAsync(claimKey); + return false; + } - // ADP protected write: CompletedNotes is cataloged. A blocked write leaves the call open for the dispatcher. - var protectedWrite = await _protectedWriteService.Value.PrepareCallWriteAsync(departmentId, call, null, null, closedByUserId, - workloadCaller: true, cancellationToken); - if (!protectedWrite.Success) + savedCall = await _callsService.SaveCallAsync(call, cancellationToken); + } + catch { - Logging.LogError($"Call {callId} not closed automatically: protected write blocked ({protectedWrite.Reason})."); - return false; + // The call is still open, so the next unit status may try again instead of waiting out the claim. + await _cacheProvider.RemoveAsync(claimKey); + throw; } - var savedCall = await _callsService.SaveCallAsync(call, cancellationToken); - _eventAggregator.SendMessage(new CallClosedEvent { DepartmentId = departmentId, Call = savedCall }); // Units that cleared keep their statuses (the release skips units back in service or out of service); shift diff --git a/Core/Resgrid.Services/CallNumberingService.cs b/Core/Resgrid.Services/CallNumberingService.cs index 653af0e1f..a0f64210e 100644 --- a/Core/Resgrid.Services/CallNumberingService.cs +++ b/Core/Resgrid.Services/CallNumberingService.cs @@ -70,8 +70,20 @@ public async Task GetNextAsync(int departmentId, CallN } var config = await _settings.GetCallNumberingConfigAsync(departmentId, true) ?? new CallNumberingConfig(); + var saved = config.YearStart(); + var month = update.YearStartMonth ?? saved.Month; + var day = update.YearStartDay ?? saved.Day; + if (!NumberingYearStart.IsValid(month, day)) + { + result.YearStartRejected = true; + return result; + } + config.Pattern = pattern; config.SequenceWidth = CallNumberFormat.EffectiveWidth(update.SequenceWidth); + config.YearStartMonth = month; + config.YearStartDay = day; + config.YearLabel = (int)new NumberingYearStart(month, day, (NumberingYearLabel)(update.YearLabel ?? (int)saved.Label)).Label; await _settings.SetCallNumberingConfigAsync(departmentId, config, cancellationToken); if (update.NextSequence.HasValue) @@ -89,22 +101,40 @@ public async Task GetNextAsync(int departmentId, CallN return result; } - public async Task RenumberCallsForYearAsync(int departmentId, int year, CancellationToken cancellationToken = default(CancellationToken)) + public async Task RenumberCallsForYearAsync(int departmentId, DateTime inYearUtc, CancellationToken cancellationToken = default(CancellationToken)) { var config = await _settings.GetCallNumberingConfigAsync(departmentId, true); var pattern = CallNumberFormat.EffectivePattern(config); - if (CallNumberFormat.ResetPeriod(pattern) == CallNumberResetPeriod.Never) + var period = CallNumberFormat.ResetPeriod(pattern); + if (period == CallNumberResetPeriod.Never) return false; + // The numbering year for a yearly pattern (a fiscal year when the department set one), the calendar year for a pattern + // with {MM} or {DD}: either way every scope inside it is renumbered whole. var timeZone = await TimeZoneAsync(departmentId); - var start = ToUtc(new DateTime(year, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), timeZone); - var end = ToUtc(new DateTime(year + 1, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), timeZone); + var yearStart = period == CallNumberResetPeriod.Yearly ? config.YearStart() : NumberingYearStart.Calendar; + var localStart = yearStart.StartOf(ToLocal(inYearUtc, timeZone)); + var start = ToUtc(localStart, timeZone); + var end = ToUtc(localStart.AddYears(1), timeZone); var calls = (await _calls.GetAllCallsByDepartmentDateRangeAsync(departmentId, start, end) ?? Enumerable.Empty()) .Where(c => c.LoggedOn < end).OrderBy(c => c.LoggedOn).ThenBy(c => c.CallId).ToList(); // Deleted calls keep their numbers, so no active call is renumbered onto one of them. var held = new HashSet(await _sequences.GetDeletedCallNumbersAsync(departmentId, start, end) ?? new List(), StringComparer.Ordinal); + // So do the calls either side of a yearly period inside the window its name can also have been written in (see + // HighestIssuedAsync): after a year start change, last year's calendar numbers can read the same as this fiscal year's. + if (period == CallNumberResetPeriod.Yearly) + { + var year = yearStart.YearOf(localStart); + var windowStart = ToUtc(new DateTime(year - 1, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), timeZone); + var windowEnd = ToUtc(new DateTime(year + 2, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), timeZone); + if (windowStart < start) + held.UnionWith(await _sequences.GetCallNumbersAsync(departmentId, windowStart, start) ?? new List()); + if (end < windowEnd) + held.UnionWith(await _sequences.GetCallNumbersAsync(departmentId, end, windowEnd) ?? new List()); + } + // Every number is worked out before any call changes. Each sequence starts again at its raised starting point, so a // department continuing earlier numbers keeps them. var issued = new Dictionary(StringComparer.Ordinal); @@ -160,15 +190,28 @@ public async Task GetNextAsync(int departmentId, CallN private static CallNumberScope Resolve(CallNumberingConfig config, DateTime utcDate, string timeZone) { - var local = string.IsNullOrWhiteSpace(timeZone) ? utcDate : DateTimeHelpers.GetLocalDateTime(utcDate, timeZone); - return CallNumberFormat.Resolve(CallNumberFormat.EffectivePattern(config), config?.SequenceWidth ?? 0, local); + return CallNumberFormat.Resolve(CallNumberFormat.EffectivePattern(config), config?.SequenceWidth ?? 0, ToLocal(utcDate, timeZone), config?.YearStart()); } private Task HighestIssuedAsync(int departmentId, CallNumberScope scope, string timeZone) { - DateTime? from = scope.PeriodStart.HasValue ? ToUtc(scope.PeriodStart.Value, timeZone) : (DateTime?)null; - DateTime? to = scope.PeriodEnd.HasValue ? ToUtc(scope.PeriodEnd.Value, timeZone) : (DateTime?)null; - return _sequences.GetHighestIssuedAsync(departmentId, scope.Prefix, scope.Suffix, from, to); + DateTime? from = scope.PeriodStart, to = scope.PeriodEnd; + // A yearly scope's text can also have been written outside its own period: the same name under the calendar year, or + // under a year start or naming the department used before. Every year named Y starts after January 1 of Y-1 and ends + // before January 1 of Y+2, so searching that window finds them and a new scope never re-issues one of their numbers. + if (scope.Period == CallNumberResetPeriod.Yearly && scope.Year.HasValue && scope.Year.Value > 1 && scope.Year.Value < 9998) + { + from = new DateTime(scope.Year.Value - 1, 1, 1, 0, 0, 0, DateTimeKind.Unspecified); + to = new DateTime(scope.Year.Value + 2, 1, 1, 0, 0, 0, DateTimeKind.Unspecified); + } + + return _sequences.GetHighestIssuedAsync(departmentId, scope.Prefix, scope.Suffix, + from.HasValue ? ToUtc(from.Value, timeZone) : (DateTime?)null, to.HasValue ? ToUtc(to.Value, timeZone) : (DateTime?)null); + } + + private static DateTime ToLocal(DateTime utcDate, string timeZone) + { + return string.IsNullOrWhiteSpace(timeZone) ? utcDate : DateTimeHelpers.GetLocalDateTime(utcDate, timeZone); } private async Task TimeZoneAsync(int departmentId) diff --git a/Core/Resgrid.Services/CallStatusAttributionService.cs b/Core/Resgrid.Services/CallStatusAttributionService.cs index 2ee23ad78..579d5947b 100644 --- a/Core/Resgrid.Services/CallStatusAttributionService.cs +++ b/Core/Resgrid.Services/CallStatusAttributionService.cs @@ -80,8 +80,11 @@ public async Task AttributeUnitStateAsync(UnitState state, UnitState previousSta if (CallStatusLinkage.IsClearingUnitState(state.State, baseTypes)) return; - var openCalls = await _callDispatchUnitRepository.GetOpenCallIdsForUnitAsync(departmentId, state.UnitId); - var callId = CallStatusAttribution.PickDispatchCall(openCalls, previousIsClearing ? previousCallId : null); + var dispatches = await _callDispatchUnitRepository.GetOpenCallUnitDispatchesForUnitAsync(departmentId, state.UnitId); + var (clearedCallId, clearedAllAt) = CallStatusAttribution.ClearedByPrevious(previousIsClearing, previous?.DestinationId, + previous?.DestinationType, previous?.Timestamp); + var callId = CallStatusAttribution.PickDispatchCall(dispatches, x => x.CallId, UnitCallInvolvement.LatestDispatchTime, + clearedCallId, clearedAllAt); if (callId.HasValue) Link(state, callId.Value, StatusDestinationSources.Dispatch); @@ -139,8 +142,10 @@ public async Task AttributeActionLogAsync(ActionLog actionLog, ActionLog previou if (string.IsNullOrWhiteSpace(actionLog.UserId) || CallStatusLinkage.IsClearingPersonnelStatus(actionLog.ActionTypeId, baseTypes)) return; - var openCalls = await _callDispatchesRepository.GetOpenCallIdsForUserAsync(departmentId, actionLog.UserId); - var callId = CallStatusAttribution.PickDispatchCall(openCalls, previousIsClearing ? previousCallId : null); + var dispatches = await _callDispatchesRepository.GetOpenCallDispatchesForUserAsync(departmentId, actionLog.UserId); + var (clearedCallId, clearedAllAt) = CallStatusAttribution.ClearedByPrevious(previousIsClearing, previous?.DestinationId, + previous?.DestinationType, previous?.Timestamp); + var callId = CallStatusAttribution.PickDispatchCall(dispatches, x => x.CallId, x => x.DispatchedOn, clearedCallId, clearedAllAt); if (callId.HasValue) Link(actionLog, callId.Value, StatusDestinationSources.Dispatch); @@ -162,9 +167,9 @@ public async Task> GetWorkingCallIdsForUnitsAsync(int depar var dispatchedByUnit = ((await _callDispatchUnitRepository.GetOpenCallUnitDispatchesForDepartmentAsync(departmentId)) ?? Enumerable.Empty()) .Where(x => x != null) .GroupBy(x => x.UnitId) - .ToDictionary(g => g.Key, g => g.Select(x => x.CallId).Distinct().ToList()); + .ToDictionary(g => g.Key, g => g.ToList()); - var openCallIds = new HashSet(dispatchedByUnit.Values.SelectMany(x => x)); + var openCallIds = new HashSet(dispatchedByUnit.Values.SelectMany(x => x).Select(x => x.CallId)); // A status can point at an open call the unit was never dispatched to (a unit that took the call itself). foreach (var linkedCallId in states diff --git a/Core/Resgrid.Services/CallsService.cs b/Core/Resgrid.Services/CallsService.cs index 7dce0b634..4008c4b6d 100644 --- a/Core/Resgrid.Services/CallsService.cs +++ b/Core/Resgrid.Services/CallsService.cs @@ -253,9 +253,9 @@ public CallsService(ICallsRepository callsRepository, ICommunicationService comm return savedCall; } - public Task RegenerateCallNumbersAsync(int departmentId, int year, CancellationToken cancellationToken = default(CancellationToken)) + public Task RegenerateCallNumbersAsync(int departmentId, DateTime inYearUtc, CancellationToken cancellationToken = default(CancellationToken)) { - return _callNumberingService.RenumberCallsForYearAsync(departmentId, year, cancellationToken); + return _callNumberingService.RenumberCallsForYearAsync(departmentId, inYearUtc, cancellationToken); } public async Task GetCurrentCallNumberAsync(DateTime utcDate, int departmentId) diff --git a/Core/Resgrid.Services/DepartmentSettingsService.cs b/Core/Resgrid.Services/DepartmentSettingsService.cs index 144c50fca..9a2272543 100644 --- a/Core/Resgrid.Services/DepartmentSettingsService.cs +++ b/Core/Resgrid.Services/DepartmentSettingsService.cs @@ -40,6 +40,7 @@ public partial class DepartmentSettingsService : IDepartmentSettingsService private static string MapStyleCacheKey = "DSetMapStyle_{0}"; private static string MapStyleNightCacheKey = "DSetMapStyleNight_{0}"; private static string CallNumberingConfigCacheKey = "DSetCallNumbering_{0}"; + private static string DocumentNumberingConfigCacheKey = "DSetDocumentNumbering_{0}"; private static TimeSpan LongCacheLength = TimeSpan.FromDays(14); // Security-relevant settings ride the standard department-data window instead, so a missed // invalidation cannot keep an old policy in force for two weeks. @@ -1228,6 +1229,46 @@ public async Task SetCallNumberingConfigAsync(int departmentI DepartmentSettingTypes.CallNumberingConfig, cancellationToken); } + public async Task GetDocumentNumberingConfigAsync(int departmentId, bool bypassCache = false) + { + async Task getSetting() + { + var setting = await GetSettingByDepartmentIdType(departmentId, DepartmentSettingTypes.DocumentNumberingConfig); + return setting?.Setting ?? string.Empty; + } + + string value; + if (Config.SystemBehaviorConfig.CacheEnabled && !bypassCache) + value = await _cacheProvider.RetrieveAsync(string.Format(DocumentNumberingConfigCacheKey, departmentId), getSetting, LongCacheLength); + else + value = await getSetting(); + + if (!String.IsNullOrWhiteSpace(value)) + { + try + { + var config = ObjectSerialization.Deserialize(value); + + if (config != null) + return config; + } + catch (Exception) + { + // A corrupt blob must never stop a department creating work orders or invoices; the built-in numbers carry on. + } + } + + return new DocumentNumberingConfig(); + } + + public async Task SetDocumentNumberingConfigAsync(int departmentId, DocumentNumberingConfig config, + CancellationToken cancellationToken = default(CancellationToken)) + { + // SaveOrUpdateSettingAsync invalidates the cached value (see the DocumentNumberingConfig case in its cache switch). + return await SaveOrUpdateSettingAsync(departmentId, ObjectSerialization.Serialize(config ?? new DocumentNumberingConfig()), + DepartmentSettingTypes.DocumentNumberingConfig, cancellationToken); + } + public async Task GetDispatchRecommendationConfigAsync(int departmentId, bool bypassCache = false) { async Task getSetting() @@ -1658,6 +1699,9 @@ private async Task InvalidateSettingCacheAsync(int departmentId, DepartmentSetti case DepartmentSettingTypes.CallNumberingConfig: cacheKey = string.Format(CallNumberingConfigCacheKey, departmentId); break; + case DepartmentSettingTypes.DocumentNumberingConfig: + cacheKey = string.Format(DocumentNumberingConfigCacheKey, departmentId); + break; case DepartmentSettingTypes.RecordsDefaultLifecyclePreset: case DepartmentSettingTypes.RecordsReviewDueHours: case DepartmentSettingTypes.RecordsNumberingConfig: diff --git a/Core/Resgrid.Services/DispatchRecommendationService.cs b/Core/Resgrid.Services/DispatchRecommendationService.cs index eddc1415d..94b43558a 100644 --- a/Core/Resgrid.Services/DispatchRecommendationService.cs +++ b/Core/Resgrid.Services/DispatchRecommendationService.cs @@ -30,13 +30,15 @@ public class DispatchRecommendationService : IDispatchRecommendationService private readonly IShiftsService _shiftsService; private readonly IRunCardActivationsRepository _runCardActivationsRepository; private readonly IEventAggregator _eventAggregator; + private readonly IDepartmentsService _departmentsService; public DispatchRecommendationService(IRunCardsService runCardsService, IUnitsService unitsService, IActionLogsService actionLogsService, IUserStateService userStateService, IPersonnelRolesService personnelRolesService, ICustomStateService customStateService, IDepartmentGroupsService departmentGroupsService, IDepartmentSettingsService departmentSettingsService, IGeoService geoService, IPersonnelLocationResolver personnelLocationResolver, IShiftsService shiftsService, - IRunCardActivationsRepository runCardActivationsRepository, IEventAggregator eventAggregator) + IRunCardActivationsRepository runCardActivationsRepository, IEventAggregator eventAggregator, + IDepartmentsService departmentsService) { _runCardsService = runCardsService; _unitsService = unitsService; @@ -51,6 +53,7 @@ public DispatchRecommendationService(IRunCardsService runCardsService, IUnitsSer _shiftsService = shiftsService; _runCardActivationsRepository = runCardActivationsRepository; _eventAggregator = eventAggregator; + _departmentsService = departmentsService; } public async Task GetRecommendationAsync(DispatchRecommendationRequest request, CancellationToken cancellationToken = default(CancellationToken)) @@ -139,6 +142,8 @@ public DispatchRecommendationService(IRunCardsService runCardsService, IUnitsSer if (config.MoveUpRecommendationsEnabled) await RunMoveUpPassAsync(context); + await FillDisplayNamesAsync(context); + return result; } @@ -332,6 +337,10 @@ private sealed class RecommendationContext public Dictionary DispatchedUnitTypes { get; set; } = new Dictionary(); /// Roles of each person already on the call (CountDispatchedTowardRequirements). public Dictionary> DispatchedUserRoles { get; set; } = new Dictionary>(StringComparer.OrdinalIgnoreCase); + /// Department unit type names by id, for naming shortfalls and move-ups. + public Dictionary UnitTypeNames { get; set; } = new Dictionary(); + /// Personnel role names by id, for naming recommended people, shortfalls and move-ups. + public Dictionary RoleNames { get; set; } = new Dictionary(); } private async Task BuildUnitCandidatesAsync(RecommendationContext context) @@ -347,6 +356,9 @@ private async Task BuildUnitCandidatesAsync(RecommendationContext context) var typeByName = BuildUnitTypeLookup(unitTypes); var customDetails = BuildCustomDetailMap(customStates); + foreach (var unitType in unitTypes.Where(t => t != null)) + context.UnitTypeNames[unitType.UnitTypeId] = unitType.Type; + var selections = (context.Card.AvailabilitySelections ?? new List()) .Where(s => s.SelectionType == (int)RunCardSelectionTypes.UnitStatus) .ToList(); @@ -421,6 +433,9 @@ private async Task BuildPersonnelCandidatesAsync(RecommendationContext context) var personnelDetails = BuildCustomDetailMap(personnelCustomState); var staffingDetails = BuildCustomDetailMap(staffingCustomState); + foreach (var role in rolesByUser.Values.Where(r => r != null).SelectMany(r => r).Where(r => r != null)) + context.RoleNames[role.PersonnelRoleId] = role.Name; + var logByUser = actionLogs.Where(l => !string.IsNullOrWhiteSpace(l.UserId)) .GroupBy(l => l.UserId).ToDictionary(g => g.Key, g => g.OrderByDescending(l => l.Timestamp).First()); var stateByUser = userStates.Where(s => !string.IsNullOrWhiteSpace(s.UserId)) @@ -1498,5 +1513,73 @@ private void AddRoleShortfall(RecommendationContext context, RunCardRoleRequirem } #endregion + + #region Display names + + /// + /// Names the people, roles and unit types the result refers to by id, so a client can show the recommendation without + /// lookups of its own. A shortfall is often for a role nobody holds or a unit type with no available unit, which the + /// candidate pools never saw, so those names come from the department's lists. + /// + private async Task FillDisplayNamesAsync(RecommendationContext context) + { + var result = context.Result; + var departmentId = context.Request.DepartmentId; + + var unnamedRoleIds = result.Personnel.Select(p => p.RoleId) + .Concat(result.Shortfalls.Where(s => !s.IsUnitRequirement).Select(s => s.TypeOrRoleId)) + .Concat(result.MoveUps.Where(m => m.PersonnelRoleId.HasValue).Select(m => m.PersonnelRoleId.Value)) + .Where(id => id > 0 && !context.RoleNames.ContainsKey(id)); + + if (unnamedRoleIds.Any()) + { + foreach (var role in (await _personnelRolesService.GetRolesForDepartmentUnlimitedAsync(departmentId) ?? new List()).Where(r => r != null)) + { + if (!context.RoleNames.ContainsKey(role.PersonnelRoleId)) + context.RoleNames[role.PersonnelRoleId] = role.Name; + } + } + + var personNames = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (result.Personnel.Any() || result.MoveUps.Any(m => !string.IsNullOrWhiteSpace(m.SuggestedUserId))) + { + foreach (var person in (await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(departmentId) ?? new List()).Where(n => n != null)) + { + if (!string.IsNullOrWhiteSpace(person.UserId) && !string.IsNullOrWhiteSpace(person.Name) && !personNames.ContainsKey(person.UserId)) + personNames[person.UserId] = person.Name.Trim(); + } + } + + foreach (var person in result.Personnel) + { + if (string.IsNullOrWhiteSpace(person.Name) && !string.IsNullOrWhiteSpace(person.UserId)) + person.Name = NameOrNull(personNames, person.UserId); + + if (string.IsNullOrWhiteSpace(person.RoleName)) + person.RoleName = NameOrNull(context.RoleNames, person.RoleId); + } + + foreach (var shortfall in result.Shortfalls.Where(s => string.IsNullOrWhiteSpace(s.TypeOrRoleName))) + shortfall.TypeOrRoleName = NameOrNull(shortfall.IsUnitRequirement ? context.UnitTypeNames : context.RoleNames, shortfall.TypeOrRoleId); + + foreach (var moveUp in result.MoveUps) + { + if (string.IsNullOrWhiteSpace(moveUp.UnitTypeName) && moveUp.UnitTypeId.HasValue) + moveUp.UnitTypeName = NameOrNull(context.UnitTypeNames, moveUp.UnitTypeId.Value); + + if (string.IsNullOrWhiteSpace(moveUp.PersonnelRoleName) && moveUp.PersonnelRoleId.HasValue) + moveUp.PersonnelRoleName = NameOrNull(context.RoleNames, moveUp.PersonnelRoleId.Value); + + if (string.IsNullOrWhiteSpace(moveUp.SuggestedUserName) && !string.IsNullOrWhiteSpace(moveUp.SuggestedUserId)) + moveUp.SuggestedUserName = NameOrNull(personNames, moveUp.SuggestedUserId); + } + } + + private static string NameOrNull(Dictionary names, TKey key) + { + return names.TryGetValue(key, out var name) && !string.IsNullOrWhiteSpace(name) ? name : null; + } + + #endregion } } diff --git a/Core/Resgrid.Services/DocumentNumberingService.cs b/Core/Resgrid.Services/DocumentNumberingService.cs new file mode 100644 index 000000000..b3dce04a1 --- /dev/null +++ b/Core/Resgrid.Services/DocumentNumberingService.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public class DocumentNumberingService : IDocumentNumberingService + { + /// The sequence the screens' sample numbers show. + private const int SampleSequence = 153; + + private readonly IDepartmentSettingsService _settings; + private readonly IDepartmentsService _departments; + private readonly IDocumentNumberSequencesRepository _sequences; + + public DocumentNumberingService(IDepartmentSettingsService settings, IDepartmentsService departments, IDocumentNumberSequencesRepository sequences) + { + _settings = settings; + _departments = departments; + _sequences = sequences; + } + + public async Task GetNumberingYearAsync(int departmentId, string kind, DateTime utcDate) + { + var yearStart = await YearStartAsync(departmentId, kind, false); + return yearStart.YearOf(ToLocal(utcDate, await TimeZoneAsync(departmentId))); + } + + public async Task GetPatternAsync(int departmentId, string kind, bool bypassCache = false) + { + var info = DocumentNumberKinds.Find(kind); + if (info == null) + return null; + + var patterns = info.IsRecords + ? (await _settings.GetRecordsNumberingConfigAsync(departmentId, bypassCache))?.DocumentPatterns + : (await _settings.GetDocumentNumberingConfigAsync(departmentId, bypassCache))?.Patterns; + return DocumentNumbering.EffectivePattern(patterns, kind); + } + + public async Task TakeCustomNumberAsync(int departmentId, string kind, DateTime utcDate, CancellationToken cancellationToken = default) + { + var pattern = await GetPatternAsync(departmentId, kind); + if (pattern == null) + return null; + + var scope = CallNumberFormat.Resolve(pattern.Pattern, pattern.SequenceWidth, ToLocal(utcDate, await TimeZoneAsync(departmentId)), await YearStartAsync(departmentId, kind, false)); + + // Only the first document of a scope pays for the seed scan, which reads the numbers already written in that text + // (built-in ones included), so a pattern that reads like earlier numbers carries on after them. + var seed = await _sequences.GetSequenceAsync(departmentId, kind, scope.Key) == null + ? await _sequences.GetHighestIssuedAsync(departmentId, kind, scope.Prefix, scope.Suffix) + : 0; + var sequence = await _sequences.TakeNextAsync(departmentId, kind, scope.Key, seed, cancellationToken); + + return scope.Format(sequence); + } + + public async Task> GetStatusesAsync(int departmentId, bool records, DateTime utcDate) + { + var statuses = new List(); + var timeZone = await TimeZoneAsync(departmentId); + foreach (var kind in DocumentNumberKinds.All.Where(k => k.IsRecords == records)) + { + statuses.Add(await StatusAsync(departmentId, kind, await GetPatternAsync(departmentId, kind.Key, true), + await YearStartAsync(departmentId, kind.Key, true), utcDate, timeZone)); + } + + return statuses; + } + + public async Task SaveAsync(int departmentId, string userId, DocumentNumberingUpdate update, CancellationToken cancellationToken = default) + { + var result = new DocumentNumberingSaveResult(); + update ??= new DocumentNumberingUpdate(); + + var config = await _settings.GetDocumentNumberingConfigAsync(departmentId, true) ?? new DocumentNumberingConfig(); + var saved = config.YearStart(); + var month = update.YearStartMonth ?? saved.Month; + var day = update.YearStartDay ?? saved.Day; + if (!NumberingYearStart.IsValid(month, day)) + { + result.YearStartRejected = true; + return result; + } + + config.YearStartMonth = month; + config.YearStartDay = day; + config.YearLabel = (int)new NumberingYearStart(month, day, (NumberingYearLabel)(update.YearLabel ?? (int)saved.Label)).Label; + config.Patterns ??= new List(); + ApplyPatterns(config.Patterns, update.Patterns, false, result); + await _settings.SetDocumentNumberingConfigAsync(departmentId, config, cancellationToken); + + await RaiseAsync(departmentId, userId, update.Patterns, false, result, cancellationToken); + return result; + } + + public async Task SaveRecordsPatternsAsync(int departmentId, string userId, List patterns, CancellationToken cancellationToken = default) + { + var result = new DocumentNumberingSaveResult(); + var config = await _settings.GetRecordsNumberingConfigAsync(departmentId, true) ?? new RecordsNumberingConfig(); + config.DocumentPatterns ??= new List(); + ApplyPatterns(config.DocumentPatterns, patterns, true, result); + await _settings.SetRecordsNumberingConfigAsync(departmentId, config, cancellationToken); + + await RaiseAsync(departmentId, userId, patterns, true, result, cancellationToken); + return result; + } + + /// Writes each posted kind's pattern; a blank one returns the kind to its built-in numbers, a refused one keeps what it had. + private static void ApplyPatterns(List target, IEnumerable updates, bool records, DocumentNumberingSaveResult result) + { + foreach (var update in (updates ?? Enumerable.Empty()).Where(u => u != null)) + { + var kind = DocumentNumberKinds.Find(update.Kind); + if (kind == null || kind.IsRecords != records) + continue; + + if (string.IsNullOrWhiteSpace(update.Pattern)) + { + DocumentNumbering.SetPattern(target, kind.Key, null, 0); + continue; + } + + if (!DocumentNumbering.IsValid(kind, update.Pattern)) + { + result.PatternsRejected.Add(kind.Key); + continue; + } + + DocumentNumbering.SetPattern(target, kind.Key, update.Pattern.Trim(), update.SequenceWidth); + } + } + + /// + /// Raises each requested next number, checked against what was just saved: a raise typed against a sequence the department + /// no longer issues from (its pattern or year start changed in the same save, or the kind went back to built-in numbers) + /// is not applied, and one below what the sequence already issues next is refused. + /// + private async Task RaiseAsync(int departmentId, string userId, IEnumerable updates, bool records, DocumentNumberingSaveResult result, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var timeZone = await TimeZoneAsync(departmentId); + foreach (var update in (updates ?? Enumerable.Empty()).Where(u => u?.NextSequence != null)) + { + var kind = DocumentNumberKinds.Find(update.Kind); + if (kind == null || kind.IsRecords != records || result.PatternsRejected.Contains(kind.Key)) + continue; + + var current = await StatusAsync(departmentId, kind, await GetPatternAsync(departmentId, kind.Key, true), await YearStartAsync(departmentId, kind.Key, true), now, timeZone); + if (!current.Custom || !string.Equals(current.ScopeKey, update.ScopeKey, StringComparison.Ordinal)) + result.NotApplied++; + else if (update.NextSequence.Value < current.NextSequence) + result.BelowCurrent.Add(current); + else + await _sequences.RaiseFloorAsync(departmentId, kind.Key, current.ScopeKey, Math.Min(update.NextSequence.Value, CallNumberFormat.MaxSequence), userId, now, cancellationToken); + } + } + + private async Task StatusAsync(int departmentId, DocumentNumberKind kind, DocumentNumberPattern pattern, NumberingYearStart yearStart, DateTime utcDate, string timeZone) + { + var local = ToLocal(utcDate, timeZone); + if (pattern == null) + { + var legacy = CallNumberFormat.Resolve(kind.LegacyPattern, kind.LegacyWidth, local, yearStart); + return new DocumentNumberStatus + { + Kind = kind.Key, + Custom = false, + Pattern = kind.LegacyPattern, + SequenceWidth = kind.LegacyWidth, + Period = legacy.Period, + Example = legacy.Format(SampleSequence) + }; + } + + var scope = CallNumberFormat.Resolve(pattern.Pattern, pattern.SequenceWidth, local, yearStart); + var existing = await _sequences.GetSequenceAsync(departmentId, kind.Key, scope.Key); + var last = existing?.LastSequence ?? await _sequences.GetHighestIssuedAsync(departmentId, kind.Key, scope.Prefix, scope.Suffix); + var next = Math.Max(last + 1, existing?.FloorSequence ?? 0); + + return new DocumentNumberStatus + { + Kind = kind.Key, + Custom = true, + Pattern = pattern.Pattern, + SequenceWidth = pattern.SequenceWidth, + ScopeKey = scope.Key, + Period = scope.Period, + NextSequence = next, + NextNumber = scope.Format(next), + Example = scope.Format(SampleSequence), + FloorSequence = existing?.FloorSequence ?? 0 + }; + } + + private async Task YearStartAsync(int departmentId, string kind, bool bypassCache) + { + if (DocumentNumberKinds.Find(kind)?.IsRecords == true) + return (await _settings.GetRecordsNumberingConfigAsync(departmentId, bypassCache) ?? new RecordsNumberingConfig()).YearStart(); + + return (await _settings.GetDocumentNumberingConfigAsync(departmentId, bypassCache) ?? new DocumentNumberingConfig()).YearStart(); + } + + private async Task TimeZoneAsync(int departmentId) + { + return (await _departments.GetDepartmentByIdAsync(departmentId, false))?.TimeZone; + } + + private static DateTime ToLocal(DateTime utcDate, string timeZone) + { + return string.IsNullOrWhiteSpace(timeZone) ? utcDate : DateTimeHelpers.GetLocalDateTime(utcDate, timeZone); + } + } +} diff --git a/Core/Resgrid.Services/Invoicing/BidsService.cs b/Core/Resgrid.Services/Invoicing/BidsService.cs index e3f8e05b4..93dfa3ca8 100644 --- a/Core/Resgrid.Services/Invoicing/BidsService.cs +++ b/Core/Resgrid.Services/Invoicing/BidsService.cs @@ -47,13 +47,15 @@ public class BidsService : IBidsService private readonly IUnitOfWork _unitOfWork; private readonly Lazy _protectedRead; private readonly Lazy _searchProjections; + private readonly IDocumentNumberingService _documentNumbering; 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 searchProjections = null) + Lazy protectedRead = null, Lazy searchProjections = null, IDocumentNumberingService documentNumbering = null) { + _documentNumbering = documentNumbering; _bids = bids; _lines = lines; _sequence = sequence; @@ -152,6 +154,9 @@ public async Task CreateDraftBidAsync(int departmentId, string contactId, s AddedOn = now, AddedByUserId = userId }; + // The int stays the unique, ordered bid id; the number people see is the department's pattern (setting 117) or that int. + bid.DisplayNumber = (_documentNumbering == null ? null : await _documentNumbering.TakeCustomNumberAsync(departmentId, DocumentNumberKinds.Bid, now, cancellationToken)) + ?? bid.BidNumber.ToString(System.Globalization.CultureInfo.InvariantCulture); 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); @@ -344,13 +349,13 @@ public async Task SendBidAsync(string bidId, int departmentId, string toEma var pdf = await GetBidPdfCoreAsync(bidId, departmentId, workload: true); if (pdf == null || pdf.Length == 0) throw new InvalidOperationException("bids_pdf_unavailable"); - var label = $"Bid #{bid.BidNumber}"; + var label = $"Bid {bid.NumberLabel()}"; var notification = new EmailNotification { To = recipient, Subject = $"{label} from {await DepartmentDisplayNameAsync(departmentId)}: {bid.Title}", Body = $"{label} — {bid.Title} — estimated {InvoicingService.FormatMoney(bid.EstimatedTotal, await CurrencyAsync(bid))} is attached." + (bid.ValidUntil.HasValue ? $" This bid is valid until {bid.ValidUntil.Value:yyyy-MM-dd}." : string.Empty), - AttachmentName = $"bid-{bid.BidNumber}.pdf", + AttachmentName = $"bid-{DocumentNumbering.FileSafe(bid.NumberText())}.pdf", AttachmentData = pdf }; var sent = await _emailService.SendInvoiceAsync(notification, departmentId, null, null, label); @@ -471,10 +476,10 @@ public static string RenderBidHtml(BidRenderModel model) var bid = model.Bid; var currency = model.Currency ?? "USD"; var sb = new StringBuilder(); - sb.Append("Resgrid | ").Append(E($"Bid #{bid.BidNumber}")).Append(""); + sb.Append("Resgrid | ").Append(E($"Bid {bid.NumberLabel()}")).Append(""); sb.Append(""); sb.Append("

").Append(E(model.DepartmentName)).Append("

"); - sb.Append("

Bid #").Append(bid.BidNumber).Append(" ").Append(E(((BidStatuses)bid.Status).ToString())).Append("

"); + sb.Append("

Bid ").Append(E(bid.NumberLabel())).Append(" ").Append(E(((BidStatuses)bid.Status).ToString())).Append("

"); sb.Append("

").Append(E(bid.Title)).Append("

"); if (!string.IsNullOrWhiteSpace(bid.Description)) sb.Append("

").Append(E(bid.Description)).Append("

"); sb.Append(""); @@ -643,7 +648,7 @@ public async Task ConvertBidToDeploymentAsync(BidConversion var item = await _calendarService.AddNewCalendarItemAsync(new CalendarItem { DepartmentId = departmentId, Title = deployment.Name, Start = start, End = end <= start ? start.AddHours(1) : end, - Description = $"Deployment for bid #{bid.BidNumber}" + (string.IsNullOrWhiteSpace(deployment.IncidentNumber) ? string.Empty : $" — incident {deployment.IncidentNumber}"), + Description = $"Deployment for bid {bid.NumberLabel()}" + (string.IsNullOrWhiteSpace(deployment.IncidentNumber) ? string.Empty : $" — incident {deployment.IncidentNumber}"), Location = Trim(request.Address), CreatorUserId = userId, IsAllDay = false, ItemType = 0, Public = false }, timeZone, cancellationToken); if (item != null && item.CalendarItemId > 0) @@ -714,7 +719,7 @@ private async Task PublishAsync(Bid bid, WorkflowTriggerEventType trigger, int? CorrelationId = bid.BidId, Payload = new { - bid.BidId, bid.BidNumber, bid.Title, bid.Status, OldStatus = oldStatus, bid.ContactId, + bid.BidId, bid.BidNumber, DisplayNumber = bid.NumberText(), bid.Title, bid.Status, OldStatus = oldStatus, bid.ContactId, ContactName = ProtectedDataEnvelope.SafeDisplay(contactName), bid.ServiceContractId, bid.IncidentNumber, bid.ValidUntil, bid.RequestedStartOn, bid.RequestedEndOn, bid.EstimatedTotal, Currency = await CurrencyAsync(bid), bid.SentOn, bid.AcceptedOn, bid.DeclinedOn, bid.ConvertedDeploymentId, bid.ConvertedCallId diff --git a/Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs b/Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs index 2ae707c60..bab37e3a1 100644 --- a/Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs +++ b/Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs @@ -151,13 +151,13 @@ public async Task BuildInvoicePacketAsync(string invoic { var invoice = await _invoicing.GetInvoiceByIdAsync(invoiceId, departmentId); if (invoice == null) throw new InvalidOperationException("invoicing_invoice_not_found"); - var packet = new ContractorInvoicePacket { FileName = $"invoice-{invoice.InvoiceNumber}-packet.zip" }; + var packet = new ContractorInvoicePacket { FileName = $"invoice-{DocumentNumbering.FileSafe(invoice.NumberText())}-packet.zip" }; using var stream = new MemoryStream(); using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) { var invoicePdf = await _invoicing.GetInvoicePdfAsync(invoiceId, departmentId); - if (invoicePdf != null && invoicePdf.Length > 0) Add(zip, packet, $"invoice-{invoice.InvoiceNumber}.pdf", invoicePdf); + if (invoicePdf != null && invoicePdf.Length > 0) Add(zip, packet, $"invoice-{DocumentNumbering.FileSafe(invoice.NumberText())}.pdf", invoicePdf); if (!string.IsNullOrWhiteSpace(invoice.DeploymentId)) { @@ -169,7 +169,7 @@ public async Task BuildInvoicePacketAsync(string invoic { var report = await _timeTracking.GetTimeReportByIdAsync(reportId, departmentId); var pdf = report == null ? null : await _timeTracking.GetTimeReportPdfAsync(reportId, departmentId); - if (pdf != null && pdf.Length > 0) Add(zip, packet, $"dtr/dtr-{report.ReportNumber}-{report.ReportDate:yyyy-MM-dd}.pdf", pdf); + if (pdf != null && pdf.Length > 0) Add(zip, packet, $"dtr/dtr-{DocumentNumbering.FileSafe(report.NumberText())}-{report.ReportDate:yyyy-MM-dd}.pdf", pdf); } catch (Exception ex) { Logging.LogException(ex, $"Invoice packet: DTR {reportId} PDF skipped."); } } diff --git a/Core/Resgrid.Services/Invoicing/ContractorChargeCalculator.cs b/Core/Resgrid.Services/Invoicing/ContractorChargeCalculator.cs index 0a56d831c..ef92966b3 100644 --- a/Core/Resgrid.Services/Invoicing/ContractorChargeCalculator.cs +++ b/Core/Resgrid.Services/Invoicing/ContractorChargeCalculator.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using Resgrid.Model; using Resgrid.Model.Invoicing; namespace Resgrid.Services.Invoicing @@ -527,7 +528,7 @@ private static string BandBreakdown(List bands) private static string Describe(DateTime day, DeploymentTimeReport report, string entryName, string subjectName, string detail) { - var parts = new List { day.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), $"DTR #{report.ReportNumber}" }; + var parts = new List { day.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), "DTR " + report.NumberLabel() }; if (!string.IsNullOrWhiteSpace(report.IncidentNumber)) parts.Add($"Incident {report.IncidentNumber}"); parts.Add(entryName); if (!string.IsNullOrWhiteSpace(subjectName)) parts.Add(subjectName); diff --git a/Core/Resgrid.Services/Invoicing/DeploymentService.cs b/Core/Resgrid.Services/Invoicing/DeploymentService.cs index 260fe9bcc..6f6ee8fbe 100644 --- a/Core/Resgrid.Services/Invoicing/DeploymentService.cs +++ b/Core/Resgrid.Services/Invoicing/DeploymentService.cs @@ -825,7 +825,7 @@ private async Task PublishAsync(Deployment deployment, WorkflowTriggerEventType SubjectName = subject?.Name, SubjectUserId = subjectUserId, RosterAction = subject?.Action, - ReportNumber = report?.ReportNumber, ReportDate = report?.ReportDate, TimeReportId = report?.DeploymentTimeReportId, ReportStatus = report?.Status, + ReportNumber = report?.ReportNumber, ReportDisplayNumber = report?.NumberText(), ReportDate = report?.ReportDate, TimeReportId = report?.DeploymentTimeReportId, ReportStatus = report?.Status, ExpenseType = expense?.ExpenseType, ExpenseAmount = expense?.Amount, ExpenseCurrency = expense?.Currency ?? deployment.Currency, AttachmentId = attachment?.DeploymentAttachmentId, AttachmentType = attachment?.AttachmentType, AttachmentName = attachment?.Name } diff --git a/Core/Resgrid.Services/Invoicing/InvoicePaymentsService.cs b/Core/Resgrid.Services/Invoicing/InvoicePaymentsService.cs index 1e7632eaa..8188c76a1 100644 --- a/Core/Resgrid.Services/Invoicing/InvoicePaymentsService.cs +++ b/Core/Resgrid.Services/Invoicing/InvoicePaymentsService.cs @@ -383,6 +383,7 @@ public async Task CreatePaymentRequestAsync(string invoic DepartmentId = departmentId, InvoiceId = invoice.InvoiceId, InvoiceNumber = invoice.InvoiceNumber, + InvoiceDisplayNumber = invoice.NumberText(), Amount = request.Amount, Currency = request.Currency, CustomerEmail = profile?.BillingEmail, @@ -475,6 +476,7 @@ public async Task GetPayPageModelAsync(string token) } model.InvoiceNumber = invoice.InvoiceNumber; + model.InvoiceLabel = invoice.NumberLabel(); model.Currency = invoice.Currency; model.AmountDue = RoundMoney(Math.Max(0, invoice.Balance)); model.DueOn = invoice.DueOn; diff --git a/Core/Resgrid.Services/Invoicing/InvoicingService.Delivery.cs b/Core/Resgrid.Services/Invoicing/InvoicingService.Delivery.cs index 2a3297c90..5317c0aa5 100644 --- a/Core/Resgrid.Services/Invoicing/InvoicingService.Delivery.cs +++ b/Core/Resgrid.Services/Invoicing/InvoicingService.Delivery.cs @@ -88,14 +88,14 @@ public async Task SendInvoiceAsync(string invoiceId, int departmentId, var pdf = useCallerAttachment ? attachment.Data : await GetInvoicePdfCoreAsync(invoiceId, departmentId, workload: true); if (pdf == null || pdf.Length == 0) throw new InvalidOperationException("invoicing_pdf_unavailable"); - var label = $"Invoice #{invoice.InvoiceNumber}"; + var label = $"Invoice {invoice.NumberLabel()}"; var notification = new EmailNotification { To = recipient, Subject = $"{label} from {await DepartmentDisplayNameAsync(departmentId)}", Body = $"{label} for {FormatMoney(invoice.Total, invoice.Currency)} is attached." + (invoice.DueOn.HasValue ? $" Payment is due by {invoice.DueOn.Value:yyyy-MM-dd}." : string.Empty) + (useCallerAttachment && attachment.Contents?.Count > 0 ? " The packet also contains: " + string.Join("; ", attachment.Contents) + "." : string.Empty), - AttachmentName = useCallerAttachment ? attachment.FileName : $"invoice-{invoice.InvoiceNumber}.pdf", + AttachmentName = useCallerAttachment ? attachment.FileName : $"invoice-{DocumentNumbering.FileSafe(invoice.NumberText())}.pdf", AttachmentData = pdf }; @@ -147,7 +147,7 @@ public static string RenderInvoiceHtml(InvoiceRenderModel model) var invoice = model.Invoice; var currency = invoice.Currency ?? "USD"; var sb = new StringBuilder(); - sb.Append("Resgrid | ").Append(E($"Invoice #{invoice.InvoiceNumber}")).Append(""); + sb.Append("Resgrid | ").Append(E($"Invoice {invoice.NumberLabel()}")).Append(""); sb.Append(""); sb.Append("
- + - + } @@ -418,7 +418,7 @@ else {
    - @foreach (var invoice in Model.Invoices) {
  • #@invoice.InvoiceNumber · @invoice.Total.ToString("N2") @invoice.Currency
  • } + @foreach (var invoice in Model.Invoices) {
  • @invoice.NumberLabel() · @invoice.Total.ToString("N2") @invoice.Currency
  • }
} diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/AddArchivedCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/AddArchivedCall.cshtml index 3d59af642..f6e1f1c7f 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/AddArchivedCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/AddArchivedCall.cshtml @@ -4,6 +4,18 @@ ViewBag.Title = "Resgrid | " + @localizer["AddArchivedCallHeader"]; Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; var mapConfig = SettingsHelper.GetDepartmentMapConfig(Resgrid.Config.InfoConfig.WebsiteKey); + + // Department > Dispatch Settings > New Call Form Fields, which archived calls follow too (an archived call has no + // dispatch time). Hidden fields are not rendered; the post enforces the required ones. + var showAddress = Model.IsFieldVisible(NewCallFieldKeys.Address); + var showGeolocation = Model.IsFieldVisible(NewCallFieldKeys.Geolocation); + var showWhat3Words = Model.IsFieldVisible(NewCallFieldKeys.What3Words); + var showLocation = showAddress || showGeolocation || showWhat3Words; + var showDispatchList = Model.IsFieldVisible(NewCallFieldKeys.DispatchList); + + Microsoft.AspNetCore.Html.IHtmlContent requiredMark(params string[] keys) => keys.Any(Model.IsFieldRequired) + ? new Microsoft.AspNetCore.Html.HtmlString(" *") + : Microsoft.AspNetCore.Html.HtmlString.Empty; } @section Styles { @@ -96,14 +108,20 @@
@Html.DropDownListFor(m => m.Call.Type, Model.CallTypes, new { @style = "width: 200px;", tabindex = "3" })
-
- -
-
-
- -
-
+ @if (Model.IsFieldVisible(NewCallFieldKeys.ContactName)) + { +
+ +
+
+ } + @if (Model.IsFieldVisible(NewCallFieldKeys.ContactInfo)) + { +
+ +
+
+ }
@@ -122,18 +140,27 @@
-
- -
-
-
- -
-
-
- -
-
+ @if (Model.IsFieldVisible(NewCallFieldKeys.ExternalId)) + { +
+ +
+
+ } + @if (Model.IsFieldVisible(NewCallFieldKeys.ReferenceId)) + { +
+ +
+
+ } + @if (Model.IsFieldVisible(NewCallFieldKeys.IncidentId)) + { +
+ +
+
+ }
@@ -144,97 +171,147 @@
-
- -
- + @if (Model.IsFieldVisible(NewCallFieldKeys.Note)) + { +
+ +
+ -
- @Html.Raw(Model.Call.Notes) +
+ @Html.Raw(Model.Call.Notes) +
-
+ }
-
- -
-
-
-
- - - @localizer["FindAddress"] - + @if (showLocation) + { +
+ +
+ @if (showAddress || showWhat3Words) + { +
+ @if (showAddress) + { + + } + @if (showWhat3Words) + { +
+ + @localizer["W3wHelp"] @localizer["LearnMore"] +
+ }
-
-
-
- - - @localizer["FindW3wLocation"] - + } + @if (showGeolocation) + { +
+
+
+ @localizer["LatitudeShort"] + + @localizer["LongitudeShort"] + + + @localizer["SetPinOnMap"] + +
+
- @localizer["W3wHelp"] @localizer["LearnMore"] -
+
+ }
-
-
-
- @localizer["LatitudeShort"] - - @localizer["LongitudeShort"] - - - @localizer["SetPinOnMap"] - +
+ } + @if (!showGeolocation) + { + @* No pin entry, but an address or what3words lookup still resolves a point here. *@ + + + } + @if (Model.IsFieldVisible(NewCallFieldKeys.DestinationPoi)) + { +
+ +
+ @Html.DropDownListFor(x => x.Call.DestinationPoiId, Model.DestinationPois, new { @class = "form-control destination-poi-search", @style = "width:100%;" }) + @localizer["DestinationHelp"] +
+
+ } + @if (Model.IsFieldVisible(NewCallFieldKeys.IndoorLocation)) + { +
+ +
+
+
+
+ +
-
-
-
- -
- @Html.DropDownListFor(x => x.Call.DestinationPoiId, Model.DestinationPois, new { @class = "form-control destination-poi-search", @style = "width:100%;" }) - @localizer["DestinationHelp"] -
-
-
- -
-
"); @@ -157,7 +157,7 @@ public static string RenderInvoiceHtml(InvoiceRenderModel model) if (!string.IsNullOrWhiteSpace(model.SecondaryTaxRegistrationNumber)) sb.Append("
Secondary tax registration: ").Append(E(model.SecondaryTaxRegistrationNumber)).Append("
"); sb.Append("
"); sb.Append("

INVOICE

"); - sb.Append(""); + sb.Append(""); sb.Append(""); if (invoice.IssuedOn.HasValue) sb.Append(""); if (invoice.DueOn.HasValue) sb.Append(""); diff --git a/Core/Resgrid.Services/Invoicing/InvoicingService.cs b/Core/Resgrid.Services/Invoicing/InvoicingService.cs index 8f8d1bb7f..eb2c53a75 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 IDocumentNumberingService _documentNumbering; private readonly Lazy _searchProjections; private readonly IContactsService _contactsService; private readonly ICallsService _callsService; @@ -52,8 +53,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, Lazy searchProjections = null) + IServiceContractRepository serviceContracts = null, Lazy searchProjections = null, + IDocumentNumberingService documentNumbering = null) { + _documentNumbering = documentNumbering; _serviceContracts = serviceContracts; _searchProjections = searchProjections; _unitOfWork = unitOfWork; @@ -367,6 +370,9 @@ public async Task CreateDraftInvoiceAsync(int departmentId, string cont AddedOn = now, AddedByUserId = userId }; + // The int stays the unique, ordered invoice id; the number people see is the department's pattern (setting 117) or that int. + invoice.DisplayNumber = (_documentNumbering == null ? null : await _documentNumbering.TakeCustomNumberAsync(departmentId, DocumentNumberKinds.Invoice, now, cancellationToken)) + ?? invoice.InvoiceNumber.ToString(System.Globalization.CultureInfo.InvariantCulture); var saved = await _invoices.SaveOrUpdateAsync(invoice, cancellationToken); if (_searchProjections?.Value != null) await _searchProjections.Value.ProjectInvoiceAsync(invoice, cancellationToken); @@ -1030,7 +1036,7 @@ private async Task PublishAsync(Invoice invoice, WorkflowTriggerEventType trigge CorrelationId = invoice.InvoiceId, Payload = new { - invoice.InvoiceId, invoice.InvoiceNumber, invoice.Status, invoice.ContactId, + invoice.InvoiceId, invoice.InvoiceNumber, DisplayNumber = invoice.NumberText(), invoice.Status, invoice.ContactId, ContactName = contactName, invoice.Currency, invoice.SubTotal, invoice.DiscountAmount, invoice.TaxAmount, invoice.Total, invoice.AmountPaid, invoice.Balance, invoice.IssuedOn, invoice.DueOn, invoice.SentOn, invoice.PaidOn, diff --git a/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs b/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs index 9bda87da2..6fb78429c 100644 --- a/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs +++ b/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs @@ -42,6 +42,7 @@ public class TimeTrackingService : ITimeTrackingService private readonly IEventAggregator _eventAggregator; private readonly IPdfProvider _pdfProvider; private readonly IUnitOfWork _unitOfWork; + private readonly IDocumentNumberingService _documentNumbering; /// A span longer than this without any break is flagged (plan C4 "breaks per 5-h rule"). public const int BreakRuleHours = 5; @@ -53,8 +54,10 @@ public class TimeTrackingService : ITimeTrackingService public TimeTrackingService(IDeploymentRepository deployments, IDeploymentPersonnelRepository personnel, IDeploymentUnitRepository units, IDeploymentEquipmentRepository equipment, IDeploymentTimeReportRepository reports, IDeploymentTimeEntryRepository entries, IDeploymentExpenseRepository expenses, IDeploymentAttachmentRepository attachments, ITimeReportNumberSequenceRepository sequence, IDeploymentService deploymentService, IDepartmentsService departmentsService, IUserProfileService userProfileService, - IUnitsService unitsService, IEventAggregator eventAggregator, IPdfProvider pdfProvider, IUnitOfWork unitOfWork) + IUnitsService unitsService, IEventAggregator eventAggregator, IPdfProvider pdfProvider, IUnitOfWork unitOfWork, + IDocumentNumberingService documentNumbering = null) { + _documentNumbering = documentNumbering; _deployments = deployments; _personnel = personnel; _units = units; @@ -164,6 +167,9 @@ public async Task CreateTimeReportAsync(string deploymentI RequestNumber = deployment.RequestNumber, CostCode = deployment.CostCode, PointOfHire = deployment.PointOfHire, AddedOn = DateTime.UtcNow, AddedByUserId = userId }; + // The int stays the unique, ordered report id; the number people see is the department's pattern (setting 117) or that int. + report.DisplayNumber = (_documentNumbering == null ? null : await _documentNumbering.TakeCustomNumberAsync(departmentId, DocumentNumberKinds.TimeReport, report.AddedOn, cancellationToken)) + ?? report.ReportNumber.ToString(System.Globalization.CultureInfo.InvariantCulture); var saved = await _reports.SaveOrUpdateAsync(report, cancellationToken); // Prefill: one Deployment entry per active subject in the report's scope, copying the most recent earlier span for the same subject when there is one. @@ -553,7 +559,7 @@ public async Task GenerateTimeReportPdfAsync(string deploy return await _deploymentService.SaveAttachmentAsync(new DeploymentAttachment { DeploymentId = report.DeploymentId, DepartmentId = departmentId, AttachmentType = (int)DeploymentAttachmentTypes.TimeReportPdf, - Name = $"DTR {report.ReportNumber} {report.ReportDate:yyyy-MM-dd}", FileName = $"dtr-{report.ReportNumber}-{report.ReportDate:yyyyMMdd}.pdf", FileType = "application/pdf", Data = pdf + Name = $"DTR {report.NumberText()} {report.ReportDate:yyyy-MM-dd}", FileName = $"dtr-{DocumentNumbering.FileSafe(report.NumberText())}-{report.ReportDate:yyyyMMdd}.pdf", FileType = "application/pdf", Data = pdf }, userId, ipAddress, userAgent, cancellationToken); } @@ -566,7 +572,7 @@ public static string RenderTimeReportHtml(DeploymentTimeReport report, Deploymen sb.Append("Resgrid | Daily Time Report"); string Named(string id) => id != null && subjectNames != null && subjectNames.TryGetValue(id, out var n) ? n : id; var title = report.Scope switch { DeploymentTimeReportScopes.Crew => "Crew Time Report", DeploymentTimeReportScopes.Individual => "Individual Time Report", _ => "Daily Time Report" }; - sb.Append("

").Append(E(department?.Name)).Append(" — ").Append(title).Append(" #").Append(report.ReportNumber).Append("

"); + sb.Append("

").Append(E(department?.Name)).Append(" — ").Append(title).Append(" ").Append(E(report.NumberLabel())).Append("

"); sb.Append("
").Append(E(deployment?.Name)).Append(" · ").Append(report.ReportDate.ToString("yyyy-MM-dd")).Append(" · ").Append(E(((DeploymentTimeReportStatuses)report.Status).ToString())); if (report.Scope == DeploymentTimeReportScopes.Crew) sb.Append(" · Crew: ").Append(E(Named(report.DeploymentUnitId))); if (report.Scope == DeploymentTimeReportScopes.Individual) sb.Append(" · Resource: ").Append(E(Named(report.DeploymentPersonnelId))); @@ -620,7 +626,7 @@ static string C(object value) var report = reports[entry.DeploymentTimeReportId]; sb.AppendLine(string.Join(",", new[] { - C(report.ReportNumber), C(report.ReportDate.ToString("yyyy-MM-dd")), C(((DeploymentTimeReportStatuses)report.Status).ToString()), C(report.IncidentNumber), C(report.ResourceOrderNumber), C(report.RequestNumber), C(report.CostCode), + C(report.NumberText()), C(report.ReportDate.ToString("yyyy-MM-dd")), C(((DeploymentTimeReportStatuses)report.Status).ToString()), C(report.IncidentNumber), C(report.ResourceOrderNumber), C(report.RequestNumber), C(report.CostCode), C(((DeploymentTimeSubjectTypes)entry.SubjectType).ToString()), C(entry.SubjectId), C(entry.SubjectId != null && names.TryGetValue(entry.SubjectId, out var name) ? name : null), C(((DeploymentTimeEntryTypes)entry.EntryType).ToString()), C(entry.StartTime), C(entry.EndTime), C(entry.PaidBreakMinutes), C(entry.UnpaidBreakMinutes), C(entry.Hours), C(entry.CrewSizeSnapshot), C(entry.CertificationCode), C(entry.MileageKm), C(entry.FuelDeductionLitres), C(entry.AgencySuppliedMeals), C(entry.AgencySuppliedAccommodation), C(entry.Notes), C(report.InvoiceId) diff --git a/Core/Resgrid.Services/Records/CallSourceDataBuilder.cs b/Core/Resgrid.Services/Records/CallSourceDataBuilder.cs index 5a5a52724..213428476 100644 --- a/Core/Resgrid.Services/Records/CallSourceDataBuilder.cs +++ b/Core/Resgrid.Services/Records/CallSourceDataBuilder.cs @@ -562,7 +562,9 @@ public static CallSourceMilestone PersonnelMilestone(int rawStatus, IReadOnlyDic case ActionBaseTypes.Cleared: case ActionBaseTypes.Returning: return CallSourceMilestone.Cleared; case ActionBaseTypes.Available: - case ActionBaseTypes.InQuarters: return CallSourceMilestone.InService; + case ActionBaseTypes.InQuarters: + case ActionBaseTypes.Completed: + case ActionBaseTypes.OnPatrol: return CallSourceMilestone.InService; default: return CallSourceMilestone.None; } } diff --git a/Core/Resgrid.Services/Records/IncidentReportsService.cs b/Core/Resgrid.Services/Records/IncidentReportsService.cs index 728d83328..1bcfff201 100644 --- a/Core/Resgrid.Services/Records/IncidentReportsService.cs +++ b/Core/Resgrid.Services/Records/IncidentReportsService.cs @@ -69,6 +69,7 @@ public partial class IncidentReportsService : IIncidentReportsService private readonly IRecordsEvidenceService _evidenceService; private readonly IIncidentSourceFeedService _feeds; private readonly IRecordsProtectionService _protection; + private readonly IDepartmentsService _departments; public IncidentReportsService(IRmsIncidentReportsRepository reports, IRmsSourceFactsRepository facts, IRmsUnitResponsesRepository units, IRmsIncidentTypesRepository types, IRmsActionTacticsRepository tactics, IRmsAidsRepository aids, IRmsLocationsRepository locations, @@ -79,8 +80,9 @@ public IncidentReportsService(IRmsIncidentReportsRepository reports, IRmsSourceF IDepartmentGroupsService groups, IUserProfileService profiles, IPersonnelRolesService roles, IUnitsService unitsService, ICallsService calls, IDepartmentDataProtectionService dataProtection, IUnitOfWork unitOfWork, INerisProfileService neris, INerisMappingService mapping, INerisValidationService validation, IRecordsAuthorizationService authorization, IRmsRecordAttachmentsRepository attachments, IRmsEvidenceArtifactsRepository evidence, IRecordsUdfService udf, IRecordsEvidenceService evidenceService, - IIncidentSourceFeedService feeds, IRecordsProtectionService protection) + IIncidentSourceFeedService feeds, IRecordsProtectionService protection, IDepartmentsService departments) { + _departments = departments; _protection = protection; _feeds = feeds; _reports = reports; @@ -1599,7 +1601,10 @@ public static string AttestationStatement(string version) private async Task AllocateRecordNumberAsync(RmsIncidentReport report, CancellationToken cancellationToken) { var config = await _settings.GetRecordsNumberingConfigAsync(report.DepartmentId); - var year = (report.CallCreatedOn ?? DateTime.UtcNow).Year; + // The department-local numbering year of the call, so a call logged on a US evening of December 31 (or of the day before + // a fiscal year starts) is not numbered into the next year. + var timeZone = (await _departments.GetDepartmentByIdAsync(report.DepartmentId, false))?.TimeZone; + var year = RecordsNumberingService.NumberingYear(config, report.CallCreatedOn ?? DateTime.UtcNow, timeZone); var scope = RecordNumberFormat.Resolve(RecordNumberFormat.EffectivePattern(config), config.SequenceWidth, config.PrefixFor(RmsDefinitionKeys.NerisIncidentReport), year, report.StationGroupId); var highest = await _reports.GetMaxRecordNumberSequenceAsync(report.DepartmentId, scope.Prefix, scope.Suffix); return scope.Format(config.NextSequence(scope.Key, highest)); diff --git a/Core/Resgrid.Services/Records/RecordsDisclosureService.cs b/Core/Resgrid.Services/Records/RecordsDisclosureService.cs index 32c7b4eed..6f810a6b0 100644 --- a/Core/Resgrid.Services/Records/RecordsDisclosureService.cs +++ b/Core/Resgrid.Services/Records/RecordsDisclosureService.cs @@ -41,13 +41,15 @@ public partial class RecordsDisclosureService : IRecordsDisclosureService private readonly IUnitOfWork _unitOfWork; private readonly IRecordsProtectionService _protection; private readonly IDomainEventOutboxService _outbox; + private readonly IDocumentNumberingService _numbering; public RecordsDisclosureService(IRmsDisclosureRequestsRepository requests, IRmsDisclosureProductionsRepository productions, IRmsOperationalRecordsRepository records, IRmsRevisionsRepository revisions, IRmsAccessAuditsRepository audits, IRecordsAuthorizationService authorization, IDepartmentSettingsService settings, IUnitOfWork unitOfWork, IRmsIncidentReportsRepository reports, IRecordsDocumentService documents, IRmsRecordAttachmentsRepository attachments, Resgrid.Model.Providers.IPdfProvider pdf, IRmsIncidentAnalysesRepository analyses, IRecordAttachmentScanner scanner, IRecordsUdfService udf, - IRecordsProtectionService protection, IDomainEventOutboxService outbox) + IRecordsProtectionService protection, IDomainEventOutboxService outbox, IDocumentNumberingService numbering) { + _numbering = numbering; _protection = protection; _outbox = outbox; _requests = requests; @@ -421,9 +423,18 @@ private static void RequireOpen(RmsDisclosureRequest request) throw new InvalidOperationException("The request is closed; log a new one rather than reopening a released answer."); } + /// + /// The department's own records request pattern when it set one, otherwise PRR-{year}-{sequence:0000}, the year being the + /// records numbering year the request was received in (department-local, a fiscal year when setting 72 starts one), so + /// request numbers restart with the department's report numbers. + /// private async Task AllocateNumberAsync(int departmentId, DateTime receivedOn) { - var prefix = NumberPrefix + receivedOn.Year + "-"; + var custom = await _numbering.TakeCustomNumberAsync(departmentId, DocumentNumberKinds.RecordsRequest, receivedOn); + if (custom != null) + return custom; + + var prefix = NumberPrefix + await _numbering.GetNumberingYearAsync(departmentId, DocumentNumberKinds.RecordsRequest, receivedOn) + "-"; var sequence = await _requests.GetMaxRequestNumberSequenceAsync(departmentId, prefix) + 1; return prefix + sequence.ToString("D4"); } diff --git a/Core/Resgrid.Services/Records/RecordsNumberingService.cs b/Core/Resgrid.Services/Records/RecordsNumberingService.cs index 5de067cac..0326b5a6d 100644 --- a/Core/Resgrid.Services/Records/RecordsNumberingService.cs +++ b/Core/Resgrid.Services/Records/RecordsNumberingService.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Repositories; using Resgrid.Model.Services; @@ -39,6 +40,16 @@ public static bool IsNumberedType(string definitionKey) return definitionKey != null && NumberedTypes().Any(t => string.Equals(t.Key, definitionKey, StringComparison.Ordinal)); } + /// + /// The numbering year a record that happened at is numbered in: the department-local date, read + /// against the department's year start (the calendar year unless it set a fiscal year). No time zone reads the date as UTC. + /// + public static int NumberingYear(RecordsNumberingConfig config, DateTime utcDate, string timeZone) + { + var local = string.IsNullOrWhiteSpace(timeZone) ? utcDate : DateTimeHelpers.GetLocalDateTime(utcDate, timeZone); + return (config ?? new RecordsNumberingConfig()).YearStart().YearOf(local); + } + public async Task> GetSequencesAsync(int departmentId, RecordsNumberingConfig config, int year) { config ??= new RecordsNumberingConfig(); @@ -89,6 +100,23 @@ public async Task> GetSequencesAsync(int depart } var config = await _settings.GetRecordsNumberingConfigAsync(departmentId, true) ?? new RecordsNumberingConfig(); + var savedYearStart = config.YearStart(); + var month = update.YearStartMonth ?? savedYearStart.Month; + var day = update.YearStartDay ?? savedYearStart.Day; + if (!NumberingYearStart.IsValid(month, day)) + { + result.YearStartRejected = true; + return result; + } + + config.YearStartMonth = month; + config.YearStartDay = day; + config.YearLabel = (int)new NumberingYearStart(month, day, (NumberingYearLabel)(update.YearLabel ?? (int)savedYearStart.Label)).Label; + var yearStart = config.YearStart(); + // The next numbers on the screen were the sequences of the year the old start named; a new start can name another. + var yearStartChanged = yearStart.Month != savedYearStart.Month || yearStart.Day != savedYearStart.Day + || (!yearStart.IsCalendarYear && yearStart.Label != savedYearStart.Label); + config.Pattern = pattern; config.SequenceWidth = RecordNumberFormat.EffectiveWidth(update.SequenceWidth); config.NumberAssignment = (int)RmsNumberAssignment.OnFinalize; @@ -111,7 +139,11 @@ public async Task> GetSequencesAsync(int depart } var requests = (update.NextNumbers ?? new List()).Where(r => r != null && !string.IsNullOrEmpty(r.ScopeKey)).ToList(); - if (requests.Count > 0) + if (requests.Count > 0 && yearStartChanged) + { + result.NotApplied += requests.Count; + } + else if (requests.Count > 0) { // Checked against the pattern being saved: a number typed against the old pattern's sequence is not carried to a new one. var sequences = await GetSequencesAsync(departmentId, config, update.Year); diff --git a/Core/Resgrid.Services/Records/RecordsPreventionGate.cs b/Core/Resgrid.Services/Records/RecordsPreventionGate.cs index eb3b9ca2d..609d75f7c 100644 --- a/Core/Resgrid.Services/Records/RecordsPreventionGate.cs +++ b/Core/Resgrid.Services/Records/RecordsPreventionGate.cs @@ -22,10 +22,12 @@ public sealed class RecordsPreventionGate private readonly IRecordsAuthorizationService _authorization; private readonly IRmsPreventionSequencesRepository _sequences; private readonly IRmsAccessAuditsRepository _audits; + private readonly IDocumentNumberingService _numbering; public RecordsPreventionGate(IRecordsCutoverService cutover, IFeatureToggleService flags, IRecordsAuthorizationService authorization, - IRmsPreventionSequencesRepository sequences, IRmsAccessAuditsRepository audits) + IRmsPreventionSequencesRepository sequences, IRmsAccessAuditsRepository audits, IDocumentNumberingService numbering) { + _numbering = numbering; _cutover = cutover; _flags = flags; _authorization = authorization; @@ -81,11 +83,22 @@ public Task HasAdminAsync(int departmentId, string userId) public Task IsDepartmentAdminAsync(int departmentId, string userId) => _authorization.IsDepartmentAdminAsync(userId, departmentId); - /// {KIND}-{year}-{sequence:0000}, allocated atomically per department/kind/year. + /// + /// The department's own pattern for the kind when it set one (setting 72's DocumentPatterns), otherwise the built-in + /// {KIND}-{year}-{sequence:0000}, allocated atomically per department/kind/year. The year is the records numbering year + /// (department-local, a fiscal year when setting 72 starts one); the counter is keyed by that name, so a year start change + /// whose name matches a year already counted carries on rather than issuing its numbers again. + /// public async Task NextNumberAsync(int departmentId, string kind, DateTime utcNow, CancellationToken cancellationToken = default) { - var next = await _sequences.NextAsync(departmentId, kind, utcNow.Year, cancellationToken); - return $"{kind}-{utcNow.Year}-{next:0000}"; + var documentKind = DocumentNumberKinds.ForPreventionKind(kind); + var custom = documentKind == null ? null : await _numbering.TakeCustomNumberAsync(departmentId, documentKind, utcNow, cancellationToken); + if (custom != null) + return custom; + + var year = await _numbering.GetNumberingYearAsync(departmentId, documentKind ?? DocumentNumberKinds.Occupancy, utcNow); + var next = await _sequences.NextAsync(departmentId, kind, year, cancellationToken); + return $"{kind}-{year}-{next:0000}"; } /// diff --git a/Core/Resgrid.Services/Records/RecordsService.cs b/Core/Resgrid.Services/Records/RecordsService.cs index 7c591d00b..7f0a61118 100644 --- a/Core/Resgrid.Services/Records/RecordsService.cs +++ b/Core/Resgrid.Services/Records/RecordsService.cs @@ -57,6 +57,7 @@ public class RecordsService : IRecordsService private readonly IRecordDefinitionsService _definitions; private readonly IRecordTypedValuesService _typedValues; private readonly IPersonnelRolesService _roles; + private readonly IDepartmentsService _departments; public RecordsService(IRmsOperationalRecordsRepository records, IRmsRecordValueService details, IRmsRecordParticipantsRepository participants, IRmsRecordUnitResponsesRepository units, IRmsRecordAttachmentsRepository attachments, @@ -65,8 +66,10 @@ public RecordsService(IRmsOperationalRecordsRepository records, IRmsRecordValueS IRecordsCutoverService cutover, IDepartmentSettingsService settings, IDepartmentGroupsService groups, IUserProfileService profiles, IUnitsService unitsService, ICallsService calls, IDepartmentDataProtectionService dataProtection, IUnitOfWork unitOfWork, IOutboundQueueProvider outboundQueue, IRecordAttachmentScanner attachmentScanner, IRecordsAuthorizationService authorization, IRecordsUdfService udf, - IRecordsProtectionService protection, IRecordDefinitionsService definitions, IRecordTypedValuesService typedValues, IPersonnelRolesService roles) + IRecordsProtectionService protection, IRecordDefinitionsService definitions, IRecordTypedValuesService typedValues, IPersonnelRolesService roles, + IDepartmentsService departments) { + _departments = departments; _protection = protection; _definitions = definitions; _typedValues = typedValues; @@ -1279,7 +1282,10 @@ private async Task AllocateRecordNumberAsync(RmsOperationalRecord record { var config = await _settings.GetRecordsNumberingConfigAsync(record.DepartmentId); var prefixBase = config.PrefixFor(record.DefinitionKey); - var year = (record.StartedOn ?? DateTime.UtcNow).Year; + // The department-local numbering year it started in, so a record started on a US evening of December 31 (or of the day + // before a fiscal year starts) is not numbered into the next year. + var timeZone = (await _departments.GetDepartmentByIdAsync(record.DepartmentId, false))?.TimeZone; + var year = RecordsNumberingService.NumberingYear(config, record.StartedOn ?? DateTime.UtcNow, timeZone); // Department definitions carry their own numbering policy (plan 4.1 "Numbering"); the department pattern is the fallback. var numbering = record.RecordType == null ? (await DefinitionVersionForAsync(record))?.Numbering : null; RecordNumberScope scope; diff --git a/Core/Resgrid.Services/Search/SearchProjectionService.cs b/Core/Resgrid.Services/Search/SearchProjectionService.cs index 2628f7b16..5cd6415c6 100644 --- a/Core/Resgrid.Services/Search/SearchProjectionService.cs +++ b/Core/Resgrid.Services/Search/SearchProjectionService.cs @@ -603,9 +603,9 @@ public async Task BuildInvoiceAsync(Invoice invoice) 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.Title = Cap("Invoice " + invoice.NumberLabel(), 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.Keywords = Cap(Join(" ", invoice.NumberText(), 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); @@ -639,9 +639,9 @@ public async Task BuildBidAsync(Bid bid) 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.Title = Cap(Join(" ", "Bid " + bid.NumberLabel(), 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.Keywords = Cap(Join(" ", bid.NumberText(), 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; diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index d6bc0dd8b..5ca41fcbf 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -177,6 +177,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Core/Resgrid.Services/WorkOrderMaintenanceCore.cs b/Core/Resgrid.Services/WorkOrderMaintenanceCore.cs index ea59b90ad..b318ed1c5 100644 --- a/Core/Resgrid.Services/WorkOrderMaintenanceCore.cs +++ b/Core/Resgrid.Services/WorkOrderMaintenanceCore.cs @@ -121,7 +121,7 @@ await WorkerTransactionAsync(departmentId, async events => if (order == null) { order = New(new ChecklistActor { DepartmentId = departmentId, UserId = completion.CreatedBy }); - order.RequestId = request; order.NumberYear = Now.Year; order.NumberSequence = await _store.NextNumberAsync(departmentId, order.NumberYear); + order.RequestId = request; await NumberAsync(order, departmentId); order.SourceType = 1; order.SourceChecklistCompletionId = intent.CompletionId; order.SourceChecklistItemId = intent.ItemId; order.SourceOccurrenceId = intent.OccurrenceId; order.Priority = intent.Priority; order.TargetUnitId = target.TargetUnitId; order.TargetGroupId = target.TargetGroupId; order.InventoryAssetId = target.InventoryAssetId; order.CurrencyCode = await DepartmentCurrencyAsync(departmentId); diff --git a/Core/Resgrid.Services/WorkOrderNotificationService.cs b/Core/Resgrid.Services/WorkOrderNotificationService.cs index e60931164..92cf3d34a 100644 --- a/Core/Resgrid.Services/WorkOrderNotificationService.cs +++ b/Core/Resgrid.Services/WorkOrderNotificationService.cs @@ -65,7 +65,7 @@ public async Task DispatchAsync(DomainEventOutboxEntry entry) try { culture = CultureInfo.GetCultureInfo(profile.Language ?? "en"); if (!SupportedLocales.GetSupportedCultures().Contains(culture.TwoLetterISOLanguageName)) culture = CultureInfo.GetCultureInfo("en"); } catch (CultureNotFoundException) { culture = CultureInfo.GetCultureInfo("en"); } // The public work-order number identifies the item without decrypting protected content. - var workOrderNumber = FormattableString.Invariant($"WO-{current.NumberYear}-{current.NumberSequence:D6}"); + var workOrderNumber = current.NumberText(); var handedOff = await _communication.SendNotificationAsync(user, entry.DepartmentId, workOrderNumber + ": " + Strings.GetString("NotificationMessage", culture), number, department, Strings.GetString("NotificationTitle", culture), profile, false, PushEventCode(current.Id)); diff --git a/Core/Resgrid.Services/WorkOrderRecurrenceService.cs b/Core/Resgrid.Services/WorkOrderRecurrenceService.cs index 454f167ac..e0b9621f6 100644 --- a/Core/Resgrid.Services/WorkOrderRecurrenceService.cs +++ b/Core/Resgrid.Services/WorkOrderRecurrenceService.cs @@ -325,7 +325,7 @@ await WorkerTransactionAsync(departmentId, async events => var order = await _store.RequestAsync(departmentId, request); if (order == null) { - order = New(owner); order.RequestId = request; order.NumberYear = Now.Year; order.NumberSequence = await _store.NextNumberAsync(departmentId, order.NumberYear); + order = New(owner); order.RequestId = request; await NumberAsync(order, departmentId); order.CurrencyCode = await DepartmentCurrencyAsync(departmentId); order.SourceType = 2; order.Type = (int)WorkOrderType.Preventive; order.Priority = row.Priority; order.TargetUnitId = row.TargetUnitId; order.TargetGroupId = row.TargetGroupId; order.InventoryAssetId = row.InventoryAssetId; order.WorkOrderRecurrenceId = row.Id; order.RecurrenceVersionId = row.CurrentVersionId; order.RecurrenceCycle = row.Cycle + 1; diff --git a/Core/Resgrid.Services/WorkOrdersService.cs b/Core/Resgrid.Services/WorkOrdersService.cs index 228f4eb6f..fc962f5ac 100644 --- a/Core/Resgrid.Services/WorkOrdersService.cs +++ b/Core/Resgrid.Services/WorkOrdersService.cs @@ -35,13 +35,27 @@ public sealed partial class WorkOrdersService : IWorkOrdersService, IWorkOrderMa private readonly IChecklistAssetSource _maintenanceAssets; private readonly IUnitsService _maintenanceUnits; private readonly Lazy _inventoryCatalog; + private readonly IDocumentNumberingService _documentNumbering; public WorkOrdersService(IWorkOrderRepository store, IWorkOrderAuthorizationService authorization, IReadinessAccessService access, IUnitOfWork uow, IAuditLogsRepository audit, IDomainEventOutboxService outbox, Lazy read, Lazy write, IRecordAttachmentScanner scanner, TimeProvider clock = null, IWorkOrderMaintenanceRepository maintenance = null, Lazy inventoryMaintenance = null, IChecklistRepository checklists = null, - IChecklistAssetSource maintenanceAssets = null, IUnitsService maintenanceUnits = null, Lazy inventoryCatalog = null) + IChecklistAssetSource maintenanceAssets = null, IUnitsService maintenanceUnits = null, Lazy inventoryCatalog = null, + IDocumentNumberingService documentNumbering = null) { _store = store; _authorization = authorization; _access = access; _uow = uow; _audit = audit; _outbox = outbox; _read = read; _write = write; _scanner = scanner; _clock = clock ?? TimeProvider.System; - _maintenance = maintenance; _inventoryMaintenance = inventoryMaintenance; _checklists = checklists; _maintenanceAssets = maintenanceAssets; _maintenanceUnits = maintenanceUnits; _inventoryCatalog = inventoryCatalog; } + _maintenance = maintenance; _inventoryMaintenance = inventoryMaintenance; _checklists = checklists; _maintenanceAssets = maintenanceAssets; _maintenanceUnits = maintenanceUnits; _inventoryCatalog = inventoryCatalog; _documentNumbering = documentNumbering; } private DateTime Now => _clock.GetUtcNow().UtcDateTime; + /// + /// Numbers a new work order: NumberYear/NumberSequence (unique, ordered) in the department's numbering year (setting 117, + /// department-local, a fiscal year when one is set), and DisplayNumber in the department's own pattern or the built-in + /// WO-{year}-{sequence:D6}. Runs under the department lock NextNumberAsync requires. + /// + private async Task NumberAsync(WorkOrder order, int departmentId) + { + order.NumberYear = _documentNumbering == null ? Now.Year : await _documentNumbering.GetNumberingYearAsync(departmentId, DocumentNumberKinds.WorkOrder, Now); + order.NumberSequence = await _store.NextNumberAsync(departmentId, order.NumberYear); + order.DisplayNumber = (_documentNumbering == null ? null : await _documentNumbering.TakeCustomNumberAsync(departmentId, DocumentNumberKinds.WorkOrder, Now)) + ?? DocumentNumbering.LegacyWorkOrderNumber(order.NumberYear, order.NumberSequence); + } private sealed class StoredContent { public WorkOrderContent Fields { get; set; } public string RequestHash { get; set; } } private static T Decode(string value) => JsonConvert.DeserializeObject(value ?? "{}"); private static string Key(WorkOrderRow row) => row.Id; @@ -160,7 +174,7 @@ private async Task CreateAsync(ChecklistActor actor, WorkOrderI input.Content.Currency = await DepartmentCurrencyAsync(actor.DepartmentId); if (previewCurrency != null && previewCurrency != input.Content.Currency) throw new WorkOrderException(409, "BulkPreviewChanged"); var hash = Fingerprint(input); - var row = New(actor); row.CurrencyCode = input.Content.Currency; row.RequestId = input.RequestId; row.NumberYear = Now.Year; row.NumberSequence = await _store.NextNumberAsync(actor.DepartmentId, row.NumberYear); Apply(row, input); + var row = New(actor); row.CurrencyCode = input.Content.Currency; row.RequestId = input.RequestId; await NumberAsync(row, actor.DepartmentId); Apply(row, input); row.OriginalDueOn = row.DueOn; row.Content = JsonConvert.SerializeObject(new StoredContent { Fields = input.Content, RequestHash = hash }); if (input.Content.ApprovedCost.HasValue && (await ReadPolicyAsync(actor)).ApprovalsEnabled) throw new WorkOrderException(409, "SpendingApprovalRequired"); await PinSlaAsync(row); @@ -329,7 +343,7 @@ public async Task ChoicesAsync(ChecklistActor actor) choices.Currency = await DepartmentCurrencyAsync(actor.DepartmentId); return choices; } - private static WorkOrderSummary Summary(WorkOrder row, WorkOrderContent c) => new WorkOrderSummary { Id = row.Id, Number = $"WO-{row.NumberYear}-{row.NumberSequence:D6}", Title = c.Title, Status = (WorkOrderStatus)row.Status, Priority = (WorkOrderPriority)row.Priority, + private static WorkOrderSummary Summary(WorkOrder row, WorkOrderContent c) => new WorkOrderSummary { Id = row.Id, Number = row.NumberText(), Title = c.Title, Status = (WorkOrderStatus)row.Status, Priority = (WorkOrderPriority)row.Priority, Revision = row.Revision, UpdatedOn = row.UpdatedOn, CreatedOn = row.CreatedOn, DueOn = row.DueOn, AssignedToUserId = row.AssignedToUserId, AssignedToRoleId = row.AssignedToRoleId, AssignedToUserIds = row.AssignedToUserIds, AssignedToRoleIds = row.AssignedToRoleIds, UnitId = row.TargetUnitId, GroupId = row.TargetGroupId, AssetId = row.InventoryAssetId }; public async Task ListAsync(ChecklistActor actor, WorkOrderFilter filter) { diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0268_AddDocumentNumbering.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0268_AddDocumentNumbering.cs new file mode 100644 index 000000000..763864274 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0268_AddDocumentNumbering.cs @@ -0,0 +1,55 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Configurable document numbers (setting 117 and setting 72's DocumentPatterns). Work orders, invoices, bids and daily time + /// reports keep their int numbers (unique, ordered, on the API) and gain DisplayNumber, the number as issued; existing rows + /// are backfilled with the text they have always shown, so a custom pattern that reads the same carries on after them. + /// DocumentNumberSequences counts the custom patterns' sequences, one row per department, kind and scope. No FK; the SQL + /// Server department purge and the readiness/business-ops cleanup delete the rows explicitly. + /// + [Migration(268)] + public class M0268_AddDocumentNumbering : Migration + { + private const string Table = "DocumentNumberSequences"; + + public override void Up() + { + if (!Schema.Table(Table).Exists()) + { + Create.Table(Table) + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("Kind").AsString(32).NotNullable() + .WithColumn("ScopeKey").AsString(100).NotNullable() + .WithColumn("LastSequence").AsInt32().NotNullable() + .WithColumn("FloorSequence").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("FloorSetOn").AsDateTime2().Nullable() + .WithColumn("FloorSetByUserId").AsString(128).Nullable() + .WithColumn("ModifiedOn").AsDateTime2().NotNullable(); + + Create.PrimaryKey("PK_DocumentNumberSequences").OnTable(Table).Columns("DepartmentId", "Kind", "ScopeKey"); + } + + foreach (var table in new[] { "WorkOrders", "Invoices", "Bids", "DeploymentTimeReports" }) + Execute.Sql($"IF COL_LENGTH('{table}', 'DisplayNumber') IS NULL ALTER TABLE [{table}] ADD [DisplayNumber] nvarchar(50) NULL;"); + + // Separate batches: the columns must exist before the statements that fill them are compiled. + Execute.Sql("UPDATE [WorkOrders] SET [DisplayNumber] = 'WO-' + CAST([NumberYear] AS nvarchar(10)) + '-' + " + + "CASE WHEN [NumberSequence] >= 1000000 THEN CAST([NumberSequence] AS nvarchar(10)) ELSE RIGHT('000000' + CAST([NumberSequence] AS nvarchar(10)), 6) END " + + "WHERE [DisplayNumber] IS NULL;"); + Execute.Sql("UPDATE [Invoices] SET [DisplayNumber] = CAST([InvoiceNumber] AS nvarchar(20)) WHERE [DisplayNumber] IS NULL;"); + Execute.Sql("UPDATE [Bids] SET [DisplayNumber] = CAST([BidNumber] AS nvarchar(20)) WHERE [DisplayNumber] IS NULL;"); + Execute.Sql("UPDATE [DeploymentTimeReports] SET [DisplayNumber] = CAST([ReportNumber] AS nvarchar(20)) WHERE [DisplayNumber] IS NULL;"); + } + + public override void Down() + { + foreach (var table in new[] { "WorkOrders", "Invoices", "Bids", "DeploymentTimeReports" }) + Execute.Sql($"IF COL_LENGTH('{table}', 'DisplayNumber') IS NOT NULL ALTER TABLE [{table}] DROP COLUMN [DisplayNumber];"); + + if (Schema.Table(Table).Exists()) + Delete.Table(Table); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0269_ConvertNumberTextToCitext.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0269_ConvertNumberTextToCitext.cs new file mode 100644 index 000000000..c67cfc7ac --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0269_ConvertNumberTextToCitext.cs @@ -0,0 +1,24 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Version-parity no-op for the PostgreSQL M0269, which moves every human-readable number stored as text (call-number + /// sequence keys, Records prevention and investigation numbers, business operations reference numbers, certification + /// type codes) to citext. + /// SQL Server's nvarchar columns already compare case-insensitively under the database collation, so nothing changes here. + /// + [Migration(269)] + public class M0269_ConvertNumberTextToCitext : Migration + { + public override void Up() + { + // Intentionally empty; see summary. + } + + public override void Down() + { + // Intentionally empty; see summary. + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0268_AddDocumentNumberingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0268_AddDocumentNumberingPg.cs new file mode 100644 index 000000000..7d0ee3ce3 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0268_AddDocumentNumberingPg.cs @@ -0,0 +1,54 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Configurable document numbers (setting 117 and setting 72's DocumentPatterns). Work orders, invoices, bids and daily time + /// reports keep their int numbers (unique, ordered, on the API) and gain displaynumber, the number as issued; existing rows + /// are backfilled with the text they have always shown, so a custom pattern that reads the same carries on after them. + /// documentnumbersequences counts the custom patterns' sequences, one row per department, kind and scope. No FK. Text + /// columns are citext, so keys and numbers compare case-insensitively as they do on SQL Server. + /// + [Migration(268)] + public class M0268_AddDocumentNumberingPg : Migration + { + private const string Table = "documentnumbersequences"; + + public override void Up() + { + if (!Schema.Table(Table).Exists()) + { + Create.Table(Table) + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("kind").AsCustom("citext").NotNullable() + .WithColumn("scopekey").AsCustom("citext").NotNullable() + .WithColumn("lastsequence").AsInt32().NotNullable() + .WithColumn("floorsequence").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("floorseton").AsDateTime2().Nullable() + .WithColumn("floorsetbyuserid").AsCustom("citext").Nullable() + .WithColumn("modifiedon").AsDateTime2().NotNullable(); + + Create.PrimaryKey("pk_documentnumbersequences").OnTable(Table).Columns("departmentid", "kind", "scopekey"); + } + + foreach (var table in new[] { "workorders", "invoices", "bids", "deploymenttimereports" }) + Execute.Sql($"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS displaynumber citext NULL;"); + + // lpad would cut a longer sequence to six digits, so it only pads the short ones. + Execute.Sql("UPDATE workorders SET displaynumber = 'WO-' || numberyear::text || '-' || " + + "CASE WHEN numbersequence >= 1000000 THEN numbersequence::text ELSE lpad(numbersequence::text, 6, '0') END WHERE displaynumber IS NULL;"); + Execute.Sql("UPDATE invoices SET displaynumber = invoicenumber::text WHERE displaynumber IS NULL;"); + Execute.Sql("UPDATE bids SET displaynumber = bidnumber::text WHERE displaynumber IS NULL;"); + Execute.Sql("UPDATE deploymenttimereports SET displaynumber = reportnumber::text WHERE displaynumber IS NULL;"); + } + + public override void Down() + { + foreach (var table in new[] { "workorders", "invoices", "bids", "deploymenttimereports" }) + Execute.Sql($"ALTER TABLE {table} DROP COLUMN IF EXISTS displaynumber;"); + + if (Schema.Table(Table).Exists()) + Delete.Table(Table); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0269_ConvertNumberTextToCitextPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0269_ConvertNumberTextToCitextPg.cs new file mode 100644 index 000000000..a1b329853 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0269_ConvertNumberTextToCitextPg.cs @@ -0,0 +1,125 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Every human-readable number stored as text goes to citext, the PostgreSQL string convention, so numbers and the + /// numbering keys behind them compare case-insensitively as they do under SQL Server's collation: the M0264 call-number + /// sequences, the Records quality review, prevention and investigation numbers, and the incident, order, request, + /// contract, registration and serial numbers on business operations rows, plus certification type codes (unique per + /// department, matched case-insensitively by the service). Calls, Records cores, disclosures and the M0268 document + /// numbers were citext from the start. Before the unique keys change type, values that differ only by case are made + /// distinct the way SQL Server's collation always kept them: call-number scopes (a pattern whose letters were recased) + /// fold into one counter keeping the highest sequence and floor, and a live certification code clashing with an older + /// one gets the M0213 "-{id}" suffix (plus "-{n}" when another live code already has that). varchar and text are binary + /// coercible to citext, so no row is rewritten; indexes on the columns are rebuilt. A missing table or column is skipped. + /// + [Migration(269)] + public class M0269_ConvertNumberTextToCitextPg : Migration + { + /// Table, column and the type it had before this migration (Down restores it). + public static readonly (string Table, string Column, string PreviousType)[] Columns = + { + ("callnumbersequences", "scopekey", "character varying(100)"), + ("callnumbersequences", "floorsetbyuserid", "character varying(128)"), + ("rmspreventionsequences", "kind", "character varying(16)"), + ("rmsqualityreviews", "recordnumber", "character varying(64)"), + ("rmsoccupancies", "occupancynumber", "character varying(32)"), + ("rmscodesections", "sectionnumber", "character varying(64)"), + ("rmsinspections", "inspectionnumber", "character varying(32)"), + ("rmshydrants", "hydrantnumber", "character varying(64)"), + ("rmspermits", "permitnumber", "character varying(32)"), + ("rmsinvestigationcases", "casenumber", "character varying(32)"), + ("rmsinvestigationcaseincidents", "recordnumber", "character varying(64)"), + ("rmsinvestigationevidence", "evidencenumber", "character varying(32)"), + ("rmsinvestigationreferrals", "referencenumber", "character varying(100)"), + ("departmentbillingidentities", "taxregistrationnumber", "character varying(100)"), + ("departmentbillingidentities", "secondarytaxregistrationnumber", "character varying(100)"), + ("departmentbillingidentities", "workerscompaccountnumber", "character varying(100)"), + ("unitcertifications", "number", "text"), + ("servicecontracts", "contractnumber", "character varying(100)"), + ("departmentcompliancedocuments", "documentnumber", "text"), + ("bids", "incidentnumber", "character varying(100)"), + ("deployments", "incidentnumber", "character varying(100)"), + ("deployments", "servicerequestnumber", "character varying(100)"), + ("deployments", "resourceordernumber", "character varying(100)"), + ("deployments", "requestnumber", "character varying(100)"), + ("deploymenttimereports", "incidentnumber", "character varying(100)"), + ("deploymenttimereports", "resourceordernumber", "character varying(100)"), + ("deploymenttimereports", "requestnumber", "character varying(100)"), + ("caloesmarsresourceprofiles", "serialnumber", "text"), + ("workforceemployerprofiles", "sosnumber", "text"), + ("workforceaffiliatedentities", "sosnumber", "text"), + ("departmentcertificationtypes", "code", "character varying(50)"), + }; + + public override void Up() + { + Execute.Sql("CREATE EXTENSION IF NOT EXISTS citext;"); + + if (Schema.Table("callnumbersequences").Column("scopekey").Exists()) + { + // The survivor of each case-folded scope is the casing written last (the pattern in use); it takes the highest + // sequence and the highest floor, with who raised that floor and when, then the other casings go. + Execute.Sql(@"UPDATE callnumbersequences s + SET lastsequence = f.lastsequence, floorsequence = f.floorsequence, floorseton = f.floorseton, + floorsetbyuserid = f.floorsetbyuserid, modifiedon = f.modifiedon + FROM (SELECT DISTINCT departmentid, FIRST_VALUE(scopekey) OVER latest AS keeper, + FIRST_VALUE(floorseton) OVER raised AS floorseton, FIRST_VALUE(floorsetbyuserid) OVER raised AS floorsetbyuserid, + MAX(lastsequence) OVER folded AS lastsequence, MAX(floorsequence) OVER folded AS floorsequence, + MAX(modifiedon) OVER folded AS modifiedon, COUNT(*) OVER folded AS copies + FROM callnumbersequences + WINDOW folded AS (PARTITION BY departmentid, lower(scopekey)), + latest AS (folded ORDER BY modifiedon DESC, scopekey COLLATE ""C""), + raised AS (folded ORDER BY floorsequence DESC, floorseton DESC NULLS LAST)) f + WHERE f.copies > 1 AND s.departmentid = f.departmentid AND s.scopekey = f.keeper;"); + Execute.Sql(@"DELETE FROM callnumbersequences s + USING (SELECT departmentid, scopekey, ROW_NUMBER() OVER (PARTITION BY departmentid, lower(scopekey) + ORDER BY modifiedon DESC, scopekey COLLATE ""C"") AS place + FROM callnumbersequences) r + WHERE r.place > 1 AND s.departmentid = r.departmentid AND s.scopekey = r.scopekey;"); + } + + // ux_departmentcertificationtypes_code covers live rows only; the oldest live spelling keeps its code. A suffixed code + // can itself match another live code in any casing (a typed "CERT-6"), so each one is checked against every live code + // in the department and takes a further "-{n}" until it is free, still within the 50-character column. + if (Schema.Table("departmentcertificationtypes").Column("code").Exists()) + Execute.Sql(@"DO $$ + DECLARE duplicate record; suffix text; candidate text; attempt int; + BEGIN + FOR duplicate IN SELECT t.departmentcertificationtypeid AS id, t.departmentid, t.code + FROM departmentcertificationtypes t + JOIN (SELECT departmentcertificationtypeid, ROW_NUMBER() OVER (PARTITION BY departmentid, lower(code) + ORDER BY departmentcertificationtypeid) AS place + FROM departmentcertificationtypes WHERE isdeleted = FALSE AND code IS NOT NULL) d + ON d.departmentcertificationtypeid = t.departmentcertificationtypeid + WHERE d.place > 1 + ORDER BY t.departmentcertificationtypeid + LOOP + attempt := 0; + LOOP + suffix := '-' || duplicate.id::text || CASE WHEN attempt = 0 THEN '' ELSE '-' || attempt::text END; + candidate := LEFT(duplicate.code, LEAST(38, 50 - length(suffix))) || suffix; + EXIT WHEN NOT EXISTS (SELECT 1 FROM departmentcertificationtypes + WHERE departmentid = duplicate.departmentid AND isdeleted = FALSE AND lower(code) = lower(candidate) + AND departmentcertificationtypeid <> duplicate.id); + attempt := attempt + 1; + END LOOP; + UPDATE departmentcertificationtypes SET code = candidate WHERE departmentcertificationtypeid = duplicate.id; + END LOOP; + END $$;"); + + foreach (var (table, column, _) in Columns) + if (Schema.Table(table).Column(column).Exists()) + Execute.Sql($"ALTER TABLE {table} ALTER COLUMN {column} TYPE citext;"); + } + + public override void Down() + { + // Folded call-number scopes stay folded. + foreach (var (table, column, previousType) in Columns) + if (Schema.Table(table).Column(column).Exists()) + Execute.Sql($"ALTER TABLE {table} ALTER COLUMN {column} TYPE {previousType};"); + } + } +} diff --git a/Providers/Resgrid.Providers.Payments/StripeConnectPaymentProvider.cs b/Providers/Resgrid.Providers.Payments/StripeConnectPaymentProvider.cs index 05a29c0b2..5313440da 100644 --- a/Providers/Resgrid.Providers.Payments/StripeConnectPaymentProvider.cs +++ b/Providers/Resgrid.Providers.Payments/StripeConnectPaymentProvider.cs @@ -106,7 +106,7 @@ public async Task CreatePaymentRequestAsync(DepartmentPa ["invoiceId"] = spec.InvoiceId, ["paymentRequestId"] = spec.PaymentRequestId }; - var label = $"Invoice {spec.InvoiceNumber}"; + var label = $"Invoice {spec.InvoiceDisplayNumber ?? spec.InvoiceNumber.ToString(CultureInfo.InvariantCulture)}"; var options = new SessionCreateOptions { Mode = "payment", diff --git a/Repositories/Resgrid.Repositories.DataRepository/CallDispatchUnitRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/CallDispatchUnitRepository.cs index bbeb9f37f..90f8fd4ce 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/CallDispatchUnitRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/CallDispatchUnitRepository.cs @@ -70,19 +70,20 @@ public async Task> GetCallUnitDispatchesByCallIdAs } } - public async Task> GetOpenCallIdsForUnitAsync(int departmentId, int unitId) + public async Task> GetOpenCallUnitDispatchesForUnitAsync(int departmentId, int unitId) { try { - var selectFunction = new Func>>(async x => + var selectFunction = new Func>>(async x => { var dynamicParameters = new DynamicParametersExtension(); dynamicParameters.Add("DepartmentId", departmentId); dynamicParameters.Add("UnitId", unitId); + dynamicParameters.Add("Now", DateTime.UtcNow); - var query = _queryFactory.GetQuery(); + var query = _queryFactory.GetQuery(); - return await x.QueryAsync(sql: query, + return await x.QueryAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); }); @@ -120,6 +121,7 @@ public async Task> GetOpenCallUnitDispatchesForDep { var dynamicParameters = new DynamicParametersExtension(); dynamicParameters.Add("DepartmentId", departmentId); + dynamicParameters.Add("Now", DateTime.UtcNow); var query = _queryFactory.GetQuery(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/CallDispatchesRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/CallDispatchesRepository.cs index 77359dd93..6a286b20e 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/CallDispatchesRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/CallDispatchesRepository.cs @@ -129,19 +129,20 @@ public async Task> GetCallDispatchesByCallIdAsync(int } } - public async Task> GetOpenCallIdsForUserAsync(int departmentId, string userId) + public async Task> GetOpenCallDispatchesForUserAsync(int departmentId, string userId) { try { - var selectFunction = new Func>>(async x => + var selectFunction = new Func>>(async x => { var dynamicParameters = new DynamicParametersExtension(); dynamicParameters.Add("DepartmentId", departmentId); dynamicParameters.Add("UserId", userId); + dynamicParameters.Add("Now", DateTime.UtcNow); - var query = _queryFactory.GetQuery(); + var query = _queryFactory.GetQuery(); - return await x.QueryAsync(sql: query, + return await x.QueryAsync(sql: query, param: dynamicParameters, transaction: _unitOfWork.Transaction); }); diff --git a/Repositories/Resgrid.Repositories.DataRepository/CallNumberSequencesRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/CallNumberSequencesRepository.cs index 0eae40deb..0506aff46 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/CallNumberSequencesRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/CallNumberSequencesRepository.cs @@ -115,6 +115,14 @@ public async Task> GetDeletedCallNumbersAsync(int departmentId, Dat return (await QueryAsync(sql, new { DepartmentId = departmentId, Deleted = true, FromUtc = DatabaseTimestamp(fromUtc), ToUtc = DatabaseTimestamp(toUtc) })).ToList(); } + public async Task> GetCallNumbersAsync(int departmentId, DateTime fromUtc, DateTime toUtc) + { + var sql = $"SELECT c.{Col("Number")} FROM {Tbl("Calls")} c WHERE c.{Col("DepartmentId")} = {P}DepartmentId " + + $"AND c.{Col("Number")} IS NOT NULL AND c.{Col("LoggedOn")} >= {P}FromUtc AND c.{Col("LoggedOn")} < {P}ToUtc"; + + return (await QueryAsync(sql, new { DepartmentId = departmentId, FromUtc = DatabaseTimestamp(fromUtc), ToUtc = DatabaseTimestamp(toUtc) })).ToList(); + } + public async Task GetHighestIssuedAsync(int departmentId, string numberPrefix, string numberSuffix, DateTime? fromUtc, DateTime? toUtc) { numberPrefix ??= string.Empty; diff --git a/Repositories/Resgrid.Repositories.DataRepository/ChatbotLinkingCodeRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/ChatbotLinkingCodeRepository.cs index ae317a91d..9110cc379 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/ChatbotLinkingCodeRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/ChatbotLinkingCodeRepository.cs @@ -39,7 +39,7 @@ public async Task GetByCodeAsync(string code) var pn = _sqlConfiguration.ParameterNotation; var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres - ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatbotlinkingcodes WHERE code = {pn}Code" + ? $"SELECT * FROM {_sqlConfiguration.SchemaName}.chatbotlinkingcodes WHERE code = {pn}Code::citext" : $"SELECT * FROM {_sqlConfiguration.SchemaName}.[ChatbotLinkingCodes] WHERE [Code] = {pn}Code"; var selectFunction = new Func>>(async x => diff --git a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs index c1c1e102e..bba65bcc0 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Configs/SqlConfiguration.cs @@ -351,9 +351,9 @@ protected SqlConfiguration() { } public string SelectCallNotesByCallIdQuery { get; set; } public string SelectAllCallGroupDispsByCallIdQuery { get; set; } public string SelectAllCallUnitDispsByCallIdQuery { get; set; } - public string SelectOpenCallIdsForUnitQuery { get; set; } + public string SelectOpenCallUnitDispatchesForUnitQuery { get; set; } public string SelectOpenCallUnitDispatchesForDepartmentQuery { get; set; } - public string SelectOpenCallIdsForUserQuery { get; set; } + public string SelectOpenCallDispatchesForUserQuery { get; set; } public string SelectCallUnitDispatchesForCallsInRangeQuery { get; set; } public string SelectCallDispatchesForCallsInRangeQuery { get; set; } public string SelectCallDispatchGroupsForCallsInRangeQuery { get; set; } diff --git a/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs index f1a632fab..599a444af 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DeleteRepository.cs @@ -66,6 +66,8 @@ DELETE FROM [dbo].[CallNotes] WHERE CallId IN (SELECT CallId FROM [dbo].[Calls] DELETE FROM [dbo].[CallLocationIndexStates] WHERE DepartmentId = @DepartmentId -- Call number counters have no FK DELETE FROM [dbo].[CallNumberSequences] WHERE DepartmentId = @DepartmentId + -- Document number counters (work orders, invoices, bids, time reports, records requests, prevention) have no FK + DELETE FROM [dbo].[DocumentNumberSequences] WHERE DepartmentId = @DepartmentId -- Department API keys have no FK DELETE FROM [dbo].[DepartmentApiKeys] WHERE DepartmentId = @DepartmentId DELETE FROM [dbo].[CallDispatches] WHERE CallId IN (SELECT CallId FROM [dbo].[Calls] WHERE DepartmentId = @DepartmentId) diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentCertificationTypeRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentCertificationTypeRepository.cs index e1ad60c4b..b24156324 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DepartmentCertificationTypeRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentCertificationTypeRepository.cs @@ -18,12 +18,12 @@ public DepartmentCertificationTypeRepository(IConnectionProvider connectionProvi public Task GetByCodeAsync(int departmentId, string code) => QueryFirstOrDefaultAsync( - $"SELECT * FROM {Tbl("DepartmentCertificationTypes")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("Code")} = {P}Code AND {Col("IsDeleted")} = {False}", + $"SELECT * FROM {Tbl("DepartmentCertificationTypes")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("Code")} = {CaseInsensitive("Code")} AND {Col("IsDeleted")} = {False}", new { DepartmentId = departmentId, Code = code ?? string.Empty }); public Task> GetByCodesAsync(int departmentId, IEnumerable codes) => QueryAsync( - $"SELECT * FROM {Tbl("DepartmentCertificationTypes")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False} AND {InList("Code", "Codes")}", + $"SELECT * FROM {Tbl("DepartmentCertificationTypes")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("IsDeleted")} = {False} AND {CaseInsensitiveInList("Code", "Codes")}", new { DepartmentId = departmentId, Codes = InListValue(codes) }); public Task> GetActiveForDepartmentAsync(int departmentId, int? appliesTo = null) => diff --git a/Repositories/Resgrid.Repositories.DataRepository/DocumentNumberSequencesRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DocumentNumberSequencesRepository.cs new file mode 100644 index 000000000..bf6d1b837 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/DocumentNumberSequencesRepository.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// Department document-number sequences (M0268): one atomic upsert per write and dialect, so two documents never share a number. + public class DocumentNumberSequencesRepository : RmsRepositoryBase, IDocumentNumberSequencesRepository + { + /// Where each document kind keeps its issued numbers, as table and text column. + private static readonly Dictionary NumberColumns = new Dictionary(StringComparer.Ordinal) + { + [DocumentNumberKinds.WorkOrder] = ("WorkOrders", "DisplayNumber"), + [DocumentNumberKinds.Invoice] = ("Invoices", "DisplayNumber"), + [DocumentNumberKinds.Bid] = ("Bids", "DisplayNumber"), + [DocumentNumberKinds.TimeReport] = ("DeploymentTimeReports", "DisplayNumber"), + [DocumentNumberKinds.RecordsRequest] = ("RmsDisclosureRequests", "RequestNumber"), + [DocumentNumberKinds.Occupancy] = ("RmsOccupancies", "OccupancyNumber"), + [DocumentNumberKinds.Inspection] = ("RmsInspections", "InspectionNumber"), + [DocumentNumberKinds.Permit] = ("RmsPermits", "PermitNumber"), + [DocumentNumberKinds.Investigation] = ("RmsInvestigationCases", "CaseNumber"), + [DocumentNumberKinds.Evidence] = ("RmsInvestigationEvidence", "EvidenceNumber") + }; + + public DocumentNumberSequencesRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) { } + + public Task GetSequenceAsync(int departmentId, string kind, string scopeKey) + { + return QueryFirstOrDefaultAsync( + $"SELECT * FROM {Tbl("DocumentNumberSequences")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("Kind")} = {P}Kind AND {Col("ScopeKey")} = {P}ScopeKey", + new { DepartmentId = departmentId, Kind = kind, ScopeKey = scopeKey }); + } + + public Task TakeNextAsync(int departmentId, string kind, string scopeKey, int seed, CancellationToken cancellationToken = default) + { + // The row stores the LAST sequence issued. Both statements insert it on first use (issuing seed + 1) and otherwise + // advance it, returning the sequence issued, in a single atomic statement. + var table = Tbl("DocumentNumberSequences"); + string sql; + if (IsPostgres) + { + sql = $@"INSERT INTO {table} AS t (departmentid, kind, scopekey, lastsequence, floorsequence, modifiedon) + VALUES ({P}DepartmentId, {P}Kind, {P}ScopeKey, {P}Seed + 1, 0, {P}Now) + ON CONFLICT (departmentid, kind, scopekey) DO UPDATE SET lastsequence = t.lastsequence + 1, modifiedon = EXCLUDED.modifiedon + RETURNING lastsequence"; + } + else + { + sql = $@"MERGE {table} WITH (HOLDLOCK) AS t + USING (SELECT {P}DepartmentId AS DepartmentId, {P}Kind AS Kind, {P}ScopeKey AS ScopeKey) AS s + ON t.[DepartmentId] = s.DepartmentId AND t.[Kind] = s.Kind AND t.[ScopeKey] = s.ScopeKey + WHEN MATCHED THEN UPDATE SET [LastSequence] = t.[LastSequence] + 1, [ModifiedOn] = {P}Now + WHEN NOT MATCHED THEN INSERT ([DepartmentId], [Kind], [ScopeKey], [LastSequence], [FloorSequence], [ModifiedOn]) VALUES (s.DepartmentId, s.Kind, s.ScopeKey, {P}Seed + 1, 0, {P}Now) + OUTPUT inserted.[LastSequence];"; + } + + return ScalarAsync(sql, new { DepartmentId = departmentId, Kind = kind, ScopeKey = scopeKey, Seed = Math.Max(0, seed), Now = DatabaseTimestamp(DateTime.UtcNow) }, cancellationToken); + } + + public Task RaiseFloorAsync(int departmentId, string kind, string scopeKey, int nextSequence, string userId, DateTime now, CancellationToken cancellationToken = default) + { + var table = Tbl("DocumentNumberSequences"); + string sql; + if (IsPostgres) + { + sql = $@"INSERT INTO {table} AS t (departmentid, kind, scopekey, lastsequence, floorsequence, floorseton, floorsetbyuserid, modifiedon) + VALUES ({P}DepartmentId, {P}Kind, {P}ScopeKey, {P}Next - 1, {P}Next, {P}Now, {P}UserId, {P}Now) + ON CONFLICT (departmentid, kind, scopekey) DO UPDATE SET lastsequence = GREATEST(t.lastsequence, EXCLUDED.lastsequence), + floorsequence = GREATEST(t.floorsequence, EXCLUDED.floorsequence), floorseton = EXCLUDED.floorseton, + floorsetbyuserid = EXCLUDED.floorsetbyuserid, modifiedon = EXCLUDED.modifiedon"; + } + else + { + sql = $@"MERGE {table} WITH (HOLDLOCK) AS t + USING (SELECT {P}DepartmentId AS DepartmentId, {P}Kind AS Kind, {P}ScopeKey AS ScopeKey) AS s + ON t.[DepartmentId] = s.DepartmentId AND t.[Kind] = s.Kind AND t.[ScopeKey] = s.ScopeKey + WHEN MATCHED THEN UPDATE SET + [LastSequence] = CASE WHEN t.[LastSequence] > {P}Next - 1 THEN t.[LastSequence] ELSE {P}Next - 1 END, + [FloorSequence] = CASE WHEN t.[FloorSequence] > {P}Next THEN t.[FloorSequence] ELSE {P}Next END, + [FloorSetOn] = {P}Now, [FloorSetByUserId] = {P}UserId, [ModifiedOn] = {P}Now + WHEN NOT MATCHED THEN INSERT ([DepartmentId], [Kind], [ScopeKey], [LastSequence], [FloorSequence], [FloorSetOn], [FloorSetByUserId], [ModifiedOn]) + VALUES (s.DepartmentId, s.Kind, s.ScopeKey, {P}Next - 1, {P}Next, {P}Now, {P}UserId, {P}Now);"; + } + + return ExecuteAsync(sql, new { DepartmentId = departmentId, Kind = kind, ScopeKey = scopeKey, Next = Math.Max(1, nextSequence), UserId = userId, Now = DatabaseTimestamp(now) }, cancellationToken); + } + + public async Task GetHighestIssuedAsync(int departmentId, string kind, string numberPrefix, string numberSuffix) + { + if (kind == null || !NumberColumns.TryGetValue(kind, out var source)) + return 0; + + numberPrefix ??= string.Empty; + numberSuffix ??= string.Empty; + var numbers = $"SELECT x.{Col(source.Column)} AS n FROM {Tbl(source.Table)} x WHERE x.{Col("DepartmentId")} = {P}DepartmentId AND x.{Col(source.Column)} LIKE {P}Pattern ESCAPE '!'"; + // The CASE keeps SUBSTRING from seeing a negative length when the prefix and suffix overlap in a short number. + var sql = IsPostgres + ? $"SELECT MAX(CAST(y.mid AS BIGINT)) FROM (SELECT CASE WHEN CHAR_LENGTH(r.n) > {P}PrefixLength + {P}SuffixLength THEN SUBSTRING(r.n FROM {P}PrefixLength + 1 FOR CHAR_LENGTH(r.n) - {P}PrefixLength - {P}SuffixLength) END AS mid FROM ({numbers}) r) y WHERE y.mid ~ '^[0-9]{{1,18}}$'" + : $"SELECT MAX(TRY_CAST(y.mid AS BIGINT)) FROM (SELECT CASE WHEN LEN(r.n) > {P}PrefixLength + {P}SuffixLength THEN SUBSTRING(r.n, {P}PrefixLength + 1, LEN(r.n) - {P}PrefixLength - {P}SuffixLength) END AS mid FROM ({numbers}) r) y WHERE LEN(y.mid) BETWEEN 1 AND 18 AND y.mid NOT LIKE '%[^0-9]%'"; + var max = await ScalarAsync(sql, new + { + DepartmentId = departmentId, + Pattern = Queries.Calls.SearchCallsQuery.EscapeLike(numberPrefix) + "%" + Queries.Calls.SearchCallsQuery.EscapeLike(numberSuffix), + PrefixLength = numberPrefix.Length, + SuffixLength = numberSuffix.Length + }); + + return (int)Math.Min(Math.Max(max ?? 0, 0), int.MaxValue - 1); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs index 8cb6c5a80..9054d6b18 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/IdentityRepository.cs @@ -63,10 +63,10 @@ public async Task GetUserByUserNameAsync(string userName) { using (IDbConnection db = new NpgsqlConnection(DataConfig.CoreConnectionString)) { - // aspnetusers columns are citext (case-insensitive), so match on the same key ASP.NET - // Identity authenticated against -- normalizedusername -- so a row whose username and - // normalizedusername have drifted apart is still found here. - var result = await db.QueryAsync($"SELECT * FROM aspnetusers WHERE normalizedusername = @normalizedUserName", new { normalizedUserName = userName?.ToUpperInvariant() }); + // Match on the same key ASP.NET Identity authenticated against -- normalizedusername -- so a row + // whose username and normalizedusername have drifted apart is still found here. The ::citext cast + // lets the citext column's index serve the lookup (a text parameter would scan the table). + var result = await db.QueryAsync($"SELECT * FROM aspnetusers WHERE normalizedusername = @normalizedUserName::citext", new { normalizedUserName = userName?.ToUpperInvariant() }); return result.FirstOrDefault(); } @@ -90,9 +90,9 @@ public IdentityUser GetUserByEmail(string email) { using (IDbConnection db = new NpgsqlConnection(DataConfig.CoreConnectionString)) { - // email is citext (case-insensitive), so plain equality is already case-insensitive and can - // use the index -- no LOWER() needed. - return db.Query($"SELECT * FROM aspnetusers WHERE email = @email", new { email = email }).FirstOrDefault(); + // email is citext, but Npgsql binds the string as text and citext = text compares as text: case-sensitive + // and unable to use the index. Casting the parameter to citext gives the case-insensitive, indexed match. + return db.Query($"SELECT * FROM aspnetusers WHERE email = @email::citext", new { email = email }).FirstOrDefault(); } } else diff --git a/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs index 03d6eeae3..1789c6733 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/InvoicingRepositories.cs @@ -229,7 +229,7 @@ public Task> GetAgingDataAsync(int departmentId) parameters.Add("DepartmentId", departmentId); parameters.Add("Statuses", OpenStatuses); return QueryAsync( - $"SELECT {Cols("InvoiceId", "InvoiceNumber", "ContactId", "Status", "DueOn", "Currency", "Total", "AmountPaid")} FROM {Tbl("Invoices")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("Status", "Statuses")} AND {Col("IsDeleted")} = {False} ORDER BY {Col("DueOn")}", + $"SELECT {Cols("InvoiceId", "InvoiceNumber", "DisplayNumber", "ContactId", "Status", "DueOn", "Currency", "Total", "AmountPaid")} FROM {Tbl("Invoices")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("Status", "Statuses")} AND {Col("IsDeleted")} = {False} ORDER BY {Col("DueOn")}", parameters); } diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs index ec124fd4b..d16359285 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/ApiDataModule.cs @@ -304,6 +304,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index 3d231110b..74d816a4c 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -324,6 +324,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs index cf8e54c73..5be6936cc 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/NonWebDataModule.cs @@ -291,6 +291,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs index e56676f7a..397bbff6a 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/TestingDataModule.cs @@ -325,6 +325,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallIdsForUserQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallDispatchesForUserQuery.cs similarity index 82% rename from Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallIdsForUserQuery.cs rename to Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallDispatchesForUserQuery.cs index b2cb8569c..a0621de5a 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallIdsForUserQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallDispatchesForUserQuery.cs @@ -5,27 +5,29 @@ namespace Resgrid.Repositories.DataRepository.Queries.Calls { - public class SelectOpenCallIdsForUserQuery : ISelectQuery + public class SelectOpenCallDispatchesForUserQuery : ISelectQuery { private readonly SqlConfiguration _sqlConfiguration; - public SelectOpenCallIdsForUserQuery(SqlConfiguration sqlConfiguration) + public SelectOpenCallDispatchesForUserQuery(SqlConfiguration sqlConfiguration) { _sqlConfiguration = sqlConfiguration; } public string GetQuery() { - var query = _sqlConfiguration.SelectOpenCallIdsForUserQuery + var query = _sqlConfiguration.SelectOpenCallDispatchesForUserQuery .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, string.Empty, _sqlConfiguration.ParameterNotation, new string[] { "%DID%", - "%USERID%" + "%USERID%", + "%NOW%" }, new string[] { "DepartmentId", - "UserId" + "UserId", + "Now" }, new string[] { "%CALLSTABLE%", diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallUnitDispatchesForDepartmentQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallUnitDispatchesForDepartmentQuery.cs index 2cca94bee..b2b2fe2df 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallUnitDispatchesForDepartmentQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallUnitDispatchesForDepartmentQuery.cs @@ -20,10 +20,12 @@ public string GetQuery() string.Empty, _sqlConfiguration.ParameterNotation, new string[] { - "%DID%" + "%DID%", + "%NOW%" }, new string[] { - "DepartmentId" + "DepartmentId", + "Now" }, new string[] { "%CALLSTABLE%", diff --git a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallIdsForUnitQuery.cs b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallUnitDispatchesForUnitQuery.cs similarity index 76% rename from Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallIdsForUnitQuery.cs rename to Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallUnitDispatchesForUnitQuery.cs index 0e673d7e1..e4b4868a8 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallIdsForUnitQuery.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Queries/Calls/SelectOpenCallUnitDispatchesForUnitQuery.cs @@ -5,27 +5,29 @@ namespace Resgrid.Repositories.DataRepository.Queries.Calls { - public class SelectOpenCallIdsForUnitQuery : ISelectQuery + public class SelectOpenCallUnitDispatchesForUnitQuery : ISelectQuery { private readonly SqlConfiguration _sqlConfiguration; - public SelectOpenCallIdsForUnitQuery(SqlConfiguration sqlConfiguration) + public SelectOpenCallUnitDispatchesForUnitQuery(SqlConfiguration sqlConfiguration) { _sqlConfiguration = sqlConfiguration; } public string GetQuery() { - var query = _sqlConfiguration.SelectOpenCallIdsForUnitQuery + var query = _sqlConfiguration.SelectOpenCallUnitDispatchesForUnitQuery .ReplaceQueryParameters(_sqlConfiguration, _sqlConfiguration.SchemaName, string.Empty, _sqlConfiguration.ParameterNotation, new string[] { "%DID%", - "%UNITID%" + "%UNITID%", + "%NOW%" }, new string[] { "DepartmentId", - "UnitId" + "UnitId", + "Now" }, new string[] { "%CALLSTABLE%", diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsPreventionRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsPreventionRepositories.cs index 30a5c7cc0..593ec3e35 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsPreventionRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsPreventionRepositories.cs @@ -368,7 +368,7 @@ public Task GetByIdForDepartmentAsync(int departmentId, string hydra => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsHydrants")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("RmsHydrantId")} = {P}Id", new { DepartmentId = departmentId, Id = hydrantId }); public Task GetByNumberAsync(int departmentId, string hydrantNumber) - => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsHydrants")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("HydrantNumber")} = {P}Number AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, Number = hydrantNumber }); + => QueryFirstOrDefaultAsync($"SELECT * FROM {Tbl("RmsHydrants")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("HydrantNumber")} = {CaseInsensitive("Number")} AND {Col("DeletedOn")} IS NULL", new { DepartmentId = departmentId, Number = hydrantNumber }); /// Numbers per round trip: DepartmentId plus this many stays under SQL Server's 2,100-parameter ceiling. public const int NumberLookupChunkSize = 1000; @@ -380,14 +380,15 @@ public async Task> GetByNumbersAsync(int foreach (var chunk in requested.Chunk(NumberLookupChunkSize)) { // The requested value is joined, not compared in memory: "h.HydrantNumber = r.RequestedNumber" is the same predicate - // as GetByNumberAsync's "HydrantNumber = @Number", so the column collation decides the match on both databases. + // as GetByNumberAsync's "HydrantNumber = @Number", so the column collation decides the match on both databases (on + // PostgreSQL the requested numbers are citext, as a text array would compare case-sensitively). var parameters = new DynamicParameters(); parameters.Add("DepartmentId", departmentId); string requestedRows; if (IsPostgres) { parameters.Add("Numbers", chunk); - requestedRows = $"unnest({P}Numbers) AS r({Col("RequestedNumber")})"; + requestedRows = $"unnest({P}Numbers::citext[]) AS r({Col("RequestedNumber")})"; } else { diff --git a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs index f4b484aa5..6dff10d0b 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/RmsRepositories.cs @@ -152,6 +152,25 @@ protected string InList(string column, string parameterName, string alias = null : $"{col} IN {P}{parameterName}"; } + /// + /// A string parameter compared with a citext column: "@Param::citext" on PostgreSQL, "@Param" on SQL Server. Npgsql binds a + /// C# string as text, and citext = text resolves to text = text, which is case-sensitive and cannot use the column's + /// index; the cast gives the case-insensitive, indexed match SQL Server's collation gives. + /// + protected string CaseInsensitive(string parameterName) + { + return IsPostgres ? $"{P}{parameterName}::citext" : $"{P}{parameterName}"; + } + + /// against a citext column (see ). + protected string CaseInsensitiveInList(string column, string parameterName, string alias = null) + { + var col = alias == null ? Col(column) : alias + "." + Col(column); + return IsPostgres + ? $"{col} = ANY({P}{parameterName}::citext[])" + : $"{col} IN {P}{parameterName}"; + } + /// Keep the declared parameter type enumerable so Dapper expands SQL Server lists; Npgsql binds the same array to ANY. protected static int[] InListValue(IEnumerable values) => (values ?? Enumerable.Empty()).ToArray(); diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs index 6cef2658b..715a8838d 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs @@ -168,7 +168,7 @@ public PostgreSqlConfiguration() INNER JOIN %SCHEMA%.%DEPARTMENTMEMBERSTABLE% dm ON ds.DepartmentId = dm.DepartmentId WHERE dm.UserId = %USERID% AND ds.SettingType = %SETTINGTYPE%"; SelectDepartmentSettingBySettingAndTypeQuery = @"SELECT ds.* FROM %SCHEMA%.%TABLENAME% ds - WHERE ds.Setting = %SETTING% AND ds.SettingType = %SETTINGTYPE%"; + WHERE ds.Setting = %SETTING%::citext AND ds.SettingType = %SETTINGTYPE%"; SelectAllDepartmentManagerInfoQuery = @"SELECT d.DepartmentId, d.Name, up.FirstName, up.LastName, u.Email FROM %SCHEMA%.%DEPARTMENTSTABLE% d INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = d.ManagingUserId @@ -178,7 +178,7 @@ public PostgreSqlConfiguration() FROM %SCHEMA%.%DEPARTMENTSTABLE% d INNER JOIN %SCHEMA%.%ASPNETUSERSTABLE% u ON u.Id = d.ManagingUserId LEFT OUTER JOIN %SCHEMA%.%USERPROFILESTABLE% up ON up.UserId = d.ManagingUserId - WHERE u.Email = %EMAILADDRESS%"; + WHERE u.Email = %EMAILADDRESS%::citext"; #endregion Department Settings @@ -186,7 +186,7 @@ public PostgreSqlConfiguration() InvitesTable = "Invites"; SelectInviteByCodeQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE Code = %CODE%"; - SelectInviteByEmailQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE EmailAddress = %EMAIL%"; + SelectInviteByEmailQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE EmailAddress = %EMAIL%::citext"; #endregion Invites @@ -233,7 +233,7 @@ public PostgreSqlConfiguration() SELECT %SCHEMA%.%DEPARTMENTSTABLE%.*, %SCHEMA%.%DEPARTMENTMEMBERSTABLE%.* FROM %SCHEMA%.%DEPARTMENTSTABLE% LEFT JOIN %SCHEMA%.%DEPARTMENTMEMBERSTABLE% ON %SCHEMA%.%DEPARTMENTMEMBERSTABLE%.DepartmentId = %SCHEMA%.%DEPARTMENTSTABLE%.DepartmentId - WHERE LinkCode = %CODE%"; + WHERE LinkCode = %CODE%::citext"; SelectDepartmentByIdQuery = @" SELECT d.*, dt.* FROM %SCHEMA%.%DEPARTMENTSTABLE% d @@ -264,13 +264,13 @@ FROM Departments d SELECT %SCHEMA%.%DEPARTMENTSTABLE%.*, %SCHEMA%.%DEPARTMENTMEMBERSTABLE%.* FROM %SCHEMA%.%DEPARTMENTSTABLE% LEFT JOIN %SCHEMA%.%DEPARTMENTMEMBERSTABLE% ON %SCHEMA%.%DEPARTMENTMEMBERSTABLE%.DepartmentId = %SCHEMA%.%DEPARTMENTSTABLE%.DepartmentId - WHERE Name = %NAME%"; + WHERE Name = %NAME%::citext"; SelectDepartmentByUsernameQuery = @"SELECT d.*, dm.* FROM %SCHEMA%.%USERSTABLE% u INNER JOIN %SCHEMA%.%DEPARTMENTMEMBERSTABLE% dm1 ON dm1.UserId = u.Id INNER JOIN %SCHEMA%.%DEPARTMENTSTABLE% d ON d.DepartmentId = dm1.DepartmentId INNER JOIN %SCHEMA%.%DEPARTMENTMEMBERSTABLE% dm ON dm.DepartmentId = d.DepartmentId - WHERE u.UserName = %USERNAME% AND d.DepartmentId = dm.DepartmentId AND dm.IsDeleted = false AND (dm.IsActive = true OR dm.IsDefault = true)"; + WHERE u.UserName = %USERNAME%::citext AND d.DepartmentId = dm.DepartmentId AND dm.IsDeleted = false AND (dm.IsActive = true OR dm.IsDefault = true)"; SelectDepartmentByUserIdQuery = @"SELECT d.*, dm.* FROM %SCHEMA%.%DEPARTMENTMEMBERSTABLE% dm1 INNER JOIN %SCHEMA%.%DEPARTMENTSTABLE% d ON d.DepartmentId = dm1.DepartmentId @@ -281,7 +281,7 @@ FROM Departments d FROM %SCHEMA%.%USERSTABLE% u INNER JOIN %SCHEMA%.%DEPARTMENTMEMBERSTABLE% dm ON dm.UserId = u.Id INNER JOIN %SCHEMA%.%DEPARTMENTSTABLE% d ON dm.DepartmentId = d.DepartmentId - WHERE u.UserName = %USERNAME% AND dm.IsActive = true"; + WHERE u.UserName = %USERNAME%::citext AND dm.IsActive = true"; SelectDepartmentStatsByUserDidQuery = @" SELECT (SELECT COUNT(*) FROM %SCHEMA%.%MESSAGESTABLE% m @@ -298,7 +298,7 @@ FROM Departments d PersonnelRolesTable = "PersonnelRoles"; PersonnelRoleUsersTable = "PersonnelRoleUsers"; SelectRoleByDidAndNameQuery = - "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DID% AND Name = %NAME%"; + "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DID% AND Name = %NAME%::citext"; SelectRolesByDidAndUserQuery = @" SELECT * FROM %SCHEMA%.%PERSONNELROLESTABLE% pr INNER JOIN %SCHEMA%.%PERSONNELROLEUSERSTABLE% pru ON pr.PersonnelRoleId = pru.PersonnelRoleId @@ -403,15 +403,15 @@ SELECT COUNT(*) FROM %SCHEMA%.%MESSAGESTABLE% m InsertRoleQuery = "INSERT INTO %SCHEMA%.%TABLENAME% %COLUMNS% VALUES(%VALUES%)"; DeleteRoleQuery = "DELETE FROM %SCHEMA%.%TABLENAME% WHERE Id = %ID%"; UpdateRoleQuery = "UPDATE %SCHEMA%.%TABLENAME% %SETVALUES% WHERE Id = %ID%"; - SelectRoleByNameQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE Name = %NAME%"; + SelectRoleByNameQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE Name = %NAME%::citext"; SelectRoleByIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE Id = %ID%"; InsertUserQuery = "INSERT INTO %SCHEMA%.%TABLENAME% %COLUMNS% VALUES(%VALUES%) RETURNING id;"; DeleteUserQuery = "DELETE FROM %SCHEMA%.%TABLENAME% WHERE Id = %ID%"; UpdateUserQuery = "UPDATE %SCHEMA%.%TABLENAME% %SETVALUES% WHERE Id = %ID%"; SelectUserByUserNameQuery = - "SELECT %SCHEMA%.%USERTABLE%.*, %SCHEMA%.%USERROLETABLE%.* FROM %SCHEMA%.%USERTABLE% LEFT JOIN %SCHEMA%.%USERROLETABLE% ON %SCHEMA%.%USERROLETABLE%.UserId = %SCHEMA%.%USERTABLE%.Id WHERE UserName = %USERNAME%"; + "SELECT %SCHEMA%.%USERTABLE%.*, %SCHEMA%.%USERROLETABLE%.* FROM %SCHEMA%.%USERTABLE% LEFT JOIN %SCHEMA%.%USERROLETABLE% ON %SCHEMA%.%USERROLETABLE%.UserId = %SCHEMA%.%USERTABLE%.Id WHERE UserName = %USERNAME%::citext"; SelectUserByEmailQuery = - "SELECT %SCHEMA%.%USERTABLE%.*, %SCHEMA%.%USERROLETABLE%.* FROM %SCHEMA%.%USERTABLE% LEFT JOIN %SCHEMA%.%USERROLETABLE% ON %SCHEMA%.%USERROLETABLE%.UserId = %SCHEMA%.%USERTABLE%.Id WHERE Email = %EMAIL%"; + "SELECT %SCHEMA%.%USERTABLE%.*, %SCHEMA%.%USERROLETABLE%.* FROM %SCHEMA%.%USERTABLE% LEFT JOIN %SCHEMA%.%USERROLETABLE% ON %SCHEMA%.%USERROLETABLE%.UserId = %SCHEMA%.%USERTABLE%.Id WHERE Email = %EMAIL%::citext"; SelectUserByIdQuery = "SELECT %SCHEMA%.%USERTABLE%.*, %SCHEMA%.%USERROLETABLE%.* FROM %SCHEMA%.%USERTABLE% LEFT JOIN %SCHEMA%.%USERROLETABLE% ON %SCHEMA%.%USERROLETABLE%.UserId = %SCHEMA%.%USERTABLE%.Id WHERE %SCHEMA%.%USERTABLE%.Id = %ID%"; InsertUserClaimQuery = "INSERT INTO %SCHEMA%.%TABLENAME% %COLUMNS% VALUES(%VALUES%)"; @@ -427,13 +427,13 @@ SELECT COUNT(*) FROM %SCHEMA%.%MESSAGESTABLE% m GetUsersByClaimQuery = "SELECT %USERFILTER% FROM %SCHEMA%.%USERTABLE%, %SCHEMA%.%USERCLAIMTABLE% WHERE ClaimValue = %CLAIMVALUE% AND ClaimType = %CLAIMTYPE%"; GetUsersInRoleQuery = - "SELECT %USERFILTER% FROM %SCHEMA%.%USERTABLE%, %SCHEMA%.%USERROLETABLE%, %SCHEMA%.%ROLETABLE% WHERE %SCHEMA%.%ROLETABLE%.Name = %ROLENAME% AND %SCHEMA%.%USERROLETABLE%.RoleId = %SCHEMA%.%ROLETABLE%.Id AND %SCHEMA%.%USERROLETABLE%.UserId = %SCHEMA%.%USERTABLE%.Id"; + "SELECT %USERFILTER% FROM %SCHEMA%.%USERTABLE%, %SCHEMA%.%USERROLETABLE%, %SCHEMA%.%ROLETABLE% WHERE %SCHEMA%.%ROLETABLE%.Name = %ROLENAME%::citext AND %SCHEMA%.%USERROLETABLE%.RoleId = %SCHEMA%.%ROLETABLE%.Id AND %SCHEMA%.%USERROLETABLE%.UserId = %SCHEMA%.%USERTABLE%.Id"; IsInRoleQuery = - "SELECT 1 FROM %SCHEMA%.%USERTABLE%, %SCHEMA%.%USERROLETABLE%, %SCHEMA%.%ROLETABLE% WHERE %SCHEMA%.%ROLETABLE%.Name = %ROLENAME% AND %SCHEMA%.%USERTABLE%.Id = %USERID% AND %SCHEMA%.%USERROLETABLE%.RoleId = %SCHEMA%.%ROLETABLE%.Id AND %SCHEMA%.%USERROLETABLE%.UserId = %SCHEMA%.%USERTABLE%.Id"; + "SELECT 1 FROM %SCHEMA%.%USERTABLE%, %SCHEMA%.%USERROLETABLE%, %SCHEMA%.%ROLETABLE% WHERE %SCHEMA%.%ROLETABLE%.Name = %ROLENAME%::citext AND %SCHEMA%.%USERTABLE%.Id = %USERID% AND %SCHEMA%.%USERROLETABLE%.RoleId = %SCHEMA%.%ROLETABLE%.Id AND %SCHEMA%.%USERROLETABLE%.UserId = %SCHEMA%.%USERTABLE%.Id"; RemoveClaimsQuery = "DELETE FROM %SCHEMA%.%TABLENAME% WHERE UserId = %ID% AND ClaimType = %CLAIMTYPE% AND ClaimValue = %CLAIMVALUE%"; RemoveUserFromRoleQuery = - "DELETE FROM %SCHEMA%.%USERROLETABLE% WHERE UserId = %USERID% AND RoleId = (SELECT Id FROM %SCHEMA%.%ROLETABLE% WHERE Name = %ROLENAME%)"; + "DELETE FROM %SCHEMA%.%USERROLETABLE% WHERE UserId = %USERID% AND RoleId = (SELECT Id FROM %SCHEMA%.%ROLETABLE% WHERE Name = %ROLENAME%::citext)"; RemoveLoginForUserQuery = "DELETE FROM %SCHEMA%.%TABLENAME% WHERE UserId = %USERID% AND LoginProvider = %LOGINPROVIDER% AND ProviderKey = %PROVIDERKEY%"; UpdateClaimForUserQuery = @@ -480,7 +480,7 @@ SELECT COUNT(*) FROM %SCHEMA%.%MESSAGESTABLE% m DistributionListsTable = "DistributionLists"; DistributionListMembersTable = "DistributionListMembers"; - SelectDListByEmailQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE EmailAddress = %EMAIL%"; + SelectDListByEmailQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE EmailAddress = %EMAIL%::citext"; SelectAllEnabledDListsQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE IsDisabled = false"; SelectDListMembersByListIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DistributionListId = %LISTID%"; @@ -684,9 +684,9 @@ group by 1 ORDER BY Timestamp DESC"; SelectUnitByDIdNameQuery = @" SELECT * FROM %SCHEMA%.%TABLENAME% - WHERE DepartmentId = %DID% AND Name = %UNITNAME% AND IsDeleted = false"; + WHERE DepartmentId = %DID% AND Name = %UNITNAME%::citext AND IsDeleted = false"; SelectUnitTypeByDIdNameQuery = - "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DID% AND Type = %TYPENAME%"; + "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DID% AND Type = %TYPENAME%::citext"; SelectUnitLogsByUnitIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE UnitId = %UNITID% ORDER BY Timestamp DESC"; SelectUnitRolesByUnitIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE UnitId = %UNITID%"; @@ -716,7 +716,7 @@ SELECT ur.* AND u.DepartmentId = %DID% AND (us.DestinationType IS NULL OR us.DestinationType = 2)"; SelectUnitByDIdTypeQuery = - "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DID% AND Type = %TYPE% AND IsDeleted = false"; + "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DID% AND Type = %TYPE%::citext AND IsDeleted = false"; SelectLastUnitStatesByDidQuery = @" SELECT q.*, u.* FROM ( @@ -1316,27 +1316,42 @@ UPDATE CallDispatches SelectCallAttachmentByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CallId = %CALLID%"; SelectAllCallGroupDispsByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CallId = %CALLID%"; SelectAllCallUnitDispsByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CallId = %CALLID%"; - SelectOpenCallIdsForUnitQuery = @" - SELECT c.CallId - FROM %SCHEMA%.%CALLSTABLE% c + SelectOpenCallUnitDispatchesForUnitQuery = @" + SELECT cdu.* + FROM %SCHEMA%.%CALLDISPATCHUNITSTABLE% cdu + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.CallId = cdu.CallId WHERE c.DepartmentId = %DID% AND c.State = 0 AND c.IsDeleted = false - AND EXISTS (SELECT 1 FROM %SCHEMA%.%CALLDISPATCHUNITSTABLE% cdu WHERE cdu.CallId = c.CallId AND cdu.UnitId = %UNITID%)"; + AND (c.HasBeenDispatched = true OR c.DispatchOn IS NULL OR c.DispatchOn <= %NOW%) + AND cdu.UnitId = %UNITID%"; SelectOpenCallUnitDispatchesForDepartmentQuery = @" SELECT cdu.* FROM %SCHEMA%.%CALLDISPATCHUNITSTABLE% cdu INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.CallId = cdu.CallId - WHERE c.DepartmentId = %DID% AND c.State = 0 AND c.IsDeleted = false"; - SelectOpenCallIdsForUserQuery = @" - SELECT c.CallId - FROM %SCHEMA%.%CALLSTABLE% c WHERE c.DepartmentId = %DID% AND c.State = 0 AND c.IsDeleted = false - AND (EXISTS (SELECT 1 FROM %SCHEMA%.%CALLDISPATCHESTABLE% cd WHERE cd.CallId = c.CallId AND cd.UserId = %USERID%) - OR EXISTS (SELECT 1 FROM %SCHEMA%.%CALLDISPATCHGROUPSTABLE% cg - INNER JOIN %SCHEMA%.%DEPARTMENTGROUPMEMBERSTABLE% gm ON gm.DepartmentGroupId = cg.DepartmentGroupId - WHERE cg.CallId = c.CallId AND gm.UserId = %USERID%) - OR EXISTS (SELECT 1 FROM %SCHEMA%.%CALLDISPATCHROLESTABLE% cr - INNER JOIN %SCHEMA%.%PERSONNELROLEUSERSTABLE% ru ON ru.PersonnelRoleId = cr.RoleId - WHERE cr.CallId = c.CallId AND ru.UserId = %USERID%))"; + AND (c.HasBeenDispatched = true OR c.DispatchOn IS NULL OR c.DispatchOn <= %NOW%)"; + SelectOpenCallDispatchesForUserQuery = @" + SELECT cd.CallId, COALESCE(cd.LastDispatchedOn, cd.DispatchedOn) AS DispatchedOn, false AS Paged + FROM %SCHEMA%.%CALLDISPATCHESTABLE% cd + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.CallId = cd.CallId + WHERE c.DepartmentId = %DID% AND c.State = 0 AND c.IsDeleted = false + AND (c.HasBeenDispatched = true OR c.DispatchOn IS NULL OR c.DispatchOn <= %NOW%) + AND cd.UserId = %USERID% + UNION ALL + SELECT cg.CallId, COALESCE(cg.LastDispatchedOn, cg.DispatchedOn), true + FROM %SCHEMA%.%CALLDISPATCHGROUPSTABLE% cg + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.CallId = cg.CallId + INNER JOIN %SCHEMA%.%DEPARTMENTGROUPMEMBERSTABLE% gm ON gm.DepartmentGroupId = cg.DepartmentGroupId + WHERE c.DepartmentId = %DID% AND c.State = 0 AND c.IsDeleted = false + AND (c.HasBeenDispatched = true OR c.DispatchOn IS NULL OR c.DispatchOn <= %NOW%) + AND gm.UserId = %USERID% + UNION ALL + SELECT cr.CallId, COALESCE(cr.LastDispatchedOn, cr.DispatchedOn), true + FROM %SCHEMA%.%CALLDISPATCHROLESTABLE% cr + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.CallId = cr.CallId + INNER JOIN %SCHEMA%.%PERSONNELROLEUSERSTABLE% ru ON ru.PersonnelRoleId = cr.RoleId + WHERE c.DepartmentId = %DID% AND c.State = 0 AND c.IsDeleted = false + AND (c.HasBeenDispatched = true OR c.DispatchOn IS NULL OR c.DispatchOn <= %NOW%) + AND ru.UserId = %USERID%"; SelectCallUnitDispatchesForCallsInRangeQuery = @" SELECT cdu.* FROM %SCHEMA%.%CALLDISPATCHUNITSTABLE% cdu @@ -1477,12 +1492,12 @@ LEFT JOIN (SELECT dgm1.* SELECT %SCHEMA%.%GROUPSTABLE%.*, %SCHEMA%.%GROUPMEMBERSSTABLE%.* FROM %SCHEMA%.%GROUPSTABLE% LEFT JOIN %SCHEMA%.%GROUPMEMBERSSTABLE% ON %SCHEMA%.%GROUPMEMBERSSTABLE%.DepartmentGroupId = %SCHEMA%.%GROUPSTABLE%.DepartmentGroupId - WHERE DispatchEmail = %CODE%"; + WHERE DispatchEmail = %CODE%::citext"; SelectGroupByMessageCodeQuery = @" SELECT %SCHEMA%.%GROUPSTABLE%.*, %SCHEMA%.%GROUPMEMBERSSTABLE%.* FROM %SCHEMA%.%GROUPSTABLE% LEFT JOIN %SCHEMA%.%GROUPMEMBERSSTABLE% ON %SCHEMA%.%GROUPMEMBERSSTABLE%.DepartmentGroupId = %SCHEMA%.%GROUPSTABLE%.DepartmentGroupId - WHERE MessageEmail = %CODE%"; + WHERE MessageEmail = %CODE%::citext"; SelectGroupByGroupIdQuery = @" SELECT dg.*, dgm.* FROM %SCHEMA%.%GROUPSTABLE% dg @@ -1994,10 +2009,10 @@ SELECT r.* SelectActiveCommTestsByScheduleTypeQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE Active = true AND ScheduleType = %SCHEDULETYPE%"; SelectCommTestTargetsByTestIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CommunicationTestId = %COMMTESTID%"; SelectCommTestRunsByTestIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CommunicationTestId = %COMMTESTID% ORDER BY StartedOn DESC"; - SelectCommTestRunByRunCodeQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE RunCode = %RUNCODE% LIMIT 1"; + SelectCommTestRunByRunCodeQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE RunCode = %RUNCODE%::citext LIMIT 1"; SelectOpenCommTestRunsQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE Status IN (0, 1, 2)"; SelectCommTestResultsByRunIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE CommunicationTestRunId = %RUNID%"; - SelectCommTestResultByResponseTokenQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE ResponseToken = %TOKEN% LIMIT 1"; + SelectCommTestResultByResponseTokenQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE ResponseToken = %TOKEN%::citext LIMIT 1"; #endregion CommunicationTests #region Commands diff --git a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs index 0fcdcbc86..3aaee1f5e 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs @@ -1247,27 +1247,42 @@ UPDATE CallDispatches SelectCallAttachmentByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [CallId] = %CALLID%"; SelectAllCallGroupDispsByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [CallId] = %CALLID%"; SelectAllCallUnitDispsByCallIdQuery = "SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [CallId] = %CALLID%"; - SelectOpenCallIdsForUnitQuery = @" - SELECT c.[CallId] - FROM %SCHEMA%.%CALLSTABLE% c + SelectOpenCallUnitDispatchesForUnitQuery = @" + SELECT cdu.* + FROM %SCHEMA%.%CALLDISPATCHUNITSTABLE% cdu + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.[CallId] = cdu.[CallId] WHERE c.[DepartmentId] = %DID% AND c.[State] = 0 AND c.[IsDeleted] = 0 - AND EXISTS (SELECT 1 FROM %SCHEMA%.%CALLDISPATCHUNITSTABLE% cdu WHERE cdu.[CallId] = c.[CallId] AND cdu.[UnitId] = %UNITID%)"; + AND (c.[HasBeenDispatched] = 1 OR c.[DispatchOn] IS NULL OR c.[DispatchOn] <= %NOW%) + AND cdu.[UnitId] = %UNITID%"; SelectOpenCallUnitDispatchesForDepartmentQuery = @" SELECT cdu.* FROM %SCHEMA%.%CALLDISPATCHUNITSTABLE% cdu INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.[CallId] = cdu.[CallId] - WHERE c.[DepartmentId] = %DID% AND c.[State] = 0 AND c.[IsDeleted] = 0"; - SelectOpenCallIdsForUserQuery = @" - SELECT c.[CallId] - FROM %SCHEMA%.%CALLSTABLE% c WHERE c.[DepartmentId] = %DID% AND c.[State] = 0 AND c.[IsDeleted] = 0 - AND (EXISTS (SELECT 1 FROM %SCHEMA%.%CALLDISPATCHESTABLE% cd WHERE cd.[CallId] = c.[CallId] AND cd.[UserId] = %USERID%) - OR EXISTS (SELECT 1 FROM %SCHEMA%.%CALLDISPATCHGROUPSTABLE% cg - INNER JOIN %SCHEMA%.%DEPARTMENTGROUPMEMBERSTABLE% gm ON gm.[DepartmentGroupId] = cg.[DepartmentGroupId] - WHERE cg.[CallId] = c.[CallId] AND gm.[UserId] = %USERID%) - OR EXISTS (SELECT 1 FROM %SCHEMA%.%CALLDISPATCHROLESTABLE% cr - INNER JOIN %SCHEMA%.%PERSONNELROLEUSERSTABLE% ru ON ru.[PersonnelRoleId] = cr.[RoleId] - WHERE cr.[CallId] = c.[CallId] AND ru.[UserId] = %USERID%))"; + AND (c.[HasBeenDispatched] = 1 OR c.[DispatchOn] IS NULL OR c.[DispatchOn] <= %NOW%)"; + SelectOpenCallDispatchesForUserQuery = @" + SELECT cd.[CallId], COALESCE(cd.[LastDispatchedOn], cd.[DispatchedOn]) AS [DispatchedOn], CAST(0 AS bit) AS [Paged] + FROM %SCHEMA%.%CALLDISPATCHESTABLE% cd + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.[CallId] = cd.[CallId] + WHERE c.[DepartmentId] = %DID% AND c.[State] = 0 AND c.[IsDeleted] = 0 + AND (c.[HasBeenDispatched] = 1 OR c.[DispatchOn] IS NULL OR c.[DispatchOn] <= %NOW%) + AND cd.[UserId] = %USERID% + UNION ALL + SELECT cg.[CallId], COALESCE(cg.[LastDispatchedOn], cg.[DispatchedOn]), CAST(1 AS bit) + FROM %SCHEMA%.%CALLDISPATCHGROUPSTABLE% cg + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.[CallId] = cg.[CallId] + INNER JOIN %SCHEMA%.%DEPARTMENTGROUPMEMBERSTABLE% gm ON gm.[DepartmentGroupId] = cg.[DepartmentGroupId] + WHERE c.[DepartmentId] = %DID% AND c.[State] = 0 AND c.[IsDeleted] = 0 + AND (c.[HasBeenDispatched] = 1 OR c.[DispatchOn] IS NULL OR c.[DispatchOn] <= %NOW%) + AND gm.[UserId] = %USERID% + UNION ALL + SELECT cr.[CallId], COALESCE(cr.[LastDispatchedOn], cr.[DispatchedOn]), CAST(1 AS bit) + FROM %SCHEMA%.%CALLDISPATCHROLESTABLE% cr + INNER JOIN %SCHEMA%.%CALLSTABLE% c ON c.[CallId] = cr.[CallId] + INNER JOIN %SCHEMA%.%PERSONNELROLEUSERSTABLE% ru ON ru.[PersonnelRoleId] = cr.[RoleId] + WHERE c.[DepartmentId] = %DID% AND c.[State] = 0 AND c.[IsDeleted] = 0 + AND (c.[HasBeenDispatched] = 1 OR c.[DispatchOn] IS NULL OR c.[DispatchOn] <= %NOW%) + AND ru.[UserId] = %USERID%"; SelectCallUnitDispatchesForCallsInRangeQuery = @" SELECT cdu.* FROM %SCHEMA%.%CALLDISPATCHUNITSTABLE% cdu diff --git a/Tests/Resgrid.Tests/AdminAssist/CatalogTests.cs b/Tests/Resgrid.Tests/AdminAssist/CatalogTests.cs index 61575b084..1aa8de129 100644 --- a/Tests/Resgrid.Tests/AdminAssist/CatalogTests.cs +++ b/Tests/Resgrid.Tests/AdminAssist/CatalogTests.cs @@ -56,7 +56,8 @@ public void Serialized_configuration_fields_have_independent_catalog_entries_inc typeof(PersonnelListStatusOrder), typeof(DepartmentSuppressStaffingInfo), typeof(UnitTypeCallStatusOverrideSetting), typeof(UnitTypeCallStatusOverride), typeof(UnitStatusThresholds), typeof(UnitStatusThreshold), typeof(NewCallFieldPolicy), typeof(NewCallFieldRule), typeof(GroupDispatchScopeConfig), typeof(DispatchRecommendationConfig), typeof(RecordsNumberingConfig), typeof(RecordsNumberingFloor), typeof(RecordsNumberingPrefix), typeof(RecordsSearchConfig), typeof(RecordsRetentionPolicy), - typeof(RecordsRetentionOverride), typeof(RecordsRetentionPolicyVersion), typeof(RecordsDisclosureConfig), typeof(CallNumberingConfig) }) + typeof(RecordsRetentionOverride), typeof(RecordsRetentionPolicyVersion), typeof(RecordsDisclosureConfig), typeof(CallNumberingConfig), + typeof(DocumentNumberingConfig), typeof(DocumentNumberPattern) }) { var fields = type.GetProperties().Where(p => p.GetCustomAttribute() != null).Select(p => type.Name + "." + p.Name); Assert.That(Catalog.Settings.Where(s => s.Binding.StartsWith(type.Name + ".")).Select(s => s.Binding), Is.EquivalentTo(fields)); diff --git a/Tests/Resgrid.Tests/Models/CallStatusAttributionTests.cs b/Tests/Resgrid.Tests/Models/CallStatusAttributionTests.cs index c60f85135..6e2ee2689 100644 --- a/Tests/Resgrid.Tests/Models/CallStatusAttributionTests.cs +++ b/Tests/Resgrid.Tests/Models/CallStatusAttributionTests.cs @@ -40,6 +40,34 @@ public void dispatch_rule_needs_exactly_one_candidate_other_than_the_cleared_cal CallStatusAttribution.PickDispatchCall(null, null).Should().BeNull(); } + [Test] + public void a_clear_cleared_the_call_it_points_at_or_everything_dispatched_before_it() + { + CallStatusAttribution.ClearedByPrevious(false, CallId, (int)DestinationEntityTypes.Call, T0).Should().Be(((int?)null, (DateTime?)null), "not a clearing status"); + CallStatusAttribution.ClearedByPrevious(true, CallId, (int)DestinationEntityTypes.Call, T0).Should().Be(((int?)CallId, (DateTime?)null)); + CallStatusAttribution.ClearedByPrevious(true, 3, (int)DestinationEntityTypes.Station, T0).Should().Be(((int?)null, (DateTime?)T0), "back in quarters"); + CallStatusAttribution.ClearedByPrevious(true, null, null, T0).Should().Be(((int?)null, (DateTime?)T0)); + CallStatusAttribution.ClearedByPrevious(true, CallId, null, T0).Should().Be(((int?)CallId, (DateTime?)T0), "an untyped legacy destination may be the call or a station"); + CallStatusAttribution.ClearedByPrevious(true, null, null, null).Should().Be(((int?)null, (DateTime?)null), "no previous status"); + } + + [Test] + public void the_timed_dispatch_rule_skips_dispatches_before_a_clear_of_everything() + { + var dispatches = new[] + { + new CallDispatchWindow { CallId = 7, DispatchedOn = T0.AddMinutes(-30) }, + new CallDispatchWindow { CallId = 8, DispatchedOn = T0.AddMinutes(-30) }, + new CallDispatchWindow { CallId = 8, DispatchedOn = T0.AddMinutes(5) } + }; + + CallStatusAttribution.PickDispatchCall(dispatches, x => x.CallId, x => x.DispatchedOn, null, null).Should().BeNull("ambiguous"); + CallStatusAttribution.PickDispatchCall(dispatches, x => x.CallId, x => x.DispatchedOn, null, T0).Should().Be(8, "8 was paged again after the clear"); + CallStatusAttribution.PickDispatchCall(dispatches, x => x.CallId, x => x.DispatchedOn, 8, T0).Should().BeNull("the clear named 8"); + CallStatusAttribution.PickDispatchCall(dispatches, x => x.CallId, x => x.DispatchedOn, null, T0.AddMinutes(10)).Should().BeNull(); + CallStatusAttribution.PickDispatchCall((CallDispatchWindow[])null, x => x.CallId, x => x.DispatchedOn, null, null).Should().BeNull(); + } + [Test] public void inference_takes_unlinked_statuses_from_dispatch_until_the_first_clearing_status() { diff --git a/Tests/Resgrid.Tests/Models/CallStatusLinkageTests.cs b/Tests/Resgrid.Tests/Models/CallStatusLinkageTests.cs index 1336dad44..c1d28e5da 100644 --- a/Tests/Resgrid.Tests/Models/CallStatusLinkageTests.cs +++ b/Tests/Resgrid.Tests/Models/CallStatusLinkageTests.cs @@ -116,5 +116,38 @@ public void custom_unit_states_resolve_through_their_base_type() CallStatusLinkage.ResolveUnitStateKind(704, baseTypes).Should().Be(UnitStateTypes.Staging, "deleted statuses still resolve for history"); CallStatusLinkage.ResolveUnitStateKind(799, baseTypes).Should().BeNull(); } + + // Completed and On Patrol are back in service, as in the availability matrix, so they end a unit's or person's part in + // a call for status linking like Available does. + [Test] + public void completed_and_on_patrol_clear_like_available() + { + var unitBaseTypes = CallStatusLinkage.BuildUnitBaseTypeMap(new[] + { + UnitCustomState( + new CustomStateDetail { CustomStateDetailId = 710, BaseType = (int)ActionBaseTypes.Completed }, + new CustomStateDetail { CustomStateDetailId = 711, BaseType = (int)ActionBaseTypes.OnPatrol }, + new CustomStateDetail { CustomStateDetailId = 712, BaseType = (int)ActionBaseTypes.OnBreak }, + new CustomStateDetail { CustomStateDetailId = 713, BaseType = (int)ActionBaseTypes.Transporting }) + }); + var personnelBaseTypes = CallStatusLinkage.BuildBaseTypeMap(new[] + { + PersonnelCustomState( + new CustomStateDetail { CustomStateDetailId = 720, BaseType = (int)ActionBaseTypes.Completed }, + new CustomStateDetail { CustomStateDetailId = 721, BaseType = (int)ActionBaseTypes.OnPatrol }, + new CustomStateDetail { CustomStateDetailId = 722, BaseType = (int)ActionBaseTypes.OnBreak }) + }, CustomStateTypes.Personnel); + + CallStatusLinkage.ResolveUnitStateKind(710, unitBaseTypes).Should().Be(UnitStateTypes.Available); + CallStatusLinkage.ResolveUnitStateKind(711, unitBaseTypes).Should().Be(UnitStateTypes.Available); + CallStatusLinkage.IsClearingUnitState(710, unitBaseTypes).Should().BeTrue(); + CallStatusLinkage.IsClearingUnitState(711, unitBaseTypes).Should().BeTrue(); + CallStatusLinkage.IsClearingUnitState(712, unitBaseTypes).Should().BeFalse("a unit on a break is still dispatchable but has not cleared"); + CallStatusLinkage.IsClearingUnitState(713, unitBaseTypes).Should().BeFalse(); + + CallStatusLinkage.IsClearingPersonnelStatus(720, personnelBaseTypes).Should().BeTrue(); + CallStatusLinkage.IsClearingPersonnelStatus(721, personnelBaseTypes).Should().BeTrue(); + CallStatusLinkage.IsClearingPersonnelStatus(722, personnelBaseTypes).Should().BeFalse(); + } } } diff --git a/Tests/Resgrid.Tests/Models/NewCallFieldPolicyTests.cs b/Tests/Resgrid.Tests/Models/NewCallFieldPolicyTests.cs index cb85b9b32..54948472c 100644 --- a/Tests/Resgrid.Tests/Models/NewCallFieldPolicyTests.cs +++ b/Tests/Resgrid.Tests/Models/NewCallFieldPolicyTests.cs @@ -111,6 +111,43 @@ public void A_hidden_field_survives_the_stored_round_trip() restored.IsVisible(NewCallFieldKeys.DispatchList).Should().BeTrue(); } + [Test] + public void A_plus_code_can_be_hidden_but_never_required() + { + // It is only a way of finding the location and is never stored on the call; the web form has no input for it, + // so a stored requirement blocked every web call. + var policy = PolicyWith(new NewCallFieldRule { Key = NewCallFieldKeys.PlusCode, Visible = true, Required = true }); + + NewCallFieldKeys.CanBeRequired(NewCallFieldKeys.PlusCode).Should().BeFalse(); + policy.IsRequired(NewCallFieldKeys.PlusCode).Should().BeFalse(); + NewCallFieldPolicyValidator.Validate(policy, new NewCallFieldValues()).Should().BeEmpty(); + } + + [Test] + public void Normalize_drops_a_stored_plus_code_requirement_but_keeps_it_hidden() + { + var policy = PolicyWith( + new NewCallFieldRule { Key = NewCallFieldKeys.PlusCode, Visible = true, Required = true }, + new NewCallFieldRule { Key = NewCallFieldKeys.What3Words, Visible = true, Required = true }).Normalize(); + + policy.Rules.Should().ContainSingle().Which.Key.Should().Be(NewCallFieldKeys.What3Words); + + var hidden = PolicyWith(new NewCallFieldRule { Key = NewCallFieldKeys.PlusCode, Visible = false, Required = true }).Normalize(); + + hidden.IsVisible(NewCallFieldKeys.PlusCode).Should().BeFalse(); + hidden.Rules.Should().ContainSingle().Which.Required.Should().BeFalse(); + } + + [Test] + public void Every_other_field_can_be_required() + { + foreach (var key in NewCallFieldKeys.All) + { + if (key != NewCallFieldKeys.PlusCode) + NewCallFieldKeys.CanBeRequired(key).Should().BeTrue(key); + } + } + [Test] public void A_policy_stored_before_visibility_was_written_still_reads_as_visible() { @@ -212,6 +249,28 @@ public void A_hidden_required_field_is_not_enforced() violations.Should().BeEmpty(); } + [Test] + public void A_field_the_surface_cannot_collect_is_not_enforced_there() + { + // The field apps have no protocol or linked-call picker, and an edit cannot schedule a call that already went + // out: requiring those there would stop calls being created or edited at all. + var policy = Requiring(NewCallFieldKeys.Protocols, NewCallFieldKeys.LinkedCall, NewCallFieldKeys.DispatchOn, NewCallFieldKeys.Address); + + var violations = NewCallFieldPolicyValidator.Validate(policy, new NewCallFieldValues() + .Unsupported(NewCallFieldKeys.Protocols, "LINKEDCALL", NewCallFieldKeys.DispatchOn)); + + violations.Should().ContainSingle().Which.Key.Should().Be(NewCallFieldKeys.Address); + } + + [Test] + public void A_supported_field_is_still_enforced() + { + var policy = Requiring(NewCallFieldKeys.Protocols, NewCallFieldKeys.LinkedCall); + + NewCallFieldPolicyValidator.Validate(policy, new NewCallFieldValues { HasProtocols = true }) + .Should().ContainSingle().Which.Key.Should().Be(NewCallFieldKeys.LinkedCall); + } + [Test] public void Describes_violations_for_an_error_body() { diff --git a/Tests/Resgrid.Tests/Repositories/CallNumberSequencesDatabaseTests.cs b/Tests/Resgrid.Tests/Repositories/CallNumberSequencesDatabaseTests.cs index d344bf525..d8c58acb0 100644 --- a/Tests/Resgrid.Tests/Repositories/CallNumberSequencesDatabaseTests.cs +++ b/Tests/Resgrid.Tests/Repositories/CallNumberSequencesDatabaseTests.cs @@ -93,14 +93,41 @@ await database.ExecuteAsync(IsPostgres var source = new Mock(); source.Setup(s => s.GetMigrations()).Returns(IsPostgres - ? new IMigration[] { new M0264_AddCallNumberSequencesPg() } - : new IMigration[] { new M0264_AddCallNumberSequences() }); + ? new IMigration[] { new M0264_AddCallNumberSequencesPg(), new M0269_ConvertNumberTextToCitextPg() } + : new IMigration[] { new M0264_AddCallNumberSequences(), new M0269_ConvertNumberTextToCitext() }); _runner = new ServiceCollection().AddFluentMigratorCore().ConfigureRunner(r => { if (IsPostgres) r.AddPostgres(); else r.AddSqlServer(); r.WithGlobalConnectionString(_connection); }).AddSingleton(source.Object).BuildServiceProvider(); - _runner.GetRequiredService().MigrateUp(); + var migrations = _runner.GetRequiredService(); + migrations.MigrateUp(264); + + if (IsPostgres) + { + await using var database = Connect(_connection); + // Minimal stand-ins, with their pre-M0269 types, for the other tables M0269 converts; certification types also + // carry the columns the code de-duplication reads, and the M0213 live-code unique index. Departments 92 and 93 + // already hold the "-{id}" code a duplicate would take (92 in another casing, 93 exactly and then once more). + await database.ExecuteAsync(@"CREATE TABLE departmentcertificationtypes (departmentcertificationtypeid serial PRIMARY KEY, + departmentid int NOT NULL, code character varying(50) NULL, isdeleted boolean NOT NULL DEFAULT false); + CREATE UNIQUE INDEX ux_departmentcertificationtypes_code ON departmentcertificationtypes (departmentid, code) WHERE isdeleted = FALSE; + INSERT INTO departmentcertificationtypes (departmentid, code, isdeleted) VALUES + (90, 'EMT', false), (90, 'emt', false), (90, 'Emt', true), (91, 'emt', false), + (92, 'CERT', false), (92, 'cert', false), (92, 'Cert-6', false), + (93, 'ABC', false), (93, 'abc', false), (93, 'abc-9', false), (93, 'ABC-9-1', false);"); + foreach (var (table, column, previousType) in M0269_ConvertNumberTextToCitextPg.Columns) + await database.ExecuteAsync($"CREATE TABLE IF NOT EXISTS {table} (); ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {column} {previousType};"); + + // One department's scope written under two casings before M0269 folds them. + await database.ExecuteAsync(@"INSERT INTO callnumbersequences (departmentid, scopekey, lastsequence, floorsequence, floorseton, floorsetbyuserid, modifiedon) VALUES + (90, 'ez26-#', 31, 0, NULL, NULL, '2026-10-01'), + (90, 'EZ26-#', 12, 25, '2026-10-02', 'admin', '2026-10-03'), + (90, 'Ez26-#', 7, 0, NULL, NULL, '2026-09-30'), + (90, 'FD26-#', 4, 0, NULL, NULL, '2026-10-01')"); + } + + migrations.MigrateUp(); } [OneTimeTearDown] @@ -126,6 +153,58 @@ await database.ExecuteAsync(IsPostgres new { DepartmentId = departmentId, Number = number, LoggedOn = loggedOn, Deleted = deleted }); } + [Test] + public async Task Postgres_number_text_columns_are_citext() + { + if (!IsPostgres) Assert.Ignore("citext is the PostgreSQL convention; SQL Server compares case-insensitively by collation."); + await using var database = Connect(_connection); + var types = (await database.QueryAsync<(string Table, string Column, string Type)>( + "SELECT table_name, column_name, udt_name FROM information_schema.columns WHERE table_schema = 'public'")).ToList(); + + var expected = M0269_ConvertNumberTextToCitextPg.Columns.Select(c => (c.Table, c.Column)).ToList(); + var converted = types.Where(t => expected.Contains((t.Table, t.Column))).ToList(); + converted.Should().HaveCount(expected.Count); + converted.Should().OnlyContain(t => t.Type == "citext", string.Join(", ", converted.Select(t => t.Table + "." + t.Column + "=" + t.Type))); + } + + [Test] + public async Task Case_variant_scopes_fold_into_one_counter() + { + if (!IsPostgres) Assert.Ignore("SQL Server's collation never let a scope exist under two casings."); + await using var database = Connect(_connection); + var rows = (await database.QueryAsync( + "SELECT * FROM callnumbersequences WHERE departmentid = 90 ORDER BY scopekey")).ToList(); + + rows.Select(r => r.ScopeKey).Should().Equal("EZ26-#", "FD26-#"); + rows[0].Should().Match(r => r.LastSequence == 31 && r.FloorSequence == 25 && r.FloorSetByUserId == "admin"); + rows[1].LastSequence.Should().Be(4); + + (await Repository().TakeNextAsync(90, "ez26-#", 0)).Should().Be(32, "the folded key is citext, so any casing reaches the one counter"); + } + + [Test] + public async Task Case_variant_live_certification_codes_get_the_id_suffix() + { + if (!IsPostgres) Assert.Ignore("SQL Server's collation never let a live code exist under two casings."); + await using var database = Connect(_connection); + var codes = (await database.QueryAsync<(int Id, int DepartmentId, string Code, bool IsDeleted)>( + "SELECT departmentcertificationtypeid, departmentid, code::text, isdeleted FROM departmentcertificationtypes WHERE departmentid IN (90, 91) ORDER BY departmentcertificationtypeid")).ToList(); + + codes.Select(c => c.Code).Should().Equal("EMT", "emt-2", "Emt", "emt"); + } + + [Test] + public async Task A_suffixed_certification_code_steps_past_a_live_code_it_would_repeat() + { + if (!IsPostgres) Assert.Ignore("SQL Server's collation never let a live code exist under two casings."); + await using var database = Connect(_connection); + var codes = (await database.QueryAsync( + "SELECT code::text FROM departmentcertificationtypes WHERE departmentid IN (92, 93) ORDER BY departmentcertificationtypeid")).ToList(); + + codes.Should().Equal(new[] { "CERT", "cert-6-1", "Cert-6", "ABC", "abc-9-2", "abc-9", "ABC-9-1" }, + "cert-6 matches Cert-6 under citext, and abc-9 then abc-9-1 are already live in department 93"); + } + [Test] public async Task A_new_scope_starts_above_its_seed_and_then_counts_up() { diff --git a/Tests/Resgrid.Tests/Repositories/DocumentNumberSequencesDatabaseTests.cs b/Tests/Resgrid.Tests/Repositories/DocumentNumberSequencesDatabaseTests.cs new file mode 100644 index 000000000..ccfce6db0 --- /dev/null +++ b/Tests/Resgrid.Tests/Repositories/DocumentNumberSequencesDatabaseTests.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Concurrent; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using FluentAssertions; +using FluentMigrator; +using FluentMigrator.Runner; +using FluentMigrator.Runner.Initialization; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Npgsql; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Providers.Migrations.Migrations; +using Resgrid.Providers.MigrationsPg.Migrations; +using Resgrid.Repositories.DataRepository; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Queries; +using Resgrid.Repositories.DataRepository.Queries.Common; +using Resgrid.Repositories.DataRepository.Servers.SqlServer; + +namespace Resgrid.Tests.Repositories +{ + /// + /// Real-database proof for M0268 on both engines: the DisplayNumber columns are added and backfilled with the numbers each + /// document always showed, the counter upsert issues each sequence once (also under concurrent callers), a raised next + /// number only ever rises, and the seed scan reads the highest sequence already written into a kind's number column. Set + /// RESGRID_ADP_SQLSERVER_TEST_CONNECTION / RESGRID_ADP_POSTGRES_TEST_CONNECTION (server-level connections) to run. + /// + [TestFixture(DatabaseTypes.SqlServer), TestFixture(DatabaseTypes.Postgres), NonParallelizable] + public class DocumentNumberSequencesDatabaseTests(DatabaseTypes type) + { + private const string Prefix = "document_number_sequences_"; + private DatabaseTypes _previous; + private string _master, _connection, _database; + private ServiceProvider _runner; + + private bool IsPostgres => type == DatabaseTypes.Postgres; + + private DbConnection Connect(string connection) => IsPostgres ? new NpgsqlConnection(connection) : new SqlConnection(connection); + + private DocumentNumberSequencesRepository Repository() + { + var connections = new Mock(); + connections.Setup(c => c.Create()).Returns(() => Connect(_connection)); + + SqlConfiguration configuration = IsPostgres ? new PostgreSqlConfiguration() : new SqlServerConfiguration(); + var queries = new ConcurrentDictionary(); + queries[typeof(InsertQuery)] = new InsertQuery(configuration); + queries[typeof(UpdateQuery)] = new UpdateQuery(configuration); + var list = new Mock(); + list.Setup(l => l.RetrieveQueryList()).Returns(queries); + + return new DocumentNumberSequencesRepository(connections.Object, configuration, Mock.Of(), new QueryFactory(list.Object)); + } + + [OneTimeSetUp] + public async Task Create_isolated_database() + { + _master = Environment.GetEnvironmentVariable(IsPostgres ? "RESGRID_ADP_POSTGRES_TEST_CONNECTION" : "RESGRID_ADP_SQLSERVER_TEST_CONNECTION"); + if (string.IsNullOrWhiteSpace(_master)) Assert.Ignore("Set a test connection to run real database checks."); + + if (IsPostgres) AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); + + _previous = DataConfig.DatabaseType; + DataConfig.DatabaseType = type; + _database = Prefix + Guid.NewGuid().ToString("N"); + await using (var master = Connect(_master)) + await master.ExecuteAsync("CREATE DATABASE " + _database); + _connection = IsPostgres + ? new NpgsqlConnectionStringBuilder(_master) { Database = _database }.ConnectionString + : new SqlConnectionStringBuilder(_master) { InitialCatalog = _database }.ConnectionString; + + // Minimal stand-ins for the tables M0268 alters and the number columns the seed scan reads, with rows written before it. + await using (var database = Connect(_connection)) + { + await database.OpenAsync(); + await database.ExecuteAsync(IsPostgres + ? @"CREATE EXTENSION IF NOT EXISTS citext; + CREATE TABLE workorders (id serial PRIMARY KEY, departmentid int NOT NULL, numberyear int NOT NULL, numbersequence int NOT NULL); + CREATE TABLE invoices (id serial PRIMARY KEY, departmentid int NOT NULL, invoicenumber int NOT NULL); + CREATE TABLE bids (id serial PRIMARY KEY, departmentid int NOT NULL, bidnumber int NOT NULL); + CREATE TABLE deploymenttimereports (id serial PRIMARY KEY, departmentid int NOT NULL, reportnumber int NOT NULL); + CREATE TABLE rmsdisclosurerequests (id serial PRIMARY KEY, departmentid int NOT NULL, requestnumber citext NULL); + INSERT INTO workorders (departmentid, numberyear, numbersequence) VALUES (1, 2026, 19), (1, 2026, 1234567); + INSERT INTO invoices (departmentid, invoicenumber) VALUES (1, 1042); + INSERT INTO bids (departmentid, bidnumber) VALUES (1, 7); + INSERT INTO deploymenttimereports (departmentid, reportnumber) VALUES (1, 3);" + : @"CREATE TABLE WorkOrders (Id int IDENTITY PRIMARY KEY, DepartmentId int NOT NULL, NumberYear int NOT NULL, NumberSequence int NOT NULL); + CREATE TABLE Invoices (Id int IDENTITY PRIMARY KEY, DepartmentId int NOT NULL, InvoiceNumber int NOT NULL); + CREATE TABLE Bids (Id int IDENTITY PRIMARY KEY, DepartmentId int NOT NULL, BidNumber int NOT NULL); + CREATE TABLE DeploymentTimeReports (Id int IDENTITY PRIMARY KEY, DepartmentId int NOT NULL, ReportNumber int NOT NULL); + CREATE TABLE RmsDisclosureRequests (Id int IDENTITY PRIMARY KEY, DepartmentId int NOT NULL, RequestNumber nvarchar(50) NULL); + INSERT INTO WorkOrders (DepartmentId, NumberYear, NumberSequence) VALUES (1, 2026, 19), (1, 2026, 1234567); + INSERT INTO Invoices (DepartmentId, InvoiceNumber) VALUES (1, 1042); + INSERT INTO Bids (DepartmentId, BidNumber) VALUES (1, 7); + INSERT INTO DeploymentTimeReports (DepartmentId, ReportNumber) VALUES (1, 3);"); + + if (database is NpgsqlConnection npgsql) + await npgsql.ReloadTypesAsync(); + } + + var source = new Mock(); + source.Setup(s => s.GetMigrations()).Returns(IsPostgres + ? new IMigration[] { new M0268_AddDocumentNumberingPg() } + : new IMigration[] { new M0268_AddDocumentNumbering() }); + _runner = new ServiceCollection().AddFluentMigratorCore().ConfigureRunner(r => + { + if (IsPostgres) r.AddPostgres(); else r.AddSqlServer(); + r.WithGlobalConnectionString(_connection); + }).AddSingleton(source.Object).BuildServiceProvider(); + _runner.GetRequiredService().MigrateUp(); + } + + [OneTimeTearDown] + public async Task Remove_only_this_fixture_database() + { + if (_database == null) return; + _runner?.Dispose(); + DataConfig.DatabaseType = _previous; + if (!_database.StartsWith(Prefix, StringComparison.Ordinal) || !Guid.TryParseExact(_database.Substring(Prefix.Length), "N", out _)) + throw new InvalidOperationException("Unexpected test database name."); + if (IsPostgres) NpgsqlConnection.ClearAllPools(); else SqlConnection.ClearAllPools(); + await using var master = Connect(_master); + await master.ExecuteAsync(IsPostgres ? "DROP DATABASE " + _database + " WITH (FORCE)" + : "ALTER DATABASE " + _database + " SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE " + _database); + } + + private async Task NumbersAsync(string table) + { + await using var database = Connect(_connection); + return (await database.QueryAsync(IsPostgres ? $"SELECT displaynumber FROM {table.ToLowerInvariant()} ORDER BY id" : $"SELECT DisplayNumber FROM {table} ORDER BY Id")).ToArray(); + } + + [Test] + public async Task Existing_documents_are_backfilled_with_the_numbers_they_always_showed() + { + (await NumbersAsync("WorkOrders")).Should().Equal("WO-2026-000019", "WO-2026-1234567"); + (await NumbersAsync("Invoices")).Should().Equal("1042"); + (await NumbersAsync("Bids")).Should().Equal("7"); + (await NumbersAsync("DeploymentTimeReports")).Should().Equal("3"); + } + + [Test] + public async Task Postgres_text_columns_are_citext() + { + if (!IsPostgres) Assert.Ignore("citext is the PostgreSQL convention; SQL Server compares case-insensitively by collation."); + + await using var database = Connect(_connection); + var types = (await database.QueryAsync<(string Table, string Column, string Type)>( + @"SELECT table_name, column_name, udt_name FROM information_schema.columns + WHERE (table_name = 'documentnumbersequences' AND column_name IN ('kind', 'scopekey', 'floorsetbyuserid')) + OR (table_name IN ('workorders', 'invoices', 'bids', 'deploymenttimereports') AND column_name = 'displaynumber')")).ToList(); + + types.Should().HaveCount(7); + types.Should().OnlyContain(t => t.Type == "citext", string.Join(", ", types.Select(t => t.Table + "." + t.Column + "=" + t.Type))); + } + + [Test] + public async Task A_new_scope_starts_above_its_seed_and_then_counts_up_per_kind() + { + var repository = Repository(); + + (await repository.GetSequenceAsync(1, DocumentNumberKinds.Invoice, "INV-2026-#")).Should().BeNull(); + (await repository.TakeNextAsync(1, DocumentNumberKinds.Invoice, "INV-2026-#", 41)).Should().Be(42); + (await repository.TakeNextAsync(1, DocumentNumberKinds.Invoice, "INV-2026-#", 41)).Should().Be(43, "the seed only applies to the first number of a scope"); + (await repository.TakeNextAsync(1, DocumentNumberKinds.Bid, "INV-2026-#", 0)).Should().Be(1, "kinds never share a sequence"); + (await repository.TakeNextAsync(2, DocumentNumberKinds.Invoice, "INV-2026-#", 0)).Should().Be(1, "departments never share a sequence"); + } + + [Test] + public async Task Concurrent_documents_never_share_a_sequence() + { + var issued = await Task.WhenAll(Enumerable.Range(0, 25).Select(_ => Task.Run(() => Repository().TakeNextAsync(3, DocumentNumberKinds.WorkOrder, "MNT-27-#", 0)))); + + issued.Should().OnlyHaveUniqueItems(); + issued.OrderBy(i => i).Should().Equal(Enumerable.Range(1, 25)); + } + + [Test] + public async Task A_raised_next_number_only_ever_rises() + { + var repository = Repository(); + await repository.TakeNextAsync(4, DocumentNumberKinds.TimeReport, "DTR-#", 10); + + await repository.RaiseFloorAsync(4, DocumentNumberKinds.TimeReport, "DTR-#", 500, "admin", new DateTime(2026, 10, 8, 12, 0, 0)); + await repository.RaiseFloorAsync(4, DocumentNumberKinds.TimeReport, "DTR-#", 200, "other", new DateTime(2026, 10, 8, 13, 0, 0)); + (await repository.TakeNextAsync(4, DocumentNumberKinds.TimeReport, "DTR-#", 0)).Should().Be(500); + (await repository.GetSequenceAsync(4, DocumentNumberKinds.TimeReport, "DTR-#")).FloorSequence.Should().Be(500); + } + + [Test] + public async Task The_seed_scan_reads_the_kinds_own_number_column() + { + var repository = Repository(); + await using (var database = Connect(_connection)) + await database.ExecuteAsync(IsPostgres + ? "INSERT INTO rmsdisclosurerequests (departmentid, requestnumber) VALUES (1, 'PRR-2026-0009'), (1, 'prr-2026-0012'), (1, 'PRR-2025-0040'), (2, 'PRR-2026-0090')" + : "INSERT INTO RmsDisclosureRequests (DepartmentId, RequestNumber) VALUES (1, 'PRR-2026-0009'), (1, 'prr-2026-0012'), (1, 'PRR-2025-0040'), (2, 'PRR-2026-0090')"); + + (await repository.GetHighestIssuedAsync(1, DocumentNumberKinds.RecordsRequest, "PRR-2026-", "")).Should().Be(12, "the number columns compare case-insensitively"); + (await repository.GetHighestIssuedAsync(1, DocumentNumberKinds.WorkOrder, "WO-2026-", "")).Should().Be(1234567, "the backfilled built-in numbers seed a pattern that reads like them"); + (await repository.GetHighestIssuedAsync(1, DocumentNumberKinds.Invoice, "", "")).Should().Be(1042); + (await repository.GetHighestIssuedAsync(1, "unknown-kind", "", "")).Should().Be(0); + } + } +} diff --git a/Tests/Resgrid.Tests/Repositories/OpenCallDispatchQueriesDatabaseTests.cs b/Tests/Resgrid.Tests/Repositories/OpenCallDispatchQueriesDatabaseTests.cs new file mode 100644 index 000000000..c0bd64bf8 --- /dev/null +++ b/Tests/Resgrid.Tests/Repositories/OpenCallDispatchQueriesDatabaseTests.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Concurrent; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using FluentAssertions; +using Microsoft.Data.SqlClient; +using Moq; +using Npgsql; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Repositories.DataRepository; +using Resgrid.Repositories.DataRepository.Configs; +using Resgrid.Repositories.DataRepository.Queries; +using Resgrid.Repositories.DataRepository.Queries.Calls; +using Resgrid.Repositories.DataRepository.Servers.SqlServer; + +namespace Resgrid.Tests.Repositories +{ + /// + /// Real-database proof for the open-dispatch queries behind status attribution, the unit working call (ActiveCallId) and + /// the auto-close on both engines: a scheduled call the unit or person is listed on only counts once it has gone out or its + /// dispatch time has passed (PR #546 review). Set RESGRID_ADP_SQLSERVER_TEST_CONNECTION / + /// RESGRID_ADP_POSTGRES_TEST_CONNECTION (server-level connections) to run. + /// + [TestFixture(DatabaseTypes.SqlServer), TestFixture(DatabaseTypes.Postgres), NonParallelizable] + public class OpenCallDispatchQueriesDatabaseTests(DatabaseTypes type) + { + private const string Prefix = "open_call_dispatches_"; + private const int DepartmentId = 7; + private const int UnitId = 10; + private const string UserId = "user-1"; + private DatabaseTypes _previous; + private string _master, _connection, _database; + + private bool IsPostgres => type == DatabaseTypes.Postgres; + + private DbConnection Connect(string connection) => IsPostgres ? new NpgsqlConnection(connection) : new SqlConnection(connection); + + private (CallDispatchUnitRepository Units, CallDispatchesRepository People) Repositories() + { + var connections = new Mock(); + connections.Setup(c => c.Create()).Returns(() => Connect(_connection)); + + SqlConfiguration configuration = IsPostgres ? new PostgreSqlConfiguration() : new SqlServerConfiguration(); + var queries = new ConcurrentDictionary(); + queries[typeof(SelectOpenCallUnitDispatchesForUnitQuery)] = new SelectOpenCallUnitDispatchesForUnitQuery(configuration); + queries[typeof(SelectOpenCallUnitDispatchesForDepartmentQuery)] = new SelectOpenCallUnitDispatchesForDepartmentQuery(configuration); + queries[typeof(SelectOpenCallDispatchesForUserQuery)] = new SelectOpenCallDispatchesForUserQuery(configuration); + var list = new Mock(); + list.Setup(l => l.RetrieveQueryList()).Returns(queries); + var factory = new QueryFactory(list.Object); + + return (new CallDispatchUnitRepository(connections.Object, configuration, Mock.Of(), factory), + new CallDispatchesRepository(connections.Object, configuration, Mock.Of(), factory)); + } + + [OneTimeSetUp] + public async Task Create_isolated_database() + { + _master = Environment.GetEnvironmentVariable(IsPostgres ? "RESGRID_ADP_POSTGRES_TEST_CONNECTION" : "RESGRID_ADP_SQLSERVER_TEST_CONNECTION"); + if (string.IsNullOrWhiteSpace(_master)) Assert.Ignore("Set a test connection to run real database checks."); + + if (IsPostgres) AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); + + _previous = DataConfig.DatabaseType; + DataConfig.DatabaseType = type; + _database = Prefix + Guid.NewGuid().ToString("N"); + await using (var master = Connect(_master)) + await master.ExecuteAsync("CREATE DATABASE " + _database); + _connection = IsPostgres + ? new NpgsqlConnectionStringBuilder(_master) { Database = _database }.ConnectionString + : new SqlConnectionStringBuilder(_master) { InitialCatalog = _database }.ConnectionString; + + // Minimal stand-ins for the tables the three queries read. + await using var database = Connect(_connection); + await database.OpenAsync(); + await database.ExecuteAsync(IsPostgres + ? @"CREATE EXTENSION IF NOT EXISTS citext; + CREATE TABLE calls (callid int PRIMARY KEY, departmentid int NOT NULL, state int NOT NULL, dispatchon timestamp NULL, + hasbeendispatched boolean NULL, isdeleted boolean NOT NULL DEFAULT false); + CREATE TABLE calldispatchunits (calldispatchunitid serial PRIMARY KEY, callid int NOT NULL, unitid int NOT NULL, + dispatchcount int NOT NULL DEFAULT 1, lastdispatchedon timestamp NULL, dispatchedon timestamp NOT NULL); + CREATE TABLE calldispatches (calldispatchid serial PRIMARY KEY, callid int NOT NULL, userid citext NOT NULL, + dispatchedon timestamp NOT NULL, lastdispatchedon timestamp NULL); + CREATE TABLE calldispatchgroups (calldispatchgroupid serial PRIMARY KEY, callid int NOT NULL, departmentgroupid int NOT NULL, + dispatchedon timestamp NOT NULL, lastdispatchedon timestamp NULL); + CREATE TABLE departmentgroupmembers (departmentgroupmemberid serial PRIMARY KEY, departmentgroupid int NOT NULL, userid citext NOT NULL); + CREATE TABLE calldispatchroles (calldispatchroleid serial PRIMARY KEY, callid int NOT NULL, roleid int NOT NULL, + dispatchedon timestamp NOT NULL, lastdispatchedon timestamp NULL); + CREATE TABLE personnelroleusers (personnelroleuserid serial PRIMARY KEY, personnelroleid int NOT NULL, userid citext NOT NULL);" + : @"CREATE TABLE Calls (CallId int PRIMARY KEY, DepartmentId int NOT NULL, State int NOT NULL, DispatchOn datetime2 NULL, + HasBeenDispatched bit NULL, IsDeleted bit NOT NULL DEFAULT 0); + CREATE TABLE CallDispatchUnits (CallDispatchUnitId int IDENTITY PRIMARY KEY, CallId int NOT NULL, UnitId int NOT NULL, + DispatchCount int NOT NULL DEFAULT 1, LastDispatchedOn datetime2 NULL, DispatchedOn datetime2 NOT NULL); + CREATE TABLE CallDispatches (CallDispatchId int IDENTITY PRIMARY KEY, CallId int NOT NULL, UserId nvarchar(128) NOT NULL, + DispatchedOn datetime2 NOT NULL, LastDispatchedOn datetime2 NULL); + CREATE TABLE CallDispatchGroups (CallDispatchGroupId int IDENTITY PRIMARY KEY, CallId int NOT NULL, DepartmentGroupId int NOT NULL, + DispatchedOn datetime2 NOT NULL, LastDispatchedOn datetime2 NULL); + CREATE TABLE DepartmentGroupMembers (DepartmentGroupMemberId int IDENTITY PRIMARY KEY, DepartmentGroupId int NOT NULL, UserId nvarchar(128) NOT NULL); + CREATE TABLE CallDispatchRoles (CallDispatchRoleId int IDENTITY PRIMARY KEY, CallId int NOT NULL, RoleId int NOT NULL, + DispatchedOn datetime2 NOT NULL, LastDispatchedOn datetime2 NULL); + CREATE TABLE PersonnelRoleUsers (PersonnelRoleUserId int IDENTITY PRIMARY KEY, PersonnelRoleId int NOT NULL, UserId nvarchar(128) NOT NULL);"); + + // The connection loaded its types before citext existed; without a reload a citext parameter cannot be sent. + if (database is NpgsqlConnection npgsql) + await npgsql.ReloadTypesAsync(); + } + + [OneTimeTearDown] + public async Task Remove_only_this_fixture_database() + { + if (_database == null) return; + DataConfig.DatabaseType = _previous; + if (!_database.StartsWith(Prefix, StringComparison.Ordinal) || !Guid.TryParseExact(_database.Substring(Prefix.Length), "N", out _)) + throw new InvalidOperationException("Unexpected test database name."); + if (IsPostgres) NpgsqlConnection.ClearAllPools(); else SqlConnection.ClearAllPools(); + await using var master = Connect(_master); + await master.ExecuteAsync(IsPostgres ? "DROP DATABASE " + _database + " WITH (FORCE)" + : "ALTER DATABASE " + _database + " SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE " + _database); + } + + private async Task SeedCallAsync(int callId, CallStates state, DateTime? dispatchOn, bool? hasBeenDispatched, bool deleted = false) + { + await using var database = Connect(_connection); + await database.ExecuteAsync(IsPostgres + ? @"INSERT INTO calls (callid, departmentid, state, dispatchon, hasbeendispatched, isdeleted) VALUES (@CallId, @DepartmentId, @State, @DispatchOn, @HasBeenDispatched, @Deleted); + INSERT INTO calldispatchunits (callid, unitid, dispatchedon) VALUES (@CallId, @UnitId, @DispatchedOn); + INSERT INTO calldispatches (callid, userid, dispatchedon) VALUES (@CallId, @UserId, @DispatchedOn);" + : @"INSERT INTO Calls (CallId, DepartmentId, State, DispatchOn, HasBeenDispatched, IsDeleted) VALUES (@CallId, @DepartmentId, @State, @DispatchOn, @HasBeenDispatched, @Deleted); + INSERT INTO CallDispatchUnits (CallId, UnitId, DispatchedOn) VALUES (@CallId, @UnitId, @DispatchedOn); + INSERT INTO CallDispatches (CallId, UserId, DispatchedOn) VALUES (@CallId, @UserId, @DispatchedOn);", + new + { + CallId = callId, DepartmentId, State = (int)state, DispatchOn = dispatchOn, HasBeenDispatched = hasBeenDispatched, Deleted = deleted, + UnitId, DispatchedOn = DateTime.UtcNow.AddMinutes(-30), UserId + }); + } + + [Test] + public async Task a_scheduled_call_counts_only_once_it_has_gone_out_or_is_due() + { + var now = DateTime.UtcNow; + await SeedCallAsync(1, CallStates.Active, null, null); // an ordinary call + await SeedCallAsync(2, CallStates.Active, now.AddHours(1), false); // scheduled, not yet sent + await SeedCallAsync(3, CallStates.Active, now.AddMinutes(-1), false); // due, the worker has not sent it yet + await SeedCallAsync(4, CallStates.Active, now.AddHours(1), true); // sent early (Dispatch Now) + await SeedCallAsync(5, CallStates.Closed, null, null); // closed + await SeedCallAsync(6, CallStates.Pending, null, null); // pending + await SeedCallAsync(7, CallStates.Active, null, null, deleted: true); // deleted + + var (units, people) = Repositories(); + + (await units.GetOpenCallUnitDispatchesForUnitAsync(DepartmentId, UnitId)).Select(x => x.CallId).Should().BeEquivalentTo(new[] { 1, 3, 4 }); + (await units.GetOpenCallUnitDispatchesForDepartmentAsync(DepartmentId)).Select(x => x.CallId).Should().BeEquivalentTo(new[] { 1, 3, 4 }); + (await people.GetOpenCallDispatchesForUserAsync(DepartmentId, UserId)).Select(x => x.CallId).Should().BeEquivalentTo(new[] { 1, 3, 4 }); + } + + // Write-time attribution compares these times with the person's last clear (PR #546 follow-up). + [Test] + public async Task a_person_gets_one_row_per_dispatch_with_its_latest_time() + { + const int OtherDepartmentId = 8, CallId = 20, GroupId = 5, RoleId = 9; + var now = DateTime.UtcNow; + await using (var database = Connect(_connection)) + { + await database.ExecuteAsync(IsPostgres + ? @"INSERT INTO calls (callid, departmentid, state, dispatchon, hasbeendispatched, isdeleted) VALUES (@CallId, @DepartmentId, 0, NULL, NULL, false); + INSERT INTO calldispatches (callid, userid, dispatchedon) VALUES (@CallId, @UserId, @Direct); + INSERT INTO calldispatchgroups (callid, departmentgroupid, dispatchedon, lastdispatchedon) VALUES (@CallId, @GroupId, @GroupFirst, @GroupAgain); + INSERT INTO departmentgroupmembers (departmentgroupid, userid) VALUES (@GroupId, @UserId), (@GroupId, 'someone-else'); + INSERT INTO calldispatchroles (callid, roleid, dispatchedon) VALUES (@CallId, @RoleId, @Role); + INSERT INTO personnelroleusers (personnelroleid, userid) VALUES (@RoleId, @UserId);" + : @"INSERT INTO Calls (CallId, DepartmentId, State, DispatchOn, HasBeenDispatched, IsDeleted) VALUES (@CallId, @DepartmentId, 0, NULL, NULL, 0); + INSERT INTO CallDispatches (CallId, UserId, DispatchedOn) VALUES (@CallId, @UserId, @Direct); + INSERT INTO CallDispatchGroups (CallId, DepartmentGroupId, DispatchedOn, LastDispatchedOn) VALUES (@CallId, @GroupId, @GroupFirst, @GroupAgain); + INSERT INTO DepartmentGroupMembers (DepartmentGroupId, UserId) VALUES (@GroupId, @UserId), (@GroupId, 'someone-else'); + INSERT INTO CallDispatchRoles (CallId, RoleId, DispatchedOn) VALUES (@CallId, @RoleId, @Role); + INSERT INTO PersonnelRoleUsers (PersonnelRoleId, UserId) VALUES (@RoleId, @UserId);", + new + { + CallId, DepartmentId = OtherDepartmentId, UserId, GroupId, RoleId, Direct = now.AddMinutes(-30), GroupFirst = now.AddMinutes(-40), + GroupAgain = now.AddMinutes(-2), Role = now.AddMinutes(-20) + }); + } + + var (_, people) = Repositories(); + var rows = (await people.GetOpenCallDispatchesForUserAsync(OtherDepartmentId, UserId)).OrderBy(x => x.DispatchedOn).ToList(); + + rows.Should().HaveCount(3); + rows.Should().OnlyContain(x => x.CallId == CallId); + rows[0].Paged.Should().BeFalse("the direct dispatch"); + rows[0].DispatchedOn.Should().BeCloseTo(now.AddMinutes(-30), TimeSpan.FromSeconds(1)); + rows[1].Paged.Should().BeTrue("the role page"); + rows[1].DispatchedOn.Should().BeCloseTo(now.AddMinutes(-20), TimeSpan.FromSeconds(1)); + rows[2].Paged.Should().BeTrue("the group page, at its redispatch"); + rows[2].DispatchedOn.Should().BeCloseTo(now.AddMinutes(-2), TimeSpan.FromSeconds(1)); + } + } +} diff --git a/Tests/Resgrid.Tests/Repositories/PostgresCaseInsensitiveLookupsDatabaseTests.cs b/Tests/Resgrid.Tests/Repositories/PostgresCaseInsensitiveLookupsDatabaseTests.cs new file mode 100644 index 000000000..fcb804e11 --- /dev/null +++ b/Tests/Resgrid.Tests/Repositories/PostgresCaseInsensitiveLookupsDatabaseTests.cs @@ -0,0 +1,314 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using FluentAssertions; +using FluentMigrator.Runner; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Npgsql; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Identity; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Model.Repositories.Queries.Contracts; +using Resgrid.Providers.MigrationsPg.Migrations; +using Resgrid.Repositories.DataRepository; +using Resgrid.Repositories.DataRepository.Queries; +using Resgrid.Repositories.DataRepository.Queries.CommunicationTests; +using Resgrid.Repositories.DataRepository.Queries.DepartmentGroups; +using Resgrid.Repositories.DataRepository.Queries.Departments; +using Resgrid.Repositories.DataRepository.Queries.DepartmentSettings; +using Resgrid.Repositories.DataRepository.Queries.DistributionLists; +using Resgrid.Repositories.DataRepository.Queries.Identity.Role; +using Resgrid.Repositories.DataRepository.Queries.Identity.User; +using Resgrid.Repositories.DataRepository.Queries.Invites; +using Resgrid.Repositories.DataRepository.Queries.PersonnelRoles; +using Resgrid.Repositories.DataRepository.Queries.Units; +using Resgrid.Repositories.DataRepository.Servers.SqlServer; + +namespace Resgrid.Tests.Repositories +{ + /// + /// PostgreSQL proof that lookups by person-entered text (user names, e-mail addresses, inbound e-mail codes, link, run and + /// linking codes, names, certification codes, hydrant numbers) match regardless of case, as SQL Server's collation + /// matches them. Npgsql binds a C# string as text, and citext = text resolves to text = text: case-sensitive, and unable + /// to use the column's citext index. Each lookup casts its parameter to citext (invite codes are uuid and need none). The + /// whole PostgreSQL migration chain runs into an isolated database so every column has its shipped type. Set + /// RESGRID_ADP_POSTGRES_TEST_CONNECTION (a server-level connection) to run. + /// + [TestFixture, NonParallelizable] + public class PostgresCaseInsensitiveLookupsDatabaseTests + { + private const string Prefix = "citext_lookups_"; + private const int DepartmentId = 900; + private const string UserId = "7d1f3c2e-9a40-4b8e-a1a2-4c5d6e7f8a90"; + private const string RoleId = "0b6c4d2a-1e3f-4a5b-8c7d-9e0f1a2b3c4d"; + + private DatabaseTypes _previousType; + private string _previousConnection, _master, _connection, _database; + private PostgreSqlConfiguration _sql; + + private NpgsqlConnection Connect() => new NpgsqlConnection(_connection); + + [OneTimeSetUp] + public async Task Migrate_and_seed_an_isolated_database() + { + _master = Environment.GetEnvironmentVariable("RESGRID_ADP_POSTGRES_TEST_CONNECTION"); + if (string.IsNullOrWhiteSpace(_master)) Assert.Ignore("Set a PostgreSQL test connection to run real database checks."); + + AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); + _previousType = DataConfig.DatabaseType; + _previousConnection = DataConfig.CoreConnectionString; + DataConfig.DatabaseType = DatabaseTypes.Postgres; + _sql = new PostgreSqlConfiguration(); + + _database = Prefix + Guid.NewGuid().ToString("N"); + await using (var master = new NpgsqlConnection(_master)) + await master.ExecuteAsync("CREATE DATABASE " + _database); + _connection = new NpgsqlConnectionStringBuilder(_master) { Database = _database }.ConnectionString; + DataConfig.CoreConnectionString = _connection; + + // The production runner's dialect (Workers.Console and Resgrid.Console): later migrations create INCLUDE indexes. + using (var services = new ServiceCollection().AddFluentMigratorCore() + .ConfigureRunner(r => r.AddPostgres11_0().WithGlobalConnectionString(_connection).ScanIn(typeof(M0001_InitialMigrationPg).Assembly).For.All()) + .BuildServiceProvider()) + services.GetRequiredService().MigrateUp(); + + // The pool loaded its types before M0001 created citext; without a reload a citext column cannot be read back. + await using (var database = Connect()) + { + await database.OpenAsync(); + await database.ReloadTypesAsync(); + await SeedAsync(database); + } + } + + [OneTimeTearDown] + public async Task Remove_only_this_fixture_database() + { + if (_database == null) return; + DataConfig.DatabaseType = _previousType; + DataConfig.CoreConnectionString = _previousConnection; + if (!_database.StartsWith(Prefix, StringComparison.Ordinal) || !Guid.TryParseExact(_database.Substring(Prefix.Length), "N", out _)) + throw new InvalidOperationException("Unexpected test database name."); + NpgsqlConnection.ClearAllPools(); + await using var master = new NpgsqlConnection(_master); + await master.ExecuteAsync("DROP DATABASE " + _database + " WITH (FORCE)"); + } + + /// Inserts a row with the given columns and a placeholder in every other required column that has no default. + private static async Task InsertAsync(DbConnection database, string table, object values) + { + var row = values.GetType().GetProperties().ToDictionary(p => p.Name.ToLowerInvariant(), p => p.GetValue(values)); + var required = await database.QueryAsync<(string Column, string Type)>( + @"SELECT column_name, data_type FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = @Table AND is_nullable = 'NO' AND column_default IS NULL AND is_identity = 'NO'", + new { Table = table }); + + var parameters = new DynamicParameters(); + var expressions = new List<(string Column, string Value)>(); + foreach (var (column, value) in row) + { + parameters.Add(column, value); + expressions.Add((column, "@" + column)); + } + foreach (var (column, type) in required.Where(r => !row.ContainsKey(r.Column))) + { + var placeholder = type switch + { + "boolean" => "false", + "smallint" or "integer" or "bigint" or "numeric" or "double precision" or "real" => "0", + "timestamp without time zone" or "timestamp with time zone" or "date" => "TIMESTAMP '2026-10-08'", + "json" or "jsonb" => "'{}'", + "bytea" => "''::bytea", + "uuid" => "'" + Guid.NewGuid() + "'", + _ => "'" + Guid.NewGuid().ToString("N") + "'" + }; + expressions.Add((column, placeholder)); + } + + await database.ExecuteAsync( + $"INSERT INTO {table} ({string.Join(", ", expressions.Select(e => "\"" + e.Column + "\""))}) VALUES ({string.Join(", ", expressions.Select(e => e.Value))})", + parameters); + } + + private static async Task SeedAsync(DbConnection database) + { + // Stored as people type them; every lookup below asks in a different case. normalizedusername is left in mixed case + // the way M0001 seeds its own accounts ("TestAccount1"), so only a case-insensitive match finds it. + await InsertAsync(database, "aspnetusers", new + { + Id = UserId, UserName = "Jane.Doe", NormalizedUserName = "Jane.Doe", Email = "Jane.Doe@Example.com", + NormalizedEmail = "JANE.DOE@EXAMPLE.COM", EmailConfirmed = true, PhoneNumberConfirmed = false, TwoFactorEnabled = false, + LockoutEnabled = false, AccessFailedCount = 0 + }); + await InsertAsync(database, "departments", new { DepartmentId, Name = "Station Nine Fire Rescue", Code = "NINE", ManagingUserId = UserId, ShowWelcome = false, LinkCode = "AbC9x7" }); + await InsertAsync(database, "departmentmembers", new { DepartmentId, UserId, IsAdmin = true, IsDisabled = false, IsHidden = false, IsDefault = true, IsActive = true, IsDeleted = false }); + await InsertAsync(database, "aspnetroles", new { Id = RoleId, Name = "Dispatchers", NormalizedName = "Dispatchers" }); + await InsertAsync(database, "aspnetuserroles", new { UserId, RoleId }); + await InsertAsync(database, "invites", new { DepartmentId, Code = Guid.NewGuid(), EmailAddress = "New.Member@Example.com", SendingUserId = UserId }); + await InsertAsync(database, "personnelroles", new { DepartmentId, Name = "Engineer" }); + await InsertAsync(database, "unittypes", new { DepartmentId, Type = "Engine" }); + await InsertAsync(database, "units", new { DepartmentId, Name = "Engine 9", Type = "Engine", IsDeleted = false }); + await InsertAsync(database, "distributionlists", new { DepartmentId, Name = "All Hands", EmailAddress = "AllHands9" }); + await InsertAsync(database, "departmentgroups", new { DepartmentId, Name = "Station 9", DispatchEmail = "St9Dispatch", MessageEmail = "St9Message" }); + await InsertAsync(database, "departmentsettings", new { DepartmentId, SettingType = (int)DepartmentSettingTypes.InternalDispatchEmail, Setting = "Ab12Cd" }); + + var testId = Guid.NewGuid().ToString(); + var runId = Guid.NewGuid().ToString(); + await InsertAsync(database, "communicationtests", new { CommunicationTestId = testId, DepartmentId }); + await InsertAsync(database, "communicationtestruns", new { CommunicationTestRunId = runId, CommunicationTestId = testId, DepartmentId, RunCode = "RunXy9" }); + await InsertAsync(database, "communicationtestresults", new { CommunicationTestResultId = Guid.NewGuid().ToString(), CommunicationTestRunId = runId, DepartmentId, UserId, ResponseToken = "TokQ7z" }); + + await InsertAsync(database, "chatbotlinkingcodes", new { Id = Guid.NewGuid().ToString(), Code = "LinkZ1", UserId, ExpiresAt = DateTime.UtcNow.AddHours(1) }); + await InsertAsync(database, "departmentcertificationtypes", new { DepartmentId, Type = "EMT Basic", Code = "EMT-B", IsDeleted = false, IsActive = true }); + await InsertAsync(database, "rmshydrants", new { RmsHydrantId = Guid.NewGuid().ToString(), DepartmentId, HydrantNumber = "H-12a" }); + } + + public static IEnumerable TemplateLookups() + { + TestCaseData Case(string name, Func query, object parameters) => + new TestCaseData(query, parameters).SetName("Template lookup ignores case: " + name); + + yield return Case("department by inbound dispatch e-mail setting", c => new SelectBySettingAndTypeQuery(c).GetQuery(), + new { Setting = "AB12CD", SettingType = (int)DepartmentSettingTypes.InternalDispatchEmail }); + yield return Case("department manager by e-mail", c => new SelectManagerInfoByEmailQuery(c).GetQuery(), new { EmailAddress = "JANE.DOE@example.COM" }); + yield return Case("invite by e-mail", c => new SelectInviteByEmailQuery(c).GetQuery(), new { EmailAddress = "new.member@example.com" }); + yield return Case("department by link code", c => new SelectDepartmentByLinkCodeQuery(c).GetQuery(), new { Code = "abc9x7" }); + yield return Case("department by name", c => new SelectDepartmentByNameQuery(c).GetQuery(), new { Name = "station nine fire rescue" }); + yield return Case("department by user name", c => new SelectDepartmentByUsernameQuery(c).GetQuery(), new { Username = "JANE.DOE" }); + yield return Case("valid department by user name", c => new SelectValidDepartmentByUsernameQuery(c).GetQuery(), new { Username = "JANE.DOE" }); + yield return Case("personnel role by name", c => new SelectRoleByDidAndNameQuery(c).GetQuery(), new { DepartmentId, Name = "ENGINEER" }); + yield return Case("identity role by normalized name", c => new SelectRoleByNameQuery(c).GetQuery(), new { Name = "DISPATCHERS" }); + yield return Case("identity user by normalized user name", c => new SelectUserByUserNameQuery(c).GetQuery(), new { UserName = "JANE.DOE" }); + yield return Case("identity user by normalized e-mail", c => new SelectUserByEmailQuery(c).GetQuery(), new { Email = "JANE.DOE@EXAMPLE.COM" }); + // GetUsersInRoleQuery carries the same cast but cannot run on either engine: its column list selects the [NotMapped] + // IdentityUser.CreateDate, which lives on AspNetUsersExt, not AspNetUsers. + yield return Case("user is in role", c => new IsInRoleQuery(c).GetQuery(), new { RoleName = "DISPATCHERS", UserId }); + yield return Case("unit by name", c => new SelectUnitByDIdNameQuery(c).GetQuery(), new { DepartmentId, UnitName = "ENGINE 9" }); + yield return Case("unit type by name", c => new SelectUnitTypeByDIdNameQuery(c).GetQuery(), new { DepartmentId, TypeName = "engine" }); + yield return Case("units by type", c => new SelectUnitByDIdTypeQuery(c).GetQuery(), new { DepartmentId, Type = "ENGINE" }); + yield return Case("distribution list by e-mail", c => new SelectDListByEmailQuery(c).GetQuery(), new { EmailAddress = "allhands9" }); + yield return Case("group by dispatch e-mail code", c => new SelectGroupByDispatchCodeQuery(c).GetQuery(), new { DispatchEmail = "st9dispatch" }); + yield return Case("group by message e-mail code", c => new SelectGroupByMessageCodeQuery(c).GetQuery(), new { MessageEmail = "ST9MESSAGE" }); + yield return Case("communication test run by run code", c => new SelectCommTestRunByRunCodeQuery(c).GetQuery(), new { RunCode = "runxy9" }); + yield return Case("communication test result by response token", c => new SelectCommTestResultByResponseTokenQuery(c).GetQuery(), new { ResponseToken = "TOKQ7Z" }); + } + + [TestCaseSource(nameof(TemplateLookups))] + public async Task Template_lookup_ignores_case(Func query, object parameters) + { + await using var database = Connect(); + var rows = (await database.QueryAsync(query(_sql), parameters)).ToList(); + + rows.Should().NotBeEmpty(); + } + + [Test] + public async Task Removing_a_user_from_a_role_ignores_the_role_name_case() + { + await using var database = Connect(); + await database.OpenAsync(); + await using var transaction = await database.BeginTransactionAsync(); + + (await database.ExecuteAsync(new RemoveUserFromRoleQuery(_sql).GetQuery(), new { UserId, RoleName = "DISPATCHERS" }, transaction)).Should().Be(1); + await transaction.RollbackAsync(); + } + + [Test] + public async Task A_text_parameter_alone_matches_case_sensitively_and_cannot_use_the_index() + { + // The reason for every cast: the same predicate without it misses the row and plans a sequential scan. + await using var database = Connect(); + await database.OpenAsync(); + await database.ExecuteAsync("SET enable_seqscan = off"); + + (await database.ExecuteScalarAsync("SELECT COUNT(*) FROM communicationtestruns WHERE runcode = @RunCode", new { RunCode = "runxy9" })).Should().Be(0); + string.Join("\n", await database.QueryAsync("EXPLAIN SELECT * FROM communicationtestruns WHERE runcode = @RunCode", new { RunCode = "runxy9" })) + .Should().Contain("Seq Scan"); + } + + [Test] + public async Task A_cast_lookup_uses_the_citext_index() + { + await using var database = Connect(); + await database.OpenAsync(); + await database.ExecuteAsync("SET enable_seqscan = off"); + + var plan = string.Join("\n", await database.QueryAsync("EXPLAIN " + new SelectCommTestRunByRunCodeQuery(_sql).GetQuery(), new { RunCode = "runxy9" })); + plan.Should().Contain("ix_communicationtestruns_runcode").And.NotContain("Seq Scan"); + } + + private static QueryFactory Queries(params IQuery[] queries) + { + var list = new ConcurrentDictionary(); + foreach (var query in queries) list[query.GetType()] = query; + var queryList = new Mock(); + queryList.Setup(l => l.RetrieveQueryList()).Returns(list); + return new QueryFactory(queryList.Object); + } + + private IConnectionProvider Connections() + { + var connections = new Mock(); + connections.Setup(c => c.Create()).Returns(() => Connect()); + return connections.Object; + } + + [Test] + public async Task Identity_store_finds_users_by_normalized_user_name_and_email() + { + // UserManager hands the store upper-cased values (UpperInvariantLookupNormalizer); the store compares them with + // UserName and Email as stored. + var users = new IdentityUserRepository(Connections(), _sql, Mock.Of(), Mock.Of(), + Queries(new SelectUserByUserNameQuery(_sql), new SelectUserByEmailQuery(_sql))); + + ((await users.GetByUserNameAsync("JANE.DOE"))?.Id).Should().Be(UserId); + ((await users.GetByEmailAsync("JANE.DOE@EXAMPLE.COM"))?.Id).Should().Be(UserId); + } + + [Test] + public async Task Identity_repository_finds_users_by_user_name_and_email() + { + var identity = new IdentityRepository(); + + ((await identity.GetUserByUserNameAsync("jane.doe"))?.Id).Should().Be(UserId); + (identity.GetUserByEmail("JANE.DOE@EXAMPLE.COM")?.Id).Should().Be(UserId); + } + + [Test] + public async Task Chatbot_linking_code_lookup_ignores_case() + { + var codes = new ChatbotLinkingCodeRepository(Connections(), _sql, Mock.Of(), Queries()); + + ((await codes.GetByCodeAsync("linkz1"))?.Code).Should().Be("LinkZ1"); + } + + [Test] + public async Task Certification_type_code_lookups_ignore_case() + { + var types = new DepartmentCertificationTypeRepository(Connections(), _sql, Mock.Of(), Queries()); + + ((await types.GetByCodeAsync(DepartmentId, "emt-b"))?.Code).Should().Be("EMT-B"); + (await types.GetByCodesAsync(DepartmentId, new[] { "emt-b" })).Select(t => t.Code).Should().Equal("EMT-B"); + } + + [Test] + public async Task Hydrant_number_lookups_ignore_case() + { + var hydrants = new RmsHydrantsRepository(Connections(), _sql, Mock.Of(), Queries()); + + ((await hydrants.GetByNumberAsync(DepartmentId, "h-12A"))?.HydrantNumber).Should().Be("H-12a"); + var batch = await hydrants.GetByNumbersAsync(DepartmentId, new[] { "h-12A" }); + batch.Should().ContainKey("h-12A"); + batch["h-12A"].HydrantNumber.Should().Be("H-12a"); + } + } +} diff --git a/Tests/Resgrid.Tests/Rms/IncidentOfficerJourneyTests.cs b/Tests/Resgrid.Tests/Rms/IncidentOfficerJourneyTests.cs index a17324c4c..d3f12ff36 100644 --- a/Tests/Resgrid.Tests/Rms/IncidentOfficerJourneyTests.cs +++ b/Tests/Resgrid.Tests/Rms/IncidentOfficerJourneyTests.cs @@ -112,7 +112,8 @@ public async Task Officer_completes_submits_corrects_and_discloses_an_incident_w var disclosures = new RecordsDisclosureService(_store.Shared.DisclosureRequestsRepo.Object, _store.Shared.DisclosureProductionsRepo.Object, _store.Shared.RecordsRepo.Object, _store.Shared.RevisionsRepo.Object, _store.Shared.AuditsRepo.Object, _authorization.Object, _settings.Object, - _store.UnitOfWork.Object, _store.ReportsRepo.Object, documents, _store.Shared.AttachmentsRepo.Object, pdf.Object, _store.AnalysesRepo.Object, scanner.Object, udf, new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.Shared.OutboxRepo.Object, _aggregator.Object)); + _store.UnitOfWork.Object, _store.ReportsRepo.Object, documents, _store.Shared.AttachmentsRepo.Object, pdf.Object, _store.AnalysesRepo.Object, scanner.Object, udf, new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.Shared.OutboxRepo.Object, _aggregator.Object), + new Resgrid.Services.DocumentNumberingService(_settings.Object, Mock.Of(), new Resgrid.Tests.Services.FakeDocumentNumberSequences())); var request = await disclosures.CreateRequestAsync(Dept, "custodian", new RmsDisclosureRequest { RequesterName = "Training requester", JurisdictionProfile = "Fixture jurisdiction", ReceivedOn = DateTime.UtcNow }); await disclosures.SaveScopeAsync(Dept, "custodian", request.RmsDisclosureRequestId, "Incident and supporting file", new RmsRecordQuery { CallId = CallId, DefinitionKey = RmsDefinitionKeys.NerisIncidentReport }, RmsRedactionProfiles.Standard); var review = await disclosures.GetReviewAsync(Dept, "custodian", request.RmsDisclosureRequestId); diff --git a/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs b/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs index 1a58ca387..143377f88 100644 --- a/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/IncidentReportsServiceTests.cs @@ -149,7 +149,7 @@ private IncidentReportsService BuildService(IRecordsUdfService udf = null, IReco _store.Shared.ScopesRepo.Object, _store.Shared.SharesRepo.Object, _store.Shared.ProjectionsRepo.Object, outbox, _settings.Object, _groups.Object, _profiles.Object, _roles.Object, _units.Object, _calls.Object, _adp.Object, _store.UnitOfWork.Object, _neris.Object, new NerisMappingService(), _validation.Object, _authorization.Object, _store.Shared.AttachmentsRepo.Object, _store.Shared.EvidenceRepo.Object, udf ?? Mock.Of(), evidence ?? Mock.Of(), - _feeds.Object, Protection); + _feeds.Object, Protection, _departments.Object); } [Test] @@ -405,6 +405,34 @@ public async Task Finalize_numbers_with_the_department_incident_report_prefix() final.Report.RecordNumber.Should().Be("IR26-0001"); } + [Test] + public async Task Finalize_numbers_by_the_fiscal_year_of_the_call() + { + // The call was logged 1 September 2026; a fiscal year starting 1 September and named by the year it ends in is FY2027. + var config = new RecordsNumberingConfig { Pattern = "{PREFIX}{YY}-{SEQ}", YearStartMonth = 9, YearStartDay = 1 }; + config.SetPrefix(RmsDefinitionKeys.NerisIncidentReport, "EZKT"); + _settings.Setup(s => s.GetRecordsNumberingConfigAsync(Dept, It.IsAny())).ReturnsAsync(config); + var started = await _service.StartFromCallAsync(Dept, "author", CallId); + + var final = await _service.FinalizeAsync(Dept, "author", started.Report.RmsIncidentReportId, started.Report.RowVersion, null, "10.0.0.1", null, null); + + final.Report.RecordNumber.Should().Be("EZKT27-0001"); + } + + [Test] + public async Task Finalize_reads_the_call_date_in_the_department_time_zone() + { + // 08:00 UTC on 1 September is still 31 August (22:00) in Honolulu, the last day of FY2026. + _departments.Setup(d => d.GetDepartmentByIdAsync(Dept, It.IsAny())).ReturnsAsync(new Department { DepartmentId = Dept, TimeZone = "Hawaiian Standard Time" }); + var config = new RecordsNumberingConfig { Pattern = "{PREFIX}{YY}-{SEQ}", YearStartMonth = 9, YearStartDay = 1 }; + _settings.Setup(s => s.GetRecordsNumberingConfigAsync(Dept, It.IsAny())).ReturnsAsync(config); + var started = await _service.StartFromCallAsync(Dept, "author", CallId); + + var final = await _service.FinalizeAsync(Dept, "author", started.Report.RmsIncidentReportId, started.Report.RowVersion, null, "10.0.0.1", null, null); + + final.Report.RecordNumber.Should().Be("INC26-0001"); + } + [Test] public async Task Finalize_writes_revision_signature_and_queues_the_submission() { diff --git a/Tests/Resgrid.Tests/Rms/Parity/RecordsParityHarness.cs b/Tests/Resgrid.Tests/Rms/Parity/RecordsParityHarness.cs index b22dfd7d5..42ce57fa9 100644 --- a/Tests/Resgrid.Tests/Rms/Parity/RecordsParityHarness.cs +++ b/Tests/Resgrid.Tests/Rms/Parity/RecordsParityHarness.cs @@ -85,7 +85,7 @@ public RecordsParityHarness() Records = new RecordsService(Store.RecordsRepo.Object, new RmsRecordValueService(Store.DetailsRepo.Object), Store.ParticipantsRepo.Object, Store.UnitsRepo.Object, Store.AttachmentsRepo.Object, Store.RevisionsRepo.Object, evidence.Object, Store.ScopesRepo.Object, Store.SharesRepo.Object, Store.ProjectionsRepo.Object, Store.AuditsRepo.Object, outbox, cutover.Object, settings.Object, groups.Object, profiles.Object, units.Object, calls.Object, adp.Object, - Store.UnitOfWork.Object, queue.Object, new NullRecordAttachmentScanner(), Authorization.Object, udf, new PassthroughRecordsProtection(), Mock.Of(), Mock.Of(), Mock.Of()); + Store.UnitOfWork.Object, queue.Object, new NullRecordAttachmentScanner(), Authorization.Object, udf, new PassthroughRecordsProtection(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of()); var branding = new Mock(); branding.Setup(b => b.GetBrandingAsync(Dept)).ReturnsAsync(new DepartmentBranding { DisplayName = "Parity Fire Department", ShortName = "PFD", AddressText = "100 Station Road" }); diff --git a/Tests/Resgrid.Tests/Rms/RecordNumberingTests.cs b/Tests/Resgrid.Tests/Rms/RecordNumberingTests.cs index fab90a782..2b4e55b25 100644 --- a/Tests/Resgrid.Tests/Rms/RecordNumberingTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordNumberingTests.cs @@ -550,6 +550,77 @@ public async Task A_next_number_typed_against_the_old_prefix_is_not_carried_to_t _saved.FloorFor("TRN-2026-#").Should().Be(40, "a type whose prefix did not change keeps its raised number"); } + [Test] + public async Task Saving_stores_the_year_start_and_refuses_a_day_not_every_year_has() + { + var saved = await _service.SaveAsync(Dept, "admin", new RecordsNumberingUpdate + { + Pattern = "{PREFIX}{YY}-{SEQ}", SequenceWidth = 4, Year = 2026, YearStartMonth = 11, YearStartDay = 1, YearLabel = (int)NumberingYearLabel.EndYear + }); + saved.YearStartRejected.Should().BeFalse(); + _saved.YearStart().Month.Should().Be(11); + _saved.YearStart().Day.Should().Be(1); + _saved.YearStart().Label.Should().Be(NumberingYearLabel.EndYear); + + var leap = await _service.SaveAsync(Dept, "admin", new RecordsNumberingUpdate { Pattern = "{PREFIX}-{SEQ}", SequenceWidth = 6, Year = 2026, YearStartMonth = 2, YearStartDay = 29 }); + leap.YearStartRejected.Should().BeTrue(); + _saved.Pattern.Should().Be("{PREFIX}{YY}-{SEQ}", "a refused year start saves nothing in setting 72"); + + await _service.SaveAsync(Dept, "admin", new RecordsNumberingUpdate { Pattern = "{PREFIX}{YY}-{SEQ}", SequenceWidth = 4, Year = 2026 }); + _saved.YearStart().Month.Should().Be(11, "a save that does not post a year start keeps the saved one"); + } + + [Test] + public async Task A_next_number_typed_before_the_year_start_changed_is_not_applied() + { + var result = await _service.SaveAsync(Dept, "admin", new RecordsNumberingUpdate + { + Pattern = "{PREFIX}-{YYYY}-{SEQ}", + SequenceWidth = 4, + Year = 2026, + YearStartMonth = 7, + YearStartDay = 1, + NextNumbers = { new RecordNextNumberRequest { ScopeKey = "TRN-2026-#", NextSequence = 40 } } + }); + + result.NotApplied.Should().Be(1, "the next numbers shown were the old year's sequences"); + _saved.FloorFor("TRN-2026-#").Should().Be(1); + _saved.YearStart().Month.Should().Be(7, "the year start itself still saves"); + } + + [Test] + public async Task Sequences_are_listed_for_a_fiscal_year_by_its_name() + { + _issued.Add("EZKT27-0004"); + var config = new RecordsNumberingConfig { Pattern = "{PREFIX}{YY}-{SEQ}", YearStartMonth = 11, YearStartDay = 1 }; + config.SetPrefix(RmsDefinitionKeys.NerisIncidentReport, "EZKT"); + + var year = RecordsNumberingService.NumberingYear(config, new DateTime(2026, 11, 3, 15, 0, 0, DateTimeKind.Utc), null); + var sequences = await _service.GetSequencesAsync(Dept, config, year); + + year.Should().Be(2027); + sequences.Single(s => s.DefinitionKeys.Contains(RmsDefinitionKeys.NerisIncidentReport)).NextNumber.Should().Be("EZKT27-0005"); + } + + [TestCase(2027, 1, 1, 3, 0, null, 2027, "no time zone reads the date as UTC")] + [TestCase(2027, 1, 1, 3, 0, "Central Standard Time", 2026, "9 PM on December 31 in Chicago is still 2026")] + [TestCase(2027, 1, 1, 7, 0, "Central Standard Time", 2027, "1 AM on January 1 in Chicago")] + public void A_records_numbering_year_is_the_departments_local_year(int y, int m, int d, int h, int min, string timeZone, int expected, string because) + { + RecordsNumberingService.NumberingYear(new RecordsNumberingConfig(), new DateTime(y, m, d, h, min, 0, DateTimeKind.Utc), timeZone).Should().Be(expected, because); + } + + [Test] + public void The_records_year_start_round_trips_through_protobuf() + { + var copy = ObjectSerialization.Deserialize(ObjectSerialization.Serialize( + new RecordsNumberingConfig { YearStartMonth = 10, YearStartDay = 1, YearLabel = (int)NumberingYearLabel.StartYear })); + + copy.YearStart().Month.Should().Be(10); + copy.YearStart().Label.Should().Be(NumberingYearLabel.StartYear); + ObjectSerialization.Deserialize(ObjectSerialization.Serialize(new RecordsNumberingConfig())).YearStart().IsCalendarYear.Should().BeTrue(); + } + #endregion } } diff --git a/Tests/Resgrid.Tests/Rms/RecordOperationalSummaryServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordOperationalSummaryServiceTests.cs index d096e5528..4aac841ff 100644 --- a/Tests/Resgrid.Tests/Rms/RecordOperationalSummaryServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordOperationalSummaryServiceTests.cs @@ -67,7 +67,7 @@ public void SetUp() _records = new RecordsService(_store.RecordsRepo.Object, new RmsRecordValueService(_store.DetailsRepo.Object), _store.ParticipantsRepo.Object, _store.UnitsRepo.Object, _store.AttachmentsRepo.Object, _store.RevisionsRepo.Object, evidence.Object, _store.ScopesRepo.Object, _store.SharesRepo.Object, _store.ProjectionsRepo.Object, _store.AuditsRepo.Object, outbox, cutover.Object, settings.Object, groups.Object, profiles.Object, units.Object, calls.Object, adp.Object, - _store.UnitOfWork.Object, queue.Object, new NullRecordAttachmentScanner(), _authorization.Object, Mock.Of(), new PassthroughRecordsProtection(), Mock.Of(), Mock.Of(), Mock.Of()); + _store.UnitOfWork.Object, queue.Object, new NullRecordAttachmentScanner(), _authorization.Object, Mock.Of(), new PassthroughRecordsProtection(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of()); _store.ProjectionsRepo.Setup(r => r.GetModifiedSinceAsync(Dept, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((int d, DateTime? since, int take, string sinceId) => _store.Projections diff --git a/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs index c09492591..13d07efa3 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs @@ -32,6 +32,8 @@ public class RecordsDisclosureServiceTests private Mock _scanner; private Mock _authorization; private Mock _settings; + private Mock _departments; + private Resgrid.Tests.Services.FakeDocumentNumberSequences _documentSequences; private RecordsDisclosureService _service; private RmsOperationalRecord _finalized; private RmsRevision _revision; @@ -51,6 +53,8 @@ public void SetUp() _authorization.Setup(a => a.IsDepartmentAdminAsync(It.IsAny(), Dept)).ReturnsAsync(() => _udfAdmin); _settings = new Mock(); + _departments = new Mock(); + _documentSequences = new Resgrid.Tests.Services.FakeDocumentNumberSequences { Issued = kind => kind == DocumentNumberKinds.RecordsRequest ? _store.DisclosureRequests.Select(r => r.RequestNumber) : Enumerable.Empty() }; _settings.Setup(s => s.GetRecordsDisclosureConfigAsync(Dept, It.IsAny())) .ReturnsAsync(new RecordsDisclosureConfig { StatutoryClockDays = 5, DefaultRedactionProfile = RmsRedactionProfiles.Standard }); @@ -69,7 +73,8 @@ public void SetUp() _service = new RecordsDisclosureService(_store.DisclosureRequestsRepo.Object, _store.DisclosureProductionsRepo.Object, _store.RecordsRepo.Object, _store.RevisionsRepo.Object, _store.AuditsRepo.Object, _authorization.Object, _settings.Object, _store.UnitOfWork.Object, _incidentStore.ReportsRepo.Object, documents, _store.AttachmentsRepo.Object, _pdf.Object, _incidentStore.AnalysesRepo.Object, _scanner.Object, udf, - new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.OutboxRepo.Object, Mock.Of())); + new PassthroughRecordsProtection(), new DomainEventOutboxService(_store.OutboxRepo.Object, Mock.Of()), + new Resgrid.Services.DocumentNumberingService(_settings.Object, _departments.Object, _documentSequences)); } private async Task ReviewedProduceAsync(int departmentId, string userId, string requestId) @@ -480,6 +485,35 @@ public async Task A_new_request_gets_a_number_and_a_statutory_clock() request.RedactionProfile.Should().Be(RmsRedactionProfiles.Standard); } + [Test] + public async Task Request_numbers_use_the_departments_own_pattern() + { + var config = new RecordsNumberingConfig(); + config.DocumentPatterns.Add(new DocumentNumberPattern { Kind = DocumentNumberKinds.RecordsRequest, Pattern = "ORR/{YY}/{SEQ}", SequenceWidth = 3 }); + _settings.Setup(s => s.GetRecordsNumberingConfigAsync(Dept, It.IsAny())).ReturnsAsync(config); + + (await _service.CreateRequestAsync(Dept, "clerk", new RmsDisclosureRequest { RequesterName = "A. Reporter", ReceivedOn = new DateTime(2026, 9, 1, 12, 0, 0, DateTimeKind.Utc) })) + .RequestNumber.Should().Be("ORR/26/001"); + (await _service.CreateRequestAsync(Dept, "clerk", new RmsDisclosureRequest { RequesterName = "B. Reporter", ReceivedOn = new DateTime(2026, 9, 2, 12, 0, 0, DateTimeKind.Utc) })) + .RequestNumber.Should().Be("ORR/26/002"); + } + + [Test] + public async Task Request_numbers_follow_the_records_fiscal_year_in_department_time() + { + // A November fiscal year named by the year it ends in: requests received from 1 November 2026 (Chicago) are FY2027. + _settings.Setup(s => s.GetRecordsNumberingConfigAsync(Dept, It.IsAny())).ReturnsAsync(new RecordsNumberingConfig { YearStartMonth = 11, YearStartDay = 1 }); + _departments.Setup(d => d.GetDepartmentByIdAsync(Dept, It.IsAny())).ReturnsAsync(new Department { DepartmentId = Dept, TimeZone = "Central Standard Time" }); + + // 04:30 UTC on 1 November is 23:30 on 31 October in Chicago. + (await _service.CreateRequestAsync(Dept, "clerk", new RmsDisclosureRequest { RequesterName = "A. Reporter", ReceivedOn = new DateTime(2026, 11, 1, 4, 30, 0, DateTimeKind.Utc) })) + .RequestNumber.Should().Be("PRR-2026-0001"); + (await _service.CreateRequestAsync(Dept, "clerk", new RmsDisclosureRequest { RequesterName = "B. Reporter", ReceivedOn = new DateTime(2026, 11, 1, 6, 0, 0, DateTimeKind.Utc) })) + .RequestNumber.Should().Be("PRR-2027-0001"); + (await _service.CreateRequestAsync(Dept, "clerk", new RmsDisclosureRequest { RequesterName = "C. Reporter", ReceivedOn = new DateTime(2027, 1, 10, 15, 0, 0, DateTimeKind.Utc) })) + .RequestNumber.Should().Be("PRR-2027-0002", "January 1 does not restart a fiscal year"); + } + [Test] public async Task A_request_without_a_requester_is_refused() { diff --git a/Tests/Resgrid.Tests/Rms/RecordsInspectionsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsInspectionsServiceTests.cs index bbbbe84d2..b21b3990d 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsInspectionsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsInspectionsServiceTests.cs @@ -40,6 +40,36 @@ public async Task SetUp() }); } + [Test] + public async Task Prevention_numbers_follow_the_records_fiscal_year_in_department_time() + { + // Setting 72 starts a July fiscal year named by the year it ends in; Chicago local time decides the day. + _h.NumberingConfig = new RecordsNumberingConfig { YearStartMonth = 7, YearStartDay = 1 }; + _h.Departments.Setup(d => d.GetDepartmentByIdAsync(Dept, It.IsAny())).ReturnsAsync(new Department { DepartmentId = Dept, TimeZone = "Central Standard Time" }); + + // 04:00 UTC on 1 July is 23:00 on 30 June in Chicago, still FY2026; two hours later FY2027 has begun. + (await _h.Gate.NextNumberAsync(Dept, RmsPreventionNumberKinds.Inspection, new DateTime(2026, 7, 1, 4, 0, 0, DateTimeKind.Utc))).Should().Be("INSP-2026-0001"); + (await _h.Gate.NextNumberAsync(Dept, RmsPreventionNumberKinds.Inspection, new DateTime(2026, 7, 1, 6, 0, 0, DateTimeKind.Utc))).Should().Be("INSP-2027-0001"); + (await _h.Gate.NextNumberAsync(Dept, RmsPreventionNumberKinds.Inspection, new DateTime(2027, 2, 1, 12, 0, 0, DateTimeKind.Utc))).Should().Be("INSP-2027-0002", "January 1 does not restart a fiscal year"); + (await _h.Gate.NextNumberAsync(Dept, RmsPreventionNumberKinds.Permit, new DateTime(2027, 2, 1, 12, 0, 0, DateTimeKind.Utc))).Should().Be("PRM-2027-0001", "each kind keeps its own sequence"); + } + + [Test] + public async Task Prevention_numbers_use_the_departments_own_pattern_and_carry_on_after_built_in_ones() + { + _h.NumberingConfig = new RecordsNumberingConfig { YearStartMonth = 7, YearStartDay = 1 }; + _h.NumberingConfig.DocumentPatterns.Add(new DocumentNumberPattern { Kind = DocumentNumberKinds.Inspection, Pattern = "FI{YY}-{SEQ}", SequenceWidth = 3 }); + _h.NumberingConfig.DocumentPatterns.Add(new DocumentNumberPattern { Kind = DocumentNumberKinds.Permit, Pattern = "PRM-{YYYY}-{SEQ}", SequenceWidth = 5 }); + _h.Permits.Rows.Add(new RmsPermit { RmsPermitId = Guid.NewGuid().ToString(), DepartmentId = Dept, PermitNumber = "PRM-2027-0041" }); + + (await _h.Gate.NextNumberAsync(Dept, RmsPreventionNumberKinds.Inspection, new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc))).Should().Be("FI27-001"); + (await _h.Gate.NextNumberAsync(Dept, RmsPreventionNumberKinds.Inspection, new DateTime(2026, 8, 2, 12, 0, 0, DateTimeKind.Utc))).Should().Be("FI27-002"); + (await _h.Gate.NextNumberAsync(Dept, RmsPreventionNumberKinds.Permit, new DateTime(2026, 8, 2, 12, 0, 0, DateTimeKind.Utc))).Should().Be("PRM-2027-00042", + "a wider pattern that reads like the built-in permit numbers carries on after them"); + (await _h.Gate.NextNumberAsync(Dept, RmsPreventionNumberKinds.Occupancy, new DateTime(2026, 8, 2, 12, 0, 0, DateTimeKind.Utc))).Should().Be("OCC-2027-0001", + "a kind with no pattern keeps the built-in numbers, in the fiscal year"); + } + [Test] public async Task Completing_an_inspection_opens_violations_from_failed_items_and_raises_trigger_161_without_the_notes() { diff --git a/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs index f6655f11b..f4c396f29 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs @@ -95,7 +95,7 @@ public void SetUp() _service = new RecordsService(_store.RecordsRepo.Object, new Resgrid.Services.Records.RmsRecordValueService(_store.DetailsRepo.Object), _store.ParticipantsRepo.Object, _store.UnitsRepo.Object, _store.AttachmentsRepo.Object, _store.RevisionsRepo.Object, _evidence.Object, _store.ScopesRepo.Object, _store.SharesRepo.Object, _store.ProjectionsRepo.Object, _store.AuditsRepo.Object, outbox, _cutover.Object, _settings.Object, _groups.Object, _profiles.Object, _units.Object, _calls.Object, _adp.Object, - _store.UnitOfWork.Object, _outboundQueue.Object, new Resgrid.Services.Records.NullRecordAttachmentScanner(), _authorization.Object, Mock.Of(), _protection, Mock.Of(), Mock.Of(), Mock.Of()); + _store.UnitOfWork.Object, _outboundQueue.Object, new Resgrid.Services.Records.NullRecordAttachmentScanner(), _authorization.Object, Mock.Of(), _protection, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of()); } [Test] @@ -515,6 +515,16 @@ public async Task Finalize_continues_from_a_raised_next_number_and_a_prefixless_ (await _service.FinalizeAsync(Dept, "author", again.Record.RmsOperationalRecordId, again.Record.RowVersion, "1", null, null)).Record.RecordNumber.Should().Be("2026-0154"); } + [Test] + public async Task Finalize_numbers_by_the_department_fiscal_year() + { + // The training started 1 September 2026; a July fiscal year named by the year it ends in makes that FY2027. + _settings.Setup(s => s.GetRecordsNumberingConfigAsync(Dept, It.IsAny())).ReturnsAsync(new RecordsNumberingConfig { Pattern = "{PREFIX}-{YYYY}-{SEQ}", YearStartMonth = 7, YearStartDay = 1 }); + + var first = await _service.CreateDraftAsync(Dept, "author", TrainingInput()); + (await _service.FinalizeAsync(Dept, "author", first.Record.RmsOperationalRecordId, first.Record.RowVersion, "1", null, null)).Record.RecordNumber.Should().Be("TRN-2027-0001"); + } + [Test] public async Task Finalize_renders_text_after_the_sequence() { diff --git a/Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs b/Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs index 402f4e285..e79092261 100644 --- a/Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs +++ b/Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs @@ -101,7 +101,7 @@ public RmsDefinitionHarness() Records = new RecordsService(Store.RecordsRepo.Object, new RmsRecordValueService(Store.DetailsRepo.Object), Store.ParticipantsRepo.Object, Store.UnitsRepo.Object, Store.AttachmentsRepo.Object, Store.RevisionsRepo.Object, Evidence.Object, Store.ScopesRepo.Object, Store.SharesRepo.Object, Store.ProjectionsRepo.Object, Store.AuditsRepo.Object, Outbox, Cutover.Object, Settings.Object, Groups.Object, Profiles.Object, Units.Object, Calls.Object, Adp.Object, - Store.UnitOfWork.Object, OutboundQueue.Object, new NullRecordAttachmentScanner(), Authorization.Object, Mock.Of(), Protection, Definitions, TypedValues, Roles.Object); + Store.UnitOfWork.Object, OutboundQueue.Object, new NullRecordAttachmentScanner(), Authorization.Object, Mock.Of(), Protection, Definitions, TypedValues, Roles.Object, Departments.Object); Reports = new RecordSavedReportsService(Defs.ReportsRepo.Object, Definitions, Records, Store.RecordsRepo.Object, Defs.ValuesRepo.Object, Defs.GroupsRepo.Object, Authorization.Object, Store.AuditsRepo.Object, Departments.Object); Deployments = new RecordDeploymentsService(Defs.OrdersRepo.Object, Defs.FillsRepo.Object, Defs.ReferencesRepo.Object, Records, Store.RecordsRepo.Object, Definitions, Templates, Authorization.Object, Store.AuditsRepo.Object, Store.UnitOfWork.Object); Connectors = new Resgrid.Services.Records.Connectors.RecordDeploymentConnectorsService(Defs.ConnectorsRepo.Object, Defs.ConnectorRunsRepo.Object, Defs.OrdersRepo.Object, Defs.FillsRepo.Object, diff --git a/Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs b/Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs index 03a17cbfe..0df56acde 100644 --- a/Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs +++ b/Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs @@ -9,6 +9,7 @@ using Resgrid.Model.Repositories.Queries; using Resgrid.Model.Services; using Resgrid.Services.Records; +using Resgrid.Tests.Services; namespace Resgrid.Tests.Rms { @@ -343,6 +344,11 @@ public sealed class RmsPreventionHarness public bool RecordsUsable { get; set; } = true; public FakeSequences Sequences { get; } = new FakeSequences(); + /// Setting 72 as the numbering service reads it; the calendar year until a test sets a fiscal year start. + public RecordsNumberingConfig NumberingConfig { get; set; } = new RecordsNumberingConfig(); + public Mock Departments { get; } = new Mock(); + /// Custom-pattern counters, seeded from the numbers the harness's stores hold. + public FakeDocumentNumberSequences DocumentSequences { get; } = new FakeDocumentNumberSequences(); public FakeAudits Audits { get; } = new FakeAudits(); public FakeOccupancies Occupancies { get; } = new FakeOccupancies(); public FakeContactLinks Links { get; } = new FakeContactLinks(); @@ -400,7 +406,19 @@ public RmsPreventionHarness() // Pois has no DepartmentId column (RESGRID-WEB-1MQ); department POIs are read through their POI types. Pois.Setup(p => p.GetAllByDepartmentIdAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("Invalid column name 'DepartmentId'.")); - Gate = new RecordsPreventionGate(Cutover.Object, Flags.Object, Authorization.Object, Sequences, Audits); + var numberingSettings = new Mock(); + numberingSettings.Setup(s => s.GetRecordsNumberingConfigAsync(It.IsAny(), It.IsAny())).ReturnsAsync(() => NumberingConfig); + DocumentSequences.Issued = kind => kind switch + { + DocumentNumberKinds.Occupancy => Occupancies.Rows.Select(r => r.OccupancyNumber), + DocumentNumberKinds.Inspection => Inspections.Rows.Select(r => r.InspectionNumber), + DocumentNumberKinds.Permit => Permits.Rows.Select(r => r.PermitNumber), + DocumentNumberKinds.Investigation => Cases.Rows.Select(r => r.CaseNumber), + DocumentNumberKinds.Evidence => Evidence.Rows.Select(r => r.EvidenceNumber), + _ => Enumerable.Empty() + }; + var numbering = new Resgrid.Services.DocumentNumberingService(numberingSettings.Object, Departments.Object, DocumentSequences); + Gate = new RecordsPreventionGate(Cutover.Object, Flags.Object, Authorization.Object, Sequences, Audits, numbering); OccupancyService = new RecordsOccupancyService(Gate, Occupancies, Links, Hazards, Crosswalks, Provenance, Ownerships, Violations, Hydrants, ContactPreplans.Object, ContactHazards.Object, Contacts.Object, Addresses.Object, Pois.Object, PoiTypes.Object, ProtectedReads.Object, Grant.Object, Protection, UnitOfWork.Object); InspectionsService = new RecordsInspectionsService(Gate, CodeSets, CodeSections, Programs, Inspections, Violations, Occupancies, Attachments, Protection, Outbox, UnitOfWork.Object); HydrantsService = new RecordsHydrantsService(Gate, Hydrants, FlowTests, Maintenance, Attachments, UnitOfWork.Object); diff --git a/Tests/Resgrid.Tests/Search/UnifiedSearchAddressTests.cs b/Tests/Resgrid.Tests/Search/UnifiedSearchAddressTests.cs index a4ad40c6e..1cd2c81f6 100644 --- a/Tests/Resgrid.Tests/Search/UnifiedSearchAddressTests.cs +++ b/Tests/Resgrid.Tests/Search/UnifiedSearchAddressTests.cs @@ -39,7 +39,7 @@ private UnifiedSearchService ServiceWithAddressMatching(bool occupancies = false _flags.Setup(f => f.IsEnabledAsync(RecordsPreventionModules.FlagKey(RecordsPreventionModule.Occupancy), 7, It.IsAny(), It.IsAny>())).ReturnsAsync(true); _recordsAuth.Setup(a => a.IsActiveMemberAsync("u1", 7)).ReturnsAsync(true); gate = new Lazy(() => new RecordsPreventionGate(_cutover.Object, _flags.Object, _recordsAuth.Object, - Mock.Of(), Mock.Of())); + Mock.Of(), Mock.Of(), Mock.Of())); } var permissions = new Resgrid.Services.PermissionsService(_permissions.Object, Mock.Of(), Mock.Of()); diff --git a/Tests/Resgrid.Tests/Services/CallAutoCloseServiceTests.cs b/Tests/Resgrid.Tests/Services/CallAutoCloseServiceTests.cs index 81071b5e2..4f1b48d22 100644 --- a/Tests/Resgrid.Tests/Services/CallAutoCloseServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CallAutoCloseServiceTests.cs @@ -65,7 +65,10 @@ public void SetUp() _settings.Setup(x => x.GetCloseCallWhenUnitsClearAsync(DeptId, It.IsAny())).ReturnsAsync(true); _customStates.Setup(x => x.GetAllCustomStatesForDepartmentAsync(DeptId)).ReturnsAsync(new List()); - _unitDispatches.Setup(x => x.GetOpenCallIdsForUnitAsync(DeptId, UnitA)).ReturnsAsync(new List { CallId }); + _unitDispatches.Setup(x => x.GetOpenCallUnitDispatchesForUnitAsync(DeptId, UnitA)).ReturnsAsync(() => new List + { + new CallDispatchUnit { CallId = CallId, UnitId = UnitA, DispatchedOn = _now.AddMinutes(-40) } + }); _unitDispatches.Setup(x => x.GetCallUnitDispatchesByCallIdAsync(CallId)).ReturnsAsync(() => new List { new CallDispatchUnit { CallId = CallId, UnitId = UnitA, DispatchedOn = _now.AddMinutes(-40) }, @@ -194,7 +197,7 @@ public async Task does_nothing_when_the_department_has_not_turned_it_on() _settings.Setup(x => x.GetCloseCallWhenUnitsClearAsync(DeptId, It.IsAny())).ReturnsAsync(false); (await _service.CloseCallsFinishedByUnitStateAsync(DeptId, Saved((int)UnitStateTypes.Available), Previous((int)UnitStateTypes.OnScene))).Should().BeEmpty(); - _unitDispatches.Verify(x => x.GetOpenCallIdsForUnitAsync(It.IsAny(), It.IsAny()), Times.Never); + _unitDispatches.Verify(x => x.GetOpenCallUnitDispatchesForUnitAsync(It.IsAny(), It.IsAny()), Times.Never); VerifyNotClosed(); } @@ -241,6 +244,24 @@ public async Task a_blocked_protected_write_leaves_the_call_open() (await _service.CloseCallsFinishedByUnitStateAsync(DeptId, Saved((int)UnitStateTypes.Available), Previous((int)UnitStateTypes.OnScene))).Should().BeEmpty(); VerifyNotClosed(); + _cache.Verify(x => x.RemoveAsync("CallAutoClose_40"), Times.Once, "the call is still open, so the next status may try again"); + } + + [Test] + public async Task a_failed_save_releases_the_close_claim() + { + _calls.Setup(x => x.SaveCallAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new TimeoutException()); + + (await _service.CloseCallsFinishedByUnitStateAsync(DeptId, Saved((int)UnitStateTypes.Available), Previous((int)UnitStateTypes.OnScene))).Should().BeEmpty(); + _events.Verify(x => x.SendMessage(It.IsAny()), Times.Never); + _cache.Verify(x => x.RemoveAsync("CallAutoClose_40"), Times.Once); + } + + [Test] + public async Task a_closed_call_keeps_its_close_claim() + { + (await _service.CloseCallsFinishedByUnitStateAsync(DeptId, Saved((int)UnitStateTypes.Available), Previous((int)UnitStateTypes.OnScene))).Should().Equal(CallId); + _cache.Verify(x => x.RemoveAsync(It.IsAny()), Times.Never); } [Test] @@ -256,10 +277,13 @@ public async Task after_a_hospital_leg_the_call_is_the_last_one_the_unit_reporte { // Dispatched to 40 now and to 39 long ago (never closed). On scene at 40, then to the hospital (a POI), then back. const int StaleCallId = 39; - _unitDispatches.Setup(x => x.GetOpenCallIdsForUnitAsync(DeptId, UnitA)).ReturnsAsync(new List { StaleCallId, CallId }); - _unitDispatches.Setup(x => x.GetCallUnitDispatchesByCallIdAsync(StaleCallId)) - .ReturnsAsync(new List { new CallDispatchUnit { CallId = StaleCallId, UnitId = UnitA, DispatchedOn = _now.AddDays(-3) } }); - _unitStates.Setup(x => x.GetAllUnitStatesForUnitInDateRangeAsync(UnitA, It.IsAny(), It.IsAny())) + _unitDispatches.Setup(x => x.GetOpenCallUnitDispatchesForUnitAsync(DeptId, UnitA)).ReturnsAsync(new List + { + new CallDispatchUnit { CallId = StaleCallId, UnitId = UnitA, DispatchedOn = _now.AddDays(-3) }, + new CallDispatchUnit { CallId = CallId, UnitId = UnitA, DispatchedOn = _now.AddMinutes(-40) } + }); + // The history is read from the earliest open dispatch, the stale one. + _unitStates.Setup(x => x.GetAllUnitStatesForUnitInDateRangeAsync(UnitA, _now.AddDays(-3), It.IsAny())) .ReturnsAsync(new List { new UnitState { UnitStateId = 80, UnitId = UnitA, State = (int)UnitStateTypes.OnScene, Timestamp = _now.AddDays(-3), DestinationId = StaleCallId, DestinationType = (int)DestinationEntityTypes.Call }, @@ -278,7 +302,7 @@ public async Task after_a_hospital_leg_the_call_is_the_last_one_the_unit_reporte [Test] public async Task a_failure_is_logged_and_leaves_the_call_open() { - _unitDispatches.Setup(x => x.GetOpenCallIdsForUnitAsync(DeptId, UnitA)).ThrowsAsync(new InvalidOperationException("db down")); + _unitDispatches.Setup(x => x.GetOpenCallUnitDispatchesForUnitAsync(DeptId, UnitA)).ThrowsAsync(new InvalidOperationException("db down")); (await _service.CloseCallsFinishedByUnitStateAsync(DeptId, Saved((int)UnitStateTypes.Available), Previous((int)UnitStateTypes.OnScene))).Should().BeEmpty(); VerifyNotClosed(); diff --git a/Tests/Resgrid.Tests/Services/CallNumberingTests.cs b/Tests/Resgrid.Tests/Services/CallNumberingTests.cs index 0ee57ce80..82b3d4dc6 100644 --- a/Tests/Resgrid.Tests/Services/CallNumberingTests.cs +++ b/Tests/Resgrid.Tests/Services/CallNumberingTests.cs @@ -109,6 +109,109 @@ public void Config_round_trips_through_protobuf() CallNumberFormat.EffectiveWidth(ObjectSerialization.Deserialize(ObjectSerialization.Serialize(new CallNumberingConfig())).SequenceWidth).Should().Be(1); } + [Test] + public void The_year_start_round_trips_through_protobuf_and_unset_is_the_calendar_year() + { + var fiscal = ObjectSerialization.Deserialize(ObjectSerialization.Serialize( + new CallNumberingConfig { Pattern = "{YY}-{SEQ}", YearStartMonth = 11, YearStartDay = 15, YearLabel = (int)NumberingYearLabel.StartYear })).YearStart(); + fiscal.Month.Should().Be(11); + fiscal.Day.Should().Be(15); + fiscal.Label.Should().Be(NumberingYearLabel.StartYear); + + var unset = ObjectSerialization.Deserialize(ObjectSerialization.Serialize(new CallNumberingConfig())).YearStart(); + unset.IsCalendarYear.Should().BeTrue(); + unset.Label.Should().Be(NumberingYearLabel.EndYear); + } + + #endregion + + #region Numbering year + + [TestCase(2026, 10, 31, 2026, "the day before the fiscal year starts is still FY2026")] + [TestCase(2026, 11, 1, 2027, "a year starting 1 November 2026 is named by the year it ends in")] + [TestCase(2027, 1, 15, 2027, "January is in the same fiscal year as the November before it")] + [TestCase(2027, 10, 31, 2027, "the last day of FY2027")] + [TestCase(2027, 11, 1, 2028, "the next fiscal year")] + public void A_fiscal_year_is_named_by_the_year_it_ends_in(int y, int m, int d, int expected, string because) + { + new NumberingYearStart(11, 1, NumberingYearLabel.EndYear).YearOf(new DateTime(y, m, d, 9, 0, 0)).Should().Be(expected, because); + } + + [TestCase(2026, 10, 31, 2025)] + [TestCase(2026, 11, 1, 2026)] + [TestCase(2027, 10, 31, 2026)] + public void A_fiscal_year_can_be_named_by_the_year_it_starts_in(int y, int m, int d, int expected) + { + new NumberingYearStart(11, 1, NumberingYearLabel.StartYear).YearOf(new DateTime(y, m, d)).Should().Be(expected); + } + + [Test] + public void A_January_1_start_is_the_calendar_year_whichever_label_is_saved() + { + new NumberingYearStart(1, 1, NumberingYearLabel.EndYear).YearOf(new DateTime(2027, 1, 1)).Should().Be(2027); + new NumberingYearStart(0, 0, NumberingYearLabel.StartYear).YearOf(new DateTime(2026, 12, 31, 23, 59, 0)).Should().Be(2026); + new NumberingYearStart(0, 0, NumberingYearLabel.EndYear).IsCalendarYear.Should().BeTrue("unset month and day read as January 1"); + } + + [Test] + public void A_numbering_year_starts_where_its_name_says() + { + var fiscal = new NumberingYearStart(7, 1, NumberingYearLabel.EndYear); + fiscal.StartOf(new DateTime(2027, 3, 4)).Should().Be(new DateTime(2026, 7, 1)); + fiscal.StartOfYear(2027).Should().Be(new DateTime(2026, 7, 1), "FY2027 of a July fiscal year starts in July 2026"); + new NumberingYearStart(7, 1, NumberingYearLabel.StartYear).StartOfYear(2027).Should().Be(new DateTime(2027, 7, 1)); + NumberingYearStart.Calendar.StartOfYear(2027).Should().Be(new DateTime(2027, 1, 1)); + } + + [TestCase(2, 29, false)] + [TestCase(2, 28, true)] + [TestCase(4, 31, false)] + [TestCase(13, 1, false)] + [TestCase(0, 1, false)] + [TestCase(12, 31, true)] + public void A_year_start_is_a_day_every_year_has(int month, int day, bool valid) + { + NumberingYearStart.IsValid(month, day).Should().Be(valid); + } + + [Test] + public void A_saved_year_start_that_no_longer_validates_numbers_by_the_calendar_year() + { + new NumberingYearStart(2, 30, NumberingYearLabel.EndYear).IsCalendarYear.Should().BeTrue(); + new NumberingYearStart(14, 1, NumberingYearLabel.EndYear).IsCalendarYear.Should().BeTrue(); + } + + [Test] + public void A_yearly_pattern_writes_and_restarts_on_the_fiscal_year() + { + var start = new NumberingYearStart(11, 1, NumberingYearLabel.EndYear); + + var before = CallNumberFormat.Resolve("{YY}-{SEQ}", 4, new DateTime(2026, 10, 31, 23, 59, 0), start); + before.Format(1).Should().Be("26-0001"); + before.PeriodStart.Should().Be(new DateTime(2025, 11, 1)); + before.PeriodEnd.Should().Be(new DateTime(2026, 11, 1)); + + var after = CallNumberFormat.Resolve("{YY}-{SEQ}", 4, new DateTime(2026, 11, 1), start); + after.Format(1).Should().Be("27-0001"); + after.Year.Should().Be(2027); + after.PeriodStart.Should().Be(new DateTime(2026, 11, 1)); + after.PeriodEnd.Should().Be(new DateTime(2027, 11, 1)); + + CallNumberFormat.Resolve("{YY}-{SEQ}", 4, new DateTime(2027, 1, 1), start).Key.Should().Be(after.Key, "January 1 does not restart a fiscal year"); + } + + [Test] + public void Month_and_day_patterns_always_follow_the_calendar() + { + var start = new NumberingYearStart(11, 1, NumberingYearLabel.EndYear); + + var scope = CallNumberFormat.Resolve("{YYYY}{MM}{DD}-{SEQ}", 3, new DateTime(2026, 11, 2, 8, 0, 0), start); + + scope.Format(4).Should().Be("20261102-004", "a date in the number reads as the day it was logged"); + scope.PeriodStart.Should().Be(new DateTime(2026, 11, 2)); + scope.PeriodEnd.Should().Be(new DateTime(2026, 11, 3)); + } + #endregion #region Service @@ -270,7 +373,7 @@ public async Task Renumbering_a_year_starts_each_sequence_at_its_raised_starting Issued("z", new DateTime(2025, 12, 30)); await _sequences.RaiseFloorAsync(Dept, "2026-#", 40, "admin", DateTime.UtcNow); - (await _service.RenumberCallsForYearAsync(Dept, 2026)).Should().BeTrue(); + (await _service.RenumberCallsForYearAsync(Dept, new DateTime(2026, 6, 1))).Should().BeTrue(); _calls.Single(c => c.CallId == 2).Number.Should().Be("2026-040"); _calls.Single(c => c.CallId == 1).Number.Should().Be("2026-041"); @@ -296,7 +399,7 @@ public async Task Renumbering_skips_numbers_deleted_calls_hold_and_never_shares_ Issued(created, new DateTime(2026, 10, 6)); }; - (await _service.RenumberCallsForYearAsync(Dept, 2026)).Should().BeTrue(); + (await _service.RenumberCallsForYearAsync(Dept, new DateTime(2026, 6, 1))).Should().BeTrue(); _calls.Where(c => !c.IsDeleted && c.LoggedOn < new DateTime(2026, 3, 1)).OrderBy(c => c.LoggedOn).Select(c => c.Number) .Should().Equal(new[] { "2026-001", "2026-003", "2026-004", "2026-005" }, "2026-002 still belongs to the deleted call"); @@ -313,7 +416,7 @@ public async Task With_no_call_created_meanwhile_the_counter_comes_back_down_to_ Issued("2026-009", new DateTime(2026, 1, 5)); (await _sequences.TakeNextAsync(Dept, "2026-#", 9)).Should().Be(10); - (await _service.RenumberCallsForYearAsync(Dept, 2026)).Should().BeTrue(); + (await _service.RenumberCallsForYearAsync(Dept, new DateTime(2026, 6, 1))).Should().BeTrue(); _calls.Select(c => c.Number).Should().Equal(new[] { "2026-001", "2026-002" }); (await _service.AllocateCallNumberAsync(Dept, new DateTime(2026, 6, 1))).Should().Be("2026-003"); @@ -325,10 +428,81 @@ public async Task A_pattern_without_a_year_is_never_renumbered_by_year() _saved = new CallNumberingConfig { Pattern = "{SEQ}", SequenceWidth = 6 }; Issued("000005", new DateTime(2026, 3, 1)); - (await _service.RenumberCallsForYearAsync(Dept, 2026)).Should().BeFalse(); + (await _service.RenumberCallsForYearAsync(Dept, new DateTime(2026, 6, 1))).Should().BeFalse(); _calls.Single().Number.Should().Be("000005"); } + [Test] + public async Task Calls_follow_the_calendar_year_by_default_while_a_fiscal_year_restarts_on_its_own_day() + { + _saved = new CallNumberingConfig { Pattern = "{YY}-{SEQ}", SequenceWidth = 4 }; + (await _service.AllocateCallNumberAsync(Dept, new DateTime(2026, 11, 15))).Should().Be("26-0001"); + (await _service.AllocateCallNumberAsync(Dept, new DateTime(2027, 1, 1, 0, 5, 0))).Should().Be("27-0001", "dispatch numbering starts fresh on January 1"); + + _saved = new CallNumberingConfig { Pattern = "FY{YY}-{SEQ}", SequenceWidth = 4, YearStartMonth = 11, YearStartDay = 1 }; + (await _service.AllocateCallNumberAsync(Dept, new DateTime(2027, 10, 31, 12, 0, 0))).Should().Be("FY27-0001"); + (await _service.AllocateCallNumberAsync(Dept, new DateTime(2027, 11, 1, 0, 5, 0))).Should().Be("FY28-0001"); + } + + [Test] + public async Task A_fiscal_year_starts_at_the_departments_local_midnight() + { + _department.TimeZone = "Central Standard Time"; + _saved = new CallNumberingConfig { Pattern = "{YY}-{SEQ}", SequenceWidth = 4, YearStartMonth = 11, YearStartDay = 1 }; + + // 04:30 UTC on 1 November 2026 is still 31 October (23:30 CDT) in Chicago; 05:30 UTC is past local midnight. + (await _service.AllocateCallNumberAsync(Dept, new DateTime(2026, 11, 1, 4, 30, 0, DateTimeKind.Utc))).Should().Be("26-0001"); + (await _service.AllocateCallNumberAsync(Dept, new DateTime(2026, 11, 1, 5, 30, 0, DateTimeKind.Utc))).Should().Be("27-0001"); + } + + [Test] + public async Task Naming_a_fiscal_year_after_its_start_year_carries_on_from_the_calendar_numbers_with_the_same_name() + { + // Calendar 2026 issued 26-1 to 26-5 before the department moved to a November year named by its start year, so + // FY2026 (November 2026 to October 2027) writes the same "26-" text. It must not issue 26-1 again. + for (var i = 1; i <= 5; i++) + Issued("26-" + i, new DateTime(2026, 2, i)); + _saved = new CallNumberingConfig { Pattern = CallNumberFormat.LegacyPattern, YearStartMonth = 11, YearStartDay = 1, YearLabel = (int)NumberingYearLabel.StartYear }; + + (await _service.GetNextAsync(Dept, null, new DateTime(2026, 11, 15))).NextNumber.Should().Be("26-6"); + (await _service.AllocateCallNumberAsync(Dept, new DateTime(2026, 11, 15))).Should().Be("26-6"); + } + + [Test] + public async Task Saving_stores_the_year_start_and_a_day_every_year_has() + { + var saved = await _service.SaveAsync(Dept, "admin", new CallNumberingUpdate { Pattern = "{YY}-{SEQ}", SequenceWidth = 4, YearStartMonth = 11, YearStartDay = 1, YearLabel = (int)NumberingYearLabel.StartYear }); + saved.YearStartRejected.Should().BeFalse(); + _saved.YearStartMonth.Should().Be(11); + _saved.YearStartDay.Should().Be(1); + _saved.YearLabel.Should().Be((int)NumberingYearLabel.StartYear); + + var leap = await _service.SaveAsync(Dept, "admin", new CallNumberingUpdate { Pattern = "FD{YYYY}-{SEQ}", SequenceWidth = 4, YearStartMonth = 2, YearStartDay = 29 }); + leap.YearStartRejected.Should().BeTrue(); + _saved.Pattern.Should().Be("{YY}-{SEQ}", "a refused year start saves nothing"); + _saved.YearStartMonth.Should().Be(11); + + await _service.SaveAsync(Dept, "admin", new CallNumberingUpdate { Pattern = "{YY}-{SEQ}", SequenceWidth = 4 }); + _saved.YearStart().Month.Should().Be(11, "a save that does not post a year start keeps the saved one"); + } + + [Test] + public async Task Renumbering_a_fiscal_year_renumbers_that_year_and_skips_numbers_written_around_it() + { + _saved = new CallNumberingConfig { Pattern = "{YYYY}-{SEQ}", SequenceWidth = 3, YearStartMonth = 11, YearStartDay = 1, YearLabel = (int)NumberingYearLabel.StartYear }; + Issued("2026-001", new DateTime(2026, 2, 1)); // calendar 2026, before the November year start was set + Issued("a", new DateTime(2026, 10, 30)); // FY2025 + Issued("b", new DateTime(2026, 11, 2)); // FY2026 + Issued("c", new DateTime(2027, 1, 5)); // FY2026 + + (await _service.RenumberCallsForYearAsync(Dept, new DateTime(2026, 12, 1))).Should().BeTrue(); + + _calls.Single(c => c.Number == "a").Should().NotBeNull("October is the previous fiscal year"); + _calls.Single(c => c.CallId == 3).Number.Should().Be("2026-002", "2026-001 already belongs to a calendar-2026 call"); + _calls.Single(c => c.CallId == 4).Number.Should().Be("2026-003"); + _calls.Select(c => c.Number).Should().OnlyHaveUniqueItems(); + } + #endregion /// In-memory CallNumberSequences with the repository's semantics, reading issued numbers from the test's calls. @@ -381,6 +555,9 @@ public Task TrySetLastSequenceAsync(int departmentId, string scopeKey, int public Task> GetDeletedCallNumbersAsync(int departmentId, DateTime fromUtc, DateTime toUtc) => Task.FromResult(Calls.Where(c => c.IsDeleted && c.Number != null && c.LoggedOn >= fromUtc && c.LoggedOn < toUtc).Select(c => c.Number).ToList()); + public Task> GetCallNumbersAsync(int departmentId, DateTime fromUtc, DateTime toUtc) => + Task.FromResult(Calls.Where(c => c.Number != null && c.LoggedOn >= fromUtc && c.LoggedOn < toUtc).Select(c => c.Number).ToList()); + public Task GetHighestIssuedAsync(int departmentId, string numberPrefix, string numberSuffix, DateTime? fromUtc, DateTime? toUtc) { SeedScans++; diff --git a/Tests/Resgrid.Tests/Services/CallStatusAttributionServiceTests.cs b/Tests/Resgrid.Tests/Services/CallStatusAttributionServiceTests.cs index 6044e90cd..5eb358ad3 100644 --- a/Tests/Resgrid.Tests/Services/CallStatusAttributionServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CallStatusAttributionServiceTests.cs @@ -50,8 +50,8 @@ public void SetUp() _customStates.Setup(x => x.GetAllCustomStatesForDepartmentAsync(DepartmentId)).ReturnsAsync(new List()); _customStates.Setup(x => x.GetDefaultUnitStatuses()).Returns(new CustomStateService(null, null, null, null).GetDefaultUnitStatuses()); - _unitDispatches.Setup(x => x.GetOpenCallIdsForUnitAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); - _dispatches.Setup(x => x.GetOpenCallIdsForUserAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + _unitDispatches.Setup(x => x.GetOpenCallUnitDispatchesForUnitAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + _dispatches.Setup(x => x.GetOpenCallDispatchesForUserAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); _units.Setup(x => x.GetAllUnitsByDepartmentIdIncludingDeletedAsync(DepartmentId)).ReturnsAsync(new List { new Unit { UnitId = UnitId, DepartmentId = DepartmentId, Name = "Engine 5" } }); _service = new CallStatusAttributionService(_calls.Object, _unitDispatches.Object, _dispatches.Object, _groupDispatches.Object, @@ -61,6 +61,13 @@ public void SetUp() private void Call(int callId, CallStates state = CallStates.Active, int departmentId = DepartmentId, DateTime? closedOn = null) => _calls.Setup(x => x.GetByIdAsync(callId)).ReturnsAsync(new Call { CallId = callId, DepartmentId = departmentId, State = (int)state, LoggedOn = T0, ClosedOn = closedOn }); + // Open dispatches sent ten minutes ago, after any Previous(...) status (those carry no time). + private static List UnitDispatches(params int[] callIds) => + callIds.Select(x => new CallDispatchUnit { CallId = x, UnitId = UnitId, DispatchedOn = DateTime.UtcNow.AddMinutes(-10) }).ToList(); + + private static List UserDispatches(params int[] callIds) => + callIds.Select(x => new CallDispatchWindow { CallId = x, UserId = UserId, DispatchedOn = DateTime.UtcNow.AddMinutes(-10) }).ToList(); + private static UnitState Previous(UnitStateTypes state, int? callId) => new UnitState { UnitId = UnitId, State = (int)state, DestinationId = callId, DestinationType = callId.HasValue ? (int)DestinationEntityTypes.Call : (int?)null }; @@ -103,7 +110,7 @@ public async Task the_clearing_status_that_ends_the_call_is_still_carried_forwar public async Task a_closed_previous_call_is_not_carried_forward_and_the_dispatch_rule_applies() { Call(42, CallStates.Closed); - _unitDispatches.Setup(x => x.GetOpenCallIdsForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(new[] { 43 }); + _unitDispatches.Setup(x => x.GetOpenCallUnitDispatchesForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(UnitDispatches(43)); var state = new UnitState { UnitId = UnitId, Timestamp = DateTime.UtcNow, State = (int)UnitStateTypes.Responding }; await _service.AttributeUnitStateAsync(state, Previous(UnitStateTypes.OnScene, 42), DepartmentId); @@ -116,13 +123,13 @@ public async Task a_closed_previous_call_is_not_carried_forward_and_the_dispatch public async Task the_dispatch_rule_skips_a_call_the_unit_already_cleared_and_ambiguous_dispatches() { Call(42); - _unitDispatches.Setup(x => x.GetOpenCallIdsForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(new[] { 42 }); + _unitDispatches.Setup(x => x.GetOpenCallUnitDispatchesForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(UnitDispatches(42)); var afterClearing = new UnitState { UnitId = UnitId, Timestamp = DateTime.UtcNow, State = (int)UnitStateTypes.Responding }; await _service.AttributeUnitStateAsync(afterClearing, Previous(UnitStateTypes.Available, 42), DepartmentId); afterClearing.DestinationId.Should().BeNull(); - _unitDispatches.Setup(x => x.GetOpenCallIdsForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(new[] { 43, 44 }); + _unitDispatches.Setup(x => x.GetOpenCallUnitDispatchesForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(UnitDispatches(43, 44)); var ambiguous = new UnitState { UnitId = UnitId, Timestamp = DateTime.UtcNow, State = (int)UnitStateTypes.Responding }; await _service.AttributeUnitStateAsync(ambiguous, Previous(UnitStateTypes.Available, null), DepartmentId); @@ -137,15 +144,120 @@ public async Task a_clearing_status_is_never_linked_by_the_dispatch_rule() await _service.AttributeUnitStateAsync(state, Previous(UnitStateTypes.Available, null), DepartmentId); state.DestinationId.Should().BeNull(); - _unitDispatches.Verify(x => x.GetOpenCallIdsForUnitAsync(It.IsAny(), It.IsAny()), Times.Never); + _unitDispatches.Verify(x => x.GetOpenCallUnitDispatchesForUnitAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + // A clear that points at no call ends every dispatch before it, as for the unit's working call (PR #546 review). + [Test] + public async Task after_clearing_to_its_station_a_unit_is_not_linked_to_the_call_it_finished() + { + var now = DateTime.UtcNow; + _unitDispatches.Setup(x => x.GetOpenCallUnitDispatchesForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(new[] + { + new CallDispatchUnit { CallId = 42, UnitId = UnitId, DispatchedOn = now.AddMinutes(-40) } + }); + var backAtStation = new UnitState { UnitId = UnitId, Timestamp = now.AddMinutes(-5), State = (int)UnitStateTypes.Available, DestinationId = 3, DestinationType = (int)DestinationEntityTypes.Station }; + var state = new UnitState { UnitId = UnitId, Timestamp = now, State = (int)UnitStateTypes.Responding }; + + await _service.AttributeUnitStateAsync(state, backAtStation, DepartmentId); + + state.DestinationId.Should().BeNull("the unit finished call 42 when it went back in quarters"); + state.DestinationSource.Should().BeNull(); + } + + [Test] + public async Task after_a_clear_a_new_dispatch_or_a_redispatch_is_the_units_call() + { + var now = DateTime.UtcNow; + var cleared = new UnitState { UnitId = UnitId, Timestamp = now.AddMinutes(-5), State = (int)UnitStateTypes.Available }; + + _unitDispatches.Setup(x => x.GetOpenCallUnitDispatchesForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(new[] + { + new CallDispatchUnit { CallId = 42, UnitId = UnitId, DispatchedOn = now.AddMinutes(-40) }, + new CallDispatchUnit { CallId = 43, UnitId = UnitId, DispatchedOn = now.AddMinutes(-1) } + }); + var newDispatch = new UnitState { UnitId = UnitId, Timestamp = now, State = (int)UnitStateTypes.Responding }; + await _service.AttributeUnitStateAsync(newDispatch, cleared, DepartmentId); + newDispatch.DestinationId.Should().Be(43); + newDispatch.DestinationSource.Should().Be((int)StatusDestinationSources.Dispatch); + + _unitDispatches.Setup(x => x.GetOpenCallUnitDispatchesForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(new[] + { + new CallDispatchUnit { CallId = 42, UnitId = UnitId, DispatchedOn = now.AddMinutes(-40), LastDispatchedOn = now.AddMinutes(-1) } + }); + var redispatch = new UnitState { UnitId = UnitId, Timestamp = now, State = (int)UnitStateTypes.Responding }; + await _service.AttributeUnitStateAsync(redispatch, cleared, DepartmentId); + redispatch.DestinationId.Should().Be(42, "the unit was sent to 42 again after it cleared"); + } + + [Test] + public async Task a_completed_or_on_patrol_status_clears_the_call_like_available() + { + Call(42); + _customStates.Setup(x => x.GetAllCustomStatesForDepartmentAsync(DepartmentId)).ReturnsAsync(new List + { + new CustomState + { + CustomStateId = 1, DepartmentId = DepartmentId, Type = (int)CustomStateTypes.Unit, Details = new List + { + new CustomStateDetail { CustomStateDetailId = 710, BaseType = (int)ActionBaseTypes.Completed }, + new CustomStateDetail { CustomStateDetailId = 711, BaseType = (int)ActionBaseTypes.OnPatrol } + } + } + }); + _unitDispatches.Setup(x => x.GetOpenCallUnitDispatchesForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(UnitDispatches(42)); + + foreach (var clear in new[] { 710, 711 }) + { + var cleared = new UnitState { UnitId = UnitId, State = clear, DestinationId = 42, DestinationType = (int)DestinationEntityTypes.Call }; + var state = new UnitState { UnitId = UnitId, Timestamp = DateTime.UtcNow, State = (int)UnitStateTypes.Responding }; + + await _service.AttributeUnitStateAsync(state, cleared, DepartmentId); + + state.DestinationId.Should().BeNull("custom status {0} cleared call 42, so the next status is not carried onto it", clear); + } + } + + [Test] + public async Task a_unit_available_before_its_dispatch_is_linked_to_it() + { + var now = DateTime.UtcNow; + _unitDispatches.Setup(x => x.GetOpenCallUnitDispatchesForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(new[] + { + new CallDispatchUnit { CallId = 42, UnitId = UnitId, DispatchedOn = now.AddMinutes(-2) } + }); + var inQuarters = new UnitState { UnitId = UnitId, Timestamp = now.AddHours(-3), State = (int)UnitStateTypes.Available, DestinationId = 3, DestinationType = (int)DestinationEntityTypes.Station }; + var state = new UnitState { UnitId = UnitId, Timestamp = now, State = (int)UnitStateTypes.Responding }; + + await _service.AttributeUnitStateAsync(state, inQuarters, DepartmentId); + + state.DestinationId.Should().Be(42); + } + + [Test] + public async Task a_person_back_at_the_station_is_linked_only_to_a_page_after_it() + { + var now = DateTime.UtcNow; + _dispatches.Setup(x => x.GetOpenCallDispatchesForUserAsync(DepartmentId, UserId)).ReturnsAsync(new[] + { + new CallDispatchWindow { CallId = 42, UserId = UserId, DispatchedOn = now.AddMinutes(-40) }, + new CallDispatchWindow { CallId = 43, UserId = UserId, DispatchedOn = now.AddMinutes(-1), Paged = true } + }); + var atStation = new ActionLog { UserId = UserId, DepartmentId = DepartmentId, ActionTypeId = (int)ActionTypes.AvailableStation, Timestamp = now.AddMinutes(-5), DestinationId = 3, DestinationType = (int)DestinationEntityTypes.Station }; + var log = new ActionLog { UserId = UserId, DepartmentId = DepartmentId, ActionTypeId = (int)ActionTypes.Responding, Timestamp = now }; + + await _service.AttributeActionLogAsync(log, atStation); + + log.DestinationId.Should().Be(43, "call 42 was paged before the person cleared to the station"); + log.DestinationSource.Should().Be((int)StatusDestinationSources.Dispatch); } [Test] public async Task a_replayed_offline_status_is_left_for_the_read_time_walk() { Call(42); - _unitDispatches.Setup(x => x.GetOpenCallIdsForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(new[] { 43 }); - _dispatches.Setup(x => x.GetOpenCallIdsForUserAsync(DepartmentId, UserId)).ReturnsAsync(new[] { 43 }); + _unitDispatches.Setup(x => x.GetOpenCallUnitDispatchesForUnitAsync(DepartmentId, UnitId)).ReturnsAsync(UnitDispatches(43)); + _dispatches.Setup(x => x.GetOpenCallDispatchesForUserAsync(DepartmentId, UserId)).ReturnsAsync(UserDispatches(43)); var replayedState = new UnitState { UnitId = UnitId, Timestamp = DateTime.UtcNow.AddHours(-2), State = (int)UnitStateTypes.OnScene }; var replayedLog = new ActionLog { UserId = UserId, DepartmentId = DepartmentId, ActionTypeId = (int)ActionTypes.OnScene, Timestamp = DateTime.UtcNow.AddHours(-2) }; @@ -154,13 +266,13 @@ public async Task a_replayed_offline_status_is_left_for_the_read_time_walk() replayedState.DestinationId.Should().BeNull("the current open calls say nothing about where the unit was two hours ago"); replayedLog.DestinationId.Should().BeNull(); - _unitDispatches.Verify(x => x.GetOpenCallIdsForUnitAsync(It.IsAny(), It.IsAny()), Times.Never); + _unitDispatches.Verify(x => x.GetOpenCallUnitDispatchesForUnitAsync(It.IsAny(), It.IsAny()), Times.Never); } [Test] public async Task attribution_failures_never_block_the_status() { - _unitDispatches.Setup(x => x.GetOpenCallIdsForUnitAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new TimeoutException()); + _unitDispatches.Setup(x => x.GetOpenCallUnitDispatchesForUnitAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new TimeoutException()); var state = new UnitState { UnitId = UnitId, Timestamp = DateTime.UtcNow, State = (int)UnitStateTypes.Responding }; await _service.Awaiting(s => s.AttributeUnitStateAsync(state, null, DepartmentId)).Should().NotThrowAsync(); @@ -183,7 +295,7 @@ public async Task a_person_placed_on_a_unit_takes_the_unit_statuses_call() public async Task a_person_paged_to_one_open_call_is_linked_to_it_but_a_previous_status_from_another_department_is_ignored() { Call(42); - _dispatches.Setup(x => x.GetOpenCallIdsForUserAsync(DepartmentId, UserId)).ReturnsAsync(new[] { 43 }); + _dispatches.Setup(x => x.GetOpenCallDispatchesForUserAsync(DepartmentId, UserId)).ReturnsAsync(UserDispatches(43)); var otherDepartment = new ActionLog { UserId = UserId, DepartmentId = 99, ActionTypeId = (int)ActionTypes.OnScene, DestinationId = 42, DestinationType = (int)DestinationEntityTypes.Call }; var log = new ActionLog { UserId = UserId, DepartmentId = DepartmentId, ActionTypeId = (int)ActionTypes.Responding, Timestamp = DateTime.UtcNow }; @@ -365,6 +477,34 @@ public async Task a_cleared_unit_works_its_other_dispatch_not_the_call_it_cleare working.Should().BeEquivalentTo(new Dictionary { { 1, 41 } }); } + // A clear that names no call ends every dispatch the unit had by then (PR #546 review). + [Test] + public async Task a_clear_without_a_call_ends_the_dispatches_before_it() + { + Call(40); + _unitDispatches.Setup(x => x.GetOpenCallUnitDispatchesForDepartmentAsync(DepartmentId)).ReturnsAsync(new List + { + new CallDispatchUnit { CallId = 40, UnitId = 1, DispatchedOn = T0 }, + new CallDispatchUnit { CallId = 40, UnitId = 2, DispatchedOn = T0 }, + new CallDispatchUnit { CallId = 40, UnitId = 3, DispatchedOn = T0, LastDispatchedOn = T0.AddMinutes(30) }, + new CallDispatchUnit { CallId = 40, UnitId = 4, DispatchedOn = T0 } + }); + + var working = await _service.GetWorkingCallIdsForUnitsAsync(DepartmentId, new List + { + // Back at its station after the dispatch: done with 40 while 40 stays open for the other units. + new UnitState { UnitStateId = 10, UnitId = 1, Timestamp = T0.AddMinutes(20), State = (int)UnitStateTypes.Available, DestinationId = 3, DestinationType = (int)DestinationEntityTypes.Station }, + // Available before it was sent: the dispatch is its work. + new UnitState { UnitStateId = 11, UnitId = 2, Timestamp = T0.AddMinutes(-5), State = (int)UnitStateTypes.Available }, + // Cleared, then sent to 40 again. + new UnitState { UnitStateId = 12, UnitId = 3, Timestamp = T0.AddMinutes(20), State = (int)UnitStateTypes.Available }, + // Cleared with no destination after the dispatch. + new UnitState { UnitStateId = 13, UnitId = 4, Timestamp = T0.AddMinutes(20), State = (int)UnitStateTypes.Available } + }); + + working.Should().BeEquivalentTo(new Dictionary { { 2, 40 }, { 3, 40 } }); + } + [Test] public async Task a_status_pointing_at_a_closed_call_falls_back_to_the_dispatch() { diff --git a/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs b/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs index e9b04be35..c94359c04 100644 --- a/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/DispatchRecommendationServiceTests.cs @@ -35,6 +35,7 @@ public class DispatchRecommendationServiceTests private Mock _shiftsService; private Mock _runCardActivationsRepository; private Mock _eventAggregator; + private Mock _departmentsService; private DispatchRecommendationService _service; private DepartmentGroup _stationA; @@ -57,6 +58,7 @@ public void SetUp() _shiftsService = new Mock(); _runCardActivationsRepository = new Mock(); _eventAggregator = new Mock(); + _departmentsService = new Mock(); _stationA = new DepartmentGroup { DepartmentGroupId = StationAId, DepartmentId = DepartmentId, Name = "Station 1", Type = (int)DepartmentGroupTypes.Station }; _stationB = new DepartmentGroup { DepartmentGroupId = StationBId, DepartmentId = DepartmentId, Name = "Station 2", Type = (int)DepartmentGroupTypes.Station }; @@ -151,7 +153,8 @@ public void SetUp() _personnelLocationResolver.Object, _shiftsService.Object, _runCardActivationsRepository.Object, - _eventAggregator.Object); + _eventAggregator.Object, + _departmentsService.Object); } private RunCard BuildCard(int engineCount = 0, int roleCount = 0, int alarmLevel = 1) @@ -826,5 +829,47 @@ public async Task escalation_still_fills_the_level_in_full() result.Units.Should().ContainSingle(u => u.UnitId == 2); result.Shortfalls.Should().ContainSingle(s => s.RequiredCount == 2 && s.FilledCount == 1); } + + // The Update Call panel lists recommended people and shortfalls by name (PR #546 review). + [Test] + public async Task recommended_people_and_their_role_are_named() + { + BuildCard(roleCount: 2); + _departmentsService.Setup(x => x.GetAllPersonnelNamesForDepartmentAsync(DepartmentId)).ReturnsAsync(new List + { + new PersonName { UserId = "user-1", FirstName = "Matthew", LastName = "Casey" }, + new PersonName { UserId = "user-2", FirstName = "Kelly", LastName = "Severide" } + }); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.Personnel.Should().HaveCount(2); + result.Personnel.Should().ContainSingle(p => p.UserId == "user-1" && p.Name == "Matthew Casey" && p.RoleName == "Firefighter"); + result.Personnel.Should().ContainSingle(p => p.UserId == "user-2" && p.Name == "Kelly Severide" && p.RoleName == "Firefighter"); + } + + [Test] + public async Task shortfalls_name_a_role_nobody_holds_and_a_unit_type_with_no_available_unit() + { + var card = BuildCard(engineCount: 1); + const int ParamedicRoleId = 201; + card.AlarmLevels.First().RoleRequirements.Add(new RunCardRoleRequirement { RunCardRoleRequirementId = 2001, RunCardAlarmLevelId = 50, PersonnelRoleId = ParamedicRoleId, RequiredCount = 1 }); + card.AlarmLevels.First().UnitRequirements.Add(new RunCardUnitRequirement { RunCardUnitRequirementId = 1001, RunCardAlarmLevelId = 50, UnitTypeId = LadderTypeId, RequiredCount = 1 }); + _personnelRolesService.Setup(x => x.GetRolesForDepartmentUnlimitedAsync(DepartmentId)).ReturnsAsync(new List + { + new PersonnelRole { PersonnelRoleId = FirefighterRoleId, DepartmentId = DepartmentId, Name = "Firefighter" }, + new PersonnelRole { PersonnelRoleId = ParamedicRoleId, DepartmentId = DepartmentId, Name = "Paramedic" } + }); + _unitsService.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(DepartmentId)).ReturnsAsync(new List + { + new UnitState { UnitId = 3, State = (int)UnitStateTypes.OutOfService, Timestamp = DateTime.UtcNow } + }); + + var result = await _service.GetRecommendationAsync(BuildRequest()); + + result.Shortfalls.Should().ContainSingle(s => !s.IsUnitRequirement && s.TypeOrRoleId == ParamedicRoleId && s.TypeOrRoleName == "Paramedic"); + result.Shortfalls.Should().ContainSingle(s => s.IsUnitRequirement && s.TypeOrRoleId == LadderTypeId && s.TypeOrRoleName == "Ladder"); + _departmentsService.Verify(x => x.GetAllPersonnelNamesForDepartmentAsync(It.IsAny()), Times.Never, "nobody was recommended"); + } } } diff --git a/Tests/Resgrid.Tests/Services/DocumentNumberingTests.cs b/Tests/Resgrid.Tests/Services/DocumentNumberingTests.cs new file mode 100644 index 000000000..d38ae36a9 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/DocumentNumberingTests.cs @@ -0,0 +1,295 @@ +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.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Department document numbers (setting 117 + setting 72's DocumentPatterns + DocumentNumberSequences): built-in numbers until + /// a department sets a pattern, fiscal numbering years per settings group, custom sequences seeded from numbers already + /// issued, raised next numbers that only rise, and the patterns each kind's column can hold. + /// + [TestFixture] + public class DocumentNumberingTests + { + private const int Dept = 11; + + private DocumentNumberingConfig _documents; + private RecordsNumberingConfig _records; + private Department _department; + private Dictionary> _issued; + private FakeDocumentNumberSequences _sequences; + private DocumentNumberingService _service; + + [SetUp] + public void SetUp() + { + _documents = new DocumentNumberingConfig(); + _records = new RecordsNumberingConfig(); + _department = new Department { DepartmentId = Dept }; + _issued = new Dictionary>(); + _sequences = new FakeDocumentNumberSequences { Issued = kind => _issued.TryGetValue(kind, out var list) ? list : Enumerable.Empty() }; + + var settings = new Mock(); + settings.Setup(s => s.GetDocumentNumberingConfigAsync(Dept, It.IsAny())) + .ReturnsAsync(() => ObjectSerialization.Deserialize(ObjectSerialization.Serialize(_documents))); + settings.Setup(s => s.SetDocumentNumberingConfigAsync(Dept, It.IsAny(), It.IsAny())) + .Callback((int d, DocumentNumberingConfig c, CancellationToken t) => _documents = c).ReturnsAsync(new DepartmentSetting()); + settings.Setup(s => s.GetRecordsNumberingConfigAsync(Dept, It.IsAny())) + .ReturnsAsync(() => ObjectSerialization.Deserialize(ObjectSerialization.Serialize(_records))); + settings.Setup(s => s.SetRecordsNumberingConfigAsync(Dept, It.IsAny(), It.IsAny())) + .Callback((int d, RecordsNumberingConfig c, CancellationToken t) => _records = c).ReturnsAsync(new DepartmentSetting()); + var departments = new Mock(); + departments.Setup(d => d.GetDepartmentByIdAsync(Dept, It.IsAny())).ReturnsAsync(() => _department); + + _service = new DocumentNumberingService(settings.Object, departments.Object, _sequences); + } + + private void Pattern(string kind, string pattern, int width) => + (DocumentNumberKinds.Find(kind).IsRecords ? _records.DocumentPatterns : _documents.Patterns).Add(new DocumentNumberPattern { Kind = kind, Pattern = pattern, SequenceWidth = width }); + + private void Issued(string kind, params string[] numbers) + { + if (!_issued.TryGetValue(kind, out var list)) + _issued[kind] = list = new List(); + list.AddRange(numbers); + } + + #region Formats + + [TestCase("1042", "#1042")] + [TestCase("00042", "#00042")] + [TestCase("INV-2026-0001", "INV-2026-0001")] + [TestCase("", "")] + [TestCase(null, null)] + public void A_number_of_digits_alone_reads_with_a_hash(string number, string expected) + { + DocumentNumbering.Display(number).Should().Be(expected); + } + + [Test] + public void A_pattern_must_fit_the_column_of_the_kind_it_numbers() + { + // 24 characters of text plus an 8-digit sequence is 32: the prevention columns' limit. + DocumentNumbering.IsValid(DocumentNumberKinds.Find(DocumentNumberKinds.Inspection), "FIRE-INSPECTION-{YYYY}-{MM}-{SEQ}").Should().BeTrue(); + DocumentNumbering.IsValid(DocumentNumberKinds.Find(DocumentNumberKinds.Inspection), "FIRE-INSPECTIONS-{YYYY}-{MM}-{SEQ}").Should().BeFalse(); + DocumentNumbering.IsValid(DocumentNumberKinds.Find(DocumentNumberKinds.Invoice), "FIRE-INSPECTIONS-{YYYY}-{MM}-{SEQ}").Should().BeTrue("invoice numbers have 50 characters"); + DocumentNumbering.IsValid(DocumentNumberKinds.Find(DocumentNumberKinds.Invoice), "INV {SEQ}").Should().BeFalse(); + DocumentNumbering.MaxRenderedLength("WO-{YY}{MM}-{SEQ}").Should().Be("WO-0000-".Length + 8); + } + + [Test] + public void A_saved_pattern_that_is_the_built_in_one_keeps_the_built_in_numbers() + { + // Tokens match in any case; fixed text is written as typed, so "wo-" is a pattern of its own. + var patterns = new List { new DocumentNumberPattern { Kind = DocumentNumberKinds.WorkOrder, Pattern = "WO-{yyyy}-{seq}", SequenceWidth = 6 } }; + DocumentNumbering.EffectivePattern(patterns, DocumentNumberKinds.WorkOrder).Should().BeNull(); + + patterns[0].SequenceWidth = 4; + DocumentNumbering.EffectivePattern(patterns, DocumentNumberKinds.WorkOrder).Pattern.Should().Be("WO-{YYYY}-{SEQ}"); + patterns[0].SequenceWidth = 6; + patterns[0].Pattern = "wo-{YYYY}-{SEQ}"; + DocumentNumbering.EffectivePattern(patterns, DocumentNumberKinds.WorkOrder).Pattern.Should().Be("wo-{YYYY}-{SEQ}"); + } + + [Test] + public void The_config_round_trips_through_protobuf() + { + var config = new DocumentNumberingConfig { YearStartMonth = 7, YearStartDay = 1, YearLabel = (int)NumberingYearLabel.StartYear }; + config.Patterns.Add(new DocumentNumberPattern { Kind = DocumentNumberKinds.Invoice, Pattern = "INV-{YYYY}-{SEQ}", SequenceWidth = 4 }); + + var copy = ObjectSerialization.Deserialize(ObjectSerialization.Serialize(config)); + + copy.YearStart().Month.Should().Be(7); + copy.YearStart().Label.Should().Be(NumberingYearLabel.StartYear); + copy.Patterns.Should().ContainSingle().Which.Pattern.Should().Be("INV-{YYYY}-{SEQ}"); + ObjectSerialization.Deserialize(ObjectSerialization.Serialize(new DocumentNumberingConfig())).YearStart().IsCalendarYear.Should().BeTrue(); + } + + #endregion + + #region Allocation + + [Test] + public async Task Every_kind_keeps_its_built_in_numbers_until_the_department_sets_a_pattern() + { + foreach (var kind in DocumentNumberKinds.All) + (await _service.TakeCustomNumberAsync(Dept, kind.Key, new DateTime(2026, 11, 2))).Should().BeNull(kind.Key); + _sequences.Rows.Should().BeEmpty(); + } + + [Test] + public async Task A_custom_invoice_pattern_issues_its_own_sequence_and_restarts_each_numbering_year() + { + _documents.YearStartMonth = 11; + _documents.YearStartDay = 1; + Pattern(DocumentNumberKinds.Invoice, "INV-{YYYY}-{SEQ}", 4); + + (await _service.TakeCustomNumberAsync(Dept, DocumentNumberKinds.Invoice, new DateTime(2026, 10, 30))).Should().Be("INV-2026-0001"); + (await _service.TakeCustomNumberAsync(Dept, DocumentNumberKinds.Invoice, new DateTime(2026, 10, 31))).Should().Be("INV-2026-0002"); + (await _service.TakeCustomNumberAsync(Dept, DocumentNumberKinds.Invoice, new DateTime(2026, 11, 1, 0, 30, 0))).Should().Be("INV-2027-0001", "a November fiscal year named by the year it ends in"); + (await _service.TakeCustomNumberAsync(Dept, DocumentNumberKinds.Invoice, new DateTime(2027, 1, 4))).Should().Be("INV-2027-0002", "January 1 does not restart a fiscal year"); + } + + [Test] + public async Task A_pattern_that_reads_like_numbers_already_issued_carries_on_after_them() + { + Issued(DocumentNumberKinds.Invoice, "1040", "1041", "1042"); + Pattern(DocumentNumberKinds.Invoice, "{SEQ}", 5); + + (await _service.TakeCustomNumberAsync(Dept, DocumentNumberKinds.Invoice, DateTime.UtcNow)).Should().Be("01043", "#1-#1042 already exist, so the padded pattern must not issue 00001"); + (await _service.TakeCustomNumberAsync(Dept, DocumentNumberKinds.Invoice, DateTime.UtcNow)).Should().Be("01044"); + _sequences.SeedScans.Should().Be(1, "only the first number of a scope reads the numbers already issued"); + } + + [Test] + public async Task A_work_order_pattern_continues_the_built_in_numbers_of_its_year() + { + Issued(DocumentNumberKinds.WorkOrder, "WO-2026-000019", "WO-2025-000400"); + Pattern(DocumentNumberKinds.WorkOrder, "WO-{YYYY}-{SEQ}", 4); + + (await _service.TakeCustomNumberAsync(Dept, DocumentNumberKinds.WorkOrder, new DateTime(2026, 6, 1))).Should().Be("WO-2026-0020"); + } + + [Test] + public async Task Documents_and_records_number_by_their_own_year_start_in_department_time() + { + _department.TimeZone = "Central Standard Time"; + _documents.YearStartMonth = 7; + _documents.YearStartDay = 1; + _records.YearStartMonth = 11; + _records.YearStartDay = 1; + + var lateOctober = new DateTime(2026, 11, 1, 4, 30, 0, DateTimeKind.Utc); // 23:30 on 31 October in Chicago + (await _service.GetNumberingYearAsync(Dept, DocumentNumberKinds.WorkOrder, lateOctober)).Should().Be(2027, "the July fiscal year 2027 began on 1 July 2026"); + (await _service.GetNumberingYearAsync(Dept, DocumentNumberKinds.Inspection, lateOctober)).Should().Be(2026, "Records' November year has not started yet in Chicago"); + (await _service.GetNumberingYearAsync(Dept, DocumentNumberKinds.Inspection, lateOctober.AddHours(2))).Should().Be(2027); + + _documents = new DocumentNumberingConfig(); + (await _service.GetNumberingYearAsync(Dept, DocumentNumberKinds.Invoice, new DateTime(2027, 1, 1, 3, 0, 0, DateTimeKind.Utc))).Should().Be(2026, + "9 PM on 31 December in Chicago is still 2026, though it is 2027 in UTC"); + } + + #endregion + + #region Saving + + [Test] + public async Task Saving_stores_the_year_start_and_valid_patterns_and_refuses_the_rest() + { + var result = await _service.SaveAsync(Dept, "admin", new DocumentNumberingUpdate + { + YearStartMonth = 11, YearStartDay = 1, YearLabel = (int)NumberingYearLabel.EndYear, + Patterns = + { + new DocumentNumberPatternUpdate { Kind = DocumentNumberKinds.WorkOrder, Pattern = " mnt-{yy}-{seq} ", SequenceWidth = 5 }, + new DocumentNumberPatternUpdate { Kind = DocumentNumberKinds.Bid, Pattern = "BID {SEQ}", SequenceWidth = 3 }, + new DocumentNumberPatternUpdate { Kind = DocumentNumberKinds.Inspection, Pattern = "INS-{SEQ}", SequenceWidth = 3 }, + new DocumentNumberPatternUpdate { Kind = DocumentNumberKinds.Invoice, Pattern = "", SequenceWidth = 4 } + } + }); + + result.YearStartRejected.Should().BeFalse(); + result.PatternsRejected.Should().Equal(DocumentNumberKinds.Bid); + _documents.YearStart().Month.Should().Be(11); + _documents.Patterns.Should().ContainSingle().Which.Pattern.Should().Be("mnt-{YY}-{SEQ}"); + _records.DocumentPatterns.Should().BeEmpty("a Records kind is not saved with setting 117"); + } + + [Test] + public async Task A_year_start_every_year_does_not_have_saves_nothing() + { + Pattern(DocumentNumberKinds.Invoice, "INV-{SEQ}", 4); + + var result = await _service.SaveAsync(Dept, "admin", new DocumentNumberingUpdate + { + YearStartMonth = 2, YearStartDay = 29, + Patterns = { new DocumentNumberPatternUpdate { Kind = DocumentNumberKinds.Invoice, Pattern = "", SequenceWidth = 1 } } + }); + + result.YearStartRejected.Should().BeTrue(); + _documents.Patterns.Should().ContainSingle("nothing was saved"); + } + + [Test] + public async Task A_raised_next_number_applies_to_the_sequence_shown_and_only_ever_rises() + { + Pattern(DocumentNumberKinds.Bid, "B{YYYY}-{SEQ}", 3); + var shown = (await _service.GetStatusesAsync(Dept, false, DateTime.UtcNow)).Single(s => s.Kind == DocumentNumberKinds.Bid); + shown.Custom.Should().BeTrue(); + shown.NextSequence.Should().Be(1); + + var raised = await _service.SaveAsync(Dept, "admin", new DocumentNumberingUpdate + { + Patterns = { new DocumentNumberPatternUpdate { Kind = DocumentNumberKinds.Bid, Pattern = "B{YYYY}-{SEQ}", SequenceWidth = 3, ScopeKey = shown.ScopeKey, NextSequence = 77 } } + }); + raised.BelowCurrent.Should().BeEmpty(); + raised.NotApplied.Should().Be(0); + (await _service.TakeCustomNumberAsync(Dept, DocumentNumberKinds.Bid, DateTime.UtcNow)).Should().EndWith("-077"); + + var lowered = await _service.SaveAsync(Dept, "admin", new DocumentNumberingUpdate + { + Patterns = { new DocumentNumberPatternUpdate { Kind = DocumentNumberKinds.Bid, Pattern = "B{YYYY}-{SEQ}", SequenceWidth = 3, ScopeKey = shown.ScopeKey, NextSequence = 10 } } + }); + lowered.BelowCurrent.Should().ContainSingle().Which.NextSequence.Should().Be(78); + } + + [Test] + public async Task A_next_number_typed_against_the_old_pattern_is_not_applied_to_the_new_one() + { + Pattern(DocumentNumberKinds.Bid, "B{YYYY}-{SEQ}", 3); + var shown = (await _service.GetStatusesAsync(Dept, false, DateTime.UtcNow)).Single(s => s.Kind == DocumentNumberKinds.Bid); + + var result = await _service.SaveAsync(Dept, "admin", new DocumentNumberingUpdate + { + Patterns = { new DocumentNumberPatternUpdate { Kind = DocumentNumberKinds.Bid, Pattern = "BID-{YY}-{SEQ}", SequenceWidth = 3, ScopeKey = shown.ScopeKey, NextSequence = 50 } } + }); + + result.NotApplied.Should().Be(1); + _sequences.Rows.Should().BeEmpty(); + } + + [Test] + public async Task Records_patterns_save_into_records_numbering_and_follow_its_year_start() + { + _records.YearStartMonth = 11; + _records.YearStartDay = 1; + _records.Pattern = "{PREFIX}-{YYYY}-{SEQ}"; + + var result = await _service.SaveRecordsPatternsAsync(Dept, "admin", new List + { + new DocumentNumberPatternUpdate { Kind = DocumentNumberKinds.RecordsRequest, Pattern = "PRR{YY}-{SEQ}", SequenceWidth = 3 }, + new DocumentNumberPatternUpdate { Kind = DocumentNumberKinds.Invoice, Pattern = "INV-{SEQ}", SequenceWidth = 3 } + }); + + result.PatternsRejected.Should().BeEmpty(); + _records.Pattern.Should().Be("{PREFIX}-{YYYY}-{SEQ}", "the rest of setting 72 is left as it was"); + _records.DocumentPatterns.Should().ContainSingle().Which.Kind.Should().Be(DocumentNumberKinds.RecordsRequest); + _documents.Patterns.Should().BeEmpty(); + (await _service.TakeCustomNumberAsync(Dept, DocumentNumberKinds.RecordsRequest, new DateTime(2026, 11, 5))).Should().Be("PRR27-001"); + } + + [Test] + public async Task Statuses_show_a_sample_of_the_built_in_numbers() + { + var statuses = await _service.GetStatusesAsync(Dept, false, new DateTime(2026, 6, 1)); + + statuses.Select(s => s.Kind).Should().Equal(DocumentNumberKinds.WorkOrder, DocumentNumberKinds.Invoice, DocumentNumberKinds.Bid, DocumentNumberKinds.TimeReport); + statuses.Single(s => s.Kind == DocumentNumberKinds.WorkOrder).Example.Should().Be("WO-2026-000153"); + statuses.Single(s => s.Kind == DocumentNumberKinds.Invoice).Example.Should().Be("153"); + statuses.Should().OnlyContain(s => !s.Custom && s.NextNumber == null); + (await _service.GetStatusesAsync(Dept, true, new DateTime(2026, 6, 1))).Single(s => s.Kind == DocumentNumberKinds.Permit).Example.Should().Be("PRM-2026-0153"); + } + + #endregion + } +} diff --git a/Tests/Resgrid.Tests/Services/FakeDocumentNumberSequences.cs b/Tests/Resgrid.Tests/Services/FakeDocumentNumberSequences.cs new file mode 100644 index 000000000..0ac9c0a57 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/FakeDocumentNumberSequences.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; + +namespace Resgrid.Tests.Services +{ + /// In-memory DocumentNumberSequences with the repository's semantics; issued numbers per kind come from the test. + public sealed class FakeDocumentNumberSequences : IDocumentNumberSequencesRepository + { + public Dictionary<(string Kind, string ScopeKey), DocumentNumberSequence> Rows { get; } = new Dictionary<(string, string), DocumentNumberSequence>(); + + /// The numbers already written for a kind (built-in and custom), as the kind's number column holds them. + public Func> Issued { get; set; } = kind => Enumerable.Empty(); + + public int SeedScans { get; private set; } + + public Task GetSequenceAsync(int departmentId, string kind, string scopeKey) => + Task.FromResult(Rows.TryGetValue((kind, scopeKey), out var row) ? row : null); + + public Task TakeNextAsync(int departmentId, string kind, string scopeKey, int seed, CancellationToken cancellationToken = default) + { + if (!Rows.TryGetValue((kind, scopeKey), out var row)) + Rows[(kind, scopeKey)] = row = new DocumentNumberSequence { DepartmentId = departmentId, Kind = kind, ScopeKey = scopeKey, LastSequence = Math.Max(0, seed) + 1 }; + else + row.LastSequence++; + row.ModifiedOn = DateTime.UtcNow; + return Task.FromResult(row.LastSequence); + } + + public Task RaiseFloorAsync(int departmentId, string kind, string scopeKey, int nextSequence, string userId, DateTime now, CancellationToken cancellationToken = default) + { + if (!Rows.TryGetValue((kind, scopeKey), out var row)) + Rows[(kind, scopeKey)] = row = new DocumentNumberSequence { DepartmentId = departmentId, Kind = kind, ScopeKey = scopeKey, LastSequence = nextSequence - 1, ModifiedOn = now }; + row.LastSequence = Math.Max(row.LastSequence, nextSequence - 1); + row.FloorSequence = Math.Max(row.FloorSequence, nextSequence); + row.FloorSetByUserId = userId; + row.FloorSetOn = now; + return Task.CompletedTask; + } + + public Task GetHighestIssuedAsync(int departmentId, string kind, string numberPrefix, string numberSuffix) + { + SeedScans++; + var highest = 0; + foreach (var n in (Issued(kind) ?? Enumerable.Empty()).Where(n => n != null)) + { + // Case-insensitive like the citext and SQL Server collations the real columns use. + if (n.Length <= numberPrefix.Length + numberSuffix.Length || !n.StartsWith(numberPrefix, StringComparison.OrdinalIgnoreCase) || !n.EndsWith(numberSuffix, StringComparison.OrdinalIgnoreCase)) + continue; + var mid = n.Substring(numberPrefix.Length, n.Length - numberPrefix.Length - numberSuffix.Length); + if (mid.All(char.IsDigit) && int.TryParse(mid, out var value)) + highest = Math.Max(highest, value); + } + return Task.FromResult(highest); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/InvoicingServiceTests.cs b/Tests/Resgrid.Tests/Services/InvoicingServiceTests.cs index 81b6c7d76..018087740 100644 --- a/Tests/Resgrid.Tests/Services/InvoicingServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/InvoicingServiceTests.cs @@ -245,6 +245,8 @@ public async Task Create_draft_assigns_the_next_number_and_the_profile_discount_ var invoice = await Build().CreateDraftInvoiceAsync(7, "contact-1", "user-1", "127.0.0.1", "test", "cad"); invoice.InvoiceNumber.Should().Be(1042); + invoice.DisplayNumber.Should().Be("1042", "without a pattern the number people see is the plain number"); + invoice.NumberLabel().Should().Be("#1042"); invoice.Status.Should().Be((int)InvoiceStatus.Draft); invoice.DiscountPercent.Should().Be(10m); invoice.Currency.Should().Be("CAD"); @@ -254,6 +256,25 @@ public async Task Create_draft_assigns_the_next_number_and_the_profile_discount_ _audits.Select(a => a.Type).Should().Equal(AuditLogTypes.InvoiceCreated); } + [Test] + public async Task Create_draft_writes_the_departments_invoice_pattern_and_keeps_the_int_number() + { + _profiles.Setup(p => p.GetByContactIdAsync("contact-1", 7)).ReturnsAsync(Profile(discount: 10m)); + _sequence.Setup(s => s.GetNextNumberAsync(7, It.IsAny())).ReturnsAsync(1043); + var numbering = new Mock(); + numbering.Setup(n => n.TakeCustomNumberAsync(7, DocumentNumberKinds.Invoice, It.IsAny(), It.IsAny())).ReturnsAsync("INV-2027-0001"); + var service = new InvoicingService(_profiles.Object, _rateCards.Object, _rateCardItems.Object, _invoices.Object, + _lineItems.Object, _payments.Object, _sequence.Object, _identities.Object, _contacts.Object, _calls.Object, _units.Object, _outbox.Object, _events.Object, + _pdf.Object, _email.Object, _departments.Object, _addresses.Object, _unitOfWork, documentNumbering: numbering.Object); + + var invoice = await service.CreateDraftInvoiceAsync(7, "contact-1", "user-1", "127.0.0.1", "test", "cad"); + + invoice.InvoiceNumber.Should().Be(1043, "the int stays the unique, ordered invoice id"); + invoice.DisplayNumber.Should().Be("INV-2027-0001"); + invoice.NumberLabel().Should().Be("INV-2027-0001"); + DocumentNumbering.FileSafe(invoice.NumberText()).Should().Be("INV-2027-0001"); + } + [Test] public async Task Create_draft_requires_an_active_billing_profile() { diff --git a/Tests/Resgrid.Tests/Services/WorkOrderP2M1Tests.cs b/Tests/Resgrid.Tests/Services/WorkOrderP2M1Tests.cs index 366765a1f..e91219f0b 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderP2M1Tests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderP2M1Tests.cs @@ -68,6 +68,35 @@ private async Task Assigned(bool safety = false) await _service.AssignAsync(_actor, id, new WorkOrderAssignment { Revision = row.Order.Revision, UserId = "manager" }); row = await _service.GetAsync(_actor, id); await _service.AcceptAssignmentAsync(_actor, id, row.Order.Revision); return id; } + [Test] + public async Task A_work_order_is_numbered_in_the_departments_numbering_year_and_pattern() + { + var numbering = new Mock(); + numbering.Setup(n => n.GetNumberingYearAsync(77, DocumentNumberKinds.WorkOrder, It.IsAny())).ReturnsAsync(2027); + numbering.Setup(n => n.TakeCustomNumberAsync(77, DocumentNumberKinds.WorkOrder, It.IsAny(), It.IsAny())).ReturnsAsync("MNT-27-0001"); + var audit = new Mock(); + audit.Setup(a => a.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((AuditLog a, CancellationToken c, bool f) => { a.AuditLogId = 1; return a; }); + var service = new WorkOrdersService(_store, _auth.Object, _access.Object, _uow.Object, audit.Object, _outbox.Object, new Lazy(() => _read.Object), + new Lazy(() => _write.Object), _scanner.Object, documentNumbering: numbering.Object); + + var created = await service.CreateAsync(_actor, Input()); + + var row = _store.All().Single(); + row.NumberYear.Should().Be(2027, "the fiscal year names NumberYear, so the built-in sequence restarts with it"); + row.NumberSequence.Should().Be(1); + row.DisplayNumber.Should().Be("MNT-27-0001"); + created.Order.Number.Should().Be("MNT-27-0001"); + } + + [Test] + public async Task A_work_order_keeps_the_built_in_number_text_without_a_pattern() + { + var created = await _service.CreateAsync(_actor, Input()); + + _store.All().Single().DisplayNumber.Should().Be(DocumentNumbering.LegacyWorkOrderNumber(DateTime.UtcNow.Year, 1)); + created.Order.Number.Should().Be(DocumentNumbering.LegacyWorkOrderNumber(DateTime.UtcNow.Year, 1)); + } + [Test] public async Task Request_retry_returns_one_number_and_one_creation_event_but_changed_retry_conflicts() { diff --git a/Tests/Resgrid.Tests/Web/User/CallSettingsSaveTests.cs b/Tests/Resgrid.Tests/Web/User/CallSettingsSaveTests.cs index 57f61770d..1addf5df5 100644 --- a/Tests/Resgrid.Tests/Web/User/CallSettingsSaveTests.cs +++ b/Tests/Resgrid.Tests/Web/User/CallSettingsSaveTests.cs @@ -85,7 +85,7 @@ public void SetUp() Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), localizer.Object, Mock.Of(), - Mock.Of(), _numbering.Object) + Mock.Of(), _numbering.Object, Mock.Of()) { ControllerContext = new ControllerContext { HttpContext = _httpContext } }; diff --git a/Tests/Resgrid.Tests/Web/User/DepartmentSettingsSaveTests.cs b/Tests/Resgrid.Tests/Web/User/DepartmentSettingsSaveTests.cs index 7e8e273ac..f8303eb42 100644 --- a/Tests/Resgrid.Tests/Web/User/DepartmentSettingsSaveTests.cs +++ b/Tests/Resgrid.Tests/Web/User/DepartmentSettingsSaveTests.cs @@ -73,7 +73,7 @@ public void SetUp() Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of>(), Mock.Of(), - Mock.Of(), Mock.Of()) + Mock.Of(), Mock.Of(), Mock.Of()) { ControllerContext = new ControllerContext { HttpContext = httpContext } }; diff --git a/Tests/Resgrid.Tests/Web/User/RecordAuthoringTests.cs b/Tests/Resgrid.Tests/Web/User/RecordAuthoringTests.cs index def944542..1a8580813 100644 --- a/Tests/Resgrid.Tests/Web/User/RecordAuthoringTests.cs +++ b/Tests/Resgrid.Tests/Web/User/RecordAuthoringTests.cs @@ -56,7 +56,7 @@ public void SetUp() _departments.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), null, Mock.Of>(), null, null, null, null, null, null, null, null, Mock.Of(), - Mock.Of(), Mock.Of(), null, definitions.Object, null, null, null, null, null, null) + Mock.Of(), Mock.Of(), null, definitions.Object, null, null, null, null, null, null, Mock.Of()) { ControllerContext = new ControllerContext { HttpContext = http }, TempData = new TempDataDictionary(http, Mock.Of()) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/BidsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/BidsController.cs index 529b03d06..8eaf8b483 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/BidsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/BidsController.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; +using Resgrid.Model; using Resgrid.Model.Invoicing; using Resgrid.Model.Services; using Resgrid.Providers.Claims; @@ -193,7 +194,7 @@ public async Task GetBidPdf(string id) if (bid == null) return NotFound(); var pdf = await _bids.GetBidPdfAsync(id, DepartmentId); if (pdf == null || pdf.Length == 0) return NoContent(); - return File(pdf, "application/pdf", $"bid-{bid.BidNumber}.pdf"); + return File(pdf, "application/pdf", $"bid-{DocumentNumbering.FileSafe(bid.NumberText())}.pdf"); } [HttpGet("GetBidConversionContext")] @@ -286,7 +287,7 @@ public async Task> GenerateDeploymentInvoi try { var invoice = await _engine.GenerateInvoiceFromDeploymentAsync(input.DeploymentId, DepartmentId, input.ThroughDate, UserId, Ip, Agent, cancellationToken); - var result = new DeploymentInvoiceResult { Data = new DeploymentInvoiceData { InvoiceId = invoice.InvoiceId, InvoiceNumber = invoice.InvoiceNumber, Total = invoice.Total, Currency = invoice.Currency, LineCount = invoice.LineItems?.Count ?? 0 }, PageSize = 1, Status = ResponseHelper.Success }; + var result = new DeploymentInvoiceResult { Data = new DeploymentInvoiceData { InvoiceId = invoice.InvoiceId, InvoiceNumber = invoice.InvoiceNumber, DisplayNumber = invoice.NumberText(), Total = invoice.Total, Currency = invoice.Currency, LineCount = invoice.LineItems?.Count ?? 0 }, PageSize = 1, Status = ResponseHelper.Success }; ResponseHelper.PopulateV4ResponseData(result); return result; } @@ -308,7 +309,7 @@ private ActionResult Ok(Bid bid) internal static BidData Map(Bid b, bool graph) => new BidData { - Id = b.BidId, BidNumber = b.BidNumber, ContactId = b.ContactId, CustomerBillingProfileId = b.CustomerBillingProfileId, ServiceContractId = b.ServiceContractId, RateScheduleId = b.RateScheduleId, Title = b.Title, + Id = b.BidId, BidNumber = b.BidNumber, DisplayNumber = b.NumberText(), ContactId = b.ContactId, CustomerBillingProfileId = b.CustomerBillingProfileId, ServiceContractId = b.ServiceContractId, RateScheduleId = b.RateScheduleId, Title = b.Title, Description = b.Description, Status = b.Status, ValidUntil = b.ValidUntil, RequestedStartOn = b.RequestedStartOn, RequestedEndOn = b.RequestedEndOn, IncidentNumber = b.IncidentNumber, DeliveryLocation = b.DeliveryLocation, DiscountPercent = b.DiscountPercent, EstimatedSubTotal = b.EstimatedSubTotal, EstimatedDiscountAmount = b.EstimatedDiscountAmount, EstimatedTaxAmount = b.EstimatedTaxAmount, EstimatedTotal = b.EstimatedTotal, Notes = b.Notes, TermsText = b.TermsText, SentOn = b.SentOn, SentToEmail = b.SentToEmail, AcceptedOn = b.AcceptedOn, DeclinedOn = b.DeclinedOn, DeclineReason = b.DeclineReason, ConvertedCallId = b.ConvertedCallId, diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs index 0cfd13cf1..ffcf570e5 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs @@ -925,7 +925,8 @@ public async Task> GetCallExtraData(int callId /// /// /// An empty rule list means the stock form -- every field visible, nothing extra required. - /// Clients apply this for usability; the same policy is enforced on SaveCall regardless. + /// Clients apply this to their new and edit call forms for usability; the same policy is enforced on SaveCall + /// and EditCall regardless. /// [HttpGet("GetNewCallFieldPolicy")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -944,7 +945,7 @@ public async Task> GetNewCallFieldPolicy( { Key = rule.Key, Visible = rule.Visible, - Required = rule.Visible && rule.Required + Required = policy.IsRequired(rule.Key) }); } } @@ -996,11 +997,32 @@ public async Task> SaveCall([FromBody] NewCallInput // time is ignored because the dispatcher decides when it goes out. var isPending = newCallInput.IsPending == true; + // Resolved before the policy check, so an id from another department cannot satisfy a linked-call requirement. + Call linkedCall = null; + if (!string.IsNullOrWhiteSpace(newCallInput.LinkedCallId)) + { + if (!int.TryParse(newCallInput.LinkedCallId, NumberStyles.Integer, CultureInfo.InvariantCulture, out int linkedCallId)) + return BadRequest("LinkedCallId is not a call id."); + + linkedCall = await _callsService.GetCallByIdAsync(linkedCallId); + + if (linkedCall == null || linkedCall.DepartmentId != effectiveDepartmentId) + return BadRequest("LinkedCallId is not a call in this department."); + } + + // Ids that are not this department's protocols are dropped, like unknown entries on the dispatch list. + var protocols = new List(); + if (newCallInput.ProtocolIds != null && newCallInput.ProtocolIds.Any()) + { + var departmentProtocols = await _protocolsService.GetAllProtocolsForDepartmentAsync(effectiveDepartmentId) ?? new List(); + protocols = departmentProtocols.Where(x => newCallInput.ProtocolIds.Contains(x.DispatchProtocolId)).ToList(); + } + // The department's new-call field policy is enforced here, not only in the clients: an old // build, an offline-queued call or a third-party integration must not be able to put an // incomplete call in front of the crews. Departments with no policy configured are unaffected. var fieldPolicy = await _departmentSettingsService.GetNewCallFieldPolicyAsync(effectiveDepartmentId); - var fieldViolations = NewCallFieldPolicyValidator.Validate(fieldPolicy, new NewCallFieldValues + var fieldValues = new NewCallFieldValues { Note = newCallInput.Note, Address = newCallInput.Address, @@ -1013,10 +1035,25 @@ public async Task> SaveCall([FromBody] NewCallInput ReferenceId = newCallInput.ReferenceId, DestinationPoiId = newCallInput.DestinationPoiId, IndoorMapZoneId = newCallInput.IndoorMapZoneId, + HasProtocols = protocols.Any(), + HasLinkedCall = linkedCall != null, DispatchOn = isPending ? null : (newCallInput.DispatchOnUtc ?? newCallInput.DispatchOn), HasDispatchList = !string.IsNullOrWhiteSpace(newCallInput.DispatchList), IsPending = isPending - }); + }; + + // A client that never sends these has no picker for them (older builds, the field apps, integrations); a + // requirement it cannot meet would stop it creating calls at all. + if (newCallInput.IndoorMapZoneId == null) + fieldValues.Unsupported(NewCallFieldKeys.IndoorLocation); + + if (newCallInput.ProtocolIds == null) + fieldValues.Unsupported(NewCallFieldKeys.Protocols); + + if (newCallInput.LinkedCallId == null) + fieldValues.Unsupported(NewCallFieldKeys.LinkedCall); + + var fieldViolations = NewCallFieldPolicyValidator.Validate(fieldPolicy, fieldValues); if (fieldViolations.Count > 0) return BadRequest(NewCallFieldPolicyValidator.DescribeViolations(fieldViolations)); @@ -1072,6 +1109,12 @@ public async Task> SaveCall([FromBody] NewCallInput if (!string.IsNullOrWhiteSpace(newCallInput.IndoorMapFloorId)) call.IndoorMapFloorId = newCallInput.IndoorMapFloorId; + if (protocols.Any()) + call.Protocols = protocols.Select(x => new CallProtocol { DispatchProtocolId = x.DispatchProtocolId, Data = x.Code }).ToList(); + + if (linkedCall != null) + call.References = new List { new CallReference { TargetCallId = linkedCall.CallId, AddedOn = DateTime.UtcNow, AddedByUserId = UserId } }; + if (newCallInput.DispatchOnUtc.HasValue && !isPending) { call.DispatchOn = DateTime.SpecifyKind(newCallInput.DispatchOnUtc.Value.Kind == DateTimeKind.Local @@ -1400,6 +1443,11 @@ public async Task> EditCall([FromBody] EditCallInpu if (call.State != (int)CallStates.Active && !isPending) return BadRequest(); + // The new-call field policy applies to edits too: a field the department hides is not on the client's edit + // form, so a blank value for it keeps what is stored, and the call must still have every required field after + // the edit. + var fieldPolicy = await _departmentSettingsService.GetNewCallFieldPolicyAsync(DepartmentId) ?? new NewCallFieldPolicy(); + var activeUsers = await _departmentsService.GetAllMembersForDepartmentAsync(DepartmentId); var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); var roles = await _personnelRolesService.GetAllRolesForDepartmentAsync(DepartmentId); @@ -1451,17 +1499,60 @@ public async Task> EditCall([FromBody] EditCallInpu if (!string.IsNullOrWhiteSpace(editCallInput.Address)) call.Address = editCallInput.Address; - call.DestinationPoiId = destinationPoi?.PoiId; + // No destination posted for a hidden destination means the client never showed it, not that it was cleared. + if (destinationPoi != null || fieldPolicy.IsVisible(NewCallFieldKeys.DestinationPoi)) + call.DestinationPoiId = destinationPoi?.PoiId; if (!string.IsNullOrWhiteSpace(editCallInput.What3Words)) call.W3W = editCallInput.What3Words; + if (!string.IsNullOrWhiteSpace(editCallInput.IndoorMapZoneId)) + { + call.IndoorMapZoneId = editCallInput.IndoorMapZoneId; + call.IndoorMapFloorId = editCallInput.IndoorMapFloorId; + } + + // Added to what the call already has, the same as the web edit form: neither can remove one here. + if (editCallInput.ProtocolIds != null && editCallInput.ProtocolIds.Any()) + { + var departmentProtocols = await _protocolsService.GetAllProtocolsForDepartmentAsync(DepartmentId) ?? new List(); + + if (call.Protocols == null) + call.Protocols = new List(); + + foreach (var protocol in departmentProtocols.Where(x => editCallInput.ProtocolIds.Contains(x.DispatchProtocolId))) + { + if (!call.Protocols.Any(x => x.DispatchProtocolId == protocol.DispatchProtocolId)) + call.Protocols.Add(new CallProtocol { CallId = call.CallId, DispatchProtocolId = protocol.DispatchProtocolId, Data = protocol.Code }); + } + } + + if (!string.IsNullOrWhiteSpace(editCallInput.LinkedCallId)) + { + if (!int.TryParse(editCallInput.LinkedCallId, NumberStyles.Integer, CultureInfo.InvariantCulture, out int linkedCallId) || linkedCallId == call.CallId) + return BadRequest("LinkedCallId is not another call's id."); + + var linkedCall = await _callsService.GetCallByIdAsync(linkedCallId); + + if (linkedCall == null || linkedCall.DepartmentId != DepartmentId) + return BadRequest("LinkedCallId is not a call in this department."); + + if (call.References == null) + call.References = new List(); + + if (!call.References.Any(x => x.TargetCallId == linkedCallId)) + call.References.Add(new CallReference { SourceCallId = call.CallId, TargetCallId = linkedCallId, AddedOn = DateTime.UtcNow, AddedByUserId = UserId }); + } + // Forms module is disabled: ignoring the posted value leaves whatever form data the call // already carries intact, so an edit from an older client can't wipe or replace it. - if (editCallInput.DispatchOn.HasValue) + if (editCallInput.DispatchOnUtc.HasValue || editCallInput.DispatchOn.HasValue) { - var dispatchOn = DateTimeHelpers.ConvertToUtc(editCallInput.DispatchOn.Value, department.TimeZone); + var dispatchOn = editCallInput.DispatchOnUtc.HasValue + ? DateTime.SpecifyKind(editCallInput.DispatchOnUtc.Value.Kind == DateTimeKind.Local + ? editCallInput.DispatchOnUtc.Value.ToUniversalTime() : editCallInput.DispatchOnUtc.Value, DateTimeKind.Utc) + : DateTimeHelpers.ConvertToUtc(editCallInput.DispatchOn.Value, department.TimeZone); if (!isPending) { @@ -1519,10 +1610,11 @@ public async Task> EditCall([FromBody] EditCallInpu var existingUnitDispatches = new List(call.UnitDispatches ?? new List()); var existingRoleDispatches = new List(call.RoleDispatches ?? new List()); - if (isPending && string.IsNullOrWhiteSpace(editCallInput.DispatchList)) + if ((isPending || !fieldPolicy.IsVisible(NewCallFieldKeys.DispatchList)) && string.IsNullOrWhiteSpace(editCallInput.DispatchList)) { // No list on an edit of a pending call keeps its proposed recipients (an empty list would otherwise mean - // "everyone"). + // "everyone"). The same when the department hides the dispatch list: the edit form never had one, so + // treating the blank as "everyone" would page the whole department on every edit. if (call.Dispatches == null) call.Dispatches = new List(); @@ -1687,6 +1779,44 @@ public async Task> EditCall([FromBody] EditCallInpu } } + // Checked against the call as the edit leaves it (a blank input keeps the stored value) and before anything + // below changes stored data. The dispatch time only means something before a call goes out. + var fieldValues = new NewCallFieldValues + { + Note = call.Notes, + Address = call.Address, + Geolocation = call.GeoLocationData, + What3Words = call.W3W, + ContactName = call.ContactName, + ContactInfo = call.ContactNumber, + ExternalId = call.ExternalIdentifier, + IncidentId = call.IncidentNumber, + ReferenceId = call.ReferenceNumber, + DestinationPoiId = call.DestinationPoiId, + IndoorMapZoneId = call.IndoorMapZoneId, + HasProtocols = call.Protocols?.Any() ?? false, + HasLinkedCall = call.References?.Any() ?? false, + HasDispatchList = (call.Dispatches?.Any() ?? false) || (call.GroupDispatches?.Any() ?? false) || + (call.UnitDispatches?.Any() ?? false) || (call.RoleDispatches?.Any() ?? false), + IsPending = isPending + }.Unsupported(NewCallFieldKeys.DispatchOn); + + // As on SaveCall: a client that never sends these has no picker for them, and a requirement it cannot meet + // would stop it saving any edit. + if (editCallInput.IndoorMapZoneId == null) + fieldValues.Unsupported(NewCallFieldKeys.IndoorLocation); + + if (editCallInput.ProtocolIds == null) + fieldValues.Unsupported(NewCallFieldKeys.Protocols); + + if (editCallInput.LinkedCallId == null) + fieldValues.Unsupported(NewCallFieldKeys.LinkedCall); + + var fieldViolations = NewCallFieldPolicyValidator.Validate(fieldPolicy, fieldValues); + + if (fieldViolations.Count > 0) + return BadRequest(NewCallFieldPolicyValidator.DescribeViolations(fieldViolations)); + // Call is in the past or is now, were dispatching now (at the end of this func) if (call.DispatchOn.HasValue && call.DispatchOn.Value <= DateTime.UtcNow) call.HasBeenDispatched = true; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/InvoicesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/InvoicesController.cs index 6bfcb196c..dab30673d 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/InvoicesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/InvoicesController.cs @@ -127,7 +127,7 @@ public async Task GetInvoicePdf(string invoiceId) if (pdf == null || pdf.Length == 0) return StatusCode((int)HttpStatusCode.ServiceUnavailable); - return File(pdf, "application/pdf", $"invoice-{invoice.InvoiceNumber}.pdf"); + return File(pdf, "application/pdf", $"invoice-{DocumentNumbering.FileSafe(invoice.NumberText())}.pdf"); } /// Record a manual payment (check, cash, ACH, external card, other). Online payments arrive through the provider path only. @@ -311,6 +311,7 @@ private static InvoiceResultData Convert(Invoice invoice, IReadOnlyDictionary GetTimeReportPdf(string id) try { var pdf = await _timeTracking.GetTimeReportPdfAsync(id, DepartmentId); - return File(pdf, "application/pdf", $"dtr-{report.ReportNumber}-{report.ReportDate:yyyyMMdd}.pdf"); + return File(pdf, "application/pdf", $"dtr-{DocumentNumbering.FileSafe(report.NumberText())}-{report.ReportDate:yyyyMMdd}.pdf"); } catch (InvalidOperationException ex) when (IsDomainError(ex)) { return BadRequest(ex.Message); } } @@ -388,7 +388,7 @@ private async Task> OkAsync(TimeReportSaveResult internal static TimeReportData Map(DeploymentTimeReport r, Department department = null, DeploymentTimeAccess access = null) => new TimeReportData { - Id = r.DeploymentTimeReportId, DeploymentId = r.DeploymentId, ReportNumber = r.ReportNumber, ReportDate = r.ReportDate, Scope = (int)r.Scope, DeploymentUnitId = r.DeploymentUnitId, DeploymentPersonnelId = r.DeploymentPersonnelId, + Id = r.DeploymentTimeReportId, DeploymentId = r.DeploymentId, ReportNumber = r.ReportNumber, DisplayNumber = r.NumberText(), ReportDate = r.ReportDate, Scope = (int)r.Scope, DeploymentUnitId = r.DeploymentUnitId, DeploymentPersonnelId = r.DeploymentPersonnelId, CanAct = access?.CanActOn(r) ?? false, Status = r.Status, IncidentNumber = r.IncidentNumber, ResourceOrderNumber = r.ResourceOrderNumber, RequestNumber = r.RequestNumber, CostCode = r.CostCode, PointOfHire = r.PointOfHire, NoClear8 = r.NoClear8, UnsafeConditionsStandDown = r.UnsafeConditionsStandDown, ContractorSignedByUserId = r.ContractorSignedByUserId, ContractorSignedOn = r.ContractorSignedOn, CustomerSignerName = r.CustomerSignerName, CustomerSignedOn = r.CustomerSignedOn, SubmittedByUserId = r.SubmittedByUserId, SubmittedOn = r.SubmittedOn, diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs index 948553916..7224431be 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/EditCallInput.cs @@ -119,11 +119,40 @@ public class EditCallInput /// public string ReferenceId { get; set; } + /// + /// Optional. Indoor map zone the incident is in. Blank keeps the stored zone; leave it out (null) when the client + /// has no indoor location picker, which also leaves the department's indoor location requirement unenforced. + /// + public string IndoorMapZoneId { get; set; } + + /// + /// Optional. Floor of . + /// + public string IndoorMapFloorId { get; set; } + + /// + /// Optional. Ids of the department's dispatch protocols to add to the call; protocols already on it are kept. Leave + /// it out (null) when the client has no protocol picker, which also leaves the protocols requirement unenforced. + /// + public List ProtocolIds { get; set; } + + /// + /// Optional. Id of another call in the department to link this call to; existing links are kept. Leave it out + /// (null) when the client has no way to pick one, which also leaves the linked-call requirement unenforced. + /// + public string LinkedCallId { get; set; } + /// /// Time in the future, in the departments local time, to dispatch the call /// public DateTime? DispatchOn { get; set; } + /// + /// Optional. The same as DispatchOn but in UTC (ISO 8601, e.g. "2026-10-07T14:30:00Z"), for clients that do not know + /// the department's time zone. When both are sent this one is used. + /// + public DateTime? DispatchOnUtc { get; set; } + /// /// Call Intake form JSON /// diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallFieldPolicyResult.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallFieldPolicyResult.cs index c95ca6066..6232c0ddf 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallFieldPolicyResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallFieldPolicyResult.cs @@ -31,10 +31,13 @@ public class NewCallFieldRuleData /// public string Key { get; set; } - /// False hides the field from the call form entirely. + /// False hides the field from the new and edit call forms entirely. public bool Visible { get; set; } - /// True blocks call creation until the field has a value. + /// + /// True blocks creating the call, or saving an edit to it, until the field has a value. Never true for a hidden + /// field or for pluscode. + /// public bool Required { get; set; } } } diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs index 9518deee9..1d09d3509 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/NewCallInput.cs @@ -118,6 +118,19 @@ public class NewCallInput /// public string ReferenceId { get; set; } + /// + /// Optional. Id of an existing call in the department to link this call to (shown as a linked call on both). Leave it + /// out (null) when the client has no way to pick one: the department's linked-call requirement is then not enforced; + /// sent blank, it is. + /// + public string LinkedCallId { get; set; } + + /// + /// Optional. Ids of the department's dispatch protocols to attach to the call. Leave it out (null) when the client + /// has no protocol picker: the department's protocols requirement is then not enforced; sent empty, it is. + /// + public List ProtocolIds { get; set; } + /// /// Time in the future, in the departments local time, to dispatch the call. Leave NULL to dispatch now. /// diff --git a/Web/Resgrid.Web.Services/Models/v4/ContractorBilling/ContractorBillingApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/ContractorBilling/ContractorBillingApiModels.cs index 47337218a..fb5cc52c7 100644 --- a/Web/Resgrid.Web.Services/Models/v4/ContractorBilling/ContractorBillingApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/ContractorBilling/ContractorBillingApiModels.cs @@ -348,6 +348,8 @@ public class BidData { public string Id { get; set; } public int BidNumber { get; set; } + /// The bid number as issued: the department's numbering pattern, or BidNumber when it keeps the built-in numbers. + public string DisplayNumber { get; set; } public string ContactId { get; set; } public string CustomerBillingProfileId { get; set; } public string ServiceContractId { get; set; } @@ -583,6 +585,8 @@ public class DeploymentInvoiceData { public string InvoiceId { get; set; } public int InvoiceNumber { get; set; } + /// The invoice number as issued: the department's numbering pattern, or InvoiceNumber when it keeps the built-in numbers. + public string DisplayNumber { get; set; } public decimal Total { get; set; } public string Currency { get; set; } public int LineCount { get; set; } diff --git a/Web/Resgrid.Web.Services/Models/v4/Deployments/DeploymentsApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Deployments/DeploymentsApiModels.cs index 4d4ea003d..1399ea4a6 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Deployments/DeploymentsApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Deployments/DeploymentsApiModels.cs @@ -268,6 +268,8 @@ public class TimeReportData public string Id { get; set; } public string DeploymentId { get; set; } public int ReportNumber { get; set; } + /// The report number as issued: the department's numbering pattern, or ReportNumber when it keeps the built-in numbers. + public string DisplayNumber { get; set; } public DateTime ReportDate { get; set; } /// DeploymentTimeReportScopes value: 0 deployment-wide DTR, 1 crew time report, 2 individual. public int Scope { get; set; } diff --git a/Web/Resgrid.Web.Services/Models/v4/Invoicing/InvoicingApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Invoicing/InvoicingApiModels.cs index a440e19f0..f7f7aa95f 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Invoicing/InvoicingApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Invoicing/InvoicingApiModels.cs @@ -53,6 +53,9 @@ public class InvoiceResultData { public string InvoiceId { get; set; } public int InvoiceNumber { get; set; } + + /// The invoice number as issued: the department's numbering pattern, or InvoiceNumber when it keeps the built-in numbers. + public string DisplayNumber { get; set; } public string ContactId { get; set; } /// The contact's display name; REDACTED on a protected row. public string ContactName { get; set; } diff --git a/Web/Resgrid.Web.Services/Models/v4/Units/UnitsInfoResult.cs b/Web/Resgrid.Web.Services/Models/v4/Units/UnitsInfoResult.cs index 7431f09f7..bffff9100 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Units/UnitsInfoResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Units/UnitsInfoResult.cs @@ -104,7 +104,9 @@ public class UnitsInfoResultData /// /// The open call the unit is working, or null: the call its latest status points at unless that status cleared it, /// otherwise its one open dispatch. A status sent without a destination is linked to this call by the server, so a - /// dispatcher setting the unit's next status starts on it. + /// dispatcher setting the unit's next status starts on it. Null means no single working call can be told, not that the + /// unit is unassigned: it is also null when the unit is dispatched to more than one open call and its latest status + /// points at none of them. /// public string ActiveCallId { get; set; } diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index bf2f80d26..97f31df7b 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -663,7 +663,8 @@ An empty rule list means the stock form -- every field visible, nothing extra required. - Clients apply this for usability; the same policy is enforced on SaveCall regardless. + Clients apply this to their new and edit call forms for usability; the same policy is enforced on SaveCall + and EditCall regardless. @@ -9694,11 +9695,40 @@ Reference Id + + + Optional. Indoor map zone the incident is in. Blank keeps the stored zone; leave it out (null) when the client + has no indoor location picker, which also leaves the department's indoor location requirement unenforced. + + + + + Optional. Floor of . + + + + + Optional. Ids of the department's dispatch protocols to add to the call; protocols already on it are kept. Leave + it out (null) when the client has no protocol picker, which also leaves the protocols requirement unenforced. + + + + + Optional. Id of another call in the department to link this call to; existing links are kept. Leave it out + (null) when the client has no way to pick one, which also leaves the linked-call requirement unenforced. + + Time in the future, in the departments local time, to dispatch the call + + + Optional. The same as DispatchOn but in UTC (ISO 8601, e.g. "2026-10-07T14:30:00Z"), for clients that do not know + the department's time zone. When both are sent this one is used. + + Call Intake form JSON @@ -9878,10 +9908,13 @@ - False hides the field from the call form entirely. + False hides the field from the new and edit call forms entirely. - True blocks call creation until the field has a value. + + True blocks creating the call, or saving an edit to it, until the field has a value. Never true for a hidden + field or for pluscode. + @@ -9993,6 +10026,19 @@ Reference Id + + + Optional. Id of an existing call in the department to link this call to (shown as a linked call on both). Leave it + out (null) when the client has no way to pick one: the department's linked-call requirement is then not enforced; + sent blank, it is. + + + + + Optional. Ids of the department's dispatch protocols to attach to the call. Leave it out (null) when the client + has no protocol picker: the department's protocols requirement is then not enforced; sent empty, it is. + + Time in the future, in the departments local time, to dispatch the call. Leave NULL to dispatch now. @@ -12816,6 +12862,9 @@ Base64 file bytes; omit to keep the stored file. + + The bid number as issued: the department's numbering pattern, or BidNumber when it keeps the built-in numbers. + BidStatuses value. @@ -12831,6 +12880,9 @@ ContractorChargeKinds value. + + The invoice number as issued: the department's numbering pattern, or InvoiceNumber when it keeps the built-in numbers. + CostRecovery.CalOesMars entitlement (paid add-on + flag). @@ -13127,6 +13179,9 @@ DeploymentAttachmentTypes value. + + The report number as issued: the department's numbering pattern, or ReportNumber when it keeps the built-in numbers. + DeploymentTimeReportScopes value: 0 deployment-wide DTR, 1 crew time report, 2 individual. @@ -13962,6 +14017,9 @@ An invoice header. Money is decimal; the status is the InvoiceStatus value (Draft 0, Sent 1, PartiallyPaid 2, Paid 3, Overdue 4, Void 5). + + The invoice number as issued: the department's numbering pattern, or InvoiceNumber when it keeps the built-in numbers. + The contact's display name; REDACTED on a protected row. @@ -18316,7 +18374,9 @@ The open call the unit is working, or null: the call its latest status points at unless that status cleared it, otherwise its one open dispatch. A status sent without a destination is linked to this call by the server, so a - dispatcher setting the unit's next status starts on it. + dispatcher setting the unit's next status starts on it. Null means no single working call can be told, not that the + unit is unassigned: it is also null when the unit is dispatched to more than one open call and its latest status + points at none of them. diff --git a/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs index a9bd6c432..76e500619 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/BidsController.cs @@ -262,7 +262,7 @@ public async Task Pdf(string id) if (bid == null) return NotFound(); var pdf = await _bids.GetBidPdfAsync(id, DepartmentId); if (pdf == null || pdf.Length == 0) return Refused(500, "bids_pdf_unavailable", "View", new { id }); - return File(pdf, "application/pdf", $"bid-{bid.BidNumber}.pdf"); + return File(pdf, "application/pdf", $"bid-{DocumentNumbering.FileSafe(bid.NumberText())}.pdf"); } [HttpGet] diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs index 50ebe5578..9df97c557 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs @@ -75,6 +75,7 @@ public partial class DepartmentController : SecureBaseController private readonly IStringLocalizer _departmentLocalizer; private readonly IProtectedReadService _protectedReadService; private readonly ICallNumberingService _callNumberingService; + private readonly IDocumentNumberingService _documentNumberingService; public DepartmentController(IDepartmentsService departmentsService, IUsersService usersService, IActionLogsService actionLogsService, IEmailService emailService, IDepartmentGroupsService departmentGroupsService, IUserProfileService userProfileService, IDeleteService deleteService, @@ -86,7 +87,7 @@ public DepartmentController(IDepartmentsService departmentsService, IUsersServic ISecurityPinService securityPinService, IRunCardsService runCardsService, IFeatureToggleService featureToggleService, IDepartmentProfileMediaService departmentProfileMediaService, IStringLocalizer departmentLocalizer, Resgrid.Model.AiDispatch.IAiDispatchEnrichmentService aiDispatchService, IProtectedReadService protectedReadService, - ICallNumberingService callNumberingService) + ICallNumberingService callNumberingService, IDocumentNumberingService documentNumberingService) { _departmentsService = departmentsService; _usersService = usersService; @@ -124,6 +125,7 @@ public DepartmentController(IDepartmentsService departmentsService, IUsersServic _aiDispatchService = aiDispatchService; _protectedReadService = protectedReadService; _callNumberingService = callNumberingService; + _documentNumberingService = documentNumberingService; } #endregion Private Members and Constructors @@ -1254,6 +1256,10 @@ private async Task FillCallNumberingAsync(CallSettingsView model) model.CallNumberPattern = CallNumberFormat.EffectivePattern(config); model.CallNumberSequenceWidth = CallNumberFormat.EffectiveWidth(config.SequenceWidth); + var yearStart = config.YearStart(); + model.CallNumberYearStartMonth = yearStart.Month; + model.CallNumberYearStartDay = yearStart.Day; + model.CallNumberYearLabel = (int)yearStart.Label; model.CallNumberScopeKey = next.ScopeKey; model.CallNumberNextNumber = next.NextNumber; model.CallNumberCurrentNextSequence = next.NextSequence; @@ -1414,22 +1420,29 @@ private async Task SaveCallNumberingAsync(CallSettingsView model, CancellationTo Pattern = model.CallNumberPattern, SequenceWidth = model.CallNumberSequenceWidth, ScopeKey = model.CallNumberScopeKey, - NextSequence = model.CallNumberNextSequence + NextSequence = model.CallNumberNextSequence, + YearStartMonth = model.CallNumberYearStartMonth, + YearStartDay = model.CallNumberYearStartDay, + YearLabel = model.CallNumberYearLabel }, cancellationToken); - var rejectedPattern = result.PatternRejected ? model.CallNumberPattern : null; + var rejected = result.PatternRejected || result.YearStartRejected; + var rejectedPattern = rejected ? model.CallNumberPattern : null; + var rejectedYearStart = rejected ? new[] { model.CallNumberYearStartMonth, model.CallNumberYearStartDay, model.CallNumberYearLabel } : null; await FillCallNumberingAsync(model); - if (!result.PatternRejected) + if (!rejected) { var after = await _departmentSettingsService.GetCallNumberingConfigAsync(DepartmentId, true); - SendProfileAudit(JsonConvert.SerializeObject(new { CallNumberPattern = CallNumberFormat.EffectivePattern(before), CallNumberSequenceWidth = CallNumberFormat.EffectiveWidth(before.SequenceWidth), beforeNext.NextNumber }), - JsonConvert.SerializeObject(new { CallNumberPattern = CallNumberFormat.EffectivePattern(after), CallNumberSequenceWidth = CallNumberFormat.EffectiveWidth(after.SequenceWidth), model.CallNumberNextNumber })); + SendProfileAudit(JsonConvert.SerializeObject(new { CallNumberPattern = CallNumberFormat.EffectivePattern(before), CallNumberSequenceWidth = CallNumberFormat.EffectiveWidth(before.SequenceWidth), CallNumberYearStart = YearStartAudit(before.YearStart()), beforeNext.NextNumber }), + JsonConvert.SerializeObject(new { CallNumberPattern = CallNumberFormat.EffectivePattern(after), CallNumberSequenceWidth = CallNumberFormat.EffectiveWidth(after.SequenceWidth), CallNumberYearStart = YearStartAudit(after.YearStart()), model.CallNumberNextNumber })); } var errors = new List(); if (result.PatternRejected) errors.Add(_departmentLocalizer["CallNumberPatternInvalid"]); + if (result.YearStartRejected) + errors.Add(_departmentLocalizer["CallNumberYearStartInvalid"]); if (result.BelowCurrent != null) errors.Add(_departmentLocalizer["CallNumberNextBelowCurrent", result.BelowCurrent.NextNumber]); if (result.NextNotApplied) @@ -1437,10 +1450,145 @@ private async Task SaveCallNumberingAsync(CallSettingsView model, CancellationTo if (errors.Count > 0) model.ErrorMessage = string.Join(" ", errors); - foreach (var key in ModelState.Keys.Where(k => k.StartsWith("CallNumber", StringComparison.Ordinal) && (k != nameof(CallSettingsView.CallNumberPattern) || !result.PatternRejected)).ToList()) + // A refused save keeps what was typed (pattern and year start) for correcting. + foreach (var key in ModelState.Keys.Where(k => k.StartsWith("CallNumber", StringComparison.Ordinal) && (!rejected || !RejectedCallNumberFields.Contains(k))).ToList()) ModelState.Remove(key); if (rejectedPattern != null) model.CallNumberPattern = rejectedPattern; + if (rejectedYearStart != null) + { + model.CallNumberYearStartMonth = rejectedYearStart[0]; + model.CallNumberYearStartDay = rejectedYearStart[1]; + model.CallNumberYearLabel = rejectedYearStart[2]; + } + } + + private static readonly HashSet RejectedCallNumberFields = new HashSet(StringComparer.Ordinal) + { + nameof(CallSettingsView.CallNumberPattern), nameof(CallSettingsView.CallNumberYearStartMonth), + nameof(CallSettingsView.CallNumberYearStartDay), nameof(CallSettingsView.CallNumberYearLabel) + }; + + /// The year start as the audit log records it, e.g. "11-01 EndYear"; "01-01" is the calendar year. + private static string YearStartAudit(NumberingYearStart yearStart) + { + return yearStart.Month.ToString("D2", CultureInfo.InvariantCulture) + "-" + yearStart.Day.ToString("D2", CultureInfo.InvariantCulture) + + (yearStart.IsCalendarYear ? string.Empty : " " + yearStart.Label); + } + + [HttpGet] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task DocumentNumbering() + { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + + var model = new DocumentNumberingView(); + await FillDocumentNumberingAsync(model); + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task DocumentNumbering(DocumentNumberingView model, CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserModifyDepartmentAsync(UserId, DepartmentId)) + return Unauthorized(); + + var before = await _departmentSettingsService.GetDocumentNumberingConfigAsync(DepartmentId, true); + var result = await _documentNumberingService.SaveAsync(DepartmentId, UserId, new DocumentNumberingUpdate + { + YearStartMonth = model.YearStartMonth, + YearStartDay = model.YearStartDay, + YearLabel = model.YearLabel, + Patterns = (model.Rows ?? new List()).Where(r => r != null).Select(r => r.ToUpdate()).ToList() + }, cancellationToken); + + var posted = model.Rows ?? new List(); + var postedYearStart = new[] { model.YearStartMonth, model.YearStartDay, model.YearLabel }; + // Show what was saved, not what was posted: a raised number left in its box would be resubmitted. A refused year start + // or pattern stays in its box for correcting. + ModelState.Clear(); + model = new DocumentNumberingView(); + await FillDocumentNumberingAsync(model); + + if (result.YearStartRejected) + { + model.YearStartMonth = postedYearStart[0]; + model.YearStartDay = postedYearStart[1]; + model.YearLabel = postedYearStart[2]; + } + foreach (var row in model.Rows) + { + var typed = posted.FirstOrDefault(p => p != null && p.Kind == row.Kind); + if (typed != null && (result.YearStartRejected || result.PatternsRejected.Contains(row.Kind))) + { + row.Pattern = typed.Pattern; + row.SequenceWidth = typed.SequenceWidth; + } + } + + if (!result.YearStartRejected) + { + var after = await _departmentSettingsService.GetDocumentNumberingConfigAsync(DepartmentId, true); + SendProfileAudit(JsonConvert.SerializeObject(DocumentNumberingAudit(before)), JsonConvert.SerializeObject(DocumentNumberingAudit(after))); + } + + var errors = new List(); + if (result.YearStartRejected) + errors.Add(_departmentLocalizer["DocumentNumberingYearStartInvalid"]); + if (result.PatternsRejected.Count > 0) + errors.Add(_departmentLocalizer["DocumentNumberingPatternsRejected"] + " (" + string.Join(", ", result.PatternsRejected.Select(DocumentNumberKindLabel)) + ")"); + if (result.BelowCurrent.Count > 0) + errors.Add(_departmentLocalizer["DocumentNumberingBelowCurrent"] + " (" + string.Join(", ", result.BelowCurrent.Select(s => s.NextNumber)) + ")"); + if (result.NotApplied > 0) + errors.Add(_departmentLocalizer["DocumentNumberingNotApplied"]); + if (errors.Count > 0) + model.ErrorMessage = string.Join(" ", errors); + // A refused year start saves nothing; otherwise anything listed above was held back from a save that went through. + if (!result.YearStartRejected) + model.Message = _departmentLocalizer[errors.Count == 0 ? "DocumentNumberingSaved" : "DocumentNumberingPartlySaved"]; + + return View(model); + } + + /// The year start and each work order, invoice, bid and daily time report row as saved, with what it issues next. + private async Task FillDocumentNumberingAsync(DocumentNumberingView model) + { + var config = await _departmentSettingsService.GetDocumentNumberingConfigAsync(DepartmentId, true); + var yearStart = config.YearStart(); + var now = DateTime.UtcNow; + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + + model.YearStartMonth = yearStart.Month; + model.YearStartDay = yearStart.Day; + model.YearLabel = (int)yearStart.Label; + model.PreviewDate = string.IsNullOrWhiteSpace(department?.TimeZone) ? now : DateTimeHelpers.GetLocalDateTime(now, department.TimeZone); + model.Rows = (await _documentNumberingService.GetStatusesAsync(DepartmentId, false, now)) + .Select(s => DocumentNumberRow.From(s, DocumentNumberKindLabel(s.Kind))).ToList(); + } + + private string DocumentNumberKindLabel(string kind) + { + switch (kind) + { + case DocumentNumberKinds.WorkOrder: return _departmentLocalizer["DocumentNumberKindWorkOrder"]; + case DocumentNumberKinds.Invoice: return _departmentLocalizer["DocumentNumberKindInvoice"]; + case DocumentNumberKinds.Bid: return _departmentLocalizer["DocumentNumberKindBid"]; + case DocumentNumberKinds.TimeReport: return _departmentLocalizer["DocumentNumberKindTimeReport"]; + default: return kind; + } + } + + private static object DocumentNumberingAudit(DocumentNumberingConfig config) + { + return new + { + DocumentNumberingYearStart = YearStartAudit(config.YearStart()), + DocumentNumberPatterns = (config.Patterns ?? new List()).Where(p => p != null) + .OrderBy(p => p.Kind, StringComparer.Ordinal).Select(p => p.Kind + " " + p.Pattern + " /" + p.SequenceWidth).ToList() + }; } [HttpGet] diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs index 0d2aa8053..db3dd6b37 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs @@ -757,7 +757,7 @@ public async Task TimeReportPdf(string id) if (report == null) return NotFound(); if (await AccessibleAsync(report.DeploymentId) == null) return Unauthorized(); var pdf = await _timeTracking.GetTimeReportPdfAsync(id, DepartmentId); - return File(pdf, "application/pdf", $"dtr-{report.ReportNumber}-{report.ReportDate:yyyyMMdd}.pdf"); + return File(pdf, "application/pdf", $"dtr-{DocumentNumbering.FileSafe(report.NumberText())}-{report.ReportDate:yyyyMMdd}.pdf"); } [HttpPost, ValidateAntiForgeryToken] diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index 5b07d2acb..caf0d7bb0 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -84,6 +84,8 @@ public class DispatchController : SecureBaseController private readonly IProtectedReadService _protectedReadService; private readonly IRecordsCutoverService _recordsCutoverService; private readonly IRecordsProtectionService _recordsProtection; + private readonly IIndoorMapService _indoorMapService; + private readonly IStringLocalizer _departmentLocalizer; public DispatchController(IDepartmentsService departmentsService, IUsersService usersService, ICallsService callsService, IDepartmentGroupsService departmentGroupsService, ICommunicationService communicationService, IQueueService queueService, @@ -100,7 +102,8 @@ public DispatchController(IDepartmentsService departmentsService, IUsersService IProtectedReadService protectedReadService, IRecordsCutoverService recordsCutoverService, IRecordsProtectionService recordsProtection, IDispatchScopeService dispatchScopeService, INearestUnitService nearestUnitService, ICallLocationHistoryService callLocationHistoryService, IStringLocalizer locationHistoryLocalizer, - IPendingCallsService pendingCallsService, ICallClosureService callClosureService) + IPendingCallsService pendingCallsService, ICallClosureService callClosureService, IIndoorMapService indoorMapService, + IStringLocalizer departmentLocalizer) { _callLocationHistoryService = callLocationHistoryService; _locationHistoryLocalizer = locationHistoryLocalizer; @@ -143,6 +146,8 @@ public DispatchController(IDepartmentsService departmentsService, IUsersService _protectedReadService = protectedReadService; _recordsCutoverService = recordsCutoverService; _recordsProtection = recordsProtection; + _indoorMapService = indoorMapService; + _departmentLocalizer = departmentLocalizer; } #endregion Private Members and Constructors @@ -252,26 +257,91 @@ public async Task NewCall() } /// - /// Adds a model error for every field the department's new-call policy requires but the form - /// left blank. Keyed to the form fields so the messages land next to the inputs. + /// The protocols, linked calls and dispatch lists the call forms post as one form key per selected item. Read + /// the same way they are unpacked when saving, so the policy sees what was actually submitted. /// - private async Task ApplyNewCallFieldPolicyAsync(NewCallView model, IFormCollection collection) + private sealed class PostedCallSelections { - var policy = await _departmentSettingsService.GetNewCallFieldPolicyAsync(DepartmentId); + public bool HasProtocols { get; init; } + public bool HasLinkedCall { get; init; } + public bool HasDispatchList { get; init; } + + public static PostedCallSelections From(IFormCollection collection) + { + var keys = collection?.Keys?.ToList() ?? new List(); + + bool HasKeyStartingWith(string prefix) => keys.Any(x => x.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + + return new PostedCallSelections + { + // A pending protocol only counts when it is actually ticked. + HasProtocols = HasKeyStartingWith("activeProtocol_") || + keys.Any(x => x.StartsWith("pendingProtocol_", StringComparison.OrdinalIgnoreCase) && collection[x] == "1"), + HasLinkedCall = HasKeyStartingWith("linkedCall_"), + // Only the four prefixes the dispatch lists actually post. A bare "dispatch" prefix also + // swallows anything else the form happens to name that way. + HasDispatchList = HasKeyStartingWith("dispatchUser_") || + HasKeyStartingWith("dispatchGroup_") || + HasKeyStartingWith("dispatchUnit_") || + HasKeyStartingWith("dispatchRole_") + }; + } + } + + /// + /// The text of a rich-text note. An untouched editor posts markup such as "<p><br></p>", which is not a + /// note and must not satisfy a required one. + /// + private static string NoteText(string html) => + string.IsNullOrWhiteSpace(html) + ? html + : System.Net.WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(html, "<[^>]*>", " ")).Trim(); + + /// The protocol ids a call form posted: every active one, and each pending one whose questions passed. + private static List PostedProtocolIds(IFormCollection collection) + { + var ids = new List(); + + foreach (var key in collection?.Keys ?? Enumerable.Empty()) + { + if (key.StartsWith("activeProtocol_") && int.TryParse(key.Replace("activeProtocol_", ""), out var activeId)) + ids.Add(activeId); + else if (key.StartsWith("pendingProtocol_") && collection[key] == "1" && int.TryParse(key.Replace("pendingProtocol_", ""), out var pendingId)) + ids.Add(pendingId); + } + + return ids.Distinct().ToList(); + } + + /// + /// Adds a model error for every field the department's new-call policy requires that the call would be left + /// without, named by the label the department sees on its settings screen. + /// + private void AddFieldPolicyErrors(NewCallFieldPolicy policy, NewCallFieldValues values) + { + foreach (var violation in NewCallFieldPolicyValidator.Validate(policy, values)) + { + ModelState.AddModelError($"NewCallField_{violation.Key}", + string.Format(_dispatchLocalizer["NewCallFieldRequired"].Value, _departmentLocalizer[$"NewCallField_{violation.Key}"].Value)); + } + } + + /// + /// Adds a model error for every field the department's new-call policy requires but the new (or archived) call + /// form left blank. are fields that form has no way to collect. + /// + private void ApplyNewCallFieldPolicy(NewCallView model, IFormCollection collection, params string[] unsupportedKeys) + { + var policy = model.FieldPolicy; if (policy == null || policy.IsEmpty) return; - // The dispatch lists, protocols and linked calls are posted as one form key per selected - // item, and are only unpacked further down -- read the same keys here so the policy sees - // what was actually submitted instead of flagging every one of them as missing. - var keys = collection?.Keys?.ToList() ?? new List(); - - bool HasKeyStartingWith(string prefix) => keys.Any(x => x.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + var selections = PostedCallSelections.From(collection); var values = new NewCallFieldValues { - Note = model.Call?.Notes, + Note = NoteText(model.Call?.Notes), Address = model.Call?.Address, // A placed pin. An address alone does not count even though it is geocoded on save, matching the v4 // SaveCall, which also checks the policy before geocoding. @@ -284,26 +354,14 @@ private async Task ApplyNewCallFieldPolicyAsync(NewCallView model, IFormCollecti ReferenceId = model.Call?.ReferenceNumber, DestinationPoiId = model.Call?.DestinationPoiId, IndoorMapZoneId = collection?["IndoorMapZoneId"].FirstOrDefault(), - // A pending protocol only counts when it is actually ticked, matching how the list is - // unpacked below. - HasProtocols = HasKeyStartingWith("activeProtocol_") || - keys.Any(x => x.StartsWith("pendingProtocol_", StringComparison.OrdinalIgnoreCase) && collection[x] == "1"), - HasLinkedCall = HasKeyStartingWith("linkedCall_"), + HasProtocols = selections.HasProtocols, + HasLinkedCall = selections.HasLinkedCall, DispatchOn = model.ScheduleDispatchDate, - // Only the four prefixes the dispatch lists actually post. A bare "dispatch" prefix also - // swallows anything else the form happens to name that way. - HasDispatchList = HasKeyStartingWith("dispatchUser_") || - HasKeyStartingWith("dispatchGroup_") || - HasKeyStartingWith("dispatchUnit_") || - HasKeyStartingWith("dispatchRole_"), + HasDispatchList = selections.HasDispatchList, IsPending = model.SaveAsPending - }; + }.Unsupported(unsupportedKeys); - foreach (var violation in NewCallFieldPolicyValidator.Validate(policy, values)) - { - ModelState.AddModelError($"NewCallField_{violation.Key}", - $"{violation.Key} is required by this department before a call can be created."); - } + AddFieldPolicyErrors(policy, values); } [HttpPost] @@ -331,7 +389,7 @@ public async Task NewCall(NewCallView model, IFormCollection coll // Same policy the apps apply and the v4 API enforces: a call-taker cannot forward an // incident to the field until the information the crews need is on it. Departments with no // policy configured are unaffected. - await ApplyNewCallFieldPolicyAsync(model, collection); + ApplyNewCallFieldPolicy(model, collection); if (ModelState.IsValid) { @@ -914,6 +972,10 @@ public async Task UpdateCall(UpdateCallView model, IFormCollectio if (!await _authorizationService.CanUserEditCallAsync(UserId, model.Call.CallId)) return Unauthorized(); + // The zone is posted outside the Call model; carried over so a re-rendered form keeps it. + model.Call.IndoorMapZoneId = collection["IndoorMapZoneId"].FirstOrDefault(); + model.Call.IndoorMapFloorId = collection["IndoorMapFloorId"].FirstOrDefault(); + model = await FillUpdateCallView(model); // Populate navigation properties (References, Contacts, etc.) so the view can @@ -930,31 +992,83 @@ public async Task UpdateCall(UpdateCallView model, IFormCollectio var call = await _callsService.GetCallByIdAsync(model.Call.CallId); call = await _callsService.PopulateCallData(call, true, true, true, true, true, true, true, true, true); + // A field the department hides is not on the form, so the call keeps what it has for it rather than + // taking the blank the form posts. + var policy = model.FieldPolicy ?? new NewCallFieldPolicy(); + bool Shows(string key) => policy.IsVisible(key); + + var indoorMapZoneId = collection["IndoorMapZoneId"].FirstOrDefault(); + var indoorMapFloorId = collection["IndoorMapFloorId"].FirstOrDefault(); + var postedPin = !String.IsNullOrEmpty(model.Latitude) && !String.IsNullOrEmpty(model.Longitude) + ? string.Format("{0},{1}", model.Latitude, model.Longitude) + : null; + + // The call as this edit would leave it must still have every field the department requires, checked + // before anything below touches stored data. The dispatch time only means something before a call goes + // out, so it is never required of an edit. + var selections = PostedCallSelections.From(collection); + AddFieldPolicyErrors(policy, new NewCallFieldValues + { + Note = Shows(NewCallFieldKeys.Note) ? NoteText(model.Call.Notes) : NoteText(call.Notes), + Address = Shows(NewCallFieldKeys.Address) ? model.Call.Address : call.Address, + Geolocation = postedPin ?? call.GeoLocationData, + What3Words = Shows(NewCallFieldKeys.What3Words) ? model.What3Word : call.W3W, + ContactName = Shows(NewCallFieldKeys.ContactName) ? model.Call.ContactName : call.ContactName, + ContactInfo = Shows(NewCallFieldKeys.ContactInfo) ? model.Call.ContactNumber : call.ContactNumber, + ExternalId = Shows(NewCallFieldKeys.ExternalId) ? model.Call.ExternalIdentifier : call.ExternalIdentifier, + IncidentId = Shows(NewCallFieldKeys.IncidentId) ? model.Call.IncidentNumber : call.IncidentNumber, + ReferenceId = Shows(NewCallFieldKeys.ReferenceId) ? model.Call.ReferenceNumber : call.ReferenceNumber, + DestinationPoiId = Shows(NewCallFieldKeys.DestinationPoi) ? destinationPoi?.PoiId : call.DestinationPoiId, + IndoorMapZoneId = Shows(NewCallFieldKeys.IndoorLocation) ? indoorMapZoneId : call.IndoorMapZoneId, + // Protocols are only ever added on this form, and a list or link left off the post keeps the call's own. + HasProtocols = selections.HasProtocols || (call.Protocols?.Any() ?? false), + HasLinkedCall = selections.HasLinkedCall || (call.References?.Any() ?? false), + HasDispatchList = selections.HasDispatchList || (call.Dispatches?.Any() ?? false) || (call.GroupDispatches?.Any() ?? false) || + (call.UnitDispatches?.Any() ?? false) || (call.RoleDispatches?.Any() ?? false), + IsPending = call.State == (int)CallStates.Pending && !model.DispatchNow && !model.ScheduleDispatchDate.HasValue + }.Unsupported(NewCallFieldKeys.DispatchOn)); + + if (!ModelState.IsValid) + return View("UpdateCall", model); + call.NatureOfCall = System.Net.WebUtility.HtmlDecode(model.Call.NatureOfCall); - call.Notes = System.Net.WebUtility.HtmlDecode(model.Call.Notes); call.Name = model.Call.Name; call.Priority = (int)model.CallPriority; call.IsCritical = model.Call.IsCritical; call.MapPage = model.Call.MapPage; call.CallSource = (int)CallSources.User; - call.ContactName = model.Call.ContactName; - call.ContactNumber = model.Call.ContactNumber; call.Public = model.Call.Public; - call.ExternalIdentifier = model.Call.ExternalIdentifier; - call.ReferenceNumber = model.Call.ReferenceNumber; - call.IncidentNumber = model.Call.IncidentNumber; - call.Address = model.Call.Address; - call.W3W = model.What3Word; - call.DestinationPoiId = destinationPoi?.PoiId; call.Type = model.Call.Type; - if (!string.IsNullOrEmpty(model.Call.Address)) - { + if (Shows(NewCallFieldKeys.Note)) + call.Notes = System.Net.WebUtility.HtmlDecode(model.Call.Notes); + + if (Shows(NewCallFieldKeys.ContactName)) + call.ContactName = model.Call.ContactName; + + if (Shows(NewCallFieldKeys.ContactInfo)) + call.ContactNumber = model.Call.ContactNumber; + + if (Shows(NewCallFieldKeys.ExternalId)) + call.ExternalIdentifier = model.Call.ExternalIdentifier; + + if (Shows(NewCallFieldKeys.ReferenceId)) + call.ReferenceNumber = model.Call.ReferenceNumber; + + if (Shows(NewCallFieldKeys.IncidentId)) + call.IncidentNumber = model.Call.IncidentNumber; + + if (Shows(NewCallFieldKeys.Address)) call.Address = model.Call.Address; - } - if (!String.IsNullOrEmpty(model.Latitude) && !String.IsNullOrEmpty(model.Longitude)) - call.GeoLocationData = string.Format("{0},{1}", model.Latitude, model.Longitude); + if (Shows(NewCallFieldKeys.What3Words)) + call.W3W = model.What3Word; + + if (Shows(NewCallFieldKeys.DestinationPoi)) + call.DestinationPoiId = destinationPoi?.PoiId; + + if (postedPin != null) + call.GeoLocationData = postedPin; var checkInTimersValue = collection["Call.CheckInTimersEnabled"].FirstOrDefault(); call.CheckInTimersEnabled = !string.IsNullOrEmpty(checkInTimersValue) && checkInTimersValue.Contains("true", StringComparison.OrdinalIgnoreCase); @@ -962,18 +1076,35 @@ public async Task UpdateCall(UpdateCallView model, IFormCollectio var part2ConsentValue = collection["Call.Part2ConsentOnFile"].FirstOrDefault(); call.Part2ConsentOnFile = !string.IsNullOrEmpty(part2ConsentValue) && part2ConsentValue.Contains("true", StringComparison.OrdinalIgnoreCase); - // Indoor map zone - var indoorMapZoneId = collection["IndoorMapZoneId"].FirstOrDefault(); - var indoorMapFloorId = collection["IndoorMapFloorId"].FirstOrDefault(); - if (!String.IsNullOrWhiteSpace(indoorMapZoneId)) + // Indoor map zone (the form carries the stored zone, so a blank one was cleared on purpose) + if (Shows(NewCallFieldKeys.IndoorLocation)) { - call.IndoorMapZoneId = indoorMapZoneId; - call.IndoorMapFloorId = indoorMapFloorId; + if (!String.IsNullOrWhiteSpace(indoorMapZoneId)) + { + call.IndoorMapZoneId = indoorMapZoneId; + call.IndoorMapFloorId = indoorMapFloorId; + } + else + { + call.IndoorMapZoneId = null; + call.IndoorMapFloorId = null; + } } - else + + // Protocols triggered by the call's type and priority are added; ones already on the call stay. + if (Shows(NewCallFieldKeys.Protocols)) { - call.IndoorMapZoneId = null; - call.IndoorMapFloorId = null; + if (call.Protocols == null) + call.Protocols = new List(); + + foreach (var protocolId in PostedProtocolIds(collection)) + { + if (call.Protocols.Any(x => x.DispatchProtocolId == protocolId)) + continue; + + var protocolCode = collection.ContainsKey($"protocolCode_{protocolId}") ? collection[$"protocolCode_{protocolId}"].ToString() : null; + call.Protocols.Add(new CallProtocol { CallId = call.CallId, DispatchProtocolId = protocolId, Data = protocolCode }); + } } List existingDispatches = new List(call.Dispatches ?? Enumerable.Empty()); @@ -1223,9 +1354,9 @@ public async Task UpdateCall(UpdateCallView model, IFormCollectio call.State = (int)CallStates.Active; } } - else if (call.DispatchOn.HasValue) + else if (call.DispatchOn.HasValue && Shows(NewCallFieldKeys.DispatchOn)) { - // User cleared a previously scheduled dispatch + // User cleared a previously scheduled dispatch (a hidden field was never on the form to clear) call.DispatchOn = null; call.HasBeenDispatched = false; } @@ -1727,22 +1858,35 @@ public async Task AddArchivedCall(NewCallView model, IFormCollect { model = await FillNewCallView(model); model.CallStates = model.CallState.ToSelectList(); - model.Call.LoggedOn = DateTime.UtcNow.TimeConverter(model.Department); model.Call.ReportingUserId = UserId; + + // The timestamp is the point of an archived call: it is entered in the department's time. Only a form that + // posted none falls back to now (this used to overwrite every entered time with now). + if (model.Call.LoggedOn == default(DateTime)) + model.Call.LoggedOn = DateTime.UtcNow.TimeConverter(model.Department); + var destinationPoi = await GetValidatedDestinationPoiAsync(model.Call?.DestinationPoiId); if (model.Call?.DestinationPoiId.HasValue == true && model.Call.DestinationPoiId.Value > 0 && destinationPoi == null) ModelState.AddModelError("Call.DestinationPoiId", _dispatchLocalizer["InvalidDestinationPoi"].Value); + // As on NewCall: the pin is posted only as Latitude/Longitude, so anything bound into GeoLocationData is dropped. + model.Call.GeoLocationData = null; + + if (model.HasInvalidPin()) + ModelState.AddModelError(nameof(model.Latitude), _dispatchLocalizer["InvalidCallCoordinates"].Value); + + // The department's field policy covers archived calls too. One has already happened, so it has no dispatch + // time to require. + ApplyNewCallFieldPolicy(model, collection, NewCallFieldKeys.DispatchOn); + if (ModelState.IsValid) { - var year = model.Call.LoggedOn.Year; - model.Call.ReportingUserId = UserId; model.Call.DepartmentId = DepartmentId; model.Call.Priority = (int)model.CallPriority; model.Call.State = 0; - model.Call.LoggedOn = model.Call.LoggedOn.ToUniversalTime(); + model.Call.LoggedOn = DateTimeHelpers.ConvertToUtc(DateTime.SpecifyKind(model.Call.LoggedOn, DateTimeKind.Unspecified), model.Department.TimeZone); model.Call.CallFormData = null; model.Call.NatureOfCall = System.Net.WebUtility.HtmlDecode(model.Call.NatureOfCall); model.Call.Notes = System.Net.WebUtility.HtmlDecode(model.Call.Notes); @@ -1755,13 +1899,44 @@ public async Task AddArchivedCall(NewCallView model, IFormCollect if (model.Call.Type == _dispatchLocalizer["NoType"].Value) model.Call.Type = null; - if (!String.IsNullOrEmpty(model.Latitude) && !String.IsNullOrEmpty(model.Longitude)) - model.Call.GeoLocationData = string.Format("{0},{1}", model.Latitude, model.Longitude); + var pin = model.PostedGeoLocation(); + if (pin != null) + model.Call.GeoLocationData = pin; // Address typed with no pin placed: locate it the same way the v4 SaveCall does, so the call // still gets a map in the web and mobile apps. else if (!string.IsNullOrWhiteSpace(model.Call.Address)) model.Call.GeoLocationData = await _geoLocationProvider.GetLatLonFromAddress(model.Call.Address); + // 42 CFR Part 2 consent is a dispatcher-set flag; subject identifiers come only from integrations. + var part2ConsentValue = collection["Call.Part2ConsentOnFile"].FirstOrDefault(); + model.Call.Part2ConsentOnFile = !string.IsNullOrEmpty(part2ConsentValue) && part2ConsentValue.Contains("true", StringComparison.OrdinalIgnoreCase); + model.Call.SubjectIdentifiers = null; + + var indoorMapZoneId = collection["IndoorMapZoneId"].FirstOrDefault(); + if (!String.IsNullOrWhiteSpace(indoorMapZoneId)) + { + model.Call.IndoorMapZoneId = indoorMapZoneId; + model.Call.IndoorMapFloorId = collection["IndoorMapFloorId"].FirstOrDefault(); + } + + model.Call.Protocols = PostedProtocolIds(collection) + .Select(id => new CallProtocol + { + DispatchProtocolId = id, + Data = collection.ContainsKey($"protocolCode_{id}") ? collection[$"protocolCode_{id}"].ToString() : null + }) + .ToList(); + + // Only contacts the form offered (this department's, and only with Security > View Contacts). + var offeredContacts = model.Contacts ?? new List(); + model.Call.Contacts = new List(); + + if (!String.IsNullOrWhiteSpace(model.PrimaryContact) && offeredContacts.Any(x => x.ContactId == model.PrimaryContact)) + model.Call.Contacts.Add(new CallContact { DepartmentId = DepartmentId, ContactId = model.PrimaryContact, CallContactType = 0 }); + + foreach (var additionalContact in (model.AdditionalContacts ?? new List()).Where(x => offeredContacts.Any(c => c.ContactId == x)).Distinct()) + model.Call.Contacts.Add(new CallContact { DepartmentId = DepartmentId, ContactId = additionalContact, CallContactType = 1 }); + List dispatchingUserIds = new List(); List dispatchingGroupIds = new List(); List dispatchingUnitIds = new List(); @@ -1935,7 +2110,8 @@ public async Task AddArchivedCall(NewCallView model, IFormCollect if (model.ReCalcuateCallNumbers) { - await _callsService.RegenerateCallNumbersAsync(DepartmentId, year, cancellationToken); + // The numbering year the archived call falls in, which is a fiscal year when the department numbers by one. + await _callsService.RegenerateCallNumbersAsync(DepartmentId, call.LoggedOn, cancellationToken); } return RedirectToAction("ArchivedCalls", "Dispatch", new { Area = "User" }); @@ -3707,6 +3883,10 @@ private async Task FillNewCallView(NewCallView model) model.DestinationPois = await GetDestinationPoiSelectListAsync(model.Call?.DestinationPoiId); + // The same policy ApplyNewCallFieldPolicyAsync enforces on post: hidden fields are not rendered, required + // ones are marked. + model.FieldPolicy = await _departmentSettingsService.GetNewCallFieldPolicyAsync(DepartmentId) ?? new NewCallFieldPolicy(); + var udfDefinition = await _userDefinedFieldsService.GetActiveDefinitionAsync(DepartmentId, (int)UdfEntityType.Call); if (udfDefinition != null) { @@ -3801,6 +3981,11 @@ private async Task FillUpdateCallView(UpdateCallView model) } model.DestinationPois = await GetDestinationPoiSelectListAsync(model.Call?.DestinationPoiId); + model.FieldPolicy = await _departmentSettingsService.GetNewCallFieldPolicyAsync(DepartmentId) ?? new NewCallFieldPolicy(); + + // The edit form used to open with an empty indoor location and post that back, clearing the zone on every save. + if (!string.IsNullOrWhiteSpace(model.Call?.IndoorMapZoneId)) + model.IndoorMapZoneName = (await _indoorMapService.GetZoneByIdAsync(model.Call.IndoorMapZoneId))?.Name; if (model.Call != null && model.Call.CallId > 0) { diff --git a/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs index ae57aeddd..8e519d4c8 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/InvoicingController.cs @@ -417,7 +417,7 @@ public async Task Pdf(string id) if (pdf == null || pdf.Length == 0) return Refused(503, "PdfUnavailable", nameof(View), new { id }); Response.Headers["X-Content-Type-Options"] = "nosniff"; - return File(pdf, "application/pdf", $"invoice-{invoice.InvoiceNumber}.pdf"); + return File(pdf, "application/pdf", $"invoice-{DocumentNumbering.FileSafe(invoice.NumberText())}.pdf"); } [HttpPost, ValidateAntiForgeryToken] diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs index 9108dfc8b..0103f95f8 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs @@ -22,6 +22,7 @@ using Resgrid.Model.Services; using Resgrid.Providers.Claims; using Resgrid.Services.Records; +using Resgrid.Web.Areas.User.Models.Departments; using Resgrid.Web.Areas.User.Models.Records; using Resgrid.Web.Helpers; @@ -104,6 +105,7 @@ public override async Task OnActionExecutionAsync(Microsoft.AspNetCore.Mvc.Filte private readonly IRecordTypedValuesService _typedValues; private readonly IContactsService _contacts; private readonly IRecordsNumberingService _numbering; + private readonly IDocumentNumberingService _documentNumbering; public RecordsController(IRecordsService recordsService, IRecordsCutoverService cutoverService, IRecordsAuthorizationService recordsAuthorizationService, IDepartmentsService departmentsService, IDepartmentGroupsService departmentGroupsService, IUnitsService unitsService, ICallsService callsService, @@ -112,8 +114,10 @@ public RecordsController(IRecordsService recordsService, IRecordsCutoverService ICompositeViewEngine viewEngine, IPdfProvider pdfProvider, IRecordsSearchService recordsSearch, IDepartmentDataProtectionService dataProtection, IDepartmentProfileMediaService branding, IRecordsPrintLayoutService printLayouts, IRecordsAccountabilityService accountability, IRecordsDashboardService dashboard, IRecordsUdfService udf, IRecordsProtectionService protection, IProtectedGrantContext grantContext, IRecordsRevealService reveal, IRecordDefinitionsService definitions, IRecordTypedValuesService typedValues, IContactsService contacts, - IRecordsBulkPacketService bulk, IRecordsFieldRolloutService fieldRollout, IFeatureToggleService featureToggles, IRecordsNumberingService numbering) + IRecordsBulkPacketService bulk, IRecordsFieldRolloutService fieldRollout, IFeatureToggleService featureToggles, IRecordsNumberingService numbering, + IDocumentNumberingService documentNumbering) { + _documentNumbering = documentNumbering; _numbering = numbering; _fieldRollout = fieldRollout; _bulk = bulk; @@ -1145,12 +1149,19 @@ public async Task Settings(RecordsSettingsView model, Cancellatio { Pattern = model.NumberPattern, SequenceWidth = model.SequenceWidth, - Year = model.NumberingYear > 0 ? model.NumberingYear : DateTime.UtcNow.Year, + Year = model.NumberingYear > 0 ? model.NumberingYear : before.NumberingYear, NextNumbers = (model.NextNumbers ?? new List()).Where(r => r?.NextSequence != null) .Select(r => new RecordNextNumberRequest { ScopeKey = r.ScopeKey, NextSequence = r.NextSequence.Value }).ToList(), Prefixes = (model.NumberPrefixes ?? new List()).Where(r => r != null) - .Select(r => new RecordNumberPrefixRequest { DefinitionKey = r.DefinitionKey, Prefix = r.Prefix }).ToList() + .Select(r => new RecordNumberPrefixRequest { DefinitionKey = r.DefinitionKey, Prefix = r.Prefix }).ToList(), + YearStartMonth = model.NumberingYearStartMonth, + YearStartDay = model.NumberingYearStartDay, + YearLabel = model.NumberingYearLabel }, cancellationToken); + // The other Records numbers (records requests, prevention, investigations) live in setting 72 too and follow its year + // start, so they save after it and not at all when it was refused. + var otherNumbers = numbering.PatternRejected || numbering.YearStartRejected ? new DocumentNumberingSaveResult() + : await _documentNumbering.SaveRecordsPatternsAsync(DepartmentId, UserId, (model.OtherNumbers ?? new List()).Where(r => r != null).Select(r => r.ToUpdate()).ToList(), cancellationToken); // Turning group scoping on is a deliberate action with a preview (plan 5.7.1): the switch needs the // explicit confirmation; every other change on the form still saves. var groupScopingBlocked = model.GroupVisibilityMode == RecordsGroupVisibilityMode.GroupScoped @@ -1229,23 +1240,46 @@ public async Task Settings(RecordsSettingsView model, Cancellatio errors.Add(_localizer["GroupScopeConfirmRequired"]); if (numbering.PatternRejected) errors.Add(_localizer["NumberPatternInvalid"]); + if (numbering.YearStartRejected) + errors.Add(_localizer["NumberingYearStartInvalid"]); if (numbering.BelowCurrent.Count > 0) errors.Add(_localizer["NextNumberBelowCurrent"] + " (" + string.Join(", ", numbering.BelowCurrent.Select(s => s.NextNumber)) + ")"); if (numbering.NotApplied > 0) errors.Add(_localizer["NextNumberPatternChanged"]); if (numbering.PrefixesRejected.Count > 0) errors.Add(_localizer["NumberPrefixInvalid"] + " (" + string.Join(", ", numbering.PrefixesRejected.Select(NumberedTypeLabel)) + ")"); + if (otherNumbers.PatternsRejected.Count > 0) + errors.Add(_localizer["OtherNumbersPatternsRejected"] + " (" + string.Join(", ", otherNumbers.PatternsRejected.Select(OtherNumberLabel)) + ")"); + if (otherNumbers.BelowCurrent.Count > 0) + errors.Add(_localizer["NextNumberBelowCurrent"] + " (" + string.Join(", ", otherNumbers.BelowCurrent.Select(s => s.NextNumber)) + ")"); + if (otherNumbers.NotApplied > 0) + errors.Add(_localizer["NextNumberPatternChanged"]); if (errors.Count > 0) after.ErrorMessage = string.Join(" ", errors); // Show what was saved, not what was posted: the sequences can change with the pattern, and a raised number - // left in its box would be resubmitted against whichever row lands there. A refused pattern or prefix stays for correcting. + // left in its box would be resubmitted against whichever row lands there. A refused pattern, year start or prefix + // stays for correcting (either refusal saves nothing in setting 72, so both keep what was typed). + var numberingRefused = numbering.PatternRejected || numbering.YearStartRejected; foreach (var key in ModelState.Keys.Where(k => k.StartsWith(nameof(RecordsSettingsView.NextNumbers), StringComparison.Ordinal) || k.StartsWith(nameof(RecordsSettingsView.NumberPrefixes), StringComparison.Ordinal) - || k == nameof(RecordsSettingsView.SequenceWidth) || (k == nameof(RecordsSettingsView.NumberPattern) && !numbering.PatternRejected)).ToList()) + || k.StartsWith(nameof(RecordsSettingsView.OtherNumbers), StringComparison.Ordinal) + || k == nameof(RecordsSettingsView.SequenceWidth) || k == nameof(RecordsSettingsView.NumberingYear) + || (!numberingRefused && (k == nameof(RecordsSettingsView.NumberPattern) || k == nameof(RecordsSettingsView.NumberingYearStartMonth) + || k == nameof(RecordsSettingsView.NumberingYearStartDay) || k == nameof(RecordsSettingsView.NumberingYearLabel)))).ToList()) ModelState.Remove(key); foreach (var row in after.NumberPrefixes.Where(r => numbering.PrefixesRejected.Contains(r.DefinitionKey))) row.Prefix = model.NumberPrefixes?.FirstOrDefault(p => p != null && p.DefinitionKey == row.DefinitionKey)?.Prefix; + // A refused pattern (or one not saved because setting 72 was refused) stays in its box for correcting. + foreach (var row in after.OtherNumbers) + { + var typed = model.OtherNumbers?.FirstOrDefault(p => p != null && p.Kind == row.Kind); + if (typed != null && (numberingRefused || otherNumbers.PatternsRejected.Contains(row.Kind))) + { + row.Pattern = typed.Pattern; + row.SequenceWidth = typed.SequenceWidth; + } + } return View(after); } @@ -1259,16 +1293,24 @@ private async Task BuildSettingsAsync(RecordsModuleState mo var numbering = await _departmentSettingsService.GetRecordsNumberingConfigAsync(DepartmentId, true); var retention = await _departmentSettingsService.GetRecordsRetentionPolicyAsync(DepartmentId, true); var search = await _departmentSettingsService.GetRecordsSearchConfigAsync(DepartmentId, true); + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); + var now = DateTime.UtcNow; + var yearStart = numbering.YearStart(); var model = new RecordsSettingsView { ModuleState = moduleState, - Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false), + Department = department, DefaultLifecyclePreset = await _departmentSettingsService.GetRecordsDefaultLifecyclePresetAsync(DepartmentId, true), ReviewDueHours = await _departmentSettingsService.GetRecordsReviewDueHoursAsync(DepartmentId, true), NumberPattern = RecordNumberFormat.EffectivePattern(numbering), SequenceWidth = RecordNumberFormat.EffectiveWidth(numbering.SequenceWidth), - NumberingYear = DateTime.UtcNow.Year, + // The department-local numbering year a record started now falls in, so the sequences listed are the ones in use. + NumberingYear = RecordsNumberingService.NumberingYear(numbering, now, department?.TimeZone), + NumberingYearStartMonth = yearStart.Month, + NumberingYearStartDay = yearStart.Day, + NumberingYearLabel = (int)yearStart.Label, + NumberingPreviewDate = string.IsNullOrWhiteSpace(department?.TimeZone) ? now : DateTimeHelpers.GetLocalDateTime(now, department.TimeZone), DepartmentDefaultYears = retention.DepartmentDefaultYears, GroupVisibilityMode = await _departmentSettingsService.GetRecordsGroupVisibilityModeAsync(DepartmentId, true), GroupScopePreview = await _recordsAuthorizationService.PreviewGroupScopingAsync(DepartmentId), @@ -1301,6 +1343,9 @@ private async Task BuildSettingsAsync(RecordsModuleState mo model.NumberPrefixes.Add(new RecordsNumberPrefixRow { DefinitionKey = type.Key, Label = NumberedTypeLabel(type.Key), DefaultPrefix = type.Value, Prefix = custom?.Prefix }); } + model.OtherNumbers = (await _documentNumbering.GetStatusesAsync(DepartmentId, true, now)) + .Select(s => DocumentNumberRow.From(s, OtherNumberLabel(s.Kind))).ToList(); + var sequences = await _numbering.GetSequencesAsync(DepartmentId, numbering, model.NumberingYear); var grouped = sequences.Any(s => s.GroupId.HasValue); var groupNames = grouped @@ -1345,6 +1390,21 @@ private string NumberedTypeLabel(string definitionKey) return RmsDefinitionKeys.LockedTypes.TryGetValue(definitionKey, out var type) ? type.ToString() : _localizer["IncidentReports"].Value; } + /// The Records kinds numbered outside the record pattern, by the names their own screens use. + private string OtherNumberLabel(string kind) + { + switch (kind) + { + case DocumentNumberKinds.RecordsRequest: return _localizer["Disclosures"].Value; + case DocumentNumberKinds.Occupancy: return _localizer["Occupancies"].Value; + case DocumentNumberKinds.Inspection: return _localizer["Inspections"].Value; + case DocumentNumberKinds.Permit: return _localizer["Permits"].Value; + case DocumentNumberKinds.Investigation: return _localizer["Investigations"].Value; + case DocumentNumberKinds.Evidence: return _localizer["Evidence"].Value; + default: return kind; + } + } + /// /// Free-text path (RMS plan section 5.10): index hits are loaded from the projection table and every hit is /// re-checked against per-record visibility before it is shown. Totals come from authorized results or are diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs index 0e674e820..2167cfcbb 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs @@ -663,7 +663,7 @@ public async Task CostRuns() var bids = await _bids.GetBidsForDepartmentAsync(DepartmentId, null, 0, 200) ?? new List(); view.Bids = bids.OrderByDescending(b => b.BidNumber).Select(b => new SelectListItem($"#{b.BidNumber} {b.Title}", b.BidId)).ToList(); foreach (var d in deployments) view.ContextLabels["D:" + d.DeploymentId] = d.Name; - foreach (var b in bids) view.ContextLabels["B:" + b.BidId] = $"#{b.BidNumber} {b.Title}"; + foreach (var b in bids) view.ContextLabels["B:" + b.BidId] = $"{b.NumberLabel()} {b.Title}"; return View(view); } @@ -675,7 +675,7 @@ public async Task CostRun(string id) if (run == null) return NotFound(); var view = Page(new WorkforceCostRunView { Run = run, Summary = await _costing.GetFieldCostSummaryAsync(id, DepartmentId) }); if (!string.IsNullOrWhiteSpace(run.DeploymentId)) { view.ContextLabel = (await _deployments.GetDeploymentByIdAsync(run.DeploymentId, DepartmentId))?.Name; view.Comparison = await _costing.CompareEstimateToActualAsync(run.DeploymentId, DepartmentId); } - else if (!string.IsNullOrWhiteSpace(run.BidId)) { var bid = await _bids.GetBidByIdAsync(run.BidId, DepartmentId); view.ContextLabel = bid == null ? run.BidId : $"#{bid.BidNumber} {bid.Title}"; } + else if (!string.IsNullOrWhiteSpace(run.BidId)) { var bid = await _bids.GetBidByIdAsync(run.BidId, DepartmentId); view.ContextLabel = bid == null ? run.BidId : $"{bid.NumberLabel()} {bid.Title}"; } else if (run.CallId.HasValue) view.ContextLabel = (await _calls.GetCallByIdAsync(run.CallId.Value))?.Name ?? $"#{run.CallId}"; return View(view); } diff --git a/Web/Resgrid.Web/Areas/User/Models/Calls/NewCallView.cs b/Web/Resgrid.Web/Areas/User/Models/Calls/NewCallView.cs index ef20ff509..f8705aed1 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calls/NewCallView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calls/NewCallView.cs @@ -48,13 +48,24 @@ public class NewCallView : BaseUserModel /// Save the call to Pending Calls without notifying anyone; a dispatcher sends it later. public bool SaveAsPending { get; set; } + /// + /// The department's new-call field policy: which built-in fields the form renders and which it marks required. + /// Never null; an empty policy is stock behaviour (everything shown, nothing required). + /// + public NewCallFieldPolicy FieldPolicy { get; set; } + public NewCallView() { What3Words = new W3W(); AdditionalContacts = new List(); DestinationPois = new List(); + FieldPolicy = new NewCallFieldPolicy(); } + public bool IsFieldVisible(string key) => FieldPolicy == null || FieldPolicy.IsVisible(key); + + public bool IsFieldRequired(string key) => FieldPolicy != null && FieldPolicy.IsRequired(key); + /// /// The pin the form posted, in the call's "lat,lon" form; null when none was placed. The form posts the pin as /// /, never as Call.GeoLocationData, so this is what the new-call field diff --git a/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs b/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs index 71696863a..6c2da06be 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs @@ -53,10 +53,24 @@ public class UpdateCallView: BaseUserModel /// The department uses run cards: the page offers the run card's recommendation for what the call still needs. public bool RunCardsEnabled { get; set; } + /// + /// The department's new-call field policy, which edits follow too: hidden fields are not rendered (and keep their + /// stored value), required ones are marked. Never null. + /// + public NewCallFieldPolicy FieldPolicy { get; set; } + + /// Name of the call's indoor map zone, so the picker opens showing it. + public string IndoorMapZoneName { get; set; } + public UpdateCallView() { AdditionalContacts = new List(); DestinationPois = new List(); + FieldPolicy = new NewCallFieldPolicy(); } + + public bool IsFieldVisible(string key) => FieldPolicy == null || FieldPolicy.IsVisible(key); + + public bool IsFieldRequired(string key) => FieldPolicy != null && FieldPolicy.IsRequired(key); } } \ No newline at end of file diff --git a/Web/Resgrid.Web/Areas/User/Models/Departments/CallSettings/CallSettingsView.cs b/Web/Resgrid.Web/Areas/User/Models/Departments/CallSettings/CallSettingsView.cs index 66b9afc10..8fa0368f9 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Departments/CallSettings/CallSettingsView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Departments/CallSettings/CallSettingsView.cs @@ -32,6 +32,10 @@ public class CallSettingsView : BaseUserModel public int? CallNumberNextSequence { get; set; } /// Department-local now, for the pattern preview. public System.DateTime CallNumberPreviewDate { get; set; } + /// The day the numbering year starts (January 1 unless the department numbers calls by a fiscal year) and which year names it. + public int CallNumberYearStartMonth { get; set; } + public int CallNumberYearStartDay { get; set; } + public int CallNumberYearLabel { get; set; } public string ErrorMessage { get; set; } } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Departments/DocumentNumberingView.cs b/Web/Resgrid.Web/Areas/User/Models/Departments/DocumentNumberingView.cs new file mode 100644 index 000000000..3707b0878 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Departments/DocumentNumberingView.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using Resgrid.Model; + +namespace Resgrid.Web.Areas.User.Models.Departments +{ + /// Department -> Document Numbering (setting 117): work order, invoice, bid and daily time report numbers. + public class DocumentNumberingView : BaseUserModel + { + public string Message { get; set; } + public string ErrorMessage { get; set; } + + /// The day the numbering year starts (January 1 unless documents follow a fiscal year) and which year names it. + public int YearStartMonth { get; set; } + public int YearStartDay { get; set; } + public int YearLabel { get; set; } + + /// Department-local now, for the pattern previews. + public DateTime PreviewDate { get; set; } + + public List Rows { get; set; } = new List(); + } + + /// + /// One document kind on a numbering screen (Document Numbering, or Records Settings for the Records kinds): the pattern the + /// department set (blank = the built-in numbers), and for a custom pattern what it issues next and a raise of that. + /// + public class DocumentNumberRow + { + public string Kind { get; set; } + public string Label { get; set; } + + /// The department's pattern; blank keeps the built-in numbers. + public string Pattern { get; set; } + public int SequenceWidth { get; set; } + + public string LegacyPattern { get; set; } + public int LegacyWidth { get; set; } + public int MaxLength { get; set; } + + public bool Custom { get; set; } + public CallNumberResetPeriod Period { get; set; } + + /// The sequence the next number was shown for; a raise against any other is not applied. + public string ScopeKey { get; set; } + public string NextNumber { get; set; } + public int CurrentNextSequence { get; set; } + public string Example { get; set; } + + /// The sequence to raise the next number to; blank leaves it. + public int? RaiseTo { get; set; } + + public static DocumentNumberRow From(DocumentNumberStatus status, string label) + { + var kind = DocumentNumberKinds.Find(status.Kind); + return new DocumentNumberRow + { + Kind = status.Kind, + Label = label, + Pattern = status.Custom ? status.Pattern : null, + SequenceWidth = status.Custom ? status.SequenceWidth : kind?.LegacyWidth ?? CallNumberFormat.DefaultWidth, + LegacyPattern = kind?.LegacyPattern, + LegacyWidth = kind?.LegacyWidth ?? CallNumberFormat.DefaultWidth, + MaxLength = kind?.MaxLength ?? 50, + Custom = status.Custom, + Period = status.Period, + ScopeKey = status.ScopeKey, + NextNumber = status.NextNumber, + CurrentNextSequence = status.NextSequence, + Example = status.Example + }; + } + + public DocumentNumberPatternUpdate ToUpdate() + { + return new DocumentNumberPatternUpdate { Kind = Kind, Pattern = Pattern, SequenceWidth = SequenceWidth, ScopeKey = ScopeKey, NextSequence = RaiseTo }; + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs index c031a0a58..ff2879370 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs @@ -324,8 +324,16 @@ public class RecordsSettingsView : RecordsBaseView public int SequenceWidth { get; set; } /// Setting 72's per-type prefixes, one row per system record type. public List NumberPrefixes { get; set; } = new List(); - /// The year the next numbers below are shown for. + /// The numbering year the next numbers below are shown for (a fiscal year's name when the year starts later than January 1). public int NumberingYear { get; set; } + /// Setting 72's year start: the day the numbering year starts (January 1 unless records follow a fiscal year) and which year names it. + public int NumberingYearStartMonth { get; set; } + public int NumberingYearStartDay { get; set; } + public int NumberingYearLabel { get; set; } + /// Department-local today, for the pattern preview. + public DateTime NumberingPreviewDate { get; set; } + /// Records request, occupancy, inspection, permit, investigation and evidence numbers (setting 72's DocumentPatterns). + public List OtherNumbers { get; set; } = new List(); public List NextNumbers { get; set; } = new List(); public int? DepartmentDefaultYears { get; set; } public List RetentionOverrides { get; set; } = new List(); diff --git a/Web/Resgrid.Web/Areas/User/Views/Bids/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Bids/Edit.cshtml index 8a6bed2c5..d7340bf19 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Bids/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Bids/Edit.cshtml @@ -3,7 +3,7 @@ @{ var b = Model.Bid; ViewBag.Title = "Resgrid | " + localizer["EditBid"]; - ViewData["Title"] = localizer["BidNumber"].Value + " " + b.BidNumber; + ViewData["Title"] = localizer["BidNumber"].Value + " " + b.NumberText(); ViewData["Subtitle"] = localizer["EditBidIntro"].Value; ViewData["RootController"] = "Bids"; ViewData["RootTitle"] = localizer["Bids"].Value; diff --git a/Web/Resgrid.Web/Areas/User/Views/Bids/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Bids/Index.cshtml index 2d5d4be74..2d9125162 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Bids/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Bids/Index.cshtml @@ -46,7 +46,7 @@ @foreach (var b in Model.Bids) {
- + diff --git a/Web/Resgrid.Web/Areas/User/Views/Bids/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Bids/View.cshtml index 423417b63..7dbbe7f55 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Bids/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Bids/View.cshtml @@ -3,8 +3,8 @@ @{ var departmentTime = DepartmentTime.From(ViewData); var b = Model.Bid; - ViewBag.Title = "Resgrid | " + localizer["BidNumber"] + " " + b.BidNumber; - ViewData["Title"] = localizer["BidNumber"].Value + " " + b.BidNumber + " — " + b.Title; + ViewBag.Title = "Resgrid | " + localizer["BidNumber"] + " " + b.NumberText(); + ViewData["Title"] = localizer["BidNumber"].Value + " " + b.NumberText() + " — " + b.Title; ViewData["RootController"] = "Bids"; ViewData["RootTitle"] = localizer["Bids"].Value; var status = (Resgrid.Model.Invoicing.BidStatuses)b.Status; diff --git a/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml index 376f842fb..d39c03e46 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml @@ -628,7 +628,7 @@ @foreach (var invoice in Model.Invoices) { - + diff --git a/Web/Resgrid.Web/Areas/User/Views/Contracts/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contracts/View.cshtml index 6a6c6ead9..c7c08f12c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Contracts/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Contracts/View.cshtml @@ -93,7 +93,7 @@ else {
Invoice #").Append(invoice.InvoiceNumber).Append("
Invoice #").Append(E(invoice.NumberText())).Append("
Status").Append(E(StatusLabel(invoice.Status))).Append("
Issued").Append(invoice.IssuedOn.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append("
Due").Append(invoice.DueOn.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append("
@b.BidNumber@b.NumberText() @b.Title @if (b.IsConverted) { } @(Model.ContactNames.TryGetValue(b.ContactId, out var contact) ? contact : b.ContactId) @await Html.PartialAsync("_BidStatusBadge", b.Status)
#@invoice.InvoiceNumber@invoice.NumberLabel() @invoicingLocalizer["Status" + (Resgrid.Model.Invoicing.InvoiceStatus)invoice.Status] @(invoice.DueOn?.ToString("yyyy-MM-dd") ?? "—") @invoice.Total.ToString("N2") @invoice.Currency
- @foreach (var b in Model.Bids) { } + @foreach (var b in Model.Bids) { }
#@b.BidNumber @b.Title@await Html.PartialAsync("~/Areas/User/Views/Bids/_BidStatusBadge.cshtml", b.Status)@b.EstimatedTotal.ToString("N2")
@b.NumberLabel() @b.Title@await Html.PartialAsync("~/Areas/User/Views/Bids/_BidStatusBadge.cshtml", b.Status)@b.EstimatedTotal.ToString("N2")
} @@ -120,7 +120,7 @@ else { - @foreach (var i in Model.Invoices) { } + @foreach (var i in Model.Invoices) { }
#@i.InvoiceNumber@(departmentTime.Date(i.IssuedOn) ?? "—")@i.Total.ToString("N2") @i.Currency
@i.NumberLabel()@(departmentTime.Date(i.IssuedOn) ?? "—")@i.Total.ToString("N2") @i.Currency
} diff --git a/Web/Resgrid.Web/Areas/User/Views/Department/CallSettings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Department/CallSettings.cshtml index 6b181d93e..064cd92ed 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Department/CallSettings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Department/CallSettings.cshtml @@ -111,12 +111,42 @@ @localizer["CallNumberSequenceWidthHelp"] + @{ + var callYearStart = new NumberingYearStart(Model.CallNumberYearStartMonth, Model.CallNumberYearStartDay, (NumberingYearLabel)Model.CallNumberYearLabel); + var callYearMonths = System.Globalization.CultureInfo.CurrentUICulture.DateTimeFormat.MonthNames.Take(12) + .Select((name, i) => new SelectListItem(name, (i + 1).ToString(System.Globalization.CultureInfo.InvariantCulture), i + 1 == callYearStart.Month)).ToList(); + } +
+ +
+
+ + + +
+
+
+
+
+ @localizer["CallNumberYearStartHelp"] +
+

@Model.CallNumberNextNumber - @localizer["CallNumberResets" + Model.CallNumberResetPeriod] + @if (Model.CallNumberResetPeriod == CallNumberResetPeriod.Yearly && !callYearStart.IsCalendarYear) + { + @localizer["CallNumberResetsYearlyOn", new DateTime(2001, callYearStart.Month, callYearStart.Day).ToString("M", System.Globalization.CultureInfo.CurrentUICulture)] + } + else + { + @localizer["CallNumberResets" + Model.CallNumberResetPeriod] + }

@@ -260,22 +290,37 @@ +} diff --git a/Web/Resgrid.Web/Areas/User/Views/DeploymentWizard/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/DeploymentWizard/Index.cshtml index 368987e6b..9312a1c26 100644 --- a/Web/Resgrid.Web/Areas/User/Views/DeploymentWizard/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/DeploymentWizard/Index.cshtml @@ -4,7 +4,7 @@ var bid = Model.Context.Bid; ViewBag.Title = "Resgrid | " + localizer["ScheduleDeploymentCall"]; ViewData["Title"] = localizer["ScheduleDeploymentCall"].Value; - ViewData["Subtitle"] = string.Format(localizer["WizardIntro"].Value, bid.BidNumber, bid.Title); + ViewData["Subtitle"] = string.Format(localizer["WizardIntro"].Value, bid.NumberText(), bid.Title); ViewData["RootController"] = "Bids"; ViewData["RootTitle"] = localizer["Bids"].Value; var steps = new[] { "WizardStepCall", "WizardStepUnits", "WizardStepSeats", "WizardStepEquipment", "WizardStepRates", "WizardStepReview" }; diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml index c12f36c8d..9223c3703 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml @@ -4,8 +4,8 @@ var departmentTime = DepartmentTime.From(ViewData); var r = Model.Report; var d = Model.Deployment; - ViewBag.Title = "Resgrid | " + string.Format(localizer["TimeReportTitle"].Value, r.ReportNumber); - ViewData["Title"] = string.Format(localizer["TimeReportTitle"].Value, r.ReportNumber); + ViewBag.Title = "Resgrid | " + string.Format(localizer["TimeReportTitle"].Value, r.NumberText()); + ViewData["Title"] = string.Format(localizer["TimeReportTitle"].Value, r.NumberText()); ViewData["Subtitle"] = $"{d.Name} · {r.ReportDate:yyyy-MM-dd} · {Model.ScopeName}"; string LocalStamp(DateTime? utc) => departmentTime.Format(utc) ?? "—"; string SubjectName(string id) => id != null && Model.SubjectNames.TryGetValue(id, out var n) ? n : id; @@ -43,7 +43,7 @@
-
@localizer["TimeReport"] #@r.ReportNumber @await Html.PartialAsync("_ReportStatusBadge", r.Status) @Model.ScopeName
+
@localizer["TimeReport"] @r.NumberLabel() @await Html.PartialAsync("_ReportStatusBadge", r.Status) @Model.ScopeName
diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml index 414278641..10da10175 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml @@ -288,7 +288,7 @@ @foreach (var r in Model.TimeReports.OrderByDescending(r => r.ReportDate)) {
@r.ReportNumber@r.ReportDate.ToString("yyyy-MM-dd")@r.NumberText()@r.ReportDate.ToString("yyyy-MM-dd") @(r.Scope switch { Resgrid.Model.Invoicing.DeploymentTimeReportScopes.Crew => string.Format(localizer["ScopeCrew"].Value, d.Units.FirstOrDefault(u => u.DeploymentUnitId == r.DeploymentUnitId)?.UnitName ?? r.DeploymentUnitId), @@ -319,7 +319,7 @@ @e.ExpenseDate.ToString("yyyy-MM-dd")@localizer["Expense" + (Resgrid.Model.Invoicing.DeploymentExpenseTypes)e.ExpenseType]@e.Description @(e.PreApproved ? "· " + localizer["PreApproved"] : "") @(e.Billable ? "" : "· " + localizer["NotBillable"])@e.City @e.Amount.ToString("N2") @e.Currency @if (e.ReceiptAttachmentId.HasValue) { }@if (!string.IsNullOrWhiteSpace(e.DeploymentTimeReportId)) { var r = Model.TimeReports.FirstOrDefault(x => x.DeploymentTimeReportId == e.DeploymentTimeReportId); #@(r?.ReportNumber) }@if (!string.IsNullOrWhiteSpace(e.DeploymentTimeReportId)) { var r = Model.TimeReports.FirstOrDefault(x => x.DeploymentTimeReportId == e.DeploymentTimeReportId); @(r == null ? null : r.NumberLabel()) } @if (Model.CanEditTime) {
@Html.AntiForgeryToken()
}
- - - - - - - - -
@commonLocalizer["Code"]@commonLocalizer["Name"]@commonLocalizer["Status"]
+ } + @if (Model.IsFieldVisible(NewCallFieldKeys.Protocols)) + { +
+ +
+ + + + + + + + + +
@commonLocalizer["Code"]@commonLocalizer["Name"]@commonLocalizer["Status"]
+
- -
- -
- - - - - - - - - -
@localizer["Name"]@localizer["Note"]@localizer["AddLinkedCall"]
+ } + @if (Model.IsFieldVisible(NewCallFieldKeys.LinkedCall)) + { +
+ +
+ + + + + + + + + +
@localizer["Name"]@localizer["Note"]@localizer["AddLinkedCall"]
+
-
+ } + @if (showDispatchList) + {
- +
- +
+ } + +
+ +
+
+ +
+ @localizer["Part2ConsentHelp"] +
+
@Html.DropDownListFor(m => m.CallState, Model.CallStates, new { @style = "width: 120px;" })
@@ -516,6 +606,34 @@ checkAlertNotes($(el).val(), "#additionalNotesIcon"); }).get(); + $('#indoorZoneSearch').select2({ + placeholder: resgrid.dispatch.getText('searchIndoorLocations', 'Search indoor locations...'), + allowClear: true, + minimumInputLength: 2, + ajax: { + url: '@Url.Action("SearchZones", "IndoorMaps", new { area = "User" })', + dataType: 'json', + delay: 300, + data: function (params) { + return { term: params.term }; + }, + processResults: function (data) { + return { results: data.results }; + } + } + }); + + $('#indoorZoneSearch').on('select2:select', function (e) { + var data = e.params.data; + $('#IndoorMapZoneId').val(data.id); + $('#IndoorMapFloorId').val(data.floorId); + }); + + $('#indoorZoneSearch').on('select2:clear', function () { + $('#IndoorMapZoneId').val(''); + $('#IndoorMapFloorId').val(''); + }); + $('.destination-poi-search').select2({ placeholder: resgrid.dispatch.getText('searchDestinationPois', 'Search destination POIs...'), allowClear: true, diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml index 8a4043fa5..544cb20f3 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/NewCall.cshtml @@ -4,6 +4,19 @@ ViewBag.Title = "Resgrid | " + @localizer["NewCallHeader"]; Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; var mapConfig = SettingsHelper.GetDepartmentMapConfig(Resgrid.Config.InfoConfig.WebsiteKey); + + // Department > Dispatch Settings > New Call Form Fields. Hidden fields are not rendered; the post enforces the + // required ones (ApplyNewCallFieldPolicyAsync), so the marker here is only a hint. + var showAddress = Model.IsFieldVisible(NewCallFieldKeys.Address); + var showGeolocation = Model.IsFieldVisible(NewCallFieldKeys.Geolocation); + var showWhat3Words = Model.IsFieldVisible(NewCallFieldKeys.What3Words); + var showLocation = showAddress || showGeolocation || showWhat3Words; + var showLinkedCall = Model.IsFieldVisible(NewCallFieldKeys.LinkedCall); + var showDispatchList = Model.IsFieldVisible(NewCallFieldKeys.DispatchList); + + Microsoft.AspNetCore.Html.IHtmlContent requiredMark(params string[] keys) => keys.Any(Model.IsFieldRequired) + ? new Microsoft.AspNetCore.Html.HtmlString(" *") + : Microsoft.AspNetCore.Html.HtmlString.Empty; } @section Styles @@ -98,18 +111,24 @@
@Html.DropDownListFor(m => m.Call.Type, Model.CallTypes, new { @style = "width: 200px;", tabindex = "3" })
-
- -
-
-
- -
-
+ @if (Model.IsFieldVisible(NewCallFieldKeys.ContactName)) + { +
+ +
+
+ } + @if (Model.IsFieldVisible(NewCallFieldKeys.ContactInfo)) + { +
+ +
+
+ }
@@ -132,24 +151,33 @@
-
- -
-
-
- -
-
-
- -
-
+ @if (Model.IsFieldVisible(NewCallFieldKeys.ExternalId)) + { +
+ +
+
+ } + @if (Model.IsFieldVisible(NewCallFieldKeys.IncidentId)) + { +
+ +
+
+ } + @if (Model.IsFieldVisible(NewCallFieldKeys.ReferenceId)) + { +
+ +
+
+ }
@@ -160,104 +188,139 @@
-
- -
- + @if (Model.IsFieldVisible(NewCallFieldKeys.Note)) + { +
+ +
+ -
- @Html.Raw(Model.Call.Notes) +
+ @Html.Raw(Model.Call.Notes) +
-
+ }
-
- -
-
- -
-
- - - @localizer["FindW3wLocation"] - + @if (showLocation) + { +
+ +
+ @if (showAddress || showWhat3Words) + { +
+ @if (showAddress) + { + + } + @if (showWhat3Words) + { +
+ + @localizer["W3wHelp"] @localizer["LearnMore"] +
+ }
- @localizer["W3wHelp"] @localizer["LearnMore"] -
-
-
-
-
- @localizer["LatitudeShort"] - - @localizer["LongitudeShort"] - - - @localizer["SetPinOnMap"] - + } + @if (showGeolocation) + { +
+
+
+ @localizer["LatitudeShort"] + + @localizer["LongitudeShort"] + + + @localizer["SetPinOnMap"] + +
+
-
+
+ }
-
-
-
- -
- @Html.DropDownListFor(x => x.Call.DestinationPoiId, Model.DestinationPois, new { @class = "form-control destination-poi-search", @style = "width:100%;" }) - @localizer["DestinationHelp"] + } + @if (!showGeolocation) + { + @* No pin entry, but an address or what3words lookup still resolves a point here so the run card + recommendation, nearest units and personnel ETAs keep working, as the address would be geocoded + on save anyway. *@ + + + } + @if (Model.IsFieldVisible(NewCallFieldKeys.DestinationPoi)) + { +
+ +
+ @Html.DropDownListFor(x => x.Call.DestinationPoiId, Model.DestinationPois, new { @class = "form-control destination-poi-search", @style = "width:100%;" }) + @localizer["DestinationHelp"] +
-
-
- -
-
-
- + } + @if (Model.IsFieldVisible(NewCallFieldKeys.IndoorLocation)) + { +
+ +
+
+
+ +
+ +
- -
-
-
- -
- - - - - - - - - -
@commonLocalizer["Code"]@commonLocalizer["Name"]@commonLocalizer["Status"]
+ } + @if (Model.IsFieldVisible(NewCallFieldKeys.Protocols)) + { +
+ +
+ + + + + + + + + +
@commonLocalizer["Code"]@commonLocalizer["Name"]@commonLocalizer["Status"]
+
-
+ } -
- -
- - - - - - - - - -
@localizer["Name"]@localizer["Note"]@localizer["AddLinkedCall"] -
+ @if (showLinkedCall) + { +
+ +
+ + + + + + + + + +
@localizer["Name"]@localizer["Note"]@localizer["AddLinkedCall"] +
+
-
+ } + @if (showDispatchList) + {
- +
- +
+ }
@@ -506,18 +575,21 @@
-
- -
-
- - + @if (Model.IsFieldVisible(NewCallFieldKeys.DispatchOn)) + { +
+ +
+
+ + +
+ @localizer["ScheduleDispatchHelp"]
- @localizer["ScheduleDispatchHelp"]
-
+ }
diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml index fe0ac20c9..47622987c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml @@ -4,6 +4,18 @@ ViewBag.Title = "Resgrid | " + @localizer["UpdateCallHeader"]; Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; var mapConfig = SettingsHelper.GetDepartmentMapConfig(Resgrid.Config.InfoConfig.WebsiteKey); + + // Department > Dispatch Settings > New Call Form Fields, which edits follow too. A hidden field is not rendered and + // UpdateCall keeps its stored value; the post enforces the required ones, so the marker here is only a hint. + var showAddress = Model.IsFieldVisible(NewCallFieldKeys.Address); + var showGeolocation = Model.IsFieldVisible(NewCallFieldKeys.Geolocation); + var showWhat3Words = Model.IsFieldVisible(NewCallFieldKeys.What3Words); + var showLocation = showAddress || showGeolocation || showWhat3Words; + var showDispatchList = Model.IsFieldVisible(NewCallFieldKeys.DispatchList); + + Microsoft.AspNetCore.Html.IHtmlContent requiredMark(params string[] keys) => keys.Any(Model.IsFieldRequired) + ? new Microsoft.AspNetCore.Html.HtmlString(" *") + : Microsoft.AspNetCore.Html.HtmlString.Empty; } @{ // ADP reveal (plan 7.2): one descriptor shared by the banner in the body and the @@ -121,14 +133,20 @@
@Html.DropDownListFor(m => m.Call.Type, Model.CallTypes, new { @style = "width: 200px;", tabindex = "3" })
-
- -
-
-
- -
-
+ @if (Model.IsFieldVisible(NewCallFieldKeys.ContactName)) + { +
+ +
+
+ } + @if (Model.IsFieldVisible(NewCallFieldKeys.ContactInfo)) + { +
+ +
+
+ }
@@ -147,18 +165,27 @@
-
- -
-
-
- -
-
-
- -
-
+ @if (Model.IsFieldVisible(NewCallFieldKeys.ExternalId)) + { +
+ +
+
+ } + @if (Model.IsFieldVisible(NewCallFieldKeys.IncidentId)) + { +
+ +
+
+ } + @if (Model.IsFieldVisible(NewCallFieldKeys.ReferenceId)) + { +
+ +
+
+ }
@@ -169,94 +196,146 @@
-
- -
- + @if (Model.IsFieldVisible(NewCallFieldKeys.Note)) + { +
+ +
+ -
- @Html.Raw(Model.Call.Notes) +
+ @Html.Raw(Model.Call.Notes) +
-
+ }
-
- -
-
-
-
- - - @localizer["FindAddress"] - + @if (showLocation) + { +
+ +
+ @if (showAddress || showWhat3Words) + { +
+ @if (showAddress) + { + + } + @if (showWhat3Words) + { +
+ + @localizer["W3wHelp"] @localizer["LearnMore"] +
+ }
-
-
-
- - - @localizer["FindW3wLocation"] - + } + @if (showGeolocation) + { +
+
+
+ @localizer["LatitudeShort"] + + @localizer["LongitudeShort"] + + + @localizer["SetPinOnMap"] + +
+
- @localizer["W3wHelp"] @localizer["LearnMore"] -
+
+ }
-
-
-
- @localizer["LatitudeShort"] - - @localizer["LongitudeShort"] - - - @localizer["SetPinOnMap"] - -
-
-
-
-
-
- -
- @Html.DropDownListFor(x => x.Call.DestinationPoiId, Model.DestinationPois, new { @class = "form-control destination-poi-search", @style = "width:100%;" }) - @localizer["DestinationHelp"] + } + @if (!showGeolocation) + { + @* No pin entry: the stored point rides along, and an address or what3words lookup can still move it, + as an edited address would be geocoded anyway. *@ + + + } + @if (Model.IsFieldVisible(NewCallFieldKeys.DestinationPoi)) + { +
+ +
+ @Html.DropDownListFor(x => x.Call.DestinationPoiId, Model.DestinationPois, new { @class = "form-control destination-poi-search", @style = "width:100%;" }) + @localizer["DestinationHelp"] +
-
-
- -
-
-
- + } + @if (Model.IsFieldVisible(NewCallFieldKeys.IndoorLocation)) + { +
+ +
+
+
+ +
+ @* Carries the stored zone, so saving without touching the picker keeps it. *@ + +
- -
-
-
- -
- - - - - - - - - -
@commonLocalizer["Code"]@commonLocalizer["Name"]@commonLocalizer["Status"]
+ } + @if (Model.IsFieldVisible(NewCallFieldKeys.Protocols)) + { +
+ +
+ @if (Model.Call.Protocols != null && Model.Call.Protocols.Any()) + { + @* Already on the call; the table below adds any the type and priority trigger. *@ +

+ @foreach (var callProtocol in Model.Call.Protocols) + { + @(callProtocol.Data ?? callProtocol.DispatchProtocolId.ToString()) + } +

+ } + + + + + + + + + +
@commonLocalizer["Code"]@commonLocalizer["Name"]@commonLocalizer["Status"]
+
-
+ } + @if (Model.IsFieldVisible(NewCallFieldKeys.LinkedCall)) + {
- +
@@ -292,6 +371,7 @@
+ } @if (Model.RunCardsEnabled) {
@@ -302,8 +382,10 @@
} + @if (showDispatchList) + {
- +
- +
+ }
@@ -369,18 +452,21 @@
-
- -
-
- - + @if (Model.IsFieldVisible(NewCallFieldKeys.DispatchOn)) + { +
+ +
+
+ + +
+ @localizer["ScheduleDispatchHelp"]
- @localizer["ScheduleDispatchHelp"]
-
+ } @if (!string.IsNullOrEmpty(Model.UdfFormHtml)) {
diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Aging.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Aging.cshtml index 923f396a5..69ea009e3 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Aging.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Aging.cshtml @@ -50,7 +50,7 @@ @foreach (var row in bucket.Invoices.OrderBy(x => x.DueOn)) { - #@row.InvoiceNumber + @DocumentNumbering.Display(row.DisplayNumber ?? row.InvoiceNumber.ToString()) @(Model.ContactName(row.ContactId) ?? "—") @(row.DueOn?.ToString("yyyy-MM-dd") ?? "—") @row.Total.ToString("N2") @row.Currency diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/BillingProfile.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/BillingProfile.cshtml index f76e0aa8f..c4a9b4bcd 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/BillingProfile.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/BillingProfile.cshtml @@ -190,7 +190,7 @@ @foreach (var invoice in Model.Invoices.Take(25)) { - #@invoice.InvoiceNumber + @invoice.NumberLabel() @await Html.PartialAsync("_Status", invoice.Status) @invoice.Balance.ToString("N2") @invoice.Currency diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Edit.cshtml index 8fd69961a..615b3caf6 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Edit.cshtml @@ -4,9 +4,9 @@ @inject IStringLocalizer localizer @{ var invoice = Model.Invoice; - ViewBag.Title = "Resgrid | " + localizer["Invoice"] + " #" + invoice.InvoiceNumber; + ViewBag.Title = "Resgrid | " + localizer["Invoice"] + " " + invoice.NumberLabel(); ViewData["Tab"] = "Index"; - ViewData["Title"] = localizer["Invoice"].Value + " #" + invoice.InvoiceNumber; + ViewData["Title"] = localizer["Invoice"].Value + " " + invoice.NumberLabel(); ViewData["Subtitle"] = localizer["EditInvoiceIntro"].Value; var lines = (invoice.LineItems ?? new List()).OrderBy(x => x.SortOrder) .Select(x => new { invoiceLineItemId = x.InvoiceLineItemId, callId = x.CallId, rateCardItemId = x.RateCardItemId, description = x.Description, quantity = x.Quantity, unitRate = x.UnitRate, taxable = x.Taxable, timeSource = x.TimeSource }).ToList(); @@ -24,7 +24,7 @@
-
@localizer["Invoice"] #@invoice.InvoiceNumber — @(Model.ContactName(invoice.ContactId) ?? "—")
+
@localizer["Invoice"] @invoice.NumberLabel() — @(Model.ContactName(invoice.ContactId) ?? "—")
@await Html.PartialAsync("_Status", invoice.Status)
diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Index.cshtml index 51fa93e20..60a3cccf7 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/Index.cshtml @@ -101,7 +101,7 @@ @foreach (var invoice in Model.Invoices) { - #@invoice.InvoiceNumber + @invoice.NumberLabel() @(Model.ContactName(invoice.ContactId) ?? "—") @await Html.PartialAsync("_Status", invoice.Status) @(departmentTime.Date(invoice.IssuedOn) ?? "—") diff --git a/Web/Resgrid.Web/Areas/User/Views/Invoicing/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Invoicing/View.cshtml index d543fa540..ac832fc8e 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Invoicing/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Invoicing/View.cshtml @@ -10,9 +10,9 @@ @{ var departmentTime = DepartmentTime.From(ViewData); var invoice = Model.Invoice; - ViewBag.Title = "Resgrid | " + localizer["Invoice"] + " #" + invoice.InvoiceNumber; + ViewBag.Title = "Resgrid | " + localizer["Invoice"] + " " + invoice.NumberLabel(); ViewData["Tab"] = "Index"; - ViewData["Title"] = localizer["Invoice"].Value + " #" + invoice.InvoiceNumber; + ViewData["Title"] = localizer["Invoice"].Value + " " + invoice.NumberLabel(); var status = (InvoiceStatus)invoice.Status; var isDraft = status == InvoiceStatus.Draft; var isOpen = status == InvoiceStatus.Sent || status == InvoiceStatus.PartiallyPaid || status == InvoiceStatus.Overdue; @@ -73,7 +73,7 @@
-
@localizer["Invoice"] #@invoice.InvoiceNumber — @(Model.ContactName(invoice.ContactId) ?? "—")
+
@localizer["Invoice"] @invoice.NumberLabel() — @(Model.ContactName(invoice.ContactId) ?? "—")
@await Html.PartialAsync("_Status", invoice.Status)
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordDeployments/Report.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordDeployments/Report.cshtml index 342d6be87..725d5db29 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordDeployments/Report.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordDeployments/Report.cshtml @@ -42,7 +42,7 @@
@localizer["TimeReports"]
- @foreach (var r in Model.TimeReports) { } + @foreach (var r in Model.TimeReports) { }
@localizer["TimeReports"]@localizer["ReportDate"]@localizer["Status"]
@r.ReportNumber@r.ReportDate.ToString("d")@localizer["ReportStatus" + (DeploymentTimeReportStatuses)r.Status]
@r.NumberText()@r.ReportDate.ToString("d")@localizer["ReportStatus" + (DeploymentTimeReportStatuses)r.Status]
@localizer["Expenses"]
diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/Settings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/Settings.cshtml index 2b978121b..f54c60ea7 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/Settings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/Settings.cshtml @@ -83,6 +83,27 @@
+ @{ + var recordYearStart = new NumberingYearStart(Model.NumberingYearStartMonth, Model.NumberingYearStartDay, (Resgrid.Model.NumberingYearLabel)Model.NumberingYearLabel); + var recordYearMonths = System.Globalization.CultureInfo.CurrentUICulture.DateTimeFormat.MonthNames.Take(12) + .Select((name, i) => new SelectListItem(name, (i + 1).ToString(System.Globalization.CultureInfo.InvariantCulture), i + 1 == recordYearStart.Month)).ToList(); + } +
+ +
+
+ + + +
+
+
+
+
@localizer["NumberingYearStartHelp"]
+
@localizer["NumberPrefixesHeader"]

@localizer["NumberPrefixesIntro"]

@@ -111,7 +132,15 @@
-
@localizer["NextNumbersHeader", Model.NumberingYear]
+ @if (recordYearStart.IsCalendarYear) + { +
@localizer["NextNumbersHeader", Model.NumberingYear]
+ } + else + { + var periodStart = recordYearStart.StartOfYear(Model.NumberingYear); +
@localizer["NextNumbersHeaderPeriod", Model.NumberingYear, periodStart.ToString("d", System.Globalization.CultureInfo.CurrentUICulture), periodStart.AddYears(1).AddDays(-1).ToString("d", System.Globalization.CultureInfo.CurrentUICulture)]
+ }

@localizer["NextNumbersIntro"]

@@ -138,6 +167,56 @@
+ +
@localizer["OtherNumbersHeader"]
+

@localizer["OtherNumbersIntro"]

+
+ + + + + + + + + + + + @for (var i = 0; i < Model.OtherNumbers.Count; i++) + { + var row = Model.OtherNumbers[i]; + + + + + + + + } + +
@localizer["NumberPrefixType"]@localizer["NumberPattern"]@localizer["SequenceWidth"]@localizer["NextNumberCurrent"]@localizer["NextNumberRaiseTo"]
+ @Html.HiddenFor(m => m.OtherNumbers[i].Kind) + @Html.HiddenFor(m => m.OtherNumbers[i].ScopeKey) + @row.Label + + + @localizer["NumberPatternExample"] + + @if (row.Custom) + { + @row.NextNumber + } + else + { + @localizer["OtherNumberBuiltIn"] @row.Example + } + + @if (row.Custom) + { + + } +
+
@@ -321,18 +400,43 @@ (function () { var pattern = $('#NumberPattern'), width = $('#SequenceWidth'), preview = $('#number-pattern-preview'); var samplePrefix = $('.number-prefix-input[data-preview-prefix="true"]'); - var year = '@Model.NumberingYear'; + var startMonth = $('#NumberingYearStartMonth'), startDay = $('#NumberingYearStartDay'), yearLabel = $('#NumberingYearLabel'); + var today = { y: @Model.NumberingPreviewDate.Year, m: @Model.NumberingPreviewDate.Month, d: @Model.NumberingPreviewDate.Day }; + // Mirrors NumberingYearStart.YearOf: the year naming today's numbering year under the year start being edited. + function numberingYear() { + var m = parseInt(startMonth.val(), 10) || 1, d = parseInt(startDay.val(), 10) || 1; + var startYear = today.m > m || (today.m === m && today.d >= d) ? today.y : today.y - 1; + return (m === 1 && d === 1) || yearLabel.val() === '@((int)Resgrid.Model.NumberingYearLabel.StartYear)' ? startYear : startYear + 1; + } // A sample only: the server validates the pattern and renders the real numbers listed below it. function render() { var digits = Math.max(@RecordNumberFormat.MinWidth, Math.min(@RecordNumberFormat.MaxWidth, parseInt(width.val(), 10) || @RecordNumberFormat.DefaultWidth)); var prefix = ($.trim(samplePrefix.val() || '') || samplePrefix.attr('placeholder') || 'INC').toUpperCase(); + var year = String(numberingYear()); preview.text((pattern.val() || '') .replace(/\{PREFIX\}/gi, prefix).replace(/\{YYYY\}/gi, year).replace(/\{YY\}/gi, year.slice(-2)) .replace(/\{GROUP\}/gi, 'G12').replace(/\{SEQ\}/gi, ('00000000' + '153').slice(-digits))); + // Which year names a fiscal year only matters when it does not start on January 1. + yearLabel.toggle(!(startMonth.val() === '1' && (parseInt(startDay.val(), 10) || 1) === 1)); + // The other Records numbers, in their own pattern or the built-in one. + $('.other-number-row').each(function () { + var row = $(this), input = row.find('.other-number-pattern'); + var text = $.trim(input.val()) || String(row.data('legacy-pattern')); + var otherDigits = Math.max(@CallNumberFormat.MinWidth, Math.min(@CallNumberFormat.MaxWidth, ($.trim(input.val()) ? parseInt(row.find('.other-number-width').val(), 10) : parseInt(row.data('legacy-width'), 10)) || @CallNumberFormat.DefaultWidth)); + var otherYear = /\{(MM|DD)\}/i.test(text) ? String(today.y) : year; + row.find('.other-number-preview').text(text + .replace(/\{YYYY\}/gi, otherYear).replace(/\{YY\}/gi, otherYear.slice(-2)) + .replace(/\{MM\}/gi, ('0' + today.m).slice(-2)).replace(/\{DD\}/gi, ('0' + today.d).slice(-2)) + .replace(/\{SEQ\}/gi, ('00000000' + '153').slice(-otherDigits))); + }); } pattern.on('input', render); width.on('input', render); samplePrefix.on('input', render); + $('.other-number-pattern, .other-number-width').on('input', render); + startMonth.on('change', render); + startDay.on('input', render); + yearLabel.on('change', render); render(); $('#records-settings-form').on('submit', function (e) { diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml index f227673cc..3cc9d6a84 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_TopNavbar.cshtml @@ -43,6 +43,7 @@
  • @commonLocalizer["DepartmentProfile"]
  • @commonLocalizer["StationAndGroups"]
  • @commonLocalizer["CallImportSettings"]
  • +
  • @commonLocalizer["DocumentNumbering"]
  • @commonLocalizer["CustomStatuses"]
  • @commonLocalizer["TextMessaging"]
  • @commonLocalizer["Templates"]
  • diff --git a/Web/Resgrid.Web/Views/Pay/Cancel.cshtml b/Web/Resgrid.Web/Views/Pay/Cancel.cshtml index e6d343b1c..a5930a088 100644 --- a/Web/Resgrid.Web/Views/Pay/Cancel.cshtml +++ b/Web/Resgrid.Web/Views/Pay/Cancel.cshtml @@ -10,7 +10,7 @@

    @localizer["PayCancelTitle"]

    @if (!string.IsNullOrWhiteSpace(Model.DepartmentName)) { -

    @Model.DepartmentName@(Model.InvoiceNumber > 0 ? " — #" + Model.InvoiceNumber : "")

    +

    @Model.DepartmentName@(Model.InvoiceNumber > 0 ? " — " + Model.InvoiceLabel : "")

    }

    @localizer["PayCancelText"]

    @localizer["BackToInvoice"]

    diff --git a/Web/Resgrid.Web/Views/Pay/Index.cshtml b/Web/Resgrid.Web/Views/Pay/Index.cshtml index a9434debf..9396b3ba5 100644 --- a/Web/Resgrid.Web/Views/Pay/Index.cshtml +++ b/Web/Resgrid.Web/Views/Pay/Index.cshtml @@ -2,12 +2,12 @@ @inject IStringLocalizer localizer @{ Layout = "_RecoveryLayout"; - ViewData["Title"] = localizer["PayPageTitle"].Value + (Model.InvoiceNumber > 0 ? " #" + Model.InvoiceNumber : ""); + ViewData["Title"] = localizer["PayPageTitle"].Value + (Model.InvoiceNumber > 0 ? " " + Model.InvoiceLabel : ""); var token = (string)ViewData["PayToken"]; }
    -

    @localizer["PayPageTitle"]@(Model.InvoiceNumber > 0 ? " #" + Model.InvoiceNumber : "")

    +

    @localizer["PayPageTitle"]@(Model.InvoiceNumber > 0 ? " " + Model.InvoiceLabel : "")

    @if (!string.IsNullOrWhiteSpace(Model.DepartmentName)) {

    @Model.DepartmentName

    diff --git a/Web/Resgrid.Web/Views/Pay/Return.cshtml b/Web/Resgrid.Web/Views/Pay/Return.cshtml index c530471cf..9664e4160 100644 --- a/Web/Resgrid.Web/Views/Pay/Return.cshtml +++ b/Web/Resgrid.Web/Views/Pay/Return.cshtml @@ -10,7 +10,7 @@

    @localizer["PayReturnTitle"]

    @if (!string.IsNullOrWhiteSpace(Model.DepartmentName)) { -

    @Model.DepartmentName@(Model.InvoiceNumber > 0 ? " — #" + Model.InvoiceNumber : "")

    +

    @Model.DepartmentName@(Model.InvoiceNumber > 0 ? " — " + Model.InvoiceLabel : "")

    }

    @localizer["PayReturnText"]

    @localizer["BackToInvoice"]

    diff --git a/Web/Resgrid.Web/wwwroot/css/admin-assist-guidance.css b/Web/Resgrid.Web/wwwroot/css/admin-assist-guidance.css index 1e50eaaf3..004569d1c 100644 --- a/Web/Resgrid.Web/wwwroot/css/admin-assist-guidance.css +++ b/Web/Resgrid.Web/wwwroot/css/admin-assist-guidance.css @@ -1,3 +1,9 @@ .admin-assist-field-help { max-width: 70ch; white-space: normal; } .admin-assist-field-highlight { outline: 3px solid #176ca4; outline-offset: 4px; scroll-margin-block: 8rem; } @media (prefers-reduced-motion: reduce) { .admin-assist-field-highlight { scroll-behavior: auto; } } +/* Numbering year start: month, day and year naming on one line; the month's help runs full width beneath them. */ +.numbering-year-start { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.numbering-year-start > select, .numbering-year-start > input { width: auto; } +.numbering-year-start > .numbering-year-day { width: 5em; } +.numbering-year-start::after { content: ''; flex-basis: 100%; order: 9; } /* a line break before the help */ +.numbering-year-start > .admin-assist-field-help { order: 10; margin: 0; } diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.addArchivedCall.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.addArchivedCall.js index d650eb404..1423b8836 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.addArchivedCall.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.addArchivedCall.js @@ -33,14 +33,18 @@ theme: 'snow' }); - let quillNotes = new Quill('#note-container', { + // The department's new-call field policy can leave the note, the map and other fields off the + // form, so anything bound to one of them has to cope with it not being there. + let quillNotes = $('#note-container').length ? new Quill('#note-container', { placeholder: '', theme: 'snow' - }); + }) : null; $(document).on('submit', '#addArchivedCallForm', function () { $('#Call_NatureOfCall').val(quillNature.root.innerHTML); - $('#Call_Notes').val(quillNotes.root.innerHTML); + if (quillNotes) { + $('#Call_Notes').val(quillNotes.root.innerHTML); + } return true; }); @@ -86,28 +90,31 @@ checkForProtocols(); }); - const tiles1 = L.tileLayer( - osmTileUrl, - { - maxZoom: 19, - attribution: osmTileAttribution - } - ); + // No map when GPS coordinates are hidden by the field policy. + if (document.getElementById('callMap')) { + const tiles1 = L.tileLayer( + osmTileUrl, + { + maxZoom: 19, + attribution: osmTileAttribution + } + ); - map = L.map('callMap', { - scrollWheelZoom: false - }).setView([centerLat, centerLng], 11).addLayer(tiles1); + map = L.map('callMap', { + scrollWheelZoom: false + }).setView([centerLat, centerLng], 11).addLayer(tiles1); - map.on('click', function (e) { - resgrid.dispatch.addArchivedCall.setMarkerLocation(e.latlng.lat, e.latlng.lng); + map.on('click', function (e) { + resgrid.dispatch.addArchivedCall.setMarkerLocation(e.latlng.lat, e.latlng.lng); - $("#Latitude").val(e.latlng.lat.toString()); - $("#Longitude").val(e.latlng.lng.toString()); + $("#Latitude").val(e.latlng.lat.toString()); + $("#Longitude").val(e.latlng.lng.toString()); - map.panTo(e.latlng); + map.panTo(e.latlng); - resgrid.dispatch.addArchivedCall.geocodeCoordinates(e.latlng.lat, e.latlng.lng); - }); + resgrid.dispatch.addArchivedCall.geocodeCoordinates(e.latlng.lat, e.latlng.lng); + }); + } $("#selectLinkedCall").select2({ dropdownParent: $("#selectCallToLinkModal"), @@ -138,7 +145,9 @@ if (result && result.Data && result.Data.Latitude && result.Data.Longitude) { var lat = result.Data.Latitude; var lng = result.Data.Longitude; - map.setView(new L.LatLng(lat, lng), 16); + if (map) { + map.setView(new L.LatLng(lat, lng), 16); + } $("#Latitude").val(lat.toString()); $("#Longitude").val(lng.toString()); resgrid.dispatch.addArchivedCall.setMarkerLocation(lat, lng); @@ -172,7 +181,9 @@ type: 'GET' }).done(function (data) { if (data && data.Latitude && data.Longitude) { - map.setView(new L.LatLng(data.Latitude, data.Longitude), 16); + if (map) { + map.setView(new L.LatLng(data.Latitude, data.Longitude), 16); + } $("#Latitude").val(data.Latitude); $("#Longitude").val(data.Longitude); @@ -365,14 +376,16 @@ }); function centerMap() { - if (centerLat && centerLng) { + if (map && centerLat && centerLng) { map.panTo(new L.LatLng(centerLat, centerLng)); } } addArchivedCall.centerMap = centerMap; function foundLocation(position) { - map.panTo(new L.LatLng(position.coords.latitude, position.coords.longitude)); + if (map) { + map.panTo(new L.LatLng(position.coords.latitude, position.coords.longitude)); + } } addArchivedCall.foundLocation = foundLocation; @@ -380,13 +393,17 @@ // Browser geolocation was denied or unavailable. Fall back to the department's // configured map centre -- this used to pan to a hardcoded coordinate in Wollongong, // Australia, which every department outside NSW saw as "the map is in the wrong place". - if (centerLat && centerLng) { + if (map && centerLat && centerLng) { map.panTo(new L.LatLng(centerLat, centerLng)); } } addArchivedCall.noLocation = noLocation; function setMarkerLocation(lat, lng) { + if (!map) { + return; + } + if (callMarker) { callMarker.setLatLng(new L.LatLng(lat, lng)); } else { diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.editcall.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.editcall.js index f52e0fcd8..470b8cc5d 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.editcall.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.editcall.js @@ -35,13 +35,17 @@ var resgrid; theme: 'snow' }); - let quillNotes = new Quill('#note-container', { + // The department's new-call field policy can leave the note, the map and other fields off the + // form, so anything bound to one of them has to cope with it not being there. + let quillNotes = $('#note-container').length ? new Quill('#note-container', { placeholder: '', theme: 'snow' - }); + }) : null; $(document).on('submit', '#updateCallForm', function () { - $('#Call_Notes').val(quillNotes.root.innerHTML); + if (quillNotes) { + $('#Call_Notes').val(quillNotes.root.innerHTML); + } $('#Call_NatureOfCall').val(quillNature.root.innerHTML); return true; @@ -107,7 +111,8 @@ var resgrid; contentType: 'application/json; charset=utf-8', type: 'GET' }).done(function (result) { - if (result) { + // No map when GPS coordinates are hidden by the field policy. + if (result && document.getElementById('callMap')) { var data = result; const tiles1 = L.tileLayer( osmTileUrl, @@ -149,7 +154,9 @@ var resgrid; if (result && result.Data && result.Data.Latitude != null && result.Data.Longitude != null) { var lat = result.Data.Latitude; var lng = result.Data.Longitude; - map.setView(new L.LatLng(lat, lng), 16); + if (map) { + map.setView(new L.LatLng(lat, lng), 16); + } $("#Latitude").val(lat.toString()); $("#Longitude").val(lng.toString()); resgrid.dispatch.editcall.setMarkerLocation(lat.toString(), lng.toString()); @@ -171,7 +178,9 @@ var resgrid; type: 'GET' }).done(function (data) { if (data && data.Latitude && data.Longitude) { - map.setView(new L.LatLng(data.Latitude, data.Longitude), 16); + if (map) { + map.setView(new L.LatLng(data.Latitude, data.Longitude), 16); + } $("#Latitude").val(data.Latitude); $("#Longitude").val(data.Longitude); @@ -607,6 +616,11 @@ var resgrid; editcall.findLocation = findLocation; function setMarkerLocation(lat, lng) { + if (!map) { + scheduleRecommendation(); + return; + } + if (callMarker) { callMarker.setLatLng(new L.LatLng(lat, lng)); } else { @@ -692,8 +706,9 @@ var resgrid; } // .val() raises no change event, so re-run the type/priority - // handler for the template's values. + // handlers for the template's values. checkForProtocols(); + scheduleRecommendation(); } }); } diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js index c10e5427b..75e8025a3 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js @@ -69,13 +69,17 @@ var resgrid; newcall.scheduleNearestUnits(); }); - let noteQuillDescription = new Quill('#note-container', { + // The department's new-call field policy can leave the note, the map and other fields off the + // form, so anything bound to one of them has to cope with it not being there. + let noteQuillDescription = $('#note-container').length ? new Quill('#note-container', { placeholder: '', theme: 'snow' - }); + }) : null; $(document).on('submit', '#newCallForm', function () { - $('#Call_Notes').val(noteQuillDescription.root.innerHTML); + if (noteQuillDescription) { + $('#Call_Notes').val(noteQuillDescription.root.innerHTML); + } $('#Call_NatureOfCall').val(quillNote2.root.innerHTML); return true; @@ -107,31 +111,34 @@ var resgrid; } }); - const tiles1 = L.tileLayer( - osmTileUrl, - { - maxZoom: 19, - attribution: osmTileAttribution - } - ); + // No map when GPS coordinates are hidden by the field policy. + if (document.getElementById('callMap')) { + const tiles1 = L.tileLayer( + osmTileUrl, + { + maxZoom: 19, + attribution: osmTileAttribution + } + ); - map = L.map('callMap', { - scrollWheelZoom: false - }).setView([centerLat, centerLng], 11).addLayer(tiles1); + map = L.map('callMap', { + scrollWheelZoom: false + }).setView([centerLat, centerLng], 11).addLayer(tiles1); - map.on('click', function (e) { - resgrid.dispatch.newcall.setMarkerLocation(e.latlng.lat, e.latlng.lng); + map.on('click', function (e) { + resgrid.dispatch.newcall.setMarkerLocation(e.latlng.lat, e.latlng.lng); - $("#Latitude").val(e.latlng.lat.toString()); - $("#Longitude").val(e.latlng.lng.toString()); - //$("#What3Word").val(''); + $("#Latitude").val(e.latlng.lat.toString()); + $("#Longitude").val(e.latlng.lng.toString()); + //$("#What3Word").val(''); - map.panTo(e.latlng); + map.panTo(e.latlng); - resgrid.dispatch.newcall.geocodeCoordinates(e.latlng.lat, e.latlng.lng); - }); + resgrid.dispatch.newcall.geocodeCoordinates(e.latlng.lat, e.latlng.lng); + }); - navigator.geolocation.getCurrentPosition(foundLocation, noLocation, { timeout: 10000 }); + navigator.geolocation.getCurrentPosition(foundLocation, noLocation, { timeout: 10000 }); + } $("#searchButton").click(function (evt) { var where = jQuery.trim($("#Call_Address").val()); if (where.length < 1) @@ -146,7 +153,9 @@ var resgrid; if (result && result.Data && result.Data.Latitude != null && result.Data.Longitude != null) { var lat = result.Data.Latitude; var lng = result.Data.Longitude; - map.setView(new L.LatLng(lat, lng), 16); + if (map) { + map.setView(new L.LatLng(lat, lng), 16); + } $("#Latitude").val(lat.toString()); $("#Longitude").val(lng.toString()); resgrid.dispatch.newcall.setMarkerLocation(lat, lng); @@ -167,7 +176,9 @@ var resgrid; type: 'GET' }).done(function (data) { if (data && data.Latitude != null && data.Longitude != null) { - map.setView(new L.LatLng(data.Latitude, data.Longitude), 16); + if (map) { + map.setView(new L.LatLng(data.Latitude, data.Longitude), 16); + } $("#Latitude").val(data.Latitude); $("#Longitude").val(data.Longitude); @@ -418,20 +429,22 @@ var resgrid; centerMap(); }); function centerMap() { - if (centerLat && centerLng) { + if (map && centerLat && centerLng) { map.panTo(new L.LatLng(centerLat, centerLng)); } } newcall.centerMap = centerMap; function foundLocation(position) { - map.panTo(new L.LatLng(position.coords.latitude, position.coords.longitude)); + if (map) { + map.panTo(new L.LatLng(position.coords.latitude, position.coords.longitude)); + } } newcall.foundLocation = foundLocation; function noLocation() { // Browser geolocation was denied or unavailable. Fall back to the department's // configured map centre -- this used to pan to a hardcoded coordinate in Wollongong, // Australia, which every department outside NSW saw as "the map is in the wrong place". - if (centerLat && centerLng) { + if (map && centerLat && centerLng) { map.panTo(new L.LatLng(centerLat, centerLng)); } } @@ -445,6 +458,10 @@ var resgrid; scheduleNearestUnits(lat, lng); scheduleRecommendations(); + if (!map) { + return; + } + if (callMarker) { callMarker.setLatLng(new L.LatLng(lat, lng)); } else { diff --git a/docs/admin-assist/settings-reference.md b/docs/admin-assist/settings-reference.md index b8db1a434..ea3c1a117 100644 --- a/docs/admin-assist/settings-reference.md +++ b/docs/admin-assist/settings-reference.md @@ -1187,6 +1187,11 @@ Default: unset. Source: DepartmentSsoConfig.EncryptedScimBearerToken. Department Settings Department address, time zone and core settings. Sets the time zone and address everything else uses, and keeps administrative access limited and recoverable. An administrator sets the department time zone, hides modules the department will not use, requires two-factor sign-in for administrators and adds a second administrator. Confirm the address and time zone; dates, maps and schedules depend on them. + +## Document Numbering + +Document Numbering How work order, invoice, bid and daily time report numbers are written, and the day their numbering year starts for fiscal-year numbering. Sets the time zone and address everything else uses, and keeps administrative access limited and recoverable. An administrator sets the department time zone, hides modules the department will not use, requires two-factor sign-in for administrators and adds a second administrator. Know your department address and time zone, and choose at least two people who will administer Resgrid. + ## Module settings @@ -1389,7 +1394,7 @@ Default: disabled, no department-wide role exceptions. Source: DepartmentSetting ## Call Numbering Config -Call Numbering Config. How new call numbers are written: the call number pattern and sequence digits, edited on Call Settings, plus a raised next number for departments continuing numbers issued before Resgrid. Each call takes its number from its sequence once, so two calls logged together never share a number. Changing the pattern never renumbers calls that already have a number. +Call Numbering Config. How new call numbers are written: the call number pattern, sequence digits and numbering year start (for calls numbered by a fiscal year), edited on Call Settings, plus a raised next number for departments continuing numbers issued before Resgrid. Each call takes its number from its sequence once, so two calls logged together never share a number. Changing the pattern never renumbers calls that already have a number. Default: legacy pattern {YY}-{SEQ}, unpadded. Source: DepartmentSettingTypes.CallNumberingConfig. @@ -1407,6 +1412,27 @@ Call Numbering Config / Sequence Width. Digits the sequence is padded to with le Default: 1. Source: CallNumberingConfig.SequenceWidth. + +## Call Numbering Config / Year Start Month + +Call Numbering Config / Year Start Month. Month the numbering year starts (1 to 12), for a department numbering calls by a fiscal year. A yearly pattern starts again at 1 on that month and day, in the department's local time, and {YYYY} and {YY} write the fiscal year's name. Patterns with {MM} or {DD} always write and restart on the calendar date. Unset (0) is January, the calendar year. Saving a new year start does not renumber calls; the sequence for the new year carries on from any number with the same text already issued. + +Default: 0 (January). Source: CallNumberingConfig.YearStartMonth. + + +## Call Numbering Config / Year Start Day + +Call Numbering Config / Year Start Day. Day of the month the numbering year starts, from 1 to that month's last day; February 29 is refused so the year starts on the same date every year. Unset (0) is the 1st. + +Default: 0 (the 1st). Source: CallNumberingConfig.YearStartDay. + + +## Call Numbering Config / Year Label + +Call Numbering Config / Year Label. Which year {YYYY} and {YY} write for a numbering year that does not start on January 1: 0, the year it ends in (the usual fiscal-year convention, so a year starting November 1, 2026 is 2027), or 1, the year it starts in. Ignored for a January 1 start. + +Default: 0 (the year it ends in). Source: CallNumberingConfig.YearLabel. + ## Unit Type Call Status Override Setting / Overrides @@ -1720,7 +1746,7 @@ Text call intake and commands Create calls and accept member commands by text me ## Call Settings -Call Settings The mailbox and format used to import calls from CAD or paging email, and the call number format and next number. Consistent call types and statuses make dispatching, response tracking and reporting reliable from the first call. A department adds Structure Fire and Medical call types, sets Responding and On Scene statuses and connects its county CAD email feed. Collect your call types, the statuses members report while responding, and the details of any CAD or paging system that should create calls. +Call Settings The mailbox and format used to import calls from CAD or paging email, and the call number format, next number and numbering year start. Consistent call types and statuses make dispatching, response tracking and reporting reliable from the first call. A department adds Structure Fire and Medical call types, sets Responding and On Scene statuses and connects its county CAD email feed. Collect your call types, the statuses members report while responding, and the details of any CAD or paging system that should create calls. ## Units Sort Order @@ -2580,7 +2606,7 @@ Default: owning records default. Source: DepartmentSettingTypes.RecordsReviewDue ## Records Numbering Config -Records Numbering Config. Department numbering for the built-in record types and incident reports: the number pattern, sequence digits, record type prefixes and raised next numbers. Numbers are assigned when a record is finalized. Department definitions with their own numbering keep it. +Records Numbering Config. Department numbering for the built-in record types and incident reports: the number pattern, sequence digits, record type prefixes, numbering year start (for records numbered by a fiscal year) and raised next numbers. Numbers are assigned when a record is finalized. Department definitions with their own numbering keep it. Default: RecordsNumberingConfig constructor. Source: DepartmentSettingTypes.RecordsNumberingConfig. @@ -2664,7 +2690,7 @@ Default: OnFinalize. Source: RecordsNumberingConfig.NumberAssignment. ## Records Numbering Config / Reset Yearly -Records Numbering Config / Reset Yearly. Restart record sequences each calendar year in the department time zone. +Records Numbering Config / Reset Yearly. Restart record sequences each numbering year in the department time zone: the calendar year unless the department set a fiscal year start (YearStartMonth and YearStartDay). Default: true. Source: RecordsNumberingConfig.ResetYearly. @@ -2710,6 +2736,34 @@ Records Numbering Config / Prefixes. The department's own prefix for each built- Default: empty. Source: RecordsNumberingConfig.Prefixes. + +## Records Numbering Config / Document Patterns + +Records Numbering Config / Document Patterns. The department's own patterns for records request (PRR-), occupancy (OCC-), inspection (INSP-), permit (PRM-), investigation (INV-) and evidence (EV-) numbers, edited under Other record numbers on Records Settings. They follow this setting's numbering year start. A kind with no entry keeps its built-in {KIND}-{year}-{sequence:0000} numbers; a custom pattern's sequences are counted in DocumentNumberSequences and carry on after any number already issued in the same text. + +Default: empty. Source: RecordsNumberingConfig.DocumentPatterns. + + +## Records Numbering Config / Year Start Month + +Records Numbering Config / Year Start Month. Month the numbering year starts (1 to 12), for a department whose records follow a fiscal year, its own or a county's or state's it reports to. Sequences whose pattern includes {YYYY} or {YY}, and department definitions that reset yearly, start again at 1 on that month and day in the department's local time, and the year tokens write the fiscal year's name. A record's year is the local date it started (an incident report's, its call's). Unset (0) is January, the calendar year. Separate from call numbering's year start. Records request numbers (PRR-year-sequence) and prevention numbers (inspections, permits, occupancies, investigations and evidence, KIND-year-sequence) use the same numbering year. + +Default: 0 (January). Source: RecordsNumberingConfig.YearStartMonth. + + +## Records Numbering Config / Year Start Day + +Records Numbering Config / Year Start Day. Day of the month the numbering year starts, from 1 to that month's last day; February 29 is refused so the year starts on the same date every year. Unset (0) is the 1st. + +Default: 0 (the 1st). Source: RecordsNumberingConfig.YearStartDay. + + +## Records Numbering Config / Year Label + +Records Numbering Config / Year Label. Which year {YYYY} and {YY} write for a numbering year that does not start on January 1: 0, the year it ends in (the usual fiscal-year convention, so a year starting November 1, 2026 is 2027), or 1, the year it starts in. Ignored for a January 1 start. + +Default: 0 (the year it ends in). Source: RecordsNumberingConfig.YearLabel. + ## Records Numbering Floor / Next Sequence @@ -2895,7 +2949,7 @@ Records Dashboard Records that are due, awaiting review or ready to submit. Keep ## Records Settings -Records Settings Numbering, retention, search and visibility rules for records. Keep incident and activity records with the calls they came from, ready for review and reporting. After each call, the officer completes an incident report from the call and a supervisor reviews it. Know which reports your department completes and who reviews them. Records may need to be enabled for your department. +Records Settings Numbering (including records request and prevention numbers and the numbering year start), retention, search and visibility rules for records. Keep incident and activity records with the calls they came from, ready for review and reporting. After each call, the officer completes an incident report from the call and a supervisor reviews it. Know which reports your department completes and who reviews them. Records may need to be enabled for your department. ## Occupancies and preplans @@ -3278,3 +3332,59 @@ Advanced Data Protection The Advanced Data Protection add-on: extra encryption a ## Enhanced AI Enhanced AI AI assistance beyond the free Admin Assist allowance. Help explain approved evidence and prepare drafts for human review when released. Ask for an explanation of a configuration finding when the conversational feature is available. Nothing to set up yet; your free Admin Assist allowance works without it. + + +## Document Numbering Config + +Document Numbering Config. How work order, invoice, bid and daily time report numbers are written (Department -> Document Numbering): one pattern per document kind and the day their numbering year starts, plus a raised next number per custom sequence. A kind with no pattern keeps its built-in numbers. Changing it never renumbers documents that already have a number. + +Default: missing = the built-in numbers. Source: DepartmentSettingTypes.DocumentNumberingConfig. + + +## Document Numbering Config / Year Start Month + +Document Numbering Config / Year Start Month. Month the numbering year starts (1 to 12) for work orders, invoices, bids and daily time reports, in the department's local time. Work order numbers (their NumberYear) and patterns with {YYYY} or {YY} start again at 1 on that month and day and write the fiscal year's name; {MM} and {DD} always write the calendar date. Unset (0) is January, the calendar year. Call numbers (setting 115) and Records numbers (setting 72) have their own year start. + +Default: 0 (January). Source: DocumentNumberingConfig.YearStartMonth. + + +## Document Numbering Config / Year Start Day + +Document Numbering Config / Year Start Day. Day of the month the numbering year starts, from 1 to that month's last day; February 29 is refused so the year starts on the same date every year. Unset (0) is the 1st. + +Default: 0 (the 1st). Source: DocumentNumberingConfig.YearStartDay. + + +## Document Numbering Config / Year Label + +Document Numbering Config / Year Label. Which year {YYYY} and {YY} write for a numbering year that does not start on January 1: 0, the year it ends in (the usual fiscal-year convention, so a year starting November 1, 2026 is 2027), or 1, the year it starts in. Ignored for a January 1 start. + +Default: 0 (the year it ends in). Source: DocumentNumberingConfig.YearLabel. + + +## Document Numbering Config / Patterns + +Document Numbering Config / Patterns. The department's own pattern per document kind (work-order, invoice, bid, time-report), in the call number grammar. A kind with no entry, an entry that no longer validates or one equal to the built-in pattern and width keeps the built-in numbers: WO-{YYYY}-{SEQ} at 6 digits for work orders and the plain number for invoices, bids and time reports. The int numbers stay as the unique, ordered ids; the issued text is stored as DisplayNumber (M0268). A custom pattern's sequences are counted in DocumentNumberSequences and seeded from the numbers already issued in the same text, so changing the pattern never re-issues a number. The number as issued is what members see on the document, its PDF and file name, exports, search and invoice lines, and for daily time reports in the Responder, Unit, IC and Dispatch apps (v4 `DisplayNumber`; the apps fall back to the plain number on servers without it). + +Default: empty. Source: DocumentNumberingConfig.Patterns. + + +## Document Number Pattern / Kind + +Document Number Pattern / Kind. The DocumentNumberKinds key the entry numbers: work-order, invoice, bid, time-report (setting 117) or records-request, occupancy, inspection, permit, investigation, evidence (setting 72's DocumentPatterns). + +Default: unset. Source: DocumentNumberPattern.Kind. + + +## Document Number Pattern / Pattern + +Document Number Pattern / Pattern. Fixed text (letters, digits, - _ . /) around {YYYY} or {YY}, {MM}, {DD} and {SEQ}, {SEQ} exactly once, up to 40 characters. Every number it can write must fit the kind's column: 50 characters, 32 for occupancy, inspection, permit, investigation and evidence numbers. + +Default: unset. Source: DocumentNumberPattern.Pattern. + + +## Document Number Pattern / Sequence Width + +Document Number Pattern / Sequence Width. Digits the sequence is padded to with leading zeros, 1 to 8. The sequence is counted as a number, so changing the width never restarts it. + +Default: the kind's built-in width. Source: DocumentNumberPattern.SequenceWidth.