fix(opgaver-grpc): update PlanningCaseSite + PlanningCase on completion#796
Merged
renemadsen merged 22 commits intostablefrom May 9, 2026
Merged
fix(opgaver-grpc): update PlanningCaseSite + PlanningCase on completion#796renemadsen merged 22 commits intostablefrom
renemadsen merged 22 commits intostablefrom
Conversation
OpgaverGrpcService.CompleteOpgave previously only updated the SDK Case row (Status=100, DoneAt, DoneAtUserModifiable) and soft-deleted the Compliance row. The companion PlanningCaseSite and PlanningCase rows (in the items_planning plugin DB) were never touched, leaving them at Status=66/77 with MicrotingSdkCaseDoneAt=NULL. The admin "filled cases" view queries PlanningCases WHERE Status=100 AND MicrotingSdkCaseDoneAt >= fromDate, so completed opgaver from device never appeared in that view. Now mirrors the post-update sequence used by BackendConfigurationCompliancesService.Update: locate the PlanningCaseSite by CreatedAt.Date==compliance.StartDate.Date and PlanningId==compliance.PlanningId, set Status=100 + MicrotingSdkCaseDoneAt=foundCase.DoneAt + DoneByUserId/Name, persist via .Update(); then locate the parent PlanningCase, set Status=100 + MicrotingSdkCaseDoneAt=foundCase.DoneAt + WorkflowState=Processed, persist. Also injects ItemsPlanningPnDbContext into the primary constructor and adds the Microting.ItemsPlanningBase.Infrastructure.Data using. No hard deletes anywhere in this change. All entity removals (the existing Compliance soft-delete) continue to use entity.Delete(dbContext). Closes the gap discovered via direct DB query: device-completed cases have correct Cases.Status=100 + DoneAtUserModifiable but stale PlanningCaseSites/PlanningCases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a backend consistency gap in the Backend Configuration plugin’s mobile “Opgaver” gRPC completion flow so that completing an opgave on a Flutter device updates the corresponding Items Planning entities, allowing the admin “filled cases” view to include the completed case.
Changes:
- Injects
ItemsPlanningPnDbContextintoOpgaverGrpcService. - After completing the SDK
Case, updatesPlanningCaseSiteand its parentPlanningCasetoStatus=100and setsMicrotingSdkCaseDoneAt/ done-by metadata to match the completed SDK case. - Updates service documentation to describe the newly-closed parity gap (and what is still deferred).
Comment on lines
+1000
to
+1015
| .SingleAsync(x => x.Id == planningCaseSite.PlanningCaseId) | ||
| .ConfigureAwait(false); | ||
|
|
||
| if (planningCase.Status != 100) | ||
| { | ||
| planningCase.Status = 100; | ||
| planningCase.MicrotingSdkCaseDoneAt = foundCase.DoneAt; | ||
| planningCase.MicrotingSdkCaseId = foundCase.Id; | ||
| planningCase.DoneByUserId = (int)sdkSiteId; | ||
| planningCase.DoneByUserName = siteName; | ||
| planningCase.WorkflowState = Constants.WorkflowStates.Processed; | ||
| await planningCase.Update(itemsPlanningPnDbContext).ConfigureAwait(false); | ||
| } | ||
|
|
||
| planningCaseSite.PlanningCaseId = planningCase.Id; | ||
| await planningCaseSite.Update(itemsPlanningPnDbContext).ConfigureAwait(false); |
Comment on lines
+974
to
+979
| // Mirror the post-update sequence from | ||
| // BackendConfigurationCompliancesService.Update (lines 307-335): | ||
| // set Status=100 on PlanningCaseSite + parent PlanningCase so the | ||
| // admin "filled cases" view (queries PlanningCases WHERE Status=100 | ||
| // AND MicrotingSdkCaseDoneAt >= fromDate) picks up device completions. | ||
| var siteName = (await sdkDbContext.Sites |
Comment on lines
+979
to
+982
| var siteName = (await sdkDbContext.Sites | ||
| .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed) | ||
| .FirstOrDefaultAsync(x => x.Id == sdkSiteId) | ||
| .ConfigureAwait(false))?.Name ?? string.Empty; |
Comment on lines
+1013
to
+1015
|
|
||
| planningCaseSite.PlanningCaseId = planningCase.Id; | ||
| await planningCaseSite.Update(itemsPlanningPnDbContext).ConfigureAwait(false); |
The previous fix updated PlanningCaseSite/PlanningCase fields, but the lookup predicate (CreatedAt.Date == compliance.StartDate.Date) silently missed for recurring/weekly/monthly tasks where PlanningCaseSite.CreatedAt predates the compliance.StartDate by days/weeks. When no row matched, the updates were silently skipped, PlanningCase.MicrotingSdkCaseDoneAt stayed NULL, and the admin reportsv2 query (PlanningCases WHERE Status=100 AND MicrotingSdkCaseDoneAt BETWEEN @from AND @to) returned no results. Switched to lookup by MicrotingSdkCaseId == foundCase.Id — the immutable 1:1 link populated when the SDK case is created. Plus a WorkflowState filter for safety (soft-deleted rows excluded). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The .Where(x => x.WorkflowState != WorkflowStates.Removed) filter excluded rows with NULL WorkflowState because in SQL `NULL != 'removed'` evaluates to NULL (false). 85 of 2681 AreaRulePlannings have NULL WorkflowState (legacy rows pre-dating the workflow-state convention). Tapping any opgave whose ARP had NULL WorkflowState resulted in arp == null → NotFound error → no DB writes → "the opgave reappears" symptom. Audited the entire file and applied the canonical pattern used across the codebase: WorkflowState != 'removed' OR WorkflowState IS NULL. This now matches the SQL the rest of the project produces (e.g. via the items-planning generated queries). No hard deletes anywhere. Read filters only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, fieldId) Core.CaseUpdate's wire format is "[fieldValueId]|[value]" where fieldValueId is the FieldValues table ROW PK, not the eForm template Field.Id. The handler was passing request.FieldId (the eForm field id, e.g. 5308). The SDK's _sqlController.FieldValueUpdate then did `db.FieldValues.FirstAsync(x => x.Id == 5308)` — looking up by FieldValue PK, finding either no row or a totally unrelated row. Field values silently never persisted: every targeted FieldValue stayed Value=NULL despite 50-120ms successful SetFieldValue POSTs. Fix: look up the FieldValue row PK for (caseId, eFormFieldId) before building the pipe-pair. If no FieldValue row exists, return NotFound. FieldValue entity has no WorkflowState column so no soft-delete filter is applied. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SetFieldValue/CompleteOpgave/SetComment looked up the compliance row by PlanningId+Deadline only — picking the oldest compliance across ALL sites for the planning. Multi-site plannings (e.g. 3632 with all compliances on SiteId=142) routed device taps from site 130 to write field values against site 142's case 5952 (soft-deleted since 2023). User saw nothing in reportsv2 because no row for their site was touched. Fix: load the current worker's accessible case IDs from the SDK first, then filter compliances to only those whose MicrotingSdkCaseId belongs to that set. Also restore the WorkflowState != Removed OR null filter on SetFieldValue's compliance lookup — the "accept regardless" comment was a design assumption that doesn't hold. The practical edit-then-complete flow has the compliance still active when SetFieldValue lands. Same pattern applied to all 5 handlers (CompleteOpgave, SetFieldValue, SetComment, UploadPhoto, RemovePhoto). No hard deletes. Read-only filter changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the fuzzy OrderBy(Deadline).FirstOrDefaultAsync compliance lookup in CompleteOpgave / SetComment / UploadPhoto / RemovePhoto / SetFieldValue with a deterministic PK lookup against request.ComplianceId, validated against the ARP's ItemPlanningId. The Index emit paths (ListOpgaver, StreamOpgaveChanges' poll loader) now surface Compliance.Id + MicrotingSdkCaseId on every Opgave so the mobile client can persist and round-trip them on every write. Legacy fallback is intentionally preserved on every site: when request.ComplianceId == 0 (older Flutter build with the field unset) the previous site-filtered ordered query still runs, so in-flight outbox payloads queued before this contract landed continue to drain. Proto: adds compliance_id + microting_sdk_case_id (int64) to Opgave, CompleteOpgaveRequest, SetCommentRequest, UploadPhotoMeta, RemovePhotoRequest, SetFieldValueRequest. Field numbers are unique within each message; existing numbers are unchanged so the wire contract is forward + backward compatible. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compliance rows whose Case is removed (missed deadline) or completed (Status=100) should not surface on the worker's mobile calendar. Updates GetTasksForWeek to skip emit when the Compliance is removed, the Case is removed, or the Case is already completed. Dedup gate refined to only skip recurrence rendering for plannings with an *actionable* compliance in the week. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit applied the actionable-only filter to all callers of GetTasksForWeek including the web admin's calendar and CalendarGrpcService, which would have hidden missed/completed rotations from the admin UI. Adds an opt-in ActionableOnly flag on CalendarTaskRequestModel; only the 5 mobile worker call sites in OpgaverGrpcService set it. Default behavior for all other callers is bit-identical to pre-c2637800. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous Unimplemented throw triggered an infinite retry loop in the flutter outbox drainer when re-tapping an already-completed row. The handler now returns the current authoritative Opgave state with no DB writes, letting flutter merge the response and converge to server truth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The SDK's Field.FieldValue (singular) is the template DefaultValue and never reassigned from the per-case FieldValues[]. Mapping that to the wire meant every stream poll overwrote the user's typed value with the template default, producing the 'type -> reset to empty' loop on the mobile worker. Plugin's MapToFormField now reads from f.FieldValues[0].Value when populated, falling back to f.FieldValue (template default) only when no per-case row exists or its value is empty. Also: SetFieldValue handler now checks the bool return of Core.CaseUpdate and throws FailedPrecondition when false, instead of swallowing silent write failures and letting the client believe the save succeeded. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The mobile worker's full opgaver list (røde opgaver included) needs the same scope as /plugins/backend-configuration-pn/task-tracker: property- scoped, no deadline window, missed and completed rotations included with per-row status. The existing GetTasksForWeek + ActionableOnly path stays untouched; this adds a sibling RPC + service method. Adds task_is_expired bool to Opgave (deadline passed and case retracted), leaves completed (Case.Status=100) as-is. ListTaskTracker request takes property_id; response is the same Opgave list shape as the calendar. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The mobile CompleteOpgave handler updated PlanningCase + PlanningCaseSite + soft-deleted Compliance, but didn't touch the SDK Case row. Completing a missed-deadline rotation (Case.WorkflowState='removed' Status=77) left the case retracted, breaking parity with the angular admin's compliance/ case Save flow which revives the case via WorkflowState='Created' + Status=100 + DoneAt=now. Now sets Case.DoneAtUserModifiable + DoneAt + SiteId + Status=100 + WorkflowState='Created' before the PlanningCase/PlanningCaseSite updates, matching BackendConfigurationCompliancesService.Update lines 234-260. Also invokes CaseUpdateDelegate to broadcast the closure event. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Parity harness s2/s3/s5 caught: angular ends with Case.WorkflowState= 'removed' while mobile left it 'created'. The angular Update flow soft-deletes the SDK Case after the inline closure (location: BackendConfigurationCompliancesService.cs:373-389 → core.CaseDelete → SqlController.CaseDelete:1069 → aCase.Delete(db)). CompleteOpgave now mirrors that step via core.CaseDelete on foundCase.MicrotingUid (with the same checkListSite fallback the angular flow uses), which writes the WorkflowState='Removed' transition + CaseVersion snapshot. Same try/catch shape as the canonical path so a transient XML-server rejection logs but doesn't fail the whole RPC. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Parity harness caught two related encoding mismatches: 1. CheckBox: angular admin's CaseUpdateHelper normalizes incoming values to "checked"/"unchecked"; the mobile gRPC handler skipped that step. 2. SingleSelect/MultiSelect: angular stores FieldOption.Key (e.g. "1"), but flutter clients render the localized FieldOptionTranslation.Text (e.g. "Ja"); without server-side translation the keys never match the SDK's option lookup at SqlController.cs:3787 and the option fails to resolve through CaseUpdateFieldValues. OpgaverGrpcService.SetFieldValue now reads the SDK Field's FieldType and canonicalizes the incoming value before constructing the SDK pipe-pair: CheckBox case-insensitively maps "1"/"true"/"checked"/"yes"/"ja" → "checked" and the inverses → "unchecked"; SingleSelect/MultiSelect resolve labels back to FieldOption.Key via FieldOptionTranslations (language-preferred, with any-language fallback). Other field types pass through unchanged so existing callers are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Parity harness s3 caught: angular's Save flow writes "" to every NULL FieldValue (and emits a FieldValueVersion per write), because its CaseEditRequest carries the full ReplyElement tree including unchanged fields. Mobile's CompleteOpgave only touched fields the user had previously SetFieldValue'd, leaving NULLs as NULL. Now reads all NULL FieldValues for the case being completed and writes "" to them in a single Core.CaseUpdate batch, mirroring angular's batch-save semantics. Non-NULL values are not touched (no clobber of existing data; no duplicate version rows for already-empty values). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eteOpgave" This reverts commit ddd909d.
The angular GET /api/backend-configuration-pn/compliances/cases?id&templateId returns a ReplyElement built by SqlController.CheckRead, which materialises every Field's FieldValue and runs them through ReadFieldValue. For Number and NumberStepper fields, ReadFieldValue rewrites NULL → "" before JSON serialisation (eform-sdk SqlController.cs lines 2217-2231) — Date does the same (lines 2233-2247) but the round-trip parser rejects "" so it never emits a write pair. Other types keep NULL on the wire. Angular's PUT then runs CaseUpdateHelper.GetFieldValuesByRequestField (eFormApi.BasePn CaseUpdateHelper.cs lines 95-103). It emits a "[fieldValueId]|" pair for every Number whose Value is non-null on the wire — which after the GET-case rewrite includes every NULL Number FieldValue. Core.CaseUpdate (Core.cs lines 1649-1654) then writes "" to those FieldValues and PnBase.Update emits a Version row. Mobile's CompleteOpgave was skipping FieldValues entirely on empty-complete, so the parity-harness s3 scenario consistently showed: - 420_SDK.FieldValueVersions: row only in ANGULAR - 420_SDK.FieldValues pk=N: Value angular="" mobile=null The previous commit (reverted in 45b0dc4) tried to fix this by writing "" to ALL NULL FieldValues for the case, which over-fired and clobbered non-Number FVs that angular deliberately leaves untouched (regressing s2/s5 in the harness). This change mirrors the EXACT canonical filter: - Field is in the case's CheckList tree (Field.WorkflowState != Removed, Field.CheckListId IN (case CheckList ∪ subtree CheckLists)) - FieldValue.CaseId == foundCase.Id - FieldValue.Value IS NULL - FieldValue.WorkflowState != Removed - Field's FieldType is Number or NumberStepper Verified by parity-harness s2 / s3 / s5: all three scenarios produce byte-identical DB deltas across angular and mobile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ploadPhoto
Parity harness s_photo_upload_delete caught: mobile's UploadPhoto wrote
only to UploadedDatas + Cases.Custom JSON, while angular's AddNewImage
wrote to UploadedDatas + FieldValues. Net effect: a photo uploaded via
mobile was invisible to the angular admin (and vice versa) because the
read paths look at different storage.
Now mirrors angular's FieldValues write while keeping Cases.Custom for
the existing mobile read path (backward compat). Extension format
normalized to no-leading-dot ("png" not ".png") matching angular's
FileName.Split(".").Last() shape. FileName now follows angular's
two-phase Create-then-Update rename, and FileLocation is populated with
the same intermediate-path shape (Path.GetTempPath()/cases-temp-files
ticks) that angular writes — column metadata only; mobile remains
S3-only for the actual bytes.
Discovers the Picture-typed FieldId by walking the case's CheckList
descendant tree (BFS), matching the harness picker and the angular UI
which passes the fieldId from the rendered template.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lback can find retracted cases Two related defects surfaced during a live device test of compliance 9810 / case 17701 (a retracted missed-deadline rotation that completes correctly in the angular admin but failed from the mobile app): 1. ListOpgaver's ActionableOnly mode strips retracted compliances from compliancesInWeek but the recurrence loop re-emits the same logical row with ComplianceId=null. Device cached compliance_id=0 in Drift, so subsequent writes went out without IDs and hit the fallback. Fix: side-dict from the stripped compliances, populate ComplianceId + SdkCaseId on the recurrence-emit model. 2. The fallback fuzzy lookup in 4 write handlers excluded retracted cases (WorkflowState != Removed on validCaseIdsForSite), so any payload with compliance_id=0 could never resolve a missed-deadline compliance — even though the success path can revive the case. Fix: drop the Cases.WorkflowState filter; let the fallback find retracted cases. The PK branch and success path already handle revival correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CompleteOpgaveRequest now carries `repeated FieldValueWrite field_values` and `string comment`. Server applies the bundle AFTER case revival (WorkflowState='created') and BEFORE the closure cascade (PlanningCase/PlanningCaseSite Status=100 + core.CaseDelete soft-delete) — same lifecycle window the angular admin path uses (BackendConfigurationCompliancesService.Update lines 223-260). Bundle apply reuses the SetFieldValue handler's helpers verbatim: (caseId, fieldId) → FieldValues.Id lookup, CanonicalizeFieldValueAsync for CheckBox/Select normalization, single batched core.CaseUpdate + core.CaseUpdateFieldValues. Skips field_id <= 0 and missing FieldValue rows so legacy-cached fields don't fail the whole bundle. Comment apply mirrors SetComment's envelope-write shape — non-empty replaces OpgaverComment body verbatim; empty string is treated as "no change" so legacy clients pass through unchanged. Per-RPC SetFieldValue and SetComment handlers remain in place for legacy outbox rows still draining from older app builds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mobile's atomic-save bundle silently dropped writes when FieldValues rows hadn't been materialized for the case (case never read via GET /compliances/cases on the admin browser side). Angular's PUT runs after a GET that materializes via Core.CaseRead; mobile's ListTaskTracker doesn't trigger that path. CompleteOpgave now calls core.CaseRead before the per-field lookup, matching angular's lazy-materialization mechanism. Field values typed on the device now reach the SDK and surface in reports. Hotfix on top of the atomic-save commit (3a4c4db). Full convergence to angular's BackendConfigurationCompliancesService.Update tracked as a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mobile's CompleteOpgave was stamping DoneAt with the server's wall clock (DateTime.UtcNow / request.ClientTsUnix), which meant a worker completing a missed-deadline rotation produced reports dated today rather than the rotation's actual scheduled deadline. Now derives doneAtUtc = compliance.Deadline (with a != default fallback to DateTime.UtcNow for legacy / partially-populated rows, mirroring the existing pattern at lines 1681 / 1938 / 2734) once at the top of the closure and propagates to Case.DoneAt / DoneAtUserModifiable, PlanningCase.MicrotingSdkCaseDoneAt, PlanningCaseSite.MicrotingSdkCaseDoneAt, and the dayKey used for the post-completion calendar refresh. The bundled comment write keeps wall-clock semantics via a separate commentAtUtc local — the comment TsUnix tracks when the worker actually authored the comment on the device, which is genuinely distinct from when the rotation was scheduled (Deadline). Core.CaseUpdate / CaseUpdateFieldValues do NOT accept a doneAt parameter (SqlController.FieldValueUpdate writes Value only, no DoneAt stamp), so no changes are needed for FieldValue rows — they don't carry a DoneAt in this code path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
After completing an opgave from the Flutter device, the case appeared
correctly in the SDK Cases table (Status=100, DoneAtUserModifiable set)
but never showed up in the admin "filled cases" view.
Root cause: PlanningCaseSites and PlanningCases remained at Status=66/77
with MicrotingSdkCaseDoneAt=NULL. The admin view's query
(Status=100 AND DoneAt >= fromDate) excluded them.
Fix
After the existing Case row update in OpgaverGrpcService.CompleteOpgave,
mirror the post-update sequence from BackendConfigurationCompliancesService.Update (lines 307-335):
Also injects ItemsPlanningPnDbContext into the primary constructor.
Still deferred (separate sub-project)
Constraint
No hard deletes anywhere. The existing Compliance soft-delete continues
to use entity.Delete(dbContext).
Test plan
appears in the admin filled-cases view immediately
🤖 Generated with Claude Code