diff --git a/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn.Test/ShiftedLookupIdsTests.cs b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn.Test/ShiftedLookupIdsTests.cs
new file mode 100644
index 00000000..e4acf573
--- /dev/null
+++ b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn.Test/ShiftedLookupIdsTests.cs
@@ -0,0 +1,415 @@
+/*
+The MIT License (MIT)
+
+Copyright (c) 2007 - 2026 Microting A/S
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+namespace ItemsPlanning.Pn.Test;
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using eFormCore;
+using ItemsPlanning.Pn.Infrastructure.Models.Import;
+using ItemsPlanning.Pn.Infrastructure.Models.Report;
+using ItemsPlanning.Pn.Services.ExcelService;
+using ItemsPlanning.Pn.Services.ItemsPlanningLocalizationService;
+using ItemsPlanning.Pn.Services.ItemsPlanningReportService;
+using ItemsPlanning.Pn.Services.PlanningImportService;
+using ItemsPlanning.Pn.Services.WordService;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Settings = Microting.eForm.Dto.Settings;
+using Microting.eForm.Infrastructure;
+using Microting.eForm.Infrastructure.Constants;
+using Microting.eForm.Infrastructure.Data.Entities;
+using Microting.eForm.Infrastructure.Helpers;
+using UploadedData = Microting.eForm.Infrastructure.Data.Entities.UploadedData;
+using Microting.eFormApi.BasePn.Abstractions;
+using Microting.eFormApi.BasePn.Infrastructure.Models.API;
+using Microting.eFormApi.BasePn.Infrastructure.Models.Application.CasePosts;
+using Microting.ItemsPlanningBase.Infrastructure.Data;
+using Microting.ItemsPlanningBase.Infrastructure.Data.Entities;
+using Microting.ItemsPlanningBase.Infrastructure.Enums;
+using NSubstitute;
+using NUnit.Framework;
+using Testcontainers.MariaDb;
+
+///
+/// The SDK seeds its FieldTypes and Languages lookup tables through AUTO_INCREMENT, so
+/// on a MariaDB Galera cluster (auto_increment_increment=3, offset=3) a fresh install
+/// gets Text=3, Picture=15, Dansk=3, English=6 ... instead of the contiguous 1, 2, 3 of
+/// older installs (eform-angular-frontend#8141). The server here is started with exactly
+/// those settings and the SDK provisions itself, so any code that hard-codes a lookup id
+/// misses the rows it is looking for.
+///
+[TestFixture]
+public class ShiftedLookupIdsTests
+{
+ private const string PluginDatabase = "items-planning-shifted-ids";
+
+ private readonly MariaDbContainer _mariaDbContainer = new MariaDbBuilder("mariadb:11.2")
+ .WithDatabase(PluginDatabase)
+ .WithUsername("root")
+ .WithPassword("secretpassword")
+ .WithCommand("--auto-increment-increment=3", "--auto-increment-offset=3")
+ .Build();
+
+ private Core _core;
+ private ItemsPlanningPnDbContext _dbContext;
+ private int _danishLanguageId;
+
+ [OneTimeSetUp]
+ public async Task OneTimeSetUp()
+ {
+ await _mariaDbContainer.StartAsync();
+
+ var pluginConnection = _mariaDbContainer.GetConnectionString();
+ var sdkConnection = pluginConnection.Replace(PluginDatabase, "420_SDK");
+
+ // The SqlController constructor migrates the SDK database and seeds FieldTypes,
+ // Languages and default settings, exactly like a fresh production install.
+ var sqlController = new SqlController(new DbContextHelper(sdkConnection));
+ await sqlController.SettingUpdate(Settings.token, "abc1234567890abc1234567890abcdef");
+ await sqlController.SettingUpdate(Settings.firstRunDone, "true");
+ await sqlController.SettingUpdate(Settings.knownSitesDone, "true");
+ await sqlController.SettingUpdate(Settings.comAddressNewApi, "none");
+
+ _core = new Core();
+ await _core.StartSqlOnly(sdkConnection);
+ await using (var sdkDbContext = _core.DbContextHelper.GetDbContext())
+ {
+ _danishLanguageId = await sdkDbContext.Languages
+ .Where(x => x.LanguageCode == "da")
+ .Select(x => x.Id)
+ .SingleAsync();
+ }
+
+ var options = new DbContextOptionsBuilder()
+ .UseMySql(pluginConnection, ServerVersion.AutoDetect(pluginConnection))
+ .Options;
+ _dbContext = new ItemsPlanningPnDbContext(options);
+ await _dbContext.Database.EnsureCreatedAsync();
+ }
+
+ [OneTimeTearDown]
+ public async Task OneTimeTearDown()
+ {
+ if (_dbContext != null)
+ {
+ await _dbContext.DisposeAsync();
+ }
+
+ if (_core != null)
+ {
+ await _core.Close();
+ }
+
+ await _mariaDbContainer.DisposeAsync();
+ }
+
+ [Test]
+ public async Task LookupTables_AreSeededWithShiftedIds()
+ {
+ // Guards the premise of the other tests: if the server ever stopped shifting
+ // ids, they would pass for the wrong reason.
+ await using var sdkDbContext = _core.DbContextHelper.GetDbContext();
+ var picture = await sdkDbContext.FieldTypes.SingleAsync(x => x.Type == Constants.FieldTypes.Picture);
+
+ Assert.That(picture.Id, Is.Not.EqualTo(5));
+ Assert.That(_danishLanguageId, Is.Not.EqualTo(1));
+ }
+
+ [Test]
+ public async Task GenerateReport_IncludesPictures_WhenPictureFieldTypeIdIsShifted()
+ {
+ var templateId = await CreatePictureTemplate();
+ var (sdkCaseId, uploadedData) = await CreateCompletedCaseWithPicture(templateId);
+ await CreatePlanningCase(templateId, sdkCaseId);
+
+ var userService = Substitute.For();
+ userService.GetCurrentUserTimeZoneInfo().Returns(TimeZoneInfo.Utc);
+ userService.GetCurrentUserLocale().Returns("da");
+ var casePostBaseService = Substitute.For();
+ casePostBaseService.GetCommonPosts(Arg.Any())
+ .Returns(new OperationDataResult(true, new CasePostsCommonModel()));
+
+ var reportLogger = new ErrorRecordingLogger();
+ var service = new ItemsPlanningReportService(
+ Substitute.For(),
+ reportLogger,
+ CoreServiceReturning(_core),
+ Substitute.For(),
+ Substitute.For(),
+ casePostBaseService,
+ _dbContext,
+ userService);
+
+ var result = await service.GenerateReport(new GenerateReportModel
+ {
+ DateFrom = DateTime.UtcNow.AddDays(-1),
+ DateTo = DateTime.UtcNow.AddDays(1),
+ TagIds = new List()
+ }, false);
+
+ Assert.That(result.Success, Is.True, string.Join(Environment.NewLine, reportLogger.Messages));
+ var report = result.Model.Single(x => x.Items.Any(y => y.MicrotingSdkCaseId == sdkCaseId));
+ Assert.That(report.ImageNames.SelectMany(x => x.Value), Does.Contain(uploadedData.FileName));
+ Assert.That(report.Items.Single(x => x.MicrotingSdkCaseId == sdkCaseId).ImagesCount, Is.EqualTo(1));
+ }
+
+ [Test]
+ public async Task ImportPlannings_StoresNameTranslationsPerLanguage_WhenLanguageIdsAreShifted()
+ {
+ var (danishName, _, _) = await ImportNewPlanning();
+
+ Assert.That(await PlanningNamesByLanguageCode(danishName), Is.EquivalentTo(ExpectedNames(danishName)));
+ }
+
+ [Test]
+ public async Task ImportPlannings_Reimport_KeepsOneNameTranslationPerLanguage_WhenLanguageIdsAreShifted()
+ {
+ // A re-import matches the existing planning and walks its translations by
+ // language; with hard-coded language ids that walk threw or created duplicates.
+ // (A re-import does not rename existing translations - that is existing
+ // behaviour, not asserted here.)
+ var (danishName, eFormName, folderLabel) = await ImportNewPlanning();
+
+ await ImportPlanning(PlanningNameCell(danishName), eFormName, folderLabel);
+
+ Assert.That(await PlanningNamesByLanguageCode(danishName), Is.EquivalentTo(ExpectedNames(danishName)));
+ }
+
+ private async Task<(string DanishName, string EFormName, string FolderLabel)> ImportNewPlanning()
+ {
+ var eFormName = await TemplateLabel(await CreatePictureTemplate());
+ var danishName = $"Dansk navn {Guid.NewGuid():N}";
+ var folderLabel = $"Folder {Guid.NewGuid():N}";
+ await ImportPlanning(PlanningNameCell(danishName), eFormName, folderLabel);
+ return (danishName, eFormName, folderLabel);
+ }
+
+ private static string PlanningNameCell(string danishName) => $"{danishName}|English name|Deutscher Name";
+
+ private static Dictionary ExpectedNames(string danishName) => new()
+ {
+ ["da"] = danishName,
+ ["en-US"] = "English name",
+ ["de-DE"] = "Deutscher Name"
+ };
+
+ private async Task ImportPlanning(string planningName, string eFormName, string folderLabel)
+ {
+ var excelService = Substitute.For();
+ excelService.ParsePlanningImportFile(Arg.Any()).Returns(new List
+ {
+ new()
+ {
+ ExcelRow = 2,
+ PlanningName = planningName,
+ EFormName = eFormName,
+ Folders = new List
+ {
+ new() { Label = folderLabel, Description = "Folder description" }
+ }
+ }
+ });
+ var userService = Substitute.For();
+ userService.GetCurrentUserTimeZoneInfo().Returns(TimeZoneInfo.Utc);
+ var logger = new ErrorRecordingLogger();
+
+ var service = new PlanningImportService(
+ _dbContext,
+ Substitute.For(),
+ CoreServiceReturning(_core),
+ userService,
+ excelService,
+ logger);
+
+ var result = await service.ImportPlannings(new MemoryStream());
+
+ Assert.That(result.Success, Is.True, string.Join(Environment.NewLine, logger.Messages));
+ }
+
+ private async Task> PlanningNamesByLanguageCode(string danishName)
+ {
+ await using var sdkDbContext = _core.DbContextHelper.GetDbContext();
+ var languageCodes = await sdkDbContext.Languages.ToDictionaryAsync(x => x.Id, x => x.LanguageCode);
+ var planningId = await _dbContext.PlanningNameTranslation
+ .Where(x => x.Name == danishName)
+ .Select(x => x.PlanningId)
+ .SingleAsync();
+ var translations = await _dbContext.PlanningNameTranslation
+ .AsNoTracking()
+ .Where(x => x.PlanningId == planningId)
+ .ToListAsync();
+ return translations.ToDictionary(x => languageCodes[x.LanguageId], x => x.Name);
+ }
+
+ private static IEFormCoreService CoreServiceReturning(Core core)
+ {
+ var coreService = Substitute.For();
+ coreService.GetCore().Returns(core);
+ return coreService;
+ }
+
+ private async Task CreatePictureTemplate()
+ {
+ var xml = $@"
+
+ 1
+
+ 0
+ Folder
+ 1
+ 2018-08-23 10:40:52
+ 2058-08-29 10:40:52
+ da
+ false
+ false
+ false
+ false
+
+
+ 2
+
+ 0
+
+ false
+ false
+ true
+ false
+
+
+
+
+ 3
+ false
+ false
+
+
+ 1
+
+
+
+
+
+
+";
+ var mainElement = await _core.TemplateFromXml(xml);
+ return await _core.TemplateCreate(mainElement);
+ }
+
+ private async Task TemplateLabel(int templateId)
+ {
+ await using var sdkDbContext = _core.DbContextHelper.GetDbContext();
+ return await sdkDbContext.CheckListTranslations
+ .Where(x => x.CheckListId == templateId && x.LanguageId == _danishLanguageId)
+ .Select(x => x.Text)
+ .SingleAsync();
+ }
+
+ private async Task<(int CaseId, UploadedData UploadedData)> CreateCompletedCaseWithPicture(int templateId)
+ {
+ await using var sdkDbContext = _core.DbContextHelper.GetDbContext();
+ var pictureField = await sdkDbContext.Fields
+ .Where(x => x.CheckList.ParentId == templateId)
+ .SingleAsync();
+
+ var sdkCase = new Case
+ {
+ CheckListId = templateId,
+ Status = 100,
+ DoneAt = DateTime.UtcNow
+ };
+ await sdkCase.Create(sdkDbContext);
+
+ var uploadedData = new UploadedData
+ {
+ FileName = $"{Guid.NewGuid():N}.jpg",
+ Checksum = "checksum",
+ Extension = ".jpg"
+ };
+ await uploadedData.Create(sdkDbContext);
+
+ await new FieldValue
+ {
+ CaseId = sdkCase.Id,
+ CheckListId = pictureField.CheckListId,
+ FieldId = pictureField.Id,
+ UploadedDataId = uploadedData.Id,
+ DoneAt = DateTime.UtcNow
+ }.Create(sdkDbContext);
+
+ return (sdkCase.Id, uploadedData);
+ }
+
+ private async Task CreatePlanningCase(int templateId, int sdkCaseId)
+ {
+ var planning = new Planning
+ {
+ RelatedEFormId = templateId,
+ RelatedEFormName = "Picture eForm",
+ RepeatType = RepeatType.Month,
+ RepeatEvery = 1,
+ StartDate = DateTime.UtcNow
+ };
+ await planning.Create(_dbContext);
+ await new PlanningNameTranslation
+ {
+ PlanningId = planning.Id,
+ LanguageId = _danishLanguageId,
+ Name = "Planning with picture"
+ }.Create(_dbContext);
+ await new PlanningCase
+ {
+ PlanningId = planning.Id,
+ MicrotingSdkeFormId = templateId,
+ MicrotingSdkCaseId = sdkCaseId,
+ MicrotingSdkCaseDoneAt = DateTime.UtcNow,
+ Status = 100
+ }.Create(_dbContext);
+ }
+}
+
+///
+/// The services under test swallow exceptions and only log them (message as Error, stack
+/// trace as Trace), so the tests record both to explain a failed result.
+///
+internal sealed class ErrorRecordingLogger : ILogger
+{
+ public List Messages { get; } = new();
+
+ public IDisposable BeginScope(TState state) where TState : notnull => null;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception,
+ Func formatter)
+ {
+ if (logLevel is >= LogLevel.Error or LogLevel.Trace)
+ {
+ Messages.Add(formatter(state, exception));
+ }
+ }
+}
diff --git a/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/Services/ItemsPlanningReportService/ItemsPlanningReportService.cs b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/Services/ItemsPlanningReportService/ItemsPlanningReportService.cs
index 0e925e14..5c205fc0 100644
--- a/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/Services/ItemsPlanningReportService/ItemsPlanningReportService.cs
+++ b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/Services/ItemsPlanningReportService/ItemsPlanningReportService.cs
@@ -262,7 +262,7 @@ await sdkDbContext.CheckListTranslations.FirstOrDefaultAsync(x =>
.Include(x => x.UploadedData)
.Where(x => x.WorkflowState != Constants.WorkflowStates.Removed)
.Where(x => x.UploadedData.WorkflowState != Constants.WorkflowStates.Removed)
- .Where(x => x.Field.FieldTypeId == 5)
+ .Where(x => x.Field.FieldType.Type == Constants.FieldTypes.Picture)
.Where(x => templateCaseIds.Contains(x.CaseId))
.Where(x => x.UploadedDataId != null)
.OrderBy(x => x.CaseId)
@@ -470,7 +470,7 @@ or Constants.FieldTypes.None
item.ImagesCount = await sdkDbContext.FieldValues
.Where(x => x.WorkflowState != Constants.WorkflowStates.Removed)
- .Where(x => x.Field.FieldTypeId == 5)
+ .Where(x => x.Field.FieldType.Type == Constants.FieldTypes.Picture)
.Where(x => x.CaseId == planningCase.MicrotingSdkCaseId)
.Where(x => x.UploadedDataId != null)
.Select(x => x.Id)
diff --git a/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/Services/PlanningImportService/PlanningImportService.cs b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/Services/PlanningImportService/PlanningImportService.cs
index 2092d598..4a7183d4 100644
--- a/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/Services/PlanningImportService/PlanningImportService.cs
+++ b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/Services/PlanningImportService/PlanningImportService.cs
@@ -86,6 +86,12 @@ public async Task> ImportPlannings(Stream
"en-US",
"de-DE"
};
+ // The "|"-separated planning name holds one name per language, in this order.
+ // Resolve the languages by code: their ids are not 1, 2, 3 on every install.
+ var languageIdsByCode = await dbContext.Languages
+ .Where(x => languageCodes.Contains(x.LanguageCode))
+ .ToDictionaryAsync(x => x.LanguageCode, x => x.Id);
+ var planningNameLanguageIds = languageCodes.Select(code => languageIdsByCode[code]).ToList();
foreach (var excelModel in fileResult)
{
@@ -415,20 +421,19 @@ public async Task> ImportPlannings(Stream
.ToList();
// create or update name tranlations planning
- var i = 1;
- foreach (var translationText in excelModel.PlanningName.Split("|"))
+ foreach (var (translationText, languageId) in
+ excelModel.PlanningName.Split("|").Zip(planningNameLanguageIds))
{
- var language = await dbContext.Languages.SingleAsync(x => x.Id == i);
var planningNameTranslation =
planningTranslations
.Where(x => x.PlanningId == planningFromDb.Id)
- .FirstOrDefault(x => x.LanguageId == i);
+ .FirstOrDefault(x => x.LanguageId == languageId);
if (planningNameTranslation == null)
{
planningNameTranslation = new PlanningNameTranslation
{
Name = translationText,
- LanguageId = language.Id,
+ LanguageId = languageId,
PlanningId = planningFromDb.Id,
CreatedByUserId = userService.UserId,
UpdatedByUserId = userService.UserId
@@ -442,8 +447,6 @@ public async Task> ImportPlannings(Stream
await planningNameTranslation.Update(context);
}
-
- i++;
}
if (excelModel.DayOfMonth != null && planningFromDb.DayOfMonth != excelModel.DayOfMonth)
@@ -541,18 +544,16 @@ await planningFromDb.PlanningsTags
await newPlanning.Create(context);
- var i = 1;
- foreach (var translationText in excelModel.PlanningName.Split("|"))
+ foreach (var (translationText, languageId) in
+ excelModel.PlanningName.Split("|").Zip(planningNameLanguageIds))
{
- var language = await dbContext.Languages.SingleAsync(x => x.Id == i);
var planningNameTranslation = new PlanningNameTranslation()
{
Name = translationText,
- LanguageId = language.Id,
+ LanguageId = languageId,
PlanningId = newPlanning.Id
};
await planningNameTranslation.Create(context);
- i += 1;
}
}