Skip to content

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, and
  • EndEffective is null or EndEffective > now, and
  • Schedule parses to a known type (Daily / Weekly / Monthly / Yearly), and
  • Time parses to a valid TimeSpan.

Any row failing a condition is inserted verbatim, exactly as today.

Explicitly out of scope#

  • Inactive rows / reactivation timing. A row imported as Active = false and later flipped to Active = true will still fire immediately on reactivation, because any StartEffective computed at import time is stale by then. Fully solving that requires recalibrating in CreateOrUpdateToolSchedule in 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 the GetDayOfWeekNumber mapping (Sunday = 7).
  • The "next" computation mirrors the source exactly: it calls the matching GetLast…RunMoment against workDate + 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's Schedule string. Unknown → caller skips.
  • Public entry point: DateTime? TryComputeStartEffective(JsonObject row, DateTime now)
  • Applies the scope gate above; returns null when the row must be left verbatim, otherwise the computed next run-moment.
  • Pure: now is a parameter; the method makes no clock calls, so unit tests are deterministic.

Wiring — StreamingImporter.ImportToolSchedulesAsync#

  • Compute now once as Amsterdam wall-clock time: TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, TimeZoneInfo.FindSystemTimeZoneById("Europe/Amsterdam")). This matches IDateLogic.Now (the scheduler treats all stored datetimes as Amsterdam wall-clock, not UTC). Using UtcNow directly would shift every computed slot by 1–2 hours.
  • Per row, after RemapToolScheduleRow, call ToolScheduleRecurrence.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 StartEffective Dapper parameter: after CustomerDataRowMapper.BuildParams(columns), call p.Add("StartEffective", next, DbType.DateTime2). DynamicParameters.Add replaces the existing entry; the INSERT column list still references @StartEffective. This stores a clean naked datetime2 wall-clock value and sidesteps the JSON-string → DateTimeOffset parse ambiguity in CustomerDataRowMapper.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, now mid-afternoon → next = next day 03: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; EndEffective in past → null; EndEffective in future → recalibrated; unparseable Time → null.
  • Boundary: now before 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 (ImportToolSchedulesAsync only)