ToolSchedule StartEffective recalibration on customer-data import#
Date: 2026-06-08
Status: Approved — ready for implementation plan
Area: BenefitManager.CommandCenter customer-data import (.bmpkg)
Problem#
When a customer's data is imported into a target environment via .bmpkg, the
TblToolSchedule rows are inserted verbatim (see
StreamingImporter.ImportToolSchedulesAsync). The matching TblToolScheduleLog
rows are not imported (they are only deleted on cascade-replace).
The scheduler that decides whether a tool is "due" lives in the BenefitManager
repo, not here. For Service = BenefitManager rows — which is every row a
.bmpkg realistically carries — the governing engine is the older
Tasper.CommandCenter.Shared.Logic.Cache.ToolScheduleCache. Its GetLastRunMoment
logic is:
lastRunMoment = most recent scheduled slot <= now
if (lastRunMoment < StartEffective) -> null // not due
... otherwise it checks TblToolScheduleLog rows // which we did not import
// -> nothing suppresses it -> DUE NOW
Because the imported StartEffective is the original (long-past) creation date,
lastRunMoment < StartEffective is false, and with no ToolScheduleLog rows to
suppress it, the schedule fires immediately on import — triggering an
unwanted import/export run in the target environment.
Why the "obvious" simpler fix does not work#
Setting StartEffective = LastRun.Date + Time was considered and rejected: the
old engine never reads the LastRun column (it reads ToolScheduleLog rows),
and LastRun.Date is in the past, so the gate lastRunMoment < StartEffective
still evaluates false → still fires.
The only lever available at import time, for the old engine, is to push
StartEffective into the future. Then lastRunMoment < StartEffective is
true → GetLastRunMoment returns null → the engine falls through to
GetNextRunMoment, which schedules the next natural slot. Confirmed against
sample Key 4861 (Monthly / Day-14): StartEffective = now (Jun 8) →
next run computed as Jun 14 → not due. ✓
Goal#
During import, rewrite StartEffective of in-scope TblToolSchedule rows to the
next scheduled run-moment after the import time, so the target environment's
scheduler waits for the next natural slot instead of firing immediately.
The next-run computation is a faithful port of the BenefitManager scheduler's own
recurrence math, so import-time math and scheduler math agree under both the
old (ToolScheduleCache) and new (ToolScheduleExecutionJob) engines.
Scope#
Recalibrate a row when:
Active == true, andEndEffectiveis null orEndEffective > now, andScheduleparses to a known type (Daily/Weekly/Monthly/Yearly), andTimeparses to a validTimeSpan.
Any row failing a condition is inserted verbatim, exactly as today.
Explicitly out of scope#
- Inactive rows / reactivation timing. A row imported as
Active = falseand later flipped toActive = truewill still fire immediately on reactivation, because anyStartEffectivecomputed at import time is stale by then. Fully solving that requires recalibrating inCreateOrUpdateToolSchedulein the BenefitManager repo — a separate change, not part of this work.
Design#
New unit — ToolScheduleRecurrence (pure static class)#
Location: src/BenefitManager.CommandCenter/Services/ToolScheduleRecurrence.cs
- Faithful port of
Tasper.CommandCenter.Shared.Data.ToolScheduleData: DateTime GetNextScheduledRunMoment(ScheduleType schedule, int day, int? month, TimeSpan time, DateTime workDate)- private
GetLastDailyRunMoment,GetLastWeeklyRunMoment,GetLastMonthlyRunMoment,GetLastYearlyRunMoment, and theGetDayOfWeekNumbermapping (Sunday = 7). - The "next" computation mirrors the source exactly: it calls the matching
GetLast…RunMomentagainstworkDate + onePeriod - 1 tick(AddDays(1),AddDays(7),AddMonths(1),AddYears(1), each.AddTicks(-1)). - No behavioural changes vs. the source — the goal is byte-for-byte equivalent run-moments.
- Internal
enum ScheduleType { Daily, Weekly, Monthly, Yearly }, parsed case-insensitively from the row'sSchedulestring. Unknown → caller skips. - Public entry point:
DateTime? TryComputeStartEffective(JsonObject row, DateTime now) - Applies the scope gate above; returns
nullwhen the row must be left verbatim, otherwise the computed next run-moment. - Pure:
nowis a parameter; the method makes no clock calls, so unit tests are deterministic.
Wiring — StreamingImporter.ImportToolSchedulesAsync#
- Compute
nowonce as Amsterdam wall-clock time:TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, TimeZoneInfo.FindSystemTimeZoneById("Europe/Amsterdam")). This matchesIDateLogic.Now(the scheduler treats all stored datetimes as Amsterdam wall-clock, not UTC). UsingUtcNowdirectly would shift every computed slot by 1–2 hours. - Per row, after
RemapToolScheduleRow, callToolScheduleRecurrence.TryComputeStartEffective(row, now)on the original row (the recalibrated fields —Schedule,Day,Month,Time,EndEffective,Active— are not FK-remapped, so reading the original row is correct). - If it returns a value, override the
StartEffectiveDapper parameter: afterCustomerDataRowMapper.BuildParams(columns), callp.Add("StartEffective", next, DbType.DateTime2).DynamicParameters.Addreplaces the existing entry; the INSERT column list still references@StartEffective. This stores a clean nakeddatetime2wall-clock value and sidesteps the JSON-string →DateTimeOffsetparse ambiguity inCustomerDataRowMapper.ParseStringValue.
Clock note#
Europe/Amsterdam resolves on .NET 10 on both Windows (ICU) and Linux. The
orchestrator currently runs on Windows/IIS.
Testing (TDD)#
Unit tests in tests/BenefitManager.CommandCenter.Tests against
ToolScheduleRecurrence with a fixed now:
- Port-equivalence / sample rows:
- Key 4861 — Monthly, Day 14, Time
23:55,now = 2026-06-08→ next =2026-06-14T23:55. - Key 4765 — Daily, Time
03:15,nowmid-afternoon → next = next day03:15. - Per type: Daily, Weekly (incl. Sunday mapping), Monthly across a
month-boundary, Yearly (with and without
Month). - Scope gates: unknown
Schedule→ null;Active = false→ null;EndEffectivein past → null;EndEffectivein future → recalibrated; unparseableTime→ null. - Boundary:
nowbefore vs. after today's slot time yields the correct same-day vs. next-day result.
Files#
- New:
src/BenefitManager.CommandCenter/Services/ToolScheduleRecurrence.cs - New: tests in
tests/BenefitManager.CommandCenter.Tests/(e.g.ToolScheduleRecurrenceTests.cs) - Edit:
src/BenefitManager.CommandCenter/Services/CustomerDataImport/StreamingImporter.cs(ImportToolSchedulesAsynconly)