Add Azure Blob Storage archive & email safety net
- Add AzureBlobStorageService, DocumentArchiveSyncService, and related config for secondary PDF archiving of invoices/reminders - Add SQL procs and schema changes for archive backfill - Update invoice/reminder services to upload PDFs to blob storage - Add telemetry counters and unit tests for blob storage/archive logic - Add Fuchs:Email:OverrideRecipient config and enforce dev/test email redirect in ProcessWebComService, with tests - Improve JS date parsing (German formats), stricter JSON date detection - Increase widget SQL timeouts, update dependencies, docs, and project files
This commit is contained in:
@@ -31,6 +31,7 @@
|
||||
- Connection strings are stored under the standard `"ConnectionStrings"` key and read via `IConfiguration.GetConnectionString(...)`.
|
||||
- `FuchsOcmsIntranet.Initialize(configuration)` must be called at app start (in `Program.cs`) before DI registration; `Fuchs_intranet` receives `IConfiguration` via its constructor.
|
||||
- `appsettings.Development.json` (git-ignored) can override secrets for local development.
|
||||
- `Fuchs:Email:OverrideRecipient` (bound via `IOptions<FuchsEmailSettings>`, section `Fuchs:Email`) is a dev/test safety net: when non-empty, `ProcessWebComService.SendEmailAsync` discards the real recipient of every outbound email and redirects it to this single address instead, so a locally-enabled mailer can never reach a real tenant-owner or end-customer. Set this only in `appsettings.Development.json` — it must stay empty in Production (`appsettings.json` documents the safe empty default).
|
||||
|
||||
## Libraries
|
||||
- Do not upgrade Spire.PDF beyond version 8.10.5.
|
||||
@@ -41,7 +42,7 @@
|
||||
|
||||
## Services & Dependency Injection
|
||||
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces; inject them into `IntranetController` (constructor injection). Do **not** reintroduce static God-classes or pass the whole controller into helpers.
|
||||
- `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`.
|
||||
- `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`.
|
||||
- Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`) are scoped. Register in `Program.cs`.
|
||||
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders** (parse + properties). Loading, persistence and PDF generation belong in the services — never `Task.Run(...).Wait()` sync-over-async.
|
||||
- Data access stays SQL-first via OCORE helpers (`getSQLDataSet_async`, `setSQLValue_async`) + stored procedures; no EF Core.
|
||||
@@ -65,7 +66,13 @@
|
||||
- Tracing/metrics are always collected; OTLP export is opt-in via `Fuchs:Telemetry:OtlpEndpoint`. Don't add exporters that fail hard when no collector is present.
|
||||
|
||||
## Testing
|
||||
- xUnit in `Fuchs.Tests`. For every service/handler change add tests covering **both** an intentionally succeeding and an intentionally failing path where feasible (use stubs/mocks; the test project has `InternalsVisibleTo`). DB-bound paths that can't be unit-tested should at least have their pure logic covered.
|
||||
- xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial:
|
||||
- For every service/handler change add tests covering **both** an intentionally succeeding and an intentionally failing path where feasible (use stubs/mocks; the test project has `InternalsVisibleTo`).
|
||||
- Cover edge cases and boundary conditions (empty/null/invalid input, disabled/feature-flag-off states, API/network errors) via `[Theory]`/`[InlineData]`/`[MemberData]` rather than a single happy-path `[Fact]`.
|
||||
- When behavior depends on configuration (e.g. `appsettings.json` vs `appsettings.Development.json`), prefer loading the **real** files layered the same way ASP.NET Core does (`ConfigurationBuilder` + `AddJsonFile`) over hand-typed literals only, so drift in the actual files is caught (see `ProcessWebComServiceTests.SendEmailAsync_OverrideRecipientFromRealAppsettings_ClearsRecipientWheneverConfigured` for the pattern).
|
||||
- Assert on the observable contract (e.g. the outgoing request payload) and on emitted telemetry (counters/histograms) when the code records them — not just the boolean return value.
|
||||
- Name tests `MethodName_Scenario_ExpectedResult`.
|
||||
- DB-bound paths that can't be unit-tested should at least have their pure logic covered.
|
||||
|
||||
## Azure Key Vault — Secret Naming
|
||||
- Secret names must satisfy the pattern `^[0-9a-zA-Z-]+$` (alphanumerics and hyphens only; no underscores, dots, or spaces).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# CLAUDE.md — Project instructions for Claude Code
|
||||
# CLAUDE.md — Project instructions for Claude Code
|
||||
|
||||
> ## ⚠️ Instruction Sync
|
||||
> This file and **`.github/copilot-instructions.md`** are two views of the same
|
||||
@@ -29,13 +29,14 @@
|
||||
- All settings in `Fuchs/appsettings.json` — **never** `Web.config` / `System.Configuration.ConfigurationManager`. App settings nested under `"Fuchs"`; connection strings under `"ConnectionStrings"`.
|
||||
- `FuchsOcmsIntranet.Initialize(configuration)` runs in `Program.cs` before DI registration.
|
||||
- `appsettings.Development.json` (git-ignored) overrides secrets locally.
|
||||
- `Fuchs:Email:OverrideRecipient` (`IOptions<FuchsEmailSettings>`, section `Fuchs:Email`) is a dev/test safety net: when non-empty, `ProcessWebComService.SendEmailAsync` discards the real recipient of every outbound email and redirects it to this single address instead, so a locally-enabled mailer can never reach a real tenant-owner or end-customer. Set only in `appsettings.Development.json` — must stay empty in Production.
|
||||
|
||||
## Libraries
|
||||
- Do **not** upgrade Spire.PDF beyond 8.10.5. Prefer OCORE / OCORE_web / OCORE_web_pdf helpers over rewriting. Do not use OCMS/OCMS_sharp — OCORE only.
|
||||
|
||||
## Services & Dependency Injection
|
||||
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
|
||||
- Services: `IComService`, `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`. Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
|
||||
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`. Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
|
||||
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
|
||||
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
|
||||
|
||||
@@ -57,7 +58,13 @@
|
||||
- Always collected; OTLP export opt-in via `Fuchs:Telemetry:OtlpEndpoint`. No exporters that hard-fail without a collector.
|
||||
|
||||
## Testing
|
||||
- xUnit in `Fuchs.Tests`. For each service/handler change, add tests for **both** an intentionally succeeding and an intentionally failing path where feasible (stubs/mocks; `InternalsVisibleTo` is enabled). Cover pure logic for DB-bound paths that can't be unit-tested.
|
||||
- xUnit in `Fuchs.Tests`. Testing must be **extensive**, not superficial:
|
||||
- For each service/handler change, cover **both** an intentionally succeeding and an intentionally failing path where feasible (stubs/mocks; `InternalsVisibleTo` is enabled).
|
||||
- Cover edge cases and boundary conditions (empty/null/invalid input, disabled/feature-flag-off states, API/network errors) via `[Theory]`/`[InlineData]`/`[MemberData]` rather than a single happy-path `[Fact]`.
|
||||
- When behavior depends on configuration (e.g. `appsettings.json` vs `appsettings.Development.json`), prefer loading the **real** files layered the same way ASP.NET Core does (`ConfigurationBuilder` + `AddJsonFile`) over hand-typed literals only, so drift in the actual files is caught (see `ProcessWebComServiceTests.SendEmailAsync_OverrideRecipientFromRealAppsettings_ClearsRecipientWheneverConfigured` for the pattern).
|
||||
- Assert on the observable contract (e.g. the outgoing request payload) and on emitted telemetry (counters/histograms) when the code records them — not just the boolean return value.
|
||||
- Name tests `MethodName_Scenario_ExpectedResult`.
|
||||
- Cover pure logic for DB-bound paths that can't be unit-tested.
|
||||
|
||||
## Secrets (Azure Key Vault)
|
||||
- Full naming rules live in `.github/copilot-instructions.md` (kept in sync). In short: names match `^[0-9a-zA-Z-]+$`, hierarchy via `--` (→ `:`), underscores → `-`, app prefix `fuchs`; register new keys in `ManagedSecretKeys` in `appsettings.json`.
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the Azure Blob Storage secondary-archive service. Covers the
|
||||
/// disabled/unconfigured path (no client), the empty-content guard, successful
|
||||
/// uploads (routing to the correct per-category container and blob name), an
|
||||
/// upload that throws (must be swallowed, never break invoice/reminder storage),
|
||||
/// and the emitted telemetry counters.
|
||||
///
|
||||
/// Azure.Storage.Blobs clients (BlobServiceClient/BlobContainerClient/BlobClient)
|
||||
/// expose a protected parameterless constructor and virtual members specifically
|
||||
/// to support mocking with Moq — see Azure SDK unit-testing guidance.
|
||||
/// </summary>
|
||||
public class AzureBlobStorageServiceTests
|
||||
{
|
||||
private const string FuchsMeterName = "Fuchs.Intranet";
|
||||
|
||||
private static AzureBlobStorageSettings CreateSettings() => new()
|
||||
{
|
||||
Enabled = true,
|
||||
InvoiceContainer = "test-invoices",
|
||||
ReminderContainer = "test-reminders"
|
||||
};
|
||||
|
||||
private static (Mock<BlobServiceClient> service, Mock<BlobContainerClient> container, Mock<BlobClient> blob)
|
||||
CreateMockedClientChain()
|
||||
{
|
||||
var blobClientMock = new Mock<BlobClient>();
|
||||
var containerClientMock = new Mock<BlobContainerClient>();
|
||||
containerClientMock.Setup(c => c.GetBlobClient(It.IsAny<string>())).Returns(blobClientMock.Object);
|
||||
containerClientMock
|
||||
.Setup(c => c.CreateIfNotExistsAsync(
|
||||
It.IsAny<PublicAccessType>(), It.IsAny<IDictionary<string, string>>(),
|
||||
It.IsAny<BlobContainerEncryptionScopeOptions>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Response<BlobContainerInfo>)null!);
|
||||
|
||||
var serviceClientMock = new Mock<BlobServiceClient>();
|
||||
serviceClientMock.Setup(s => s.GetBlobContainerClient(It.IsAny<string>())).Returns(containerClientMock.Object);
|
||||
|
||||
return (serviceClientMock, containerClientMock, blobClientMock);
|
||||
}
|
||||
|
||||
// ── Disabled / unconfigured (feature-flag-off) ─────────────────────────────
|
||||
[Fact]
|
||||
public async Task UploadInvoicePdfAsync_NoClient_ReturnsNullWithoutThrowing()
|
||||
{
|
||||
var svc = new AzureBlobStorageService(client: null, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
|
||||
Uri? result = await svc.UploadInvoicePdfAsync("INV1", "Rechnung_INV1.pdf", new byte[] { 1, 2, 3 });
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
// ── Boundary condition: empty content ──────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task UploadReminderPdfAsync_EmptyContent_ReturnsNullWithoutCallingClient()
|
||||
{
|
||||
var (service, container, blob) = CreateMockedClientChain();
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
|
||||
Uri? result = await svc.UploadReminderPdfAsync("REM1", "Zahlungserinnerung_REM1.pdf", Array.Empty<byte>());
|
||||
|
||||
Assert.Null(result);
|
||||
container.Verify(c => c.CreateIfNotExistsAsync(
|
||||
It.IsAny<PublicAccessType>(), It.IsAny<IDictionary<string, string>>(),
|
||||
It.IsAny<BlobContainerEncryptionScopeOptions>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
blob.Verify(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── Success path: invoice routing + counter ─────────────────────────────────
|
||||
[Fact]
|
||||
public async Task UploadInvoicePdfAsync_ClientSucceeds_ReturnsBlobUriAndUsesInvoiceContainer()
|
||||
{
|
||||
var expectedUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/Rechnung_INV1.pdf");
|
||||
var (service, container, blob) = CreateMockedClientChain();
|
||||
blob.Setup(b => b.Uri).Returns(expectedUri);
|
||||
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Response<BlobContentInfo>)null!);
|
||||
|
||||
long delta = 0;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.uploads")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
|
||||
listener.Start();
|
||||
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
Uri? result = await svc.UploadInvoicePdfAsync("INV1", "Rechnung_INV1.pdf", new byte[] { 1, 2, 3, 4 });
|
||||
|
||||
Assert.Equal(expectedUri, result);
|
||||
service.Verify(s => s.GetBlobContainerClient("test-invoices"), Times.Once);
|
||||
container.Verify(c => c.GetBlobClient("INV1/Rechnung_INV1.pdf"), Times.Once);
|
||||
blob.Verify(b => b.UploadAsync(It.IsAny<Stream>(), true, It.IsAny<CancellationToken>()), Times.Once);
|
||||
Assert.True(delta >= 1, "fuchs.blobstorage.uploads counter should have been incremented on a successful upload.");
|
||||
}
|
||||
|
||||
// ── Success path: reminder routing (different container) ──────────────────
|
||||
[Fact]
|
||||
public async Task UploadReminderPdfAsync_ClientSucceeds_UsesReminderContainer()
|
||||
{
|
||||
var (service, container, blob) = CreateMockedClientChain();
|
||||
blob.Setup(b => b.Uri).Returns(new Uri("https://test.blob.core.windows.net/test-reminders/REM2/Zahlungserinnerung_REM2.pdf"));
|
||||
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Response<BlobContentInfo>)null!);
|
||||
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
Uri? result = await svc.UploadReminderPdfAsync("REM2", "Zahlungserinnerung_REM2.pdf", new byte[] { 5, 6 });
|
||||
|
||||
Assert.NotNull(result);
|
||||
service.Verify(s => s.GetBlobContainerClient("test-reminders"), Times.Once);
|
||||
container.Verify(c => c.GetBlobClient("REM2/Zahlungserinnerung_REM2.pdf"), Times.Once);
|
||||
}
|
||||
|
||||
// ── Boundary condition: blank filename falls back to "{id}.pdf" ───────────
|
||||
[Fact]
|
||||
public async Task UploadInvoicePdfAsync_BlankFileName_FallsBackToIdPdfBlobName()
|
||||
{
|
||||
var (service, container, blob) = CreateMockedClientChain();
|
||||
blob.Setup(b => b.Uri).Returns(new Uri("https://test.blob.core.windows.net/test-invoices/INV3/INV3.pdf"));
|
||||
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Response<BlobContentInfo>)null!);
|
||||
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
Uri? result = await svc.UploadInvoicePdfAsync("INV3", " ", new byte[] { 7 });
|
||||
|
||||
Assert.NotNull(result);
|
||||
container.Verify(c => c.GetBlobClient("INV3/INV3.pdf"), Times.Once);
|
||||
}
|
||||
|
||||
// ── Failure path: client throws, must not propagate ────────────────────────
|
||||
[Fact]
|
||||
public async Task UploadReminderPdfAsync_ClientThrows_ReturnsNullAndIncrementsFailedCounter()
|
||||
{
|
||||
var (service, container, blob) = CreateMockedClientChain();
|
||||
blob.Setup(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException("simulated storage failure"));
|
||||
|
||||
long delta = 0;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.uploads.failed")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
|
||||
listener.Start();
|
||||
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
Uri? result = await svc.UploadReminderPdfAsync("REM3", "Zahlungserinnerung_REM3.pdf", new byte[] { 9, 9 });
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.True(delta >= 1, "fuchs.blobstorage.uploads.failed counter should have been incremented on a failed upload.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DocumentArchiveSyncService"/> — the one-shot startup backfill for the
|
||||
/// Azure Blob Storage secondary archive. Only the disabled/feature-flag-off fast path is
|
||||
/// unit-testable without a real SQL Server connection (the enabled path drives
|
||||
/// fds__getInvoiceFiles_ForBlobArchive / fds__getReminderFiles_ForBlobArchive against
|
||||
/// fds__invoices / fds__reminder); the enabled path is exercised manually/in integration
|
||||
/// environments, consistent with InvoiceService/ReminderService which are likewise DB-bound
|
||||
/// and have no unit tests of their SQL-calling members.
|
||||
/// </summary>
|
||||
public class DocumentArchiveSyncServiceTests
|
||||
{
|
||||
private static Fuchs_intranet CreateIntranet() =>
|
||||
new(new ConfigurationBuilder().Build());
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_FeatureDisabled_NeverTouchesBlobStorageAndCompletesImmediately()
|
||||
{
|
||||
var blobStorage = new Mock<IBlobStorageService>(MockBehavior.Strict);
|
||||
var settings = Options.Create(new AzureBlobStorageSettings { Enabled = false });
|
||||
using var service = new DocumentArchiveSyncService(
|
||||
CreateIntranet(), blobStorage.Object, settings, NullLogger<DocumentArchiveSyncService>.Instance);
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
blobStorage.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_FeatureDisabled_DoesNotThrowEvenWithUnconfiguredIntranet()
|
||||
{
|
||||
// The disabled fast path must return before any SQL access is attempted, so an
|
||||
// intranet instance with no real connection string configured is still safe to use.
|
||||
var blobStorage = new Mock<IBlobStorageService>(MockBehavior.Strict);
|
||||
var settings = Options.Create(new AzureBlobStorageSettings { Enabled = false });
|
||||
using var service = new DocumentArchiveSyncService(
|
||||
CreateIntranet(), blobStorage.Object, settings, NullLogger<DocumentArchiveSyncService>.Instance);
|
||||
|
||||
var exception = await Record.ExceptionAsync(async () =>
|
||||
{
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
});
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Fuchs.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DocumentMetadataBuilder"/> — the per-blob metadata projection used by the
|
||||
/// Azure Blob Storage archive (see <see cref="AzureBlobStorageService"/> and
|
||||
/// <see cref="DocumentArchiveSyncService"/>). Covers the "skip if absent from the row, keep empty
|
||||
/// string if present-but-empty" contract, case-insensitive column lookup (SQL rows are frequently
|
||||
/// lower-cased), and value stringification (DateTime round-trip formatting, DBNull/null handling).
|
||||
/// </summary>
|
||||
public class DocumentMetadataBuilderTests
|
||||
{
|
||||
// ── Field presence contract ──────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Build_FieldPresentWithValue_IncludesStringifiedValue()
|
||||
{
|
||||
var row = new Dictionary<string, object?> { ["InvoiceId"] = "INV-42" };
|
||||
|
||||
var metadata = DocumentMetadataBuilder.Build(row, new[] { "InvoiceId" });
|
||||
|
||||
Assert.Equal("INV-42", metadata["InvoiceId"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_FieldAbsentFromRow_SkipsFieldEntirely()
|
||||
{
|
||||
// Reminders have no file_guid column — must be skipped, not stored as empty.
|
||||
var row = new Dictionary<string, object?> { ["Id"] = "REM1" };
|
||||
|
||||
var metadata = DocumentMetadataBuilder.Build(row, new[] { "Id", "file_guid" });
|
||||
|
||||
Assert.True(metadata.ContainsKey("Id"));
|
||||
Assert.False(metadata.ContainsKey("file_guid"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void Build_FieldPresentButNullOrEmpty_StoresEmptyStringRatherThanSkipping(object? value)
|
||||
{
|
||||
var row = new Dictionary<string, object?> { ["InvoiceTitle"] = value };
|
||||
|
||||
var metadata = DocumentMetadataBuilder.Build(row, new[] { "InvoiceTitle" });
|
||||
|
||||
Assert.True(metadata.ContainsKey("InvoiceTitle"));
|
||||
Assert.Equal("", metadata["InvoiceTitle"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_FieldPresentButDBNull_StoresEmptyString()
|
||||
{
|
||||
var row = new Dictionary<string, object?> { ["InvoiceTitle"] = DBNull.Value };
|
||||
|
||||
var metadata = DocumentMetadataBuilder.Build(row, new[] { "InvoiceTitle" });
|
||||
|
||||
Assert.True(metadata.ContainsKey("InvoiceTitle"));
|
||||
Assert.Equal("", metadata["InvoiceTitle"]);
|
||||
}
|
||||
|
||||
// ── Case-insensitive lookup ──────────────────────────────────────────────
|
||||
[Theory]
|
||||
[InlineData("invoiceid")]
|
||||
[InlineData("INVOICEID")]
|
||||
[InlineData("InVoIcEId")]
|
||||
public void Build_CaseInsensitiveRowKey_MatchesConfiguredFieldName(string rowKey)
|
||||
{
|
||||
var row = new Dictionary<string, object?> { [rowKey] = "INV-7" };
|
||||
|
||||
var metadata = DocumentMetadataBuilder.Build(row, new[] { "InvoiceId" });
|
||||
|
||||
Assert.Equal("INV-7", metadata["InvoiceId"]);
|
||||
}
|
||||
|
||||
// ── Value stringification ────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Build_DateTimeValue_FormatsAsRoundTripString()
|
||||
{
|
||||
var dt = new DateTime(2026, 3, 14, 9, 30, 0, DateTimeKind.Utc);
|
||||
var row = new Dictionary<string, object?> { ["DateCreated"] = dt };
|
||||
|
||||
var metadata = DocumentMetadataBuilder.Build(row, new[] { "DateCreated" });
|
||||
|
||||
Assert.Equal(dt.ToString("O"), metadata["DateCreated"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(42, "42")]
|
||||
[InlineData(true, "True")]
|
||||
public void Build_NonStringValue_UsesToString(object value, string expected)
|
||||
{
|
||||
var row = new Dictionary<string, object?> { ["Version"] = value };
|
||||
|
||||
var metadata = DocumentMetadataBuilder.Build(row, new[] { "Version" });
|
||||
|
||||
Assert.Equal(expected, metadata["Version"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_GuidValue_UsesDefaultGuidFormat()
|
||||
{
|
||||
var guid = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
var row = new Dictionary<string, object?> { ["file_guid"] = guid };
|
||||
|
||||
var metadata = DocumentMetadataBuilder.Build(row, new[] { "file_guid" });
|
||||
|
||||
Assert.Equal(guid.ToString(), metadata["file_guid"]);
|
||||
}
|
||||
|
||||
// ── Field-list edge cases ────────────────────────────────────────────────
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Build_WhitespaceFieldName_IsSkipped(string field)
|
||||
{
|
||||
var row = new Dictionary<string, object?> { ["Id"] = "X1" };
|
||||
|
||||
var metadata = DocumentMetadataBuilder.Build(row, new[] { field, "Id" });
|
||||
|
||||
Assert.Single(metadata);
|
||||
Assert.Equal("X1", metadata["Id"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_EmptyFieldsList_ReturnsEmptyDictionary()
|
||||
{
|
||||
var row = new Dictionary<string, object?> { ["Id"] = "X1" };
|
||||
|
||||
var metadata = DocumentMetadataBuilder.Build(row, Array.Empty<string>());
|
||||
|
||||
Assert.Empty(metadata);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_EmptyRow_SkipsAllConfiguredFields()
|
||||
{
|
||||
var row = new Dictionary<string, object?>();
|
||||
|
||||
var metadata = DocumentMetadataBuilder.Build(row, new[] { "Id", "Version", "InvoiceId" });
|
||||
|
||||
Assert.Empty(metadata);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DuplicateFieldNamesInFieldList_ProducesSingleEntry()
|
||||
{
|
||||
var row = new Dictionary<string, object?> { ["Id"] = "X1" };
|
||||
|
||||
var metadata = DocumentMetadataBuilder.Build(row, new[] { "Id", "Id" });
|
||||
|
||||
Assert.Single(metadata);
|
||||
Assert.Equal("X1", metadata["Id"]);
|
||||
}
|
||||
|
||||
// ── Realistic per-document-type row shapes ───────────────────────────────
|
||||
[Fact]
|
||||
public void Build_InvoiceRowShape_ProjectsAllConfiguredInvoiceFieldsAndSkipsReminderOnlyColumn()
|
||||
{
|
||||
var row = new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = "INV1",
|
||||
["Version"] = 2,
|
||||
["InvoiceId"] = "R-2026-001",
|
||||
["InvoiceTitle"] = "Rechnung",
|
||||
["DocumentName"] = "Rechnung_INV1.pdf",
|
||||
["file_guid"] = Guid.Parse("11111111-1111-1111-1111-111111111111")
|
||||
};
|
||||
var fields = new[] { "Id", "Version", "InvoiceId", "InvoiceTitle", "InvId", "DocumentName", "file_guid" };
|
||||
|
||||
var metadata = DocumentMetadataBuilder.Build(row, fields);
|
||||
|
||||
Assert.Equal("INV1", metadata["Id"]);
|
||||
Assert.Equal("2", metadata["Version"]);
|
||||
Assert.Equal("R-2026-001", metadata["InvoiceId"]);
|
||||
Assert.Equal("Rechnung", metadata["InvoiceTitle"]);
|
||||
Assert.Equal("Rechnung_INV1.pdf", metadata["DocumentName"]);
|
||||
Assert.Equal("11111111-1111-1111-1111-111111111111", metadata["file_guid"]);
|
||||
Assert.False(metadata.ContainsKey("InvId"), "Invoices have no InvId column — must be skipped, not empty.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ReminderRowShape_SkipsInvoiceOnlyColumns()
|
||||
{
|
||||
var row = new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = "REM1",
|
||||
["Version"] = 0,
|
||||
["InvId"] = "INV1",
|
||||
["DocumentName"] = "Zahlungserinnerung_REM1.pdf"
|
||||
};
|
||||
var fields = new[] { "Id", "Version", "InvoiceId", "InvoiceTitle", "InvId", "DocumentName", "file_guid" };
|
||||
|
||||
var metadata = DocumentMetadataBuilder.Build(row, fields);
|
||||
|
||||
Assert.Equal("REM1", metadata["Id"]);
|
||||
Assert.Equal("0", metadata["Version"]);
|
||||
Assert.Equal("INV1", metadata["InvId"]);
|
||||
Assert.Equal("Zahlungserinnerung_REM1.pdf", metadata["DocumentName"]);
|
||||
Assert.False(metadata.ContainsKey("InvoiceId"));
|
||||
Assert.False(metadata.ContainsKey("InvoiceTitle"));
|
||||
Assert.False(metadata.ContainsKey("file_guid"));
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json.Linq;
|
||||
@@ -53,7 +55,7 @@ public class ProcessWebComServiceTests
|
||||
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true)
|
||||
private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true, string? overrideRecipient = null)
|
||||
{
|
||||
var settings = Options.Create(new ProcessWebComSettings
|
||||
{
|
||||
@@ -62,10 +64,15 @@ public class ProcessWebComServiceTests
|
||||
AccountId = "acct",
|
||||
Token = "tok"
|
||||
});
|
||||
var emailSettings = Options.Create(new FuchsEmailSettings
|
||||
{
|
||||
OverrideRecipient = overrideRecipient
|
||||
});
|
||||
return new ProcessWebComService(
|
||||
NullLogger<ProcessWebComService>.Instance,
|
||||
intranet: null!,
|
||||
settings,
|
||||
emailSettings,
|
||||
new StubHttpClientFactory(handler));
|
||||
}
|
||||
|
||||
@@ -122,6 +129,129 @@ public class ProcessWebComServiceTests
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
}
|
||||
|
||||
// ── Dev/test recipient override safety net ─────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_ov1", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("dev-inbox@example.test", json["recipient"]!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_SubjectRetainsOriginalRecipientForTraceability()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
|
||||
|
||||
await svc.SendEmailAsync("inv_ov2", "Rechnung 123", "<p>hi</p>", "realcustomer@example.de", "Kunde");
|
||||
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
string subject = json["subject"]!.ToString();
|
||||
Assert.Contains("realcustomer@example.de", subject);
|
||||
Assert.Contains("Rechnung 123", subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_InvalidOriginalAddressIsStillRedirected()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_ov3", "Subject", "<p>hi</p>", "not-an-email", "Kunde");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("dev-inbox@example.test", json["recipient"]!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_NoOverrideConfigured_SendsToOriginalRecipient()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
|
||||
await svc.SendEmailAsync("inv_ov4", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde");
|
||||
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("realcustomer@example.de", json["recipient"]!.ToString());
|
||||
}
|
||||
|
||||
// ── Override enforcement across every real appsettings*.json environment ───
|
||||
// Loads the ACTUAL Fuchs/appsettings*.json files the same way ASP.NET Core
|
||||
// layers them (base file + optional environment-specific overlay). These files
|
||||
// are copied into this test assembly's output directory via the Fuchs project
|
||||
// reference (Microsoft.NET.Sdk.Web auto-includes appsettings*.json as Content
|
||||
// with CopyToOutputDirectory). This proves the safety net holds for whichever
|
||||
// environment/appsettings file ends up "active" at runtime - not just a
|
||||
// hand-typed literal - so a future edit that silently breaks the override key
|
||||
// or its value in any appsettings*.json file would fail this test.
|
||||
//
|
||||
// Note: this API has a single "recipient" field - there is no distinct
|
||||
// to/cc/bcc concept anywhere in the codebase (see ProcessWebComService.
|
||||
// SendEmailAsync / payload.recipient). "to/cc/bcc cleared" is therefore fully
|
||||
// satisfied by asserting that field no longer carries the original recipient
|
||||
// once an override is configured.
|
||||
public static IEnumerable<object[]> AppSettingsEnvironments()
|
||||
{
|
||||
yield return new object[] { "" }; // base appsettings.json only ("Production"-like default)
|
||||
foreach (var file in Directory.EnumerateFiles(AppContext.BaseDirectory, "appsettings.*.json"))
|
||||
{
|
||||
string[] parts = Path.GetFileName(file).Split('.');
|
||||
if (parts.Length == 3 && parts[0] == "appsettings" && parts[2] == "json")
|
||||
yield return new object[] { parts[1] };
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveOverrideRecipientForEnvironment(string environmentName)
|
||||
{
|
||||
var builder = new ConfigurationBuilder()
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false);
|
||||
if (!string.IsNullOrEmpty(environmentName))
|
||||
builder.AddJsonFile($"appsettings.{environmentName}.json", optional: true, reloadOnChange: false);
|
||||
return builder.Build()["Fuchs:Email:OverrideRecipient"] ?? "";
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AppSettingsEnvironments))]
|
||||
public async Task SendEmailAsync_OverrideRecipientFromRealAppsettings_ClearsRecipientWheneverConfigured(string environmentName)
|
||||
{
|
||||
string overrideRecipient = ResolveOverrideRecipientForEnvironment(environmentName);
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, overrideRecipient: overrideRecipient);
|
||||
|
||||
const string originalRecipient = "realcustomer@tenant-owner.example";
|
||||
bool result = await svc.SendEmailAsync(
|
||||
"env_" + (string.IsNullOrEmpty(environmentName) ? "base" : environmentName),
|
||||
"Subject", "<p>hi</p>", originalRecipient, "Kunde");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
string sentRecipient = json["recipient"]!.ToString();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(overrideRecipient))
|
||||
{
|
||||
// Override configured for this environment: the real recipient (to/cc/bcc)
|
||||
// must be fully cleared and replaced by the override - never leaked.
|
||||
Assert.Equal(overrideRecipient, sentRecipient);
|
||||
Assert.NotEqual(originalRecipient, sentRecipient);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No override configured for this environment: real recipient is used normally.
|
||||
Assert.Equal(originalRecipient, sentRecipient);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Attachment payload contract ────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_WithAttachment_EmbedsBase64InPayload()
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
# Invoice Lifecycle — From First Click to Customer Email
|
||||
|
||||
Concept document describing the full, end-to-end invoicing process in the
|
||||
Fuchs Intranet: how an office user turns a completed service request into an
|
||||
invoice, how the invoice is drafted/previewed/edited, how it is finalised, and
|
||||
how it reaches the customer by email (including resend and reminders). This
|
||||
complements [`INVOICE_SET_PRICING.md`](INVOICE_SET_PRICING.md) (pricing/display
|
||||
rules for set items) and [`EVAL_live_invoice_editing.md`](EVAL_live_invoice_editing.md)
|
||||
(why the editor is stateless).
|
||||
|
||||
> Scope note: this document describes the **implemented** flow in `Fuchs`
|
||||
> (ASP.NET Core MVC intranet, jQuery front-end). It does not cover the Razor
|
||||
> Pages areas of the workspace — the intranet/invoice module predates those and
|
||||
> is intentionally kept as-is (see `.github/copilot-instructions.md`).
|
||||
|
||||
## 1. Actors & building blocks
|
||||
|
||||
| Layer | Files | Responsibility |
|
||||
|---|---|---|
|
||||
| **Browser UI** | `js/intranet/modules/fis.req.js`, `fis.inv_shared.js`, `fis.inv.js` (bundled to `wwwroot/web/fis.req.de.js` / `fis.inv.de.js` via gulp `min:js`) | Request list, invoice editor dialog, PDF preview rendering, all user interaction |
|
||||
| **Controller** | `Controllers/IntranetController.Requests.cs`, `.Invoices.cs`, `.Invoices2.cs`, `.Reminder.cs` | Thin action dispatch (`req/*`, `inv/*`, `rem/*`), auth checks, request/response shaping |
|
||||
| **Services (DI)** | `Services/IInvoiceService` + `InvoiceService`, `Services/IComService` + `ProcessWebComService`, `Services/IPdfService` + `FuchsPdfService`, `Services/IReminderService` + `ReminderService` | Register/render/store invoices, send email, render PDF documents |
|
||||
| **Data model** | `code/FdsInvoiceData.cs` | Pure data holder: parses the posted `invc` JSON, builds SQL parameters (`BuildInvoiceParams`), exposes registration fields |
|
||||
| **PDF rendering** | `code/FuchsPdf.cs` (`ApplyInvoice`, `CreatePage_Letter`, `AddGirocode`), `code/InvoiceSetPricing.cs` | MigraDoc layout, draft watermark, GiroCode SEPA QR, set-pricing line transformation |
|
||||
| **Database** | `Fuchs_Database` SSDT project — `fds__prepInvoice`, `fds__createInvoice`, `fds__setInvoice`, `fds__createInvoice_Details`, `fds__setInvoiceFinal`, `fds__setInvoiceFile`, `fds__setInvoiceSent`, `fds__getInvoice`, `fds__newInvoiceId` | SQL-first persistence, invoice numbering, finalisation, sent/paid status |
|
||||
|
||||
All backend I/O is SQL-first via OCORE helpers (`getSQLDataSet_async`,
|
||||
`setSQLValue_async`) and stored procedures — there is no EF Core in this path
|
||||
(see `.github/instructions/ocore.instructions.md`).
|
||||
|
||||
## 2. High-level lifecycle
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Service request\ncompleted] --> B[Create invoice\nfrom request]
|
||||
B --> C[Draft invoice\nregistered]
|
||||
C --> D[Preview / Edit\nloop]
|
||||
D -->|adjust items,\naddress, set-mode...| D
|
||||
D -->|confirm| E[Finalise\ninvoice]
|
||||
E --> F[Render + store\nfinal PDF]
|
||||
F --> G[Email to\ncustomer]
|
||||
G --> H[Mark as sent]
|
||||
H -.optional.-> I[Resend email]
|
||||
H -.optional.-> J[Reminder /\nMahnung]
|
||||
E -.optional.-> K[Storno / Credit\nnote]
|
||||
```
|
||||
|
||||
## 3. Entry point — from the request list
|
||||
|
||||
Invoices are **not** created from a standalone "new invoice" wizard; they are
|
||||
always created *from* one or more completed service requests.
|
||||
|
||||
1. The office user opens the **Aufträge** (requests) list, rendered by
|
||||
`fis.req.js`. `$req.init2/init3` load the list from `req/reql`
|
||||
(`HandleRequestList` → `fds__getRequests_list[2]`).
|
||||
2. Selecting a request row and clicking the invoice icon calls
|
||||
`$inv.cInv` (bound in `fis.req.js` via `.click({ id: rw.Id }, $inv.cInv)`),
|
||||
which — after an auth check (`fds_inv` level 2) — calls `$inv.cInv2({ id })`.
|
||||
3. `$inv.cInv2` posts to **`req/get`** (`HandleRequestGet` →
|
||||
`fds__getRequest_details`, mode `r`) to load the request(s) plus any
|
||||
already-linked invoice, then renders the invoice editor dialog
|
||||
(`invoice_layout`) and wires `$inv.eM` (the contextual top menu: save,
|
||||
set-mode switch, §13b toggle, contact-person edit).
|
||||
4. Multiple requests can be bundled onto a single invoice (the editor supports
|
||||
several request "blocks", each becoming its own item group).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User (browser)
|
||||
participant JS as fis.req.js / fis.inv_shared.js
|
||||
participant C as IntranetController
|
||||
participant DB as SQL (fds__*)
|
||||
|
||||
U->>JS: click invoice icon on request row
|
||||
JS->>JS: $inv.cInv -> $inv.cInv2({id})
|
||||
JS->>C: POST req/get {id, mode:'r'}
|
||||
C->>DB: EXEC fds__getRequest_details
|
||||
DB-->>C: admin, requests, items, inv
|
||||
C-->>JS: JSON {admin, requests, inv}
|
||||
JS->>U: render invoice editor dialog (items, totals, address, email)
|
||||
```
|
||||
|
||||
## 4. Drafting, preview and editing
|
||||
|
||||
The invoice editor is **stateless**: the browser holds the working model
|
||||
(`table.invi` jQuery `.data()`), the server never caches a partial invoice
|
||||
between requests. Every preview/save round-trip posts the *entire* invoice
|
||||
payload; see `EVAL_live_invoice_editing.md` for the rationale.
|
||||
|
||||
### 4.1 What the user can change
|
||||
- **Line items** — quantities, prices, notes, combine into one sum
|
||||
(`$inv.rendersrq`, `$inv.quantChange`).
|
||||
- **Recipient fields** — invoice title, address, email, provision
|
||||
location/period (inline edit fields, `fm(...)` helper in `fis.inv_shared.js`).
|
||||
- **§13b reverse-charge** toggle (`$inv.sp13b`) — suppresses VAT lines/columns.
|
||||
- **Set-pricing display mode** (`$inv.ssetmode` / `setSetmode`) — `SetPrice`
|
||||
(default) / `ItemPrices` / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely
|
||||
presentational — totals never change.
|
||||
- **Contact person** for the invoice (`$inv.sctp`, stored in `CustomValues`).
|
||||
|
||||
All of this recalculates client-side totals live via the `fds.inv` event
|
||||
(`$inv.invSumUpdate`), which also builds the **backend item contract**
|
||||
(`$inv.itemToContract`: `{ id, type, title, desc, qty, price_net, total_net,
|
||||
vat }` plus set header/member tagging) that is posted to the server.
|
||||
|
||||
### 4.2 Posting a draft / preview
|
||||
|
||||
| User action | JS entry point | Endpoint | Controller handler | Effect |
|
||||
|---|---|---|---|---|
|
||||
| First save without preview | `$inv.ssave` | `req/save` | `Do_Process_Requests` case `save` | `RegisterInvoiceAsync(change: id present)` — creates or updates the draft row, **no PDF rendered** |
|
||||
| Preview a **new** invoice | `$inv.sprev(false)` | `req/sprep` | case `sprep` | Registers draft, then `GenerateInvoicePdf` → renders preview images |
|
||||
| Preview an **existing** draft | `$inv.sprev(true)` (aka `$inv.sedit`) | `req/sedit` | case `sedit` | Same as `sprep` but requires `id`, updates existing draft |
|
||||
| Cancel out of preview | dialog `cancel` handler | `req/sdel` | case `sdel` | `fds__remInvoice` — deletes the (unfinalised) draft row |
|
||||
|
||||
Before posting, `$inv.invcPayload(d)` normalizes the editor's internal field
|
||||
names into the exact names `FdsInvoiceData.BuildInvoiceParams` reads (totals
|
||||
from `sms.ttn/ttb`, `invoicetitle→title`, `loc→provisionlocation`,
|
||||
`paymentterms→paymentterm`, etc. — see `INVOICE_SET_PRICING.md` for the full
|
||||
mapping table). The posted shape is always `{ admin, req, sms, new }`.
|
||||
|
||||
On the server, `RegisterInvoiceAsync` (in `InvoiceService`) turns the posted
|
||||
JSON into SQL parameters and calls, in one batch:
|
||||
- **New invoice**: `fds__createInvoice` (allocates the `Id`, returns a fresh
|
||||
row) → `fds__createInvoice_Details` (service net/VAT + `InvoiceOptions`,
|
||||
e.g. `setmode:itemprices`, `§13b`).
|
||||
- **Existing draft**: `fds__setInvoice` (same parameter set, updates in place)
|
||||
→ `fds__createInvoice_Details` again.
|
||||
|
||||
`GenerateInvoicePdf` (still in `InvoiceService`) builds `FuchsPdf.FdsTextBlocks`
|
||||
and calls `IPdfService.WriteLetterAsync` + `ApplyInvoice`, which internally
|
||||
uses `InvoiceSetPricing.Build(...)` to turn the posted item contract into
|
||||
ordered, priced/unpriced lines. While the invoice is **not yet final**
|
||||
(`IsDraft == true`), the rendered PDF carries a diagonal **"Entwurf"/draft
|
||||
overlay watermark** (`CreatePage_Letter(..., draft: true)` stamps
|
||||
`Data/overlay.png`) and — importantly — **no GiroCode payment QR** is added
|
||||
(`AddGirocode` is only called `if (!inv.IsDraft && payAmount > 0 ...)`).
|
||||
The preview images are returned as base64 (`DocToImageCollectionAsync`) and
|
||||
shown in a modal (`$ocms.dlg(... form:false, button:'Rechnung erstellen' ...)`).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant JS as fis.inv_shared.js
|
||||
participant C as IntranetController.Requests
|
||||
participant S as InvoiceService
|
||||
participant DB as SQL
|
||||
|
||||
U->>JS: edit items / address / set-mode
|
||||
JS->>JS: fds.inv event -> invSumUpdate (recalculate + build items[])
|
||||
U->>JS: click "Vorschau" (preview)
|
||||
JS->>C: POST req/sprep or req/sedit {invc: JSON, id?}
|
||||
C->>S: RegisterInvoiceAsync(invoice, change, invId)
|
||||
S->>DB: fds__createInvoice / fds__setInvoice + fds__createInvoice_Details
|
||||
DB-->>S: registered invoice row (Id, ...)
|
||||
S->>S: GenerateInvoicePdf (draft=true -> watermark, no GiroCode)
|
||||
S-->>C: MigraDoc Document
|
||||
C-->>JS: {id, img[] (base64 pages), total}
|
||||
JS->>U: show PDF preview modal (confirm / cancel)
|
||||
```
|
||||
|
||||
From the preview modal the user can loop back to editing (close and adjust),
|
||||
**cancel** (deletes the draft via `req/sdel`), or **confirm** to finalise.
|
||||
|
||||
## 5. Finalisation ("Rechnung erstellen")
|
||||
|
||||
Confirming the preview modal posts to **`req/sconf`**
|
||||
(`HandleRequestSconf`):
|
||||
|
||||
1. `EXEC fds__setInvoiceFinal @Id, @authuser` — guarded by
|
||||
`isFinal = 0 AND isSent = 0` so it can only fire once per draft. This is
|
||||
also where the **real, sequential invoice number** is assigned:
|
||||
`[InvoiceId] = fds__newInvoiceId(YEAR())` → format `R<year>-<0000>`
|
||||
(the draft only ever had the internal numeric `Id`). `Version` is bumped
|
||||
and any linked "replaces" bookkeeping (storno/credit chains) is updated.
|
||||
2. On success (`IsFinal == true` returned), the controller:
|
||||
- Reloads the invoice via `LoadInvoiceAsync`.
|
||||
- Calls `StoreInvoiceDocumentFileAsync` → renders the **final** PDF
|
||||
(`draft` now reflects `IsFinal`, so the watermark disappears and the
|
||||
GiroCode payment QR is added) → `fds__setInvoiceFile` persists the PDF
|
||||
bytes on the invoice row → `IBlobStorageService.UploadInvoicePdfAsync`
|
||||
archives a copy to blob storage.
|
||||
- Re-fetches the invoice (`fds__getInvoice`) to get the final
|
||||
`SendToEmail` / `DocumentName` / `InvoiceBalance`.
|
||||
3. If a recipient email is present and the PDF rendered, the controller emails
|
||||
it immediately (see §6) and marks the invoice `Sent` via
|
||||
`fds__setInvoiceSent @auto=true`.
|
||||
4. The browser opens the stored PDF in a new tab (`req/idoc`), returns to the
|
||||
request list, and reloads it (`$ocms.init('req')`, `$inv.rReload()`).
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[POST req/sconf] --> B{fds__setInvoiceFinal\nisFinal=0 AND isSent=0?}
|
||||
B -- no --> Z[500 - Aktion war nicht erfolgreich]
|
||||
B -- yes --> C[Assign real InvoiceId\nR-yyyy-nnnn, bump Version]
|
||||
C --> D[LoadInvoiceAsync]
|
||||
D --> E[Render final PDF\n[no watermark, + GiroCode]]
|
||||
E --> F[fds__setInvoiceFile\npersist PDF bytes]
|
||||
F --> G[Upload to blob storage\nIBlobStorageService]
|
||||
G --> H{SendToEmail set\nand PDF non-empty?}
|
||||
H -- yes --> I[IComService.SendEmailAsync\nattach PDF inline]
|
||||
I --> J[fds__setInvoiceSent auto=true]
|
||||
H -- no --> K[Skip email - PDF only]
|
||||
J --> L[Open PDF in new tab / reload request list]
|
||||
K --> L
|
||||
```
|
||||
|
||||
## 6. Sending the invoice by email
|
||||
|
||||
Email delivery is handled by `IComService` / `ProcessWebComService`, which
|
||||
talks to the **ProcessWeb Mailer API** (`push_com`, `comType=email`).
|
||||
|
||||
- `HandleRequestSconf` builds the HTML body via `BuildInvoiceBody(balance,
|
||||
paymentTerms)` — a fixed German thank-you text, the amount (if non-zero),
|
||||
and payment instructions (IBAN/BIC) with the invoice's payment term
|
||||
(`fdInv.PaymentTerms`, e.g. `10wd` → "10 Werktagen").
|
||||
- The rendered PDF bytes are attached as `{ [DocumentName] = filebyte }` — a
|
||||
single-entry dictionary of filename → bytes.
|
||||
- `SendEmailAsync(reference, subject, htmlBody, recipient, displayName,
|
||||
attachments)` is called with `reference = "inv_<InvoiceId>"`,
|
||||
`subject = "SanitärFuchs - <DocumentName>"`.
|
||||
- Inside `ProcessWebComService.SendEmailAsync`:
|
||||
- The recipient email is validated; a configured
|
||||
`Fuchs:Email:OverrideRecipient` (dev/test safety net, must stay empty in
|
||||
Production) redirects **all** outbound mail to a single address.
|
||||
- A signature is appended to the HTML body.
|
||||
- If the mailer is disabled (`_settings.Enabled == false`), sending is a
|
||||
documented no-op.
|
||||
- Attachments are base64-encoded inline (`{ filename, mimeType,
|
||||
contentBase64 }`) and POSTed to the Mailer API.
|
||||
- Only on a **successful** send does the controller call
|
||||
`fds__setInvoiceSent @auto=true`, which sets `IsSent = 1` — so a failed
|
||||
mailer call leaves the invoice finalised but *not marked sent* (visible via
|
||||
the "sis" — "als versendet markieren" — manual action, see §7).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as IntranetController
|
||||
participant Com as IComService (ProcessWebComService)
|
||||
participant API as ProcessWeb Mailer API
|
||||
participant DB as SQL
|
||||
|
||||
C->>C: BuildInvoiceBody(balance, paymentTerms)
|
||||
C->>Com: SendEmailAsync("inv_<Id>", subject, body, email, "", {pdf})
|
||||
Com->>Com: validate email, apply OverrideRecipient (dev), append signature
|
||||
alt mailer enabled
|
||||
Com->>API: POST push_com {comType:"email", attachments:[base64 pdf]}
|
||||
API-->>Com: success/failure
|
||||
else mailer disabled
|
||||
Com-->>Com: no-op (logged)
|
||||
end
|
||||
Com-->>C: sent: true/false
|
||||
opt sent == true
|
||||
C->>DB: EXEC fds__setInvoiceSent @auto=true
|
||||
end
|
||||
```
|
||||
|
||||
## 7. After finalisation — manual actions
|
||||
|
||||
Once an invoice is `IsFinal`, the request/invoice list menus
|
||||
(`$inv.iMn`/`$inv.iMnr` in `fis.inv_shared.js`) expose:
|
||||
|
||||
| Action | JS | Endpoint | Notes |
|
||||
|---|---|---|---|
|
||||
| **Resend email** | `$inv.ccInv`/context menu → *(re-)send* → `HandleRequestResend` | `req/resend` | Re-renders the stored/registered PDF bytes (`RenderInvoicePdfBytesAsync`, no re-registration) and re-sends via `IComService`, **without** re-touching `IsSent` |
|
||||
| **Mark as sent manually** | `$inv.sis(id)` | `inv/sis` | `fds__setInvoiceSent @auto=false` — for invoices sent outside the system (e.g. printed/posted) |
|
||||
| **View / display PDF** | `$inv.disp(id, 'inv')` | `inv/rdoc` | Renders page images for on-screen display only |
|
||||
| **Download / open PDF** | `req/idoc` or `inv/pget`-derived | `req/idoc` | Serves the stored (or freshly rendered) PDF inline |
|
||||
| **Mark paid / unpaid** | `$inv.setPyd`/`setUpd` | `inv/setpyd` / `inv/setupd` | `fds__setInvoicePayed` / `fds__setInvoiceUNPayed` |
|
||||
| **Storno (cancel)** | `$inv.storno` → `$inv.cSt` | `inv/storno` (mode `simple`/`copy`) | `fds__createStorno_simple` / `fds__createStorno_copy`; the replaced invoice is cross-linked (`Replaces_InvId`) and cancelled once the storno itself is finalised |
|
||||
| **Credit note** | `$inv.credit` → `$inv.cSt` | `inv/credit` (mode `credit`) | `fds__createCredit_simple` — same preview/finalise/email loop as a normal invoice |
|
||||
| **Continue editing a draft** | `$inv.cntInv` / `clCntInv` | `inv/get` | Only available while `isFinal == false`; re-opens the same stateless editor |
|
||||
|
||||
Storno/credit invoices are **new invoice drafts** created from the original
|
||||
one — they go through the exact same preview → finalise → email pipeline
|
||||
described in §4–§6, just seeded from `fds__createStorno_*`/`fds__createCredit_simple`
|
||||
instead of from a service request.
|
||||
|
||||
## 8. Reminders (Mahnungen) — downstream of a sent, unpaid invoice
|
||||
|
||||
Not part of the invoice creation flow itself, but the natural continuation
|
||||
once an invoice is sent and remains unpaid:
|
||||
|
||||
- `$inv.ccRem(id, InvoiceId)` → `rem/lrem` (load prior reminder history) →
|
||||
reminder editor (`$inv.ccRem_s2`) → `rem/get` → **preview** via `rem/prep`
|
||||
(`$inv.rprev`, same stateless pattern as invoice preview) → **finalise**
|
||||
via `rem/conf` → PDF opened, list reloaded.
|
||||
- `$inv.dspRem(id)` lists existing reminders (`inv/getrem`); each can be
|
||||
resent (`rem/resend`, mirrors `req/resend`) or downloaded.
|
||||
- `$inv.srs(id)` → `rem/srs` marks a reminder as sent manually (mirrors
|
||||
`inv/sis`).
|
||||
- Reminder PDFs use the same draft-watermark/GiroCode rule as invoices
|
||||
(`FuchsPdf.ApplyReminder`, `rem.IsDraft`).
|
||||
|
||||
This is handled by `IReminderService`/`ReminderService` and
|
||||
`IntranetController.Reminder.cs`, following the identical
|
||||
draft-preview-finalise-email shape as invoices — intentionally, so the two
|
||||
flows share the same mental model for the office user.
|
||||
|
||||
## 9. End-to-end summary diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Requests
|
||||
R1[Service request completed] --> R2[Open request list - req/reql]
|
||||
R2 --> R3[Select request(s), click invoice icon]
|
||||
end
|
||||
|
||||
subgraph Draft & Edit
|
||||
R3 --> D1[req/get - load request + existing invoice]
|
||||
D1 --> D2[Edit items, address, email,\nset-mode, §13b, contact]
|
||||
D2 --> D3[req/save - persist draft only]
|
||||
D2 --> D4[req/sprep or req/sedit -\nregister + render draft PDF]
|
||||
D4 --> D5[Preview modal\ndraft watermark, no GiroCode]
|
||||
D5 -->|edit more| D2
|
||||
D5 -->|cancel| D6[req/sdel - delete draft]
|
||||
end
|
||||
|
||||
subgraph Finalise & Send
|
||||
D5 -->|confirm| F1[req/sconf -\nfds__setInvoiceFinal]
|
||||
F1 --> F2[Assign real InvoiceId\nR-yyyy-nnnn]
|
||||
F2 --> F3[Render final PDF\nno watermark, + GiroCode]
|
||||
F3 --> F4[fds__setInvoiceFile +\nblob storage upload]
|
||||
F4 --> F5{SendToEmail set?}
|
||||
F5 -- yes --> F6[IComService.SendEmailAsync\nProcessWeb Mailer API]
|
||||
F6 --> F7[fds__setInvoiceSent auto=true]
|
||||
F5 -- no --> F8[PDF stored, not emailed]
|
||||
end
|
||||
|
||||
subgraph After Sending
|
||||
F7 --> A1[req/resend - resend email]
|
||||
F7 --> A2[inv/setpyd / setupd - payment status]
|
||||
F7 --> A3[inv/storno or inv/credit -\nnew linked invoice draft]
|
||||
F7 --> A4[rem/* - reminder / Mahnung flow]
|
||||
A3 -.re-enters.-> D2
|
||||
end
|
||||
```
|
||||
|
||||
## 10. Key invariants worth remembering
|
||||
|
||||
- **Stateless editor**: every preview/save/finalise call re-posts the full
|
||||
`invc` JSON; the server never holds a partial invoice in memory or session
|
||||
between requests (see `EVAL_live_invoice_editing.md`).
|
||||
- **Totals come from the registration, not the rendered lines**: `sms.ttn`
|
||||
/`sms.ttb` (posted) become `InvoiceBalance`/`InvoiceBalance_net`; display
|
||||
mode (set pricing) never changes what the customer owes.
|
||||
- **Numbering happens only at finalisation**: the draft has an internal `Id`
|
||||
but no `InvoiceId` until `fds__setInvoiceFinal` assigns
|
||||
`R<year>-<sequence>` — so previews never "burn" an invoice number.
|
||||
- **Draft vs. final changes the rendered PDF**: draft = watermark overlay, no
|
||||
GiroCode; final = no watermark, GiroCode payment QR added when there's a
|
||||
positive balance.
|
||||
- **Email is best-effort and tracked**: `IsSent` is only set `true`
|
||||
automatically after a *successful* send; a failed send still leaves a
|
||||
correctly finalised, stored invoice that staff can resend or mark sent
|
||||
manually.
|
||||
- **Storno/credit/reminder all reuse the same pipeline**: they are not special
|
||||
cases in the UI/backend contract — they are just differently-seeded drafts
|
||||
going through the identical preview → finalise → email sequence.
|
||||
5
|
||||
+9
-8
@@ -34,12 +34,12 @@
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.16.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.16.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.15.2" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.16.0" />
|
||||
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
|
||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||
<PackageReference Include="PDFsharp" Version="6.2.4" />
|
||||
@@ -51,10 +51,11 @@
|
||||
<PackageReference Include="MimeKit" Version="4.17.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<!-- New packages (needed for .NET 10) -->
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="4.0.0" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.8" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.8" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.9" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.9" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.29.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="App_Data\cache\" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace Fuchs.Observability;
|
||||
@@ -41,6 +41,10 @@ public static class FuchsTelemetry
|
||||
Meter.CreateCounter<long>("fuchs.banking.mt940.rows", "{row}", "Number of MT940 transaction lines parsed.");
|
||||
public static readonly Counter<long> MfrCalls =
|
||||
Meter.CreateCounter<long>("fuchs.mfr.calls", "{call}", "Number of MFR ERP client calls initiated.");
|
||||
public static readonly Counter<long> BlobUploadsSucceeded =
|
||||
Meter.CreateCounter<long>("fuchs.blobstorage.uploads", "{upload}", "Number of documents successfully archived to Azure Blob Storage.");
|
||||
public static readonly Counter<long> BlobUploadsFailed =
|
||||
Meter.CreateCounter<long>("fuchs.blobstorage.uploads.failed", "{upload}", "Number of documents that failed to archive to Azure Blob Storage.");
|
||||
|
||||
// ── Performance histograms (durations in milliseconds) ───────────────────
|
||||
public static readonly Histogram<double> PdfRenderDuration =
|
||||
|
||||
+23
-6
@@ -36,8 +36,10 @@ public class Program
|
||||
// Key Vault + DPAPI secret management (must run before FuchsOcmsIntranet.Initialize)
|
||||
builder.AddSecretManagement();
|
||||
|
||||
// Assemble connection strings from templates + resolved credentials
|
||||
AssembleConnectionStrings(builder.Configuration);
|
||||
// Assemble connection strings from templates + resolved credentials.
|
||||
// In Development, "_Dev"-suffixed credential keys are preferred so a reachable
|
||||
// Key Vault can never override them with production DB credentials.
|
||||
AssembleConnectionStrings(builder.Configuration, builder.Environment);
|
||||
|
||||
// Initialize the Fuchs intranet singleton with configuration
|
||||
FuchsOcmsIntranet.Initialize(builder.Configuration);
|
||||
@@ -80,6 +82,9 @@ public class Program
|
||||
|
||||
// Communication service (email + SMS via ProcessWeb Mailer API)
|
||||
builder.Services.Configure<ProcessWebComSettings>(builder.Configuration.GetSection("Fuchs:Mailer"));
|
||||
// Dev/test safety net: Fuchs:Email:OverrideRecipient redirects every outbound email
|
||||
// (see appsettings.Development.json) so real tenant-owners/end-customers are never emailed.
|
||||
builder.Services.Configure<FuchsEmailSettings>(builder.Configuration.GetSection("Fuchs:Email"));
|
||||
builder.Services.AddHttpClient("ProcessWebMailer");
|
||||
builder.Services.AddScoped<IComService, ProcessWebComService>();
|
||||
|
||||
@@ -92,6 +97,15 @@ public class Program
|
||||
builder.Services.AddScoped<IInvoiceService, InvoiceService>();
|
||||
builder.Services.AddScoped<IReminderService, ReminderService>();
|
||||
|
||||
// Secondary archive: invoice/reminder PDFs additionally stored in Azure Blob Storage.
|
||||
// Disabled by default (Fuchs:AzureStorage:Enabled) — see AzureBlobStorageService.
|
||||
builder.Services.Configure<AzureBlobStorageSettings>(builder.Configuration.GetSection("Fuchs:AzureStorage"));
|
||||
builder.Services.AddSingleton<IBlobStorageService, AzureBlobStorageService>();
|
||||
|
||||
// One-shot startup backfill: archives invoices/reminders that already had a file in SQL
|
||||
// before Blob Storage archiving was enabled. No-ops when Fuchs:AzureStorage:Enabled is false.
|
||||
builder.Services.AddHostedService<DocumentArchiveSyncService>();
|
||||
|
||||
// ── OpenTelemetry: tracing + metrics ─────────────────────────────────
|
||||
// Instrumentation is always collected; OTLP export is enabled only when
|
||||
// an endpoint is configured (Fuchs:Telemetry:OtlpEndpoint), so a missing
|
||||
@@ -172,15 +186,18 @@ public class Program
|
||||
/// resolved credential secrets, then overrides the config entries in-place.
|
||||
/// When appsettings.Development.json supplies a complete connection string (no tokens),
|
||||
/// the replace is a no-op and the original value is preserved.
|
||||
/// In Development, "{key}_Dev" credential keys are tried first. These are never populated
|
||||
/// by Key Vault (no matching ManagedSecretKeys entry exists for them), so a developer whose
|
||||
/// machine happens to reach the shared Key Vault can never have production DB credentials
|
||||
/// silently override their local appsettings.Development.json values.
|
||||
/// </summary>
|
||||
private static void AssembleConnectionStrings(ConfigurationManager config)
|
||||
private static void AssembleConnectionStrings(ConfigurationManager config, IWebHostEnvironment environment)
|
||||
{
|
||||
const string userToken = "{username}";
|
||||
const string passToken = "{password}";
|
||||
|
||||
(string csName, string userKey, string passKey)[] pairs =
|
||||
[
|
||||
("ocms_ConnectionString", "ConnectionStrings:ocms_username", "ConnectionStrings:ocms_password"),
|
||||
("fuchs_fds_ConnectionString", "ConnectionStrings:fuchs_fds_username", "ConnectionStrings:fuchs_fds_password"),
|
||||
];
|
||||
|
||||
@@ -192,8 +209,8 @@ public class Program
|
||||
if (!template.Contains(userToken, StringComparison.Ordinal) &&
|
||||
!template.Contains(passToken, StringComparison.Ordinal)) continue;
|
||||
|
||||
var user = config[userKey] ?? "";
|
||||
var pass = config[passKey] ?? "";
|
||||
var user = (environment.IsDevelopment() ? config[$"{userKey}_Dev"] : null) ?? config[userKey] ?? "";
|
||||
var pass = (environment.IsDevelopment() ? config[$"{passKey}_Dev"] : null) ?? config[passKey] ?? "";
|
||||
overrides[$"ConnectionStrings:{csName}"] = template
|
||||
.Replace(userToken, user, StringComparison.Ordinal)
|
||||
.Replace(passToken, pass, StringComparison.Ordinal);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Diagnostics;
|
||||
using Azure;
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Archives finalized invoice/reminder PDFs (and, via <see cref="UploadDocumentAsync"/>, any
|
||||
/// future file-bearing document type) to Azure Blob Storage, in addition to the existing SQL
|
||||
/// Server storage (see <see cref="InvoiceService"/> and <see cref="ReminderService"/>). This is
|
||||
/// a best-effort secondary archive: when <see cref="AzureBlobStorageSettings.Enabled"/> is
|
||||
/// <c>false</c> (default) or no connection string is configured, uploads are skipped and only
|
||||
/// logged; upload failures are caught and logged rather than propagated, so a missing or
|
||||
/// unreachable storage account never breaks invoice/reminder finalization.
|
||||
/// </summary>
|
||||
public class AzureBlobStorageService : IBlobStorageService
|
||||
{
|
||||
private readonly ILogger<AzureBlobStorageService> _logger;
|
||||
private readonly AzureBlobStorageSettings _settings;
|
||||
private readonly BlobServiceClient? _client;
|
||||
|
||||
public AzureBlobStorageService(IConfiguration configuration,
|
||||
IOptions<AzureBlobStorageSettings> settings,
|
||||
ILogger<AzureBlobStorageService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_settings = settings.Value;
|
||||
|
||||
if (_settings.Enabled)
|
||||
{
|
||||
string? connectionString = configuration.GetConnectionString("AzureBlobStorage_ConnectionString");
|
||||
if (!string.IsNullOrWhiteSpace(connectionString) && connectionString != "MANAGED_BY_KEYVAULT")
|
||||
{
|
||||
_client = new BlobServiceClient(connectionString);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"AzureBlobStorageService is enabled but ConnectionStrings:AzureBlobStorage_ConnectionString " +
|
||||
"is not configured — uploads will be skipped.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Test-only constructor allowing an already-built (typically mocked) client to be injected.</summary>
|
||||
internal AzureBlobStorageService(BlobServiceClient? client, AzureBlobStorageSettings settings,
|
||||
ILogger<AzureBlobStorageService> logger)
|
||||
{
|
||||
_client = client;
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<Uri?> UploadInvoicePdfAsync(string invoiceId, string fileName, byte[] content,
|
||||
IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default)
|
||||
=> UploadAsync("invoice", _settings.InvoiceContainer, invoiceId, fileName, content, sourceRow, cancellationToken);
|
||||
|
||||
public Task<Uri?> UploadReminderPdfAsync(string reminderId, string fileName, byte[] content,
|
||||
IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default)
|
||||
=> UploadAsync("reminder", _settings.ReminderContainer, reminderId, fileName, content, sourceRow, cancellationToken);
|
||||
|
||||
public Task<Uri?> UploadDocumentAsync(string category, string containerName, string documentId, string fileName,
|
||||
byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default)
|
||||
=> UploadAsync(category, containerName, documentId, fileName, content, sourceRow, cancellationToken);
|
||||
|
||||
public async Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_client == null) return false;
|
||||
|
||||
string blobName = BuildBlobName(documentId, fileName);
|
||||
try
|
||||
{
|
||||
var containerClient = _client.GetBlobContainerClient(containerName);
|
||||
var blobClient = containerClient.GetBlobClient(blobName);
|
||||
Response<bool> response = await blobClient.ExistsAsync(cancellationToken);
|
||||
return response.Value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Blob existence check failed for {Container}/{Blob} — treating as not archived.",
|
||||
containerName, blobName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildBlobName(string documentId, string fileName) =>
|
||||
$"{documentId}/{(string.IsNullOrWhiteSpace(fileName) ? $"{documentId}.pdf" : fileName)}";
|
||||
|
||||
private async Task<Uri?> UploadAsync(string category, string containerName, string documentId,
|
||||
string fileName, byte[] content, IReadOnlyDictionary<string, object?>? sourceRow, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
_logger.LogDebug("Blob upload skipped for {Category} {Id} — storage disabled/unconfigured.", category, documentId);
|
||||
return null;
|
||||
}
|
||||
if (content.Length == 0)
|
||||
{
|
||||
_logger.LogWarning("Blob upload skipped for {Category} {Id} — empty content.", category, documentId);
|
||||
return null;
|
||||
}
|
||||
|
||||
using var act = FuchsTelemetry.StartActivity("blobstorage.upload");
|
||||
act?.SetTag("fuchs.blobstorage.category", category);
|
||||
act?.SetTag("fuchs.blobstorage.id", documentId);
|
||||
string blobName = BuildBlobName(documentId, fileName);
|
||||
Dictionary<string, string>? metadata = sourceRow != null
|
||||
? DocumentMetadataBuilder.Build(sourceRow, _settings.MetadataFields)
|
||||
: null;
|
||||
|
||||
try
|
||||
{
|
||||
var containerClient = _client.GetBlobContainerClient(containerName);
|
||||
await containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken);
|
||||
var blobClient = containerClient.GetBlobClient(blobName);
|
||||
using var stream = new MemoryStream(content, writable: false);
|
||||
if (metadata is { Count: > 0 })
|
||||
{
|
||||
var options = new BlobUploadOptions { Metadata = metadata };
|
||||
await blobClient.UploadAsync(stream, options, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await blobClient.UploadAsync(stream, overwrite: true, cancellationToken);
|
||||
}
|
||||
|
||||
FuchsTelemetry.BlobUploadsSucceeded.Add(1, new KeyValuePair<string, object?>("category", category));
|
||||
_logger.LogInformation("Uploaded {Category} {Id} to container {Container} as {Blob}.",
|
||||
category, documentId, containerName, blobName);
|
||||
return blobClient.Uri;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FuchsTelemetry.BlobUploadsFailed.Add(1, new KeyValuePair<string, object?>("category", category));
|
||||
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
_logger.LogError(ex, "Blob upload failed for {Category} {Id} in container {Container}.",
|
||||
category, documentId, containerName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Azure Blob Storage settings, bound from appsettings.json → "Fuchs:AzureStorage".
|
||||
/// The storage account connection string itself is a secret and therefore lives
|
||||
/// under the standard <c>ConnectionStrings</c> key (see <see cref="AzureBlobStorageService"/>,
|
||||
/// which reads it via <c>IConfiguration.GetConnectionString("AzureBlobStorage_ConnectionString")</c>)
|
||||
/// instead of being bound here.
|
||||
/// </summary>
|
||||
public class AzureBlobStorageSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// When <c>false</c> (default) blob uploads are skipped and only logged, so the
|
||||
/// feature is opt-in and never impacts environments that haven't configured a
|
||||
/// storage account + Key Vault secret yet. Set to <c>true</c> to enable archiving.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>Blob container that stores finalized invoice PDFs.</summary>
|
||||
public string InvoiceContainer { get; set; } = "fuchs-invoices";
|
||||
|
||||
/// <summary>Blob container that stores finalized reminder PDFs.</summary>
|
||||
public string ReminderContainer { get; set; } = "fuchs-reminders";
|
||||
|
||||
/// <summary>
|
||||
/// Column/property names considered when building the per-blob metadata dictionary
|
||||
/// (see <see cref="DocumentMetadataBuilder"/>). Not every document type has every
|
||||
/// column: fields absent from a given source row are skipped entirely, while fields
|
||||
/// that are present but hold an empty value are still stored as an empty string.
|
||||
/// </summary>
|
||||
public List<string> MetadataFields { get; set; } =
|
||||
new() { "Id", "Version", "InvoiceId", "InvoiceTitle", "InvId", "DocumentName", "file_guid" };
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Data;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using static OCORE.commons;
|
||||
using static OCORE.SQL.sql;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Startup backfill for the Azure Blob Storage secondary archive (see <see cref="AzureBlobStorageService"/>).
|
||||
/// When <see cref="AzureBlobStorageSettings.Enabled"/> is <c>true</c>, this one-shot background task
|
||||
/// enumerates every invoice/reminder that already has a file stored in SQL Server
|
||||
/// (<c>fds__getInvoiceFiles_ForBlobArchive</c> / <c>fds__getReminderFiles_ForBlobArchive</c>), skips
|
||||
/// documents already archived (<see cref="IBlobStorageService.ExistsAsync"/>), and uploads the rest —
|
||||
/// fetching bytes lazily via <c>fds__getInvoiceFileContent</c> / <c>fds__getReminderFileContent</c> so the
|
||||
/// enumeration query itself stays lightweight (no VARBINARY column). New invoices/reminders created after
|
||||
/// startup are archived inline by <see cref="InvoiceService"/> / <see cref="ReminderService"/>; this service
|
||||
/// only covers historical documents that predate the feature being enabled.
|
||||
/// Runs once at startup (not periodic) and never throws: failures are logged so a database or storage
|
||||
/// hiccup during startup can never prevent the app from serving requests.
|
||||
/// </summary>
|
||||
public class DocumentArchiveSyncService : BackgroundService
|
||||
{
|
||||
private const int BackfillConcurrency = 4;
|
||||
|
||||
private readonly Fuchs_intranet _intranet;
|
||||
private readonly IBlobStorageService _blobStorage;
|
||||
private readonly AzureBlobStorageSettings _settings;
|
||||
private readonly ILogger<DocumentArchiveSyncService> _logger;
|
||||
|
||||
public DocumentArchiveSyncService(Fuchs_intranet intranet, IBlobStorageService blobStorage,
|
||||
IOptions<AzureBlobStorageSettings> settings, ILogger<DocumentArchiveSyncService> logger)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_blobStorage = blobStorage;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string Conn => _intranet.Intranet__SQLConnectionString;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!_settings.Enabled)
|
||||
{
|
||||
_logger.LogDebug("DocumentArchiveSyncService skipped — Fuchs:AzureStorage:Enabled is false.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var act = FuchsTelemetry.StartActivity("blobstorage.backfill");
|
||||
_logger.LogInformation("DocumentArchiveSyncService starting startup backfill.");
|
||||
try
|
||||
{
|
||||
int invoices = await SyncInvoicesAsync(stoppingToken);
|
||||
int reminders = await SyncRemindersAsync(stoppingToken);
|
||||
_logger.LogInformation(
|
||||
"DocumentArchiveSyncService completed: {Invoices} invoice(s), {Reminders} reminder(s) newly archived.",
|
||||
invoices, reminders);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("DocumentArchiveSyncService backfill cancelled (application shutting down).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "DocumentArchiveSyncService backfill failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> SyncInvoicesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var dt = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__getInvoiceFiles_ForBlobArchive];",
|
||||
Conn, Security: _intranet.GetDbSecurity(), options: new FIS_SQLOptions());
|
||||
if (dt.Count == 0) return 0;
|
||||
|
||||
int archived = 0;
|
||||
var rows = dt.DataTable.Rows.Cast<DataRow>().ToList();
|
||||
await Parallel.ForEachAsync(rows,
|
||||
new ParallelOptions { MaxDegreeOfParallelism = BackfillConcurrency, CancellationToken = cancellationToken },
|
||||
async (row, ct) =>
|
||||
{
|
||||
string id = row.nz("Id");
|
||||
if (string.IsNullOrEmpty(id)) return;
|
||||
try
|
||||
{
|
||||
string fileName = row.nz("DocumentName").ne($"Rechnung_{id}.pdf");
|
||||
if (await _blobStorage.ExistsAsync(_settings.InvoiceContainer, id, fileName, ct))
|
||||
return;
|
||||
|
||||
byte[]? content = await GetFileContentAsync(
|
||||
"EXECUTE [dbo].[fds__getInvoiceFileContent] @Id;", id);
|
||||
if (content is not { Length: > 0 }) return;
|
||||
|
||||
var uri = await _blobStorage.UploadInvoicePdfAsync(
|
||||
id, fileName, content, row.toObjectDictionary(), ct);
|
||||
if (uri != null) Interlocked.Increment(ref archived);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invoice backfill failed for {Id} — skipping.", id);
|
||||
}
|
||||
});
|
||||
return archived;
|
||||
}
|
||||
|
||||
private async Task<int> SyncRemindersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var dt = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__getReminderFiles_ForBlobArchive];",
|
||||
Conn, Security: _intranet.GetDbSecurity(), options: new FIS_SQLOptions());
|
||||
if (dt.Count == 0) return 0;
|
||||
|
||||
int archived = 0;
|
||||
var rows = dt.DataTable.Rows.Cast<DataRow>().ToList();
|
||||
await Parallel.ForEachAsync(rows,
|
||||
new ParallelOptions { MaxDegreeOfParallelism = BackfillConcurrency, CancellationToken = cancellationToken },
|
||||
async (row, ct) =>
|
||||
{
|
||||
string id = row.nz("Id");
|
||||
if (string.IsNullOrEmpty(id)) return;
|
||||
try
|
||||
{
|
||||
string fileName = row.nz("DocumentName").ne($"Zahlungserinnerung_{id}.pdf");
|
||||
if (await _blobStorage.ExistsAsync(_settings.ReminderContainer, id, fileName, ct))
|
||||
return;
|
||||
|
||||
byte[]? content = await GetFileContentAsync(
|
||||
"EXECUTE [dbo].[fds__getReminderFileContent] @Id;", id);
|
||||
if (content is not { Length: > 0 }) return;
|
||||
|
||||
var uri = await _blobStorage.UploadReminderPdfAsync(
|
||||
id, fileName, content, row.toObjectDictionary(), ct);
|
||||
if (uri != null) Interlocked.Increment(ref archived);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Reminder backfill failed for {Id} — skipping.", id);
|
||||
}
|
||||
});
|
||||
return archived;
|
||||
}
|
||||
|
||||
private async Task<byte[]?> GetFileContentAsync(string sql, string id)
|
||||
{
|
||||
var pl = new List<SqlParameter> { SQL_VarChar("@Id", id) };
|
||||
var dt = await getSQLDatatable_async(sql, Conn, pl,
|
||||
Security: _intranet.GetDbSecurity(), options: new FIS_SQLOptions());
|
||||
return dt.Count > 0 ? dt.FirstRow.no("file", null) as byte[] : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the per-blob metadata dictionary used when archiving documents (invoice/reminder PDFs,
|
||||
/// and any future file-bearing type) to Azure Blob Storage — see <see cref="AzureBlobStorageService"/>
|
||||
/// and <see cref="AzureBlobStorageSettings.MetadataFields"/>.
|
||||
/// Only fields configured in <see cref="AzureBlobStorageSettings.MetadataFields"/> that are ALSO
|
||||
/// present as a key on the source row are included: a field absent from a given document type's
|
||||
/// row shape (e.g. reminders have no <c>file_guid</c>) is skipped entirely, while a field that is
|
||||
/// present but holds a null/empty value is still emitted as an empty string.
|
||||
/// </summary>
|
||||
public static class DocumentMetadataBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Projects <paramref name="row"/> onto <paramref name="fields"/>. Column lookup is
|
||||
/// case-insensitive because SQL-sourced rows (see <c>toObjectDictionary</c> /
|
||||
/// <c>GenericObjectDictionary</c>) are frequently lower-cased.
|
||||
/// </summary>
|
||||
public static Dictionary<string, string> Build(IReadOnlyDictionary<string, object?> row, IEnumerable<string> fields)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>();
|
||||
foreach (string field in fields)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(field)) continue;
|
||||
if (!TryGetValue(row, field, out object? value)) continue;
|
||||
metadata[field] = Stringify(value);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static bool TryGetValue(IReadOnlyDictionary<string, object?> row, string field, out object? value)
|
||||
{
|
||||
if (row.TryGetValue(field, out value)) return true;
|
||||
|
||||
// Fall back to a case-insensitive match: rows built from SQL results are frequently
|
||||
// lower-cased (see toObjectDictionary/GenericObjectDictionary) while MetadataFields
|
||||
// entries are written using the column's natural casing (e.g. "InvoiceId").
|
||||
foreach (var kvp in row)
|
||||
{
|
||||
if (string.Equals(kvp.Key, field, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = kvp.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string Stringify(object? value) =>
|
||||
value switch
|
||||
{
|
||||
null => "",
|
||||
DBNull => "",
|
||||
DateTime dt => dt.ToString("O"),
|
||||
_ => value.ToString() ?? ""
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Email safety-net settings, bound from appsettings.json → "Fuchs:Email".
|
||||
/// </summary>
|
||||
public class FuchsEmailSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Dev/test safety net: when set to a non-empty address, <see cref="ProcessWebComService"/>
|
||||
/// discards the real recipient of every outbound email (to/cc/bcc) and redirects it to this
|
||||
/// single address instead, so a locally-enabled mailer can never reach a real tenant-owner or
|
||||
/// end-customer while testing. Configure this only in <c>appsettings.Development.json</c> —
|
||||
/// it must stay empty/unset in Production.
|
||||
/// </summary>
|
||||
public string? OverrideRecipient { get; set; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -124,7 +124,8 @@ public class FuchsWidgetService : IWidgetService
|
||||
{
|
||||
case "sql_table":
|
||||
{
|
||||
var dt = await getSQLDatatable_async(sql, Conn, Params(userAccountId), Security: dbSec);
|
||||
var dt = await getSQLDatatable_async(sql, Conn, Params(userAccountId), Security: dbSec,
|
||||
options: new FIS_SQLOptions { CommandTimeout = 90 });
|
||||
widgetData = new
|
||||
{
|
||||
name,
|
||||
@@ -141,7 +142,8 @@ public class FuchsWidgetService : IWidgetService
|
||||
|
||||
case "sql_indicator":
|
||||
{
|
||||
var dt = await getSQLDatatable_async(sql, Conn, Params(userAccountId), Security: dbSec);
|
||||
var dt = await getSQLDatatable_async(sql, Conn, Params(userAccountId), Security: dbSec,
|
||||
options: new FIS_SQLOptions { CommandTimeout = 90 });
|
||||
var firstRow = dt.DataTable.Rows.Count > 0
|
||||
? dt.DataTable.Rows[0].toObjectDictionary()
|
||||
: new Dictionary<string, object?>();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for archiving finalized documents (invoice/reminder PDFs, and any future
|
||||
/// file-bearing type) to Azure Blob Storage, in addition to the existing SQL Server storage
|
||||
/// (<c>fds__setInvoiceFile</c> / <c>fds__setReminderFile</c>).
|
||||
/// </summary>
|
||||
public interface IBlobStorageService
|
||||
{
|
||||
/// <summary>
|
||||
/// Uploads a finalized invoice PDF to Azure Blob Storage. When <paramref name="sourceRow"/> is
|
||||
/// supplied (typically <c>FdsInvoiceData.InvoiceRegistration</c>), blob metadata is projected from
|
||||
/// it using <see cref="AzureBlobStorageSettings.MetadataFields"/> — see <see cref="DocumentMetadataBuilder"/>.
|
||||
/// Returns the blob URI, or <c>null</c> when storage is disabled/unconfigured
|
||||
/// or the upload failed — failures never break the primary DB-storage flow.
|
||||
/// </summary>
|
||||
Task<Uri?> UploadInvoicePdfAsync(string invoiceId, string fileName, byte[] content,
|
||||
IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a finalized reminder PDF to Azure Blob Storage. When <paramref name="sourceRow"/> is
|
||||
/// supplied (typically <c>FdsReminderData.ReminderRegistration</c>), blob metadata is projected from
|
||||
/// it using <see cref="AzureBlobStorageSettings.MetadataFields"/> — see <see cref="DocumentMetadataBuilder"/>.
|
||||
/// Returns the blob URI, or <c>null</c> when storage is disabled/unconfigured
|
||||
/// or the upload failed — failures never break the primary DB-storage flow.
|
||||
/// </summary>
|
||||
Task<Uri?> UploadReminderPdfAsync(string reminderId, string fileName, byte[] content,
|
||||
IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Generic upload for any document category — used by the startup archive backfill so
|
||||
/// invoice/reminder/future file types can all be archived through one entry point. The
|
||||
/// caller supplies the target container name and a category label (used for logging/telemetry),
|
||||
/// plus the source row driving metadata projection (see <see cref="DocumentMetadataBuilder"/>).
|
||||
/// Returns the blob URI, or <c>null</c> when storage is disabled/unconfigured or the upload failed.
|
||||
/// </summary>
|
||||
Task<Uri?> UploadDocumentAsync(string category, string containerName, string documentId, string fileName,
|
||||
byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> if a blob already exists for the given container/document/filename
|
||||
/// combination. Used by the startup backfill to skip documents that were already archived.
|
||||
/// Returns <c>false</c> (never throws) when storage is disabled/unconfigured or the check fails.
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Data;
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
@@ -22,12 +22,15 @@ public class InvoiceService : IInvoiceService
|
||||
{
|
||||
private readonly Fuchs_intranet _intranet;
|
||||
private readonly IPdfService _pdf;
|
||||
private readonly IBlobStorageService _blobStorage;
|
||||
private readonly ILogger<InvoiceService> _logger;
|
||||
|
||||
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, ILogger<InvoiceService> logger)
|
||||
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
|
||||
ILogger<InvoiceService> logger)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_pdf = pdf;
|
||||
_blobStorage = blobStorage;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -142,7 +145,12 @@ public class InvoiceService : IInvoiceService
|
||||
bool r = await setSQLValue_async(
|
||||
"EXECUTE [dbo].[fds__setInvoiceFile] @Id, @file;",
|
||||
Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
|
||||
return r ? ba : Array.Empty<byte>();
|
||||
if (!r) return Array.Empty<byte>();
|
||||
|
||||
string fileName = invoice.InvoiceRegistration?.getString("DocumentName")
|
||||
.ne($"Rechnung_{invoice.Id}.pdf") ?? $"Rechnung_{invoice.Id}.pdf";
|
||||
await _blobStorage.UploadInvoicePdfAsync(invoice.Id, fileName, ba, invoice.InvoiceRegistration);
|
||||
return ba;
|
||||
}
|
||||
|
||||
public async Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData invoice, bool draft, fds.IFdsMfr mfr)
|
||||
|
||||
@@ -23,6 +23,7 @@ public class ProcessWebComService : IComService
|
||||
private readonly ILogger<ProcessWebComService> _logger;
|
||||
private readonly Fuchs_intranet _intranet;
|
||||
private readonly ProcessWebComSettings _settings;
|
||||
private readonly FuchsEmailSettings _emailSettings;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
private const string SignatureIntro =
|
||||
@@ -34,11 +35,13 @@ public class ProcessWebComService : IComService
|
||||
ILogger<ProcessWebComService> logger,
|
||||
Fuchs_intranet intranet,
|
||||
IOptions<ProcessWebComSettings> settings,
|
||||
IOptions<FuchsEmailSettings> emailSettings,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_intranet = intranet;
|
||||
_settings = settings.Value;
|
||||
_emailSettings = emailSettings.Value;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
@@ -47,6 +50,22 @@ public class ProcessWebComService : IComService
|
||||
{
|
||||
using var act = FuchsTelemetry.StartActivity("email.send");
|
||||
act?.SetTag("fuchs.email.ref", reference);
|
||||
|
||||
string overrideRecipient = _emailSettings.OverrideRecipient ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(overrideRecipient))
|
||||
{
|
||||
// Dev/test safety net: discard the real recipient (to/cc/bcc) entirely and
|
||||
// redirect every outbound email to a single controlled inbox, so a locally
|
||||
// enabled mailer can never reach a real tenant-owner or end-customer.
|
||||
_logger.LogWarning(
|
||||
"SendEmailAsync: recipient override active for ref {Reference} – redirecting from '{OriginalEmail}' to '{OverrideRecipient}'",
|
||||
reference, email, overrideRecipient);
|
||||
act?.SetTag("fuchs.email.overridden", true);
|
||||
act?.SetTag("fuchs.email.original_recipient", email);
|
||||
subject = $"[DEV \u2192 {email}] {subject}";
|
||||
email = overrideRecipient;
|
||||
}
|
||||
|
||||
if (!IsValidEmail(email))
|
||||
{
|
||||
_logger.LogWarning("SendEmailAsync: invalid email address '{Email}' for ref {Reference}", email, reference);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Data;
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
@@ -22,12 +22,15 @@ public class ReminderService : IReminderService
|
||||
{
|
||||
private readonly Fuchs_intranet _intranet;
|
||||
private readonly IPdfService _pdf;
|
||||
private readonly IBlobStorageService _blobStorage;
|
||||
private readonly ILogger<ReminderService> _logger;
|
||||
|
||||
public ReminderService(Fuchs_intranet intranet, IPdfService pdf, ILogger<ReminderService> logger)
|
||||
public ReminderService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
|
||||
ILogger<ReminderService> logger)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_pdf = pdf;
|
||||
_blobStorage = blobStorage;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -145,7 +148,12 @@ public class ReminderService : IReminderService
|
||||
bool r = await setSQLValue_async(
|
||||
"EXECUTE [dbo].[fds__setReminderFile] @Id, @file;",
|
||||
Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
|
||||
return r ? ba : Array.Empty<byte>();
|
||||
if (!r) return Array.Empty<byte>();
|
||||
|
||||
string fileName = reminder.ReminderRegistration?.getString("DocumentName")
|
||||
.ne($"Zahlungserinnerung_{reminder.Id}.pdf") ?? $"Zahlungserinnerung_{reminder.Id}.pdf";
|
||||
await _blobStorage.UploadReminderPdfAsync(reminder.Id, fileName, ba, reminder.ReminderRegistration);
|
||||
return ba;
|
||||
}
|
||||
|
||||
public async Task<byte[]> GetReminderFileAsync(FdsReminderData reminder, bool draft,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"ocms_ConnectionString": "Server=localhost;Database=ocms;User Id=DEV_USERNAME;Password=DEV_PASSWORD;TrustServerCertificate=True;",
|
||||
"fuchs_fds_ConnectionString": "Server=localhost;Database=fuchs_fds;User Id=DEV_USERNAME;Password=DEV_PASSWORD;TrustServerCertificate=True;"
|
||||
"fuchs_fds_ConnectionString": "Data Source=MSSQL4.NBG4.DOMAINXYZ.DE,10439;Initial Catalog=site_fuchs_dev;Persist Security Info=False;TrustServerCertificate=true;Encrypt=true;User ID={username};password='{password}';Connect Timeout=60;Load Balance Timeout=240;Max Pool Size=500;",
|
||||
"fuchs_fds_username_Dev": "fuchs_dev",
|
||||
"fuchs_fds_password_Dev": "!Po@cGZ5bUn37khO"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
@@ -12,9 +13,14 @@
|
||||
"Fuchs": {
|
||||
"FDS_Intranet_DebugState": true,
|
||||
"DevAutoLogin": true,
|
||||
"DevAutoLoginEmail": "your.email@example.com",
|
||||
"DevAutoLoginEmail": "info@processweb.de",
|
||||
"Email": {
|
||||
"DevRedirectAddress": "service@emails.processweb.de"
|
||||
"OverrideRecipient": "service@emails.processweb.de"
|
||||
},
|
||||
"AzureStorage": {
|
||||
"Enabled": false,
|
||||
"InvoiceContainer": "dev-fuchs-invoices",
|
||||
"ReminderContainer": "dev-fuchs-reminders"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-7
@@ -5,10 +5,9 @@
|
||||
"CacheFilePath": "secrets.cache",
|
||||
"SyncIntervalHours": 6,
|
||||
"ManagedSecretKeys": [
|
||||
"ConnectionStrings--ocms-username",
|
||||
"ConnectionStrings--ocms-password",
|
||||
"ConnectionStrings--fuchs-fds-username",
|
||||
"ConnectionStrings--fuchs-fds-password",
|
||||
"ConnectionStrings--AzureBlobStorage-ConnectionString",
|
||||
"Fuchs--SMS-APIKey",
|
||||
"Fuchs--Mailer--Token",
|
||||
"Fuchs--fuchs-captcha-TOTP",
|
||||
@@ -23,12 +22,10 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"ocms_ConnectionString": "Server=DB_SERVER;Database=ocms;User Id={username};Password={password};TrustServerCertificate=True;",
|
||||
"fuchs_fds_ConnectionString": "Server=DB_SERVER;Database=fuchs_fds;User Id={username};Password={password};TrustServerCertificate=True;",
|
||||
"ocms_username": "MANAGED_BY_KEYVAULT",
|
||||
"ocms_password": "MANAGED_BY_KEYVAULT",
|
||||
"fuchs_fds_ConnectionString": "Data Source=MSSQL4.NBG4.DOMAINXYZ.DE,10439;Initial Catalog=site_fuchs_dev;Persist Security Info=False;TrustServerCertificate=true;Encrypt=true;User ID={username};password='{password}';Connect Timeout=60;Load Balance Timeout=240;Max Pool Size=500;",
|
||||
"fuchs_fds_username": "MANAGED_BY_KEYVAULT",
|
||||
"fuchs_fds_password": "MANAGED_BY_KEYVAULT"
|
||||
"fuchs_fds_password": "MANAGED_BY_KEYVAULT",
|
||||
"AzureBlobStorage_ConnectionString": "MANAGED_BY_KEYVAULT"
|
||||
},
|
||||
"Fuchs": {
|
||||
"ocms_guid": "00094b8f-a822-4e9c-b627-87802f93fca8",
|
||||
@@ -45,6 +42,15 @@
|
||||
"Token": "MANAGED_BY_KEYVAULT",
|
||||
"Enabled": false
|
||||
},
|
||||
"Email": {
|
||||
"OverrideRecipient": ""
|
||||
},
|
||||
"AzureStorage": {
|
||||
"Enabled": false,
|
||||
"InvoiceContainer": "fuchs-invoices",
|
||||
"ReminderContainer": "fuchs-reminders",
|
||||
"MetadataFields": [ "Id", "Version", "InvoiceId", "InvoiceTitle", "InvId", "DocumentName", "file_guid" ]
|
||||
},
|
||||
"Telemetry": {
|
||||
"Enabled": true,
|
||||
"OtlpEndpoint": ""
|
||||
|
||||
@@ -150,7 +150,7 @@ $fis.resetPass = function (id, fds) {
|
||||
$fis.wdg = function (options) {
|
||||
let wf = $(this).empty();
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('wdg/one'), data: { short_name: options.wdg }, success: function (response, textStatus, jqXHR) {
|
||||
url: $ocms.url('wdg/one'), data: { short_name: options.wdg }, timeout: 90000, success: function (response, textStatus, jqXHR) {
|
||||
let wi = options.wdg, wx = response[wi];
|
||||
if (!wx) { wf.ldng(0); return; }
|
||||
let dbl = $.inArrayRegEx('dblwidth', wx.rendering_options) > -1, tiny = $.inArrayRegEx('tiny', wx.rendering_options) > -1;
|
||||
@@ -175,7 +175,7 @@ $fis.wdg = function (options) {
|
||||
var tdr = $$.tr().appendTo(tblset.bdy);
|
||||
$.each(wx.columns, function (ci, col) {
|
||||
var tdc = $$.td().appendTo(tdr);
|
||||
if (dx[col] instanceof Date || $ocms.isDateString(dx[col]) === true) {
|
||||
if (dx[col] instanceof Date || $ocms.isJSONDateString(dx[col]) === true) {
|
||||
tdc.text(fdt(dx[col], $t.dateformat));
|
||||
} else {
|
||||
tdc.rwText(dx[col]);
|
||||
|
||||
@@ -168,21 +168,34 @@ function ne(inp, alt) {
|
||||
return (inp || '') === '' ? (alt || '') : inp;
|
||||
}
|
||||
function pad(i, n) { return (i || '').toString().padStart(n, '0').substr(-1 * n); }
|
||||
function twoDigitYear(y) { var n = parseInt(y, 10); return n + (n < 70 ? 2000 : 1900); }
|
||||
/* Parses dot-separated German short dates (dd.MM.yyyy / dd.MM.yy, optionally with a time part).
|
||||
Returns null if the string does not match, so callers can fall back to other parsing. */
|
||||
function parseGermanDate(si) {
|
||||
var g = si.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})(?:[\sT](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/);
|
||||
if (g === null) { return null; }
|
||||
var yr = g[3].length === 2 ? twoDigitYear(g[3]) : parseInt(g[3], 10);
|
||||
return new Date(yr, parseInt(g[2], 10) - 1, parseInt(g[1], 10), parseInt(g[4] || '0', 10), parseInt(g[5] || '0', 10), parseInt(g[6] || '0', 10));
|
||||
}
|
||||
function parseISO(s) {
|
||||
let si = s || '';
|
||||
if (si === '') { return null };
|
||||
if (/\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)/.test(si) === true) {
|
||||
return new Date(si);
|
||||
} else {
|
||||
var b = s.split(/\D/);
|
||||
return new Date(b[0], b[1] - 1, b[2], b[3], b[4], b[5]);
|
||||
var gd = parseGermanDate(si);
|
||||
if (gd !== null) { return gd; }
|
||||
var b = si.split(/\D/);
|
||||
return new Date(b[0], b[1] - 1, b[2], b[3] || 0, b[4] || 0, b[5] || 0);
|
||||
}
|
||||
}
|
||||
function parseISOLocal(s) {
|
||||
let si = s || '';
|
||||
if (si === '') { return null };
|
||||
var b = s.split(/\D/);
|
||||
return new Date(b[0], b[1] - 1, b[2], b[3], b[4], b[5]);
|
||||
var gd = parseGermanDate(si);
|
||||
if (gd !== null) { return gd; }
|
||||
var b = si.split(/\D/);
|
||||
return new Date(b[0], b[1] - 1, b[2], b[3] || 0, b[4] || 0, b[5] || 0);
|
||||
}
|
||||
function fnum(i, style) {
|
||||
/* { style: 'decimal/currency/percent', currency: 'USD/EUR', currencyDisplay: 'symbol/code/name', minimumIntegerDigits: 1, minimumFractionDigits: 2, maximumFractionDigits: 3, useGrouping: true } */
|
||||
|
||||
@@ -11,9 +11,13 @@
|
||||
$(this).remove();
|
||||
api.rendered = false;
|
||||
};
|
||||
$ocms.isDateString = function (inp) {
|
||||
/* Tests whether a string is a JSON/ISO-8601 date(-time) value as emitted by the
|
||||
backend's JSON serializer (e.g. Newtonsoft "2021-09-09T00:00:00[.fff][Z|+hh:mm]").
|
||||
Deliberately NOT based on the native Date constructor, which guesses ambiguous
|
||||
formats (e.g. dotted dd.MM.yy strings) heuristically and inconsistently. */
|
||||
$ocms.isJSONDateString = function (inp) {
|
||||
if (typeof inp !== 'string') { return false; } else {
|
||||
return isNaN(new Date(inp)) === false;
|
||||
return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/.test(inp);
|
||||
}
|
||||
}
|
||||
$ocms.failure = function (jqXHR) {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -403,21 +403,34 @@ function ne(inp, alt) {
|
||||
return (inp || '') === '' ? (alt || '') : inp;
|
||||
}
|
||||
function pad(i, n) { return (i || '').toString().padStart(n, '0').substr(-1 * n); }
|
||||
function twoDigitYear(y) { var n = parseInt(y, 10); return n + (n < 70 ? 2000 : 1900); }
|
||||
/* Parses dot-separated German short dates (dd.MM.yyyy / dd.MM.yy, optionally with a time part).
|
||||
Returns null if the string does not match, so callers can fall back to other parsing. */
|
||||
function parseGermanDate(si) {
|
||||
var g = si.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})(?:[\sT](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/);
|
||||
if (g === null) { return null; }
|
||||
var yr = g[3].length === 2 ? twoDigitYear(g[3]) : parseInt(g[3], 10);
|
||||
return new Date(yr, parseInt(g[2], 10) - 1, parseInt(g[1], 10), parseInt(g[4] || '0', 10), parseInt(g[5] || '0', 10), parseInt(g[6] || '0', 10));
|
||||
}
|
||||
function parseISO(s) {
|
||||
let si = s || '';
|
||||
if (si === '') { return null };
|
||||
if (/\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)/.test(si) === true) {
|
||||
return new Date(si);
|
||||
} else {
|
||||
var b = s.split(/\D/);
|
||||
return new Date(b[0], b[1] - 1, b[2], b[3], b[4], b[5]);
|
||||
var gd = parseGermanDate(si);
|
||||
if (gd !== null) { return gd; }
|
||||
var b = si.split(/\D/);
|
||||
return new Date(b[0], b[1] - 1, b[2], b[3] || 0, b[4] || 0, b[5] || 0);
|
||||
}
|
||||
}
|
||||
function parseISOLocal(s) {
|
||||
let si = s || '';
|
||||
if (si === '') { return null };
|
||||
var b = s.split(/\D/);
|
||||
return new Date(b[0], b[1] - 1, b[2], b[3], b[4], b[5]);
|
||||
var gd = parseGermanDate(si);
|
||||
if (gd !== null) { return gd; }
|
||||
var b = si.split(/\D/);
|
||||
return new Date(b[0], b[1] - 1, b[2], b[3] || 0, b[4] || 0, b[5] || 0);
|
||||
}
|
||||
function fnum(i, style) {
|
||||
/* { style: 'decimal/currency/percent', currency: 'USD/EUR', currencyDisplay: 'symbol/code/name', minimumIntegerDigits: 1, minimumFractionDigits: 2, maximumFractionDigits: 3, useGrouping: true } */
|
||||
@@ -1775,9 +1788,13 @@ class NumArray extends Array {
|
||||
$(this).remove();
|
||||
api.rendered = false;
|
||||
};
|
||||
$ocms.isDateString = function (inp) {
|
||||
/* Tests whether a string is a JSON/ISO-8601 date(-time) value as emitted by the
|
||||
backend's JSON serializer (e.g. Newtonsoft "2021-09-09T00:00:00[.fff][Z|+hh:mm]").
|
||||
Deliberately NOT based on the native Date constructor, which guesses ambiguous
|
||||
formats (e.g. dotted dd.MM.yy strings) heuristically and inconsistently. */
|
||||
$ocms.isJSONDateString = function (inp) {
|
||||
if (typeof inp !== 'string') { return false; } else {
|
||||
return isNaN(new Date(inp)) === false;
|
||||
return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/.test(inp);
|
||||
}
|
||||
}
|
||||
$ocms.failure = function (jqXHR) {
|
||||
@@ -2975,7 +2992,7 @@ $fis.resetPass = function (id, fds) {
|
||||
$fis.wdg = function (options) {
|
||||
let wf = $(this).empty();
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('wdg/one'), data: { short_name: options.wdg }, success: function (response, textStatus, jqXHR) {
|
||||
url: $ocms.url('wdg/one'), data: { short_name: options.wdg }, timeout: 90000, success: function (response, textStatus, jqXHR) {
|
||||
let wi = options.wdg, wx = response[wi];
|
||||
if (!wx) { wf.ldng(0); return; }
|
||||
let dbl = $.inArrayRegEx('dblwidth', wx.rendering_options) > -1, tiny = $.inArrayRegEx('tiny', wx.rendering_options) > -1;
|
||||
@@ -3000,7 +3017,7 @@ $fis.wdg = function (options) {
|
||||
var tdr = $$.tr().appendTo(tblset.bdy);
|
||||
$.each(wx.columns, function (ci, col) {
|
||||
var tdc = $$.td().appendTo(tdr);
|
||||
if (dx[col] instanceof Date || $ocms.isDateString(dx[col]) === true) {
|
||||
if (dx[col] instanceof Date || $ocms.isJSONDateString(dx[col]) === true) {
|
||||
tdc.text(fdt(dx[col], $t.dateformat));
|
||||
} else {
|
||||
tdc.rwText(dx[col]);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -222,21 +222,34 @@ function ne(inp, alt) {
|
||||
return (inp || '') === '' ? (alt || '') : inp;
|
||||
}
|
||||
function pad(i, n) { return (i || '').toString().padStart(n, '0').substr(-1 * n); }
|
||||
function twoDigitYear(y) { var n = parseInt(y, 10); return n + (n < 70 ? 2000 : 1900); }
|
||||
/* Parses dot-separated German short dates (dd.MM.yyyy / dd.MM.yy, optionally with a time part).
|
||||
Returns null if the string does not match, so callers can fall back to other parsing. */
|
||||
function parseGermanDate(si) {
|
||||
var g = si.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})(?:[\sT](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/);
|
||||
if (g === null) { return null; }
|
||||
var yr = g[3].length === 2 ? twoDigitYear(g[3]) : parseInt(g[3], 10);
|
||||
return new Date(yr, parseInt(g[2], 10) - 1, parseInt(g[1], 10), parseInt(g[4] || '0', 10), parseInt(g[5] || '0', 10), parseInt(g[6] || '0', 10));
|
||||
}
|
||||
function parseISO(s) {
|
||||
let si = s || '';
|
||||
if (si === '') { return null };
|
||||
if (/\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)/.test(si) === true) {
|
||||
return new Date(si);
|
||||
} else {
|
||||
var b = s.split(/\D/);
|
||||
return new Date(b[0], b[1] - 1, b[2], b[3], b[4], b[5]);
|
||||
var gd = parseGermanDate(si);
|
||||
if (gd !== null) { return gd; }
|
||||
var b = si.split(/\D/);
|
||||
return new Date(b[0], b[1] - 1, b[2], b[3] || 0, b[4] || 0, b[5] || 0);
|
||||
}
|
||||
}
|
||||
function parseISOLocal(s) {
|
||||
let si = s || '';
|
||||
if (si === '') { return null };
|
||||
var b = s.split(/\D/);
|
||||
return new Date(b[0], b[1] - 1, b[2], b[3], b[4], b[5]);
|
||||
var gd = parseGermanDate(si);
|
||||
if (gd !== null) { return gd; }
|
||||
var b = si.split(/\D/);
|
||||
return new Date(b[0], b[1] - 1, b[2], b[3] || 0, b[4] || 0, b[5] || 0);
|
||||
}
|
||||
function fnum(i, style) {
|
||||
/* { style: 'decimal/currency/percent', currency: 'USD/EUR', currencyDisplay: 'symbol/code/name', minimumIntegerDigits: 1, minimumFractionDigits: 2, maximumFractionDigits: 3, useGrouping: true } */
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -37,9 +37,9 @@
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Squid-Box.SevenZipSharp" Version="1.6.2.24" />
|
||||
<PackageReference Include="Topshelf" Version="4.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.9" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.9" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="4.0">
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
@@ -329,6 +329,8 @@
|
||||
<Build Include="dbo\Stored Procedures\fds__getRequest_details.sql" />
|
||||
<Build Include="dbo\Stored Procedures\fds__getReportDocument.sql" />
|
||||
<Build Include="dbo\Stored Procedures\fds__getReminder.sql" />
|
||||
<Build Include="dbo\Stored Procedures\fds__getReminderFiles_ForBlobArchive.sql" />
|
||||
<Build Include="dbo\Stored Procedures\fds__getReminderFileContent.sql" />
|
||||
<Build Include="dbo\Stored Procedures\fds__getInvRequestItems.sql" />
|
||||
<Build Include="dbo\Stored Procedures\fds__getInvPayments.sql" />
|
||||
<Build Include="dbo\Stored Procedures\fds__getInvoices_list2.sql" />
|
||||
@@ -336,6 +338,8 @@
|
||||
<Build Include="dbo\Stored Procedures\fds__getInvoices_list.sql" />
|
||||
<Build Include="dbo\Stored Procedures\fds__getInvoiceReminder.sql" />
|
||||
<Build Include="dbo\Stored Procedures\fds__getInvoice.sql" />
|
||||
<Build Include="dbo\Stored Procedures\fds__getInvoiceFiles_ForBlobArchive.sql" />
|
||||
<Build Include="dbo\Stored Procedures\fds__getInvoiceFileContent.sql" />
|
||||
<Build Include="dbo\Stored Procedures\fds__getFDSDocument.sql" />
|
||||
<Build Include="dbo\Stored Procedures\fds__getDatevExports.sql" />
|
||||
<Build Include="dbo\Stored Procedures\fds__getBankingtransfers_questionable.sql" />
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
-- =============================================
|
||||
-- Author: <Author,,Name>
|
||||
-- Create date: <Create Date,,>
|
||||
-- Description: Returns the stored file bytes for a single invoice, for use by
|
||||
-- the Azure Blob Storage startup backfill after the row has been
|
||||
-- selected via fds__getInvoiceFiles_ForBlobArchive.
|
||||
-- =============================================
|
||||
CREATE PROCEDURE [dbo].[fds__getInvoiceFileContent]
|
||||
@Id varchar(10)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT TOP(1) [Id], [file]
|
||||
FROM [dbo].[fds__invoices]
|
||||
WHERE [Id] = @Id AND @Id is not null AND [file] IS NOT NULL;
|
||||
END
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
-- =============================================
|
||||
-- Author: <Author,,Name>
|
||||
-- Create date: <Create Date,,>
|
||||
-- Description: Enumerates invoices that already have a stored file, returning
|
||||
-- lightweight metadata only (no file bytes) so the Azure Blob
|
||||
-- Storage startup backfill (see AzureBlobStorageService /
|
||||
-- DocumentArchiveSyncService) can enumerate candidates cheaply
|
||||
-- before fetching each file's content individually via
|
||||
-- fds__getInvoiceFileContent.
|
||||
-- =============================================
|
||||
CREATE PROCEDURE [dbo].[fds__getInvoiceFiles_ForBlobArchive]
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT
|
||||
[Id]
|
||||
,[Version]
|
||||
,[InvoiceId]
|
||||
,[InvoiceTitle]
|
||||
,[DocumentName]
|
||||
,[file_guid]
|
||||
FROM [dbo].[fds__invoices]
|
||||
WHERE [file] IS NOT NULL
|
||||
ORDER BY [Id];
|
||||
END
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
-- =============================================
|
||||
-- Author: <Author,,Name>
|
||||
-- Create date: <Create Date,,>
|
||||
-- Description: Returns the stored file bytes for a single reminder, for use by
|
||||
-- the Azure Blob Storage startup backfill after the row has been
|
||||
-- selected via fds__getReminderFiles_ForBlobArchive.
|
||||
-- =============================================
|
||||
CREATE PROCEDURE [dbo].[fds__getReminderFileContent]
|
||||
@Id varchar(10)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT TOP(1) [Id], [file]
|
||||
FROM [dbo].[fds__reminder]
|
||||
WHERE [Id] = @Id AND @Id is not null AND [file] IS NOT NULL;
|
||||
END
|
||||
@@ -0,0 +1,25 @@
|
||||
|
||||
-- =============================================
|
||||
-- Author: <Author,,Name>
|
||||
-- Create date: <Create Date,,>
|
||||
-- Description: Enumerates reminders that already have a stored file, returning
|
||||
-- lightweight metadata only (no file bytes) so the Azure Blob
|
||||
-- Storage startup backfill (see AzureBlobStorageService /
|
||||
-- DocumentArchiveSyncService) can enumerate candidates cheaply
|
||||
-- before fetching each file's content individually via
|
||||
-- fds__getReminderFileContent.
|
||||
-- =============================================
|
||||
CREATE PROCEDURE [dbo].[fds__getReminderFiles_ForBlobArchive]
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT
|
||||
[Id]
|
||||
,[Version]
|
||||
,[InvId]
|
||||
,[DocumentName]
|
||||
FROM [dbo].[fds__reminder]
|
||||
WHERE [file] IS NOT NULL
|
||||
ORDER BY [Id];
|
||||
END
|
||||
@@ -24,6 +24,7 @@
|
||||
[DateModified] DATETIME NOT NULL,
|
||||
[UserModified] VARCHAR (25) NOT NULL,
|
||||
[file] VARBINARY (MAX) NULL,
|
||||
[file_guid] UNIQUEIDENTIFIER CONSTRAINT [DF_fds__reminder_file_guid] DEFAULT (newid()) NOT NULL,
|
||||
CONSTRAINT [PK_fds__reminder] PRIMARY KEY CLUSTERED ([Id] ASC)
|
||||
);
|
||||
|
||||
|
||||
@@ -24,6 +24,16 @@
|
||||
<BuildType Solution="server02.processweb.de|*" Project="Debug" />
|
||||
</Project>
|
||||
<Project Path="Fuchs/Fuchs.csproj" />
|
||||
<Project Path="Fuchs_Database/FuchsDatabase.sqlproj" Id="c062672e-866d-4c74-b6de-8d660a42e885">
|
||||
<BuildType Solution="db-dev.processweb.de|*" Project="Release" />
|
||||
<BuildType Solution="server02.processweb.de|*" Project="Release" />
|
||||
<Platform Project="AnyCPU" />
|
||||
<Build Solution="db-dev.processweb.de|*" Project="false" />
|
||||
<Build Solution="Debug|*" Project="false" />
|
||||
<Build Solution="Release|*" Project="false" />
|
||||
<Build Solution="server02.processweb.de|*" Project="false" />
|
||||
<Deploy />
|
||||
</Project>
|
||||
<Project Path="Fuchs_DataService/Fuchs_DataService.csproj" />
|
||||
<Project Path="MFR_RESTClient/MFR_RESTClient.csproj" />
|
||||
<Project Path="OCORE/OCORE/OCORE.csproj">
|
||||
|
||||
@@ -26,17 +26,17 @@
|
||||
<PackageReference Include="Microsoft.Rest.ClientRuntime" Version="2.3.24" />
|
||||
<PackageReference Include="System.Spatial" Version="5.8.5" />
|
||||
<!-- Updated packages -->
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.19.1" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="RestSharp" Version="114.0.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
|
||||
<!-- New packages (replacements) -->
|
||||
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.20.1" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.9" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.9" />
|
||||
<!-- Deprecated but kept for compatibility (review in follow-up) -->
|
||||
<PackageReference Include="Microsoft.IdentityModel.Abstractions" Version="8.19.1" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Clients.ActiveDirectory" Version="5.3.0" />
|
||||
|
||||
Reference in New Issue
Block a user