feat: Implement mfr truncation fix and enhance invoice handling
Playwright Tests / test (push) Canceled after 0s
Playwright Tests / test (push) Canceled after 0s
- Added a new SQL script to address the "String or binary data would be truncated" error in mfr sync by widening the Note fields in service objects and companies to NVARCHAR(MAX). - Updated the table types and associated stored procedures to accommodate the changes, ensuring compatibility with existing data. - Queued a re-sync for previously rejected rows due to truncation issues. - Created application logins for fuchs_fds and fuchs_web with placeholder passwords for initial setup. - Introduced functions for invoice backdating and invoice date retrieval to streamline invoice processing. - Developed a password generation function with customizable parameters for length and character types. - Added a random number generation function to support password creation.
This commit is contained in:
@@ -3,6 +3,7 @@ using Fuchs.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using static OCORE.SQL.sql;
|
||||
using static OCORE.web.mvc_helper_async;
|
||||
|
||||
namespace Fuchs.Controllers;
|
||||
@@ -14,6 +15,20 @@ namespace Fuchs.Controllers;
|
||||
// only signals (ADR 0007).
|
||||
public partial class IntranetController
|
||||
{
|
||||
/// <summary>
|
||||
/// The date the calling user may backdate an invoice to (last working day of the previous month), or
|
||||
/// null when not allowed — fds_inv > 2 and no invoice finalized after that date yet. The SQL function
|
||||
/// is the single source; fds__setInvoiceFinal re-checks it when @backdate = 1 (ADR 0032).
|
||||
/// </summary>
|
||||
private async Task<DateOnly?> GetInvoiceBackdateAsync(string fn, string id, string code)
|
||||
{
|
||||
var val = await getSQLValue_async<string>(
|
||||
"SELECT CONVERT(varchar(10), [dbo].[fds__fn_invoiceBackdate](@authuser), 23);",
|
||||
_intranet.Intranet__SQLConnectionString, "",
|
||||
StdParamlist(), Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
return InvoiceDates.ParseBackdate(val.Result);
|
||||
}
|
||||
|
||||
/// <summary>Standard 410 when a session token is unknown/expired — the client re-opens the draft.</summary>
|
||||
private IActionResult DraftGone() => StatusCode(410, new { error = "expired" });
|
||||
|
||||
@@ -71,7 +86,8 @@ public partial class IntranetController
|
||||
return await JSONAsync(new { ok = true, version = session.Version });
|
||||
}
|
||||
|
||||
// POST inv/dpreview — { token } → { img[], total } (rendered straight from the cache)
|
||||
// POST inv/dpreview — { token } → { img[], total, backdate } (rendered straight from the cache).
|
||||
// backdate ("yyyy-MM-dd" or null) drives the admin-only "Rechnungsdatum Vormonat" checkbox (ADR 0032).
|
||||
private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("token")) return BadRequest400();
|
||||
@@ -81,7 +97,8 @@ public partial class IntranetController
|
||||
var doc = await _invoiceDrafts.RenderPreviewAsync(token);
|
||||
if (doc == null) return DraftGone();
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
|
||||
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
DateOnly? backdate = await GetInvoiceBackdateAsync(fn, id, code);
|
||||
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages, backdate = backdate?.ToString("yyyy-MM-dd") });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,22 @@ namespace Fuchs.Controllers;
|
||||
// Partial class: Invoice sub-handlers
|
||||
public partial class IntranetController
|
||||
{
|
||||
/// <summary>True when <paramref name="raw"/> is an mfr invoice id (positive integer); Intranet-created
|
||||
/// invoices use alphanumeric ids and have no mfr entity to reset.</summary>
|
||||
internal static bool TryGetMfrInvoiceId(string? raw, out long mfrId)
|
||||
=> long.TryParse(raw?.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out mfrId) && mfrId > 0;
|
||||
|
||||
private async Task<IActionResult> HandleInvoicePget(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id")) { _logger.LogWarning("HandleInvoicePget: missing 'id' form field user={User}", UserAccountID); return BadRequest400(); }
|
||||
if (!long.TryParse(Form("id"), out long tgtid)) { _logger.LogWarning("HandleInvoicePget: invalid 'id' value='{Value}' user={User}", Form("id"), UserAccountID); return BadRequest400(); }
|
||||
if (!TryGetMfrInvoiceId(Form("id"), out long tgtid))
|
||||
{
|
||||
// Intranet-created invoices carry an alphanumeric Id (fds__fn_invoice_id) and have no mfr
|
||||
// entity to reset. Both storno paths call pget first, so rejecting them here (400) left the
|
||||
// storno window empty. Legacy parity: skip the mfr reset and report success.
|
||||
_logger.LogDebug("HandleInvoicePget: non-mfr invoice id='{Value}', nothing to reset user={User}", Form("id"), UserAccountID);
|
||||
return await JSONAsync(new { ok = true });
|
||||
}
|
||||
_logger.LogDebug("HandleInvoicePget tgtid={TgtId} user={User}", tgtid, UserAccountID);
|
||||
|
||||
using (var mfr = _mfrFactory.Create())
|
||||
@@ -263,6 +275,16 @@ public partial class IntranetController
|
||||
return await JSONAsync(new { payments = sqldt.Tables("items").toArrayofObjectDictionaries() });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DATEV overview (<c>inv/datev</c>). Parameters are bound by name: the proc's signature is
|
||||
/// (@tgtdate, @mode, @files, @authuser) and the former positional call put @authuser into
|
||||
/// @files and never supplied @authuser, so every call failed. @files = 0 — the overview needs
|
||||
/// no PDF bytes. Result sets in proc order: header, invoice documents, bookings, debtors.
|
||||
/// </summary>
|
||||
internal const string DatevOverviewSql =
|
||||
"EXECUTE [dbo].[fds__getDatevExports] @tgtdate = @tgtdate, @mode = @mode, @files = 0, @authuser = @authuser;";
|
||||
internal static readonly string[] DatevOverviewTables = { "admin", "files", "invoices", "debits" };
|
||||
|
||||
private async Task<IActionResult> HandleDatev(string fn, string id, string code)
|
||||
{
|
||||
if (!DateTime.TryParseExact(Form("tgt"), "yy-MM-dd",
|
||||
@@ -274,10 +296,10 @@ public partial class IntranetController
|
||||
string mode = Form("mode").ne("m");
|
||||
_logger.LogDebug("HandleDatev tgtdate={TgtDate} mode={Mode} user={User}", tgtdate.ToString("yy-MM-dd"), mode, UserAccountID);
|
||||
var dset = await getSQLDataSet_async(
|
||||
"EXECUTE [dbo].[fds__getDatevExports] @tgtdate, @mode, @authuser;",
|
||||
DatevOverviewSql,
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_Date("@tgtdate", tgtdate), SQL_VarChar("@mode", mode)),
|
||||
tablenames: new[] { "files", "invoices", "debits" },
|
||||
tablenames: DatevOverviewTables,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
_logger.LogDebug("HandleDatev tgtdate={TgtDate} files={FileCount} invoices={InvCount} user={User}",
|
||||
tgtdate.ToString("yy-MM-dd"), dset.Tables("files").Rows.Count, dset.Tables("invoices").Rows.Count, UserAccountID);
|
||||
@@ -465,7 +487,10 @@ public partial class IntranetController
|
||||
["NameOrNumber"] = "",
|
||||
["htmltext"] = d["Text"],
|
||||
["position"] = d["Position"],
|
||||
["SortOrder"] = d["SortOrder"]
|
||||
["SortOrder"] = d["SortOrder"],
|
||||
// Round-trips the referenced invoice (CanceledInvoice / IntInvoiceAllocation) so a
|
||||
// re-saved draft keeps it — fds__setInvoiceFinal needs it for Replaces/allocations.
|
||||
["reference_InvId"] = d.nz("reference_InvId") is { Length: > 0 } refId ? refId : null
|
||||
});
|
||||
}
|
||||
sdic["items"] = itm;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.SqlClient;
|
||||
@@ -302,14 +303,41 @@ public partial class IntranetController
|
||||
private async Task<IActionResult> HandleRequestSconf(string fn, string id, string code)
|
||||
{
|
||||
if (!HasForm("id")) { _logger.LogWarning("HandleRequestSconf: missing form field 'id', user={User}", UserAccountID); return BadRequest400(); }
|
||||
_logger.LogInformation("HandleRequestSconf: finalizing invoice {InvId} user={User}", Form("id"), UserAccountID);
|
||||
// Admin option: date the invoice to the last working day of the previous month (ADR 0032).
|
||||
// fds__setInvoiceFinal re-validates (fds_inv > 2, no later invoice) and throws otherwise.
|
||||
bool backdate = InvoiceDates.IsBackdateRequested(Form("backdate"));
|
||||
_logger.LogInformation("HandleRequestSconf: finalizing invoice {InvId} backdate={Backdate} user={User}", Form("id"), backdate, UserAccountID);
|
||||
var dt = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser;",
|
||||
"EXECUTE [dbo].[fds__setInvoiceFinal] @Id, @authuser, @backdate;",
|
||||
_intranet.Intranet__SQLConnectionString,
|
||||
StdParamlist(SQL_VarChar("@Id", Form("id"))),
|
||||
StdParamlist(SQL_VarChar("@Id", Form("id")), SQL_Bit("@backdate", backdate)),
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
if (!string.IsNullOrEmpty(dt.Exception))
|
||||
_logger.LogError("HandleRequestSconf: SQL error finalizing invoice {InvId}: {SqlError}, user={User}", Form("id"), dt.Exception, UserAccountID);
|
||||
_logger.LogError("HandleRequestSconf: SQL error {SqlErrorNumber} finalizing invoice {InvId}: {SqlError}, user={User}", dt.ExceptionCode, Form("id"), dt.Exception, UserAccountID);
|
||||
// Only SQL error 60001 is a backdate rejection. A 0-row result means the invoice is already
|
||||
// final/sent (double submit) — it must not be reported as "create it again without backdating".
|
||||
var outcome = InvoiceDates.ClassifyFinalizeResult(dt.Exception, dt.ExceptionCode, dt.Count);
|
||||
if (backdate)
|
||||
{
|
||||
FuchsTelemetry.InvoicesBackdated.Add(1, new KeyValuePair<string, object?>("outcome", InvoiceDates.OutcomeTag(outcome)));
|
||||
if (outcome == InvoiceFinalizeOutcome.Finalized)
|
||||
_logger.LogInformation(new EventId(0, "InvoiceBackdated"),
|
||||
"Invoice {InvId} finalized with backdated invoice date {DateFinalized} user={User}",
|
||||
Form("id"), dt.FirstRow.toObjectDictionary().nz("DateFinalized"), UserAccountID);
|
||||
}
|
||||
switch (outcome)
|
||||
{
|
||||
case InvoiceFinalizeOutcome.BackdateNotAllowed:
|
||||
return await InvoiceIssueResult(
|
||||
"Die Rechnung wurde nicht erstellt: Das Rechnungsdatum kann nicht mehr auf den Vormonat gesetzt werden (es wurde bereits eine Rechnung im neuen Monat erstellt). Bitte ohne Rückdatierung erneut erstellen.",
|
||||
Form("id"));
|
||||
case InvoiceFinalizeOutcome.AlreadyFinal:
|
||||
return await InvoiceIssueResult(
|
||||
"Die Rechnung ist bereits abgeschlossen oder versendet und wurde nicht erneut erstellt.",
|
||||
Form("id"));
|
||||
case InvoiceFinalizeOutcome.Error:
|
||||
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.", Form("id"));
|
||||
}
|
||||
var frdic = dt.FirstRow.toObjectDictionary();
|
||||
if (frdic.TryGetValue("IsFinal", out var isFinal) && isFinal is true)
|
||||
{
|
||||
|
||||
@@ -446,3 +446,4 @@ public class MfrClientFactory : IMfrClientFactory, IDisposable
|
||||
|
||||
- The SQL schema lives in the **`Fuchs_Database`** SSDT project (source of truth). The backend is SQL-first: it calls stored procedures, table-valued types (e.g. `fds__tt__bankingtransactions`) and functions via OCORE ADO.NET helpers.
|
||||
- When changing a stored procedure's name/parameters or a table type, update both the SSDT project and the calling C# in the same change, and keep the MT940/CAMT banking column mappings aligned with `fds__tt__bankingtransactions`.
|
||||
- The SSDT project is the compile step for all T-SQL: it references the `master` system dacpac and treats T-SQL warnings as errors (`TreatTSqlWarningsAsErrors`, ADR 0036), so an unresolved or ambiguous reference fails `msbuild Fuchs_Database/FuchsDatabase.sqlproj` instead of surfacing at runtime.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-30
|
||||
applyTo:
|
||||
- "Fuchs_Database/dbo/Functions/fds__fn_invoiceBackdate.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__setInvoiceFinal.sql"
|
||||
- "Fuchs/code/InvoiceDates.cs"
|
||||
- "Fuchs/Services/ERechnungMapper.cs"
|
||||
- "Fuchs/Services/InvoiceService.cs"
|
||||
- "Fuchs/Controllers/IntranetController.InvoiceDraft.cs"
|
||||
- "Fuchs/Controllers/IntranetController.Requests.cs"
|
||||
- "Fuchs/js/intranet/modules/fis.inv_shared.js"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0032 — Invoices may be backdated to the last working day of the previous month (admins only)
|
||||
|
||||
## Context
|
||||
|
||||
Work done at the end of a month is often invoiced in the first days of the next month. The customer
|
||||
wants those invoices dated to the previous month. The invoice date was always the moment of
|
||||
finalisation (`fds__setInvoiceFinal`: `DateFinalized = GETUTCDATE()`), and the invoice number is
|
||||
drawn from the year of that date (`fds__newInvoiceId(Year(...))`).
|
||||
|
||||
Two invoice dates also existed side by side: the PDF printed `DateCreated` (draft creation) as
|
||||
"Rechnungsdatum", while the embedded ZUGFeRD/XRechnung issue date (BT-2) used `DateFinalized` — a
|
||||
visible date and an e-invoice date that could disagree.
|
||||
|
||||
## Decision
|
||||
|
||||
- **One invoice date.** PDF "Rechnungsdatum" and e-invoice issue date both come from
|
||||
`InvoiceDates.IssueDate`: `DateFinalized` (converted from the stored UTC to the German calendar
|
||||
day), falling back to `DateCreated` for drafts.
|
||||
- **Backdating option.** When finalising, a user with `fds_inv > 2` may date the invoice to the
|
||||
**last working day (Mon–Fri) of the previous month** — a fixed date, not a free choice. Public
|
||||
holidays are not excluded (possible later extension).
|
||||
- **Only while no invoice exists after that date.** Invoice numbers must ascend with invoice dates,
|
||||
so the option is offered and accepted only while no invoice has been finalized after the backdate
|
||||
day (a 2 h margin covers the UTC storage).
|
||||
- **Single source of truth:** `[dbo].[fds__fn_invoiceBackdate](@authuser)` returns the allowed date or
|
||||
NULL (authorisation + eligibility). `inv/dpreview` returns it so the preview dialog shows the
|
||||
per-invoice checkbox; `req/sconf` passes `@backdate = 1`, and `fds__setInvoiceFinal` re-evaluates
|
||||
the function and `THROW`s (60001) when it is no longer allowed — nothing is finalised then.
|
||||
- **Serialized finalisation.** `fds__setInvoiceFinal` runs check + numbering `UPDATE` in one
|
||||
transaction under an exclusive, transaction-owned app lock (`sp_getapplock`, resource
|
||||
`fds__setInvoiceFinal`) taken by every finalisation, so a concurrent normal finalisation cannot
|
||||
slip between the eligibility check and the backdated write and break the date/number order.
|
||||
- `req/sconf` maps only SQL error 60001 to the "no longer possible, create without backdating"
|
||||
message. A 0-row result (invoice already final/sent, e.g. a double submit) is reported as
|
||||
"already finalised" — never as a backdate rejection, which would invite a duplicate invoice.
|
||||
- A backdated invoice is stored with `DateFinalized = <day> 12:00` (UTC, stays on the same German
|
||||
day), its number comes from that day's year, and `DueDate` is recomputed from the payment term
|
||||
anchored on that day.
|
||||
## Consequences
|
||||
|
||||
- The PDF of every newly finalised invoice now shows the finalisation date instead of the draft
|
||||
creation date (intended: it now matches the e-invoice and the number sequence).
|
||||
- In January the option yields a December date and therefore a number from the previous year's range.
|
||||
- Lists, reports, DATEV and reminders follow the same invoice date — see ADR 0035.
|
||||
- Telemetry: `fuchs.invoices.backdated` (outcome ok / rejected / already_final / error); successful
|
||||
backdating logs EventId `InvoiceBackdated`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Free date picker for admins** — rejected: breaks the date/number order too easily; the need is
|
||||
exactly "previous month's last working day".
|
||||
- **Global admin switch "backdate everything until the first invoice of the month"** — rejected by
|
||||
the customer in favour of an explicit per-invoice checkbox.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-30
|
||||
applyTo:
|
||||
- "Fuchs_Database/dbo/Tables/mfr__*.sql"
|
||||
- "Fuchs_Database/dbo/User Defined Types/mfr__tt__*.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/mfr__updt__*.sql"
|
||||
- "Fuchs_DataService/FdsMfrClient.cs"
|
||||
- "Fuchs_DataService/FdsColumnWidths.cs"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0033 — mfr free-text columns are NVARCHAR(MAX); table, table type and copies stay aligned
|
||||
|
||||
## Context
|
||||
mfr publishes no length limits for its string properties (`MFR_RESTClient/Docs/mfr_interface_description.md`).
|
||||
`[mfr__serviceobjects].[Note]` and `[mfr__companies].[Note]` were `NVARCHAR(255)`, as were their
|
||||
merge types `mfr__tt__serviceobjects` / `mfr__tt__companies`. From 2026-09-25, users typed longer
|
||||
notes in mfr (ServiceObject up to 753 chars, Company up to 723). The `mfr__updt__*` merge proc copies
|
||||
the staging table into the table type, so SQL Server rejected every batch holding such a row
|
||||
(msg 8152, which names no column). That included every Company/Contact/ServiceRequest sync that
|
||||
`$expand`s the row. The result was about 1000 errors in five days, plus rows the short sync never
|
||||
re-fetched, because it only reads rows newer than the local `DateModified` watermark. Merge failures
|
||||
were silent until 8bcec40 began logging them, so earlier occurrences went unnoticed.
|
||||
|
||||
## Decision
|
||||
- Free-text mfr properties (`Note`, `Description`, `Comment`, …) are stored as `NVARCHAR(MAX)`.
|
||||
Bounded widths are kept only for values mfr structurally keeps short (ids, codes, names), and
|
||||
those are revisited when data proves otherwise.
|
||||
- A width change is made in the target table, its `mfr__tt__*` type **and** every table variable
|
||||
or type that copies the row with `SELECT *` (e.g. `@company` in `fds__getRequest_details`,
|
||||
`fds__prepInvoice`, `fds__prepStorno_recreate`), all in the same change. Otherwise the truncation
|
||||
just moves to the next copy.
|
||||
- Truncating at the mapping layer was rejected. The data service does **not** truncate
|
||||
silently. Instead, when a merge fails with 8152/2628, `FdsColumnWidths` compares the staged
|
||||
batch against the narrower of the table and type widths. The error log then names column, longest
|
||||
length, limit and an entity Id (`over-long columns: Note: 753 > 255 chars (1 row, e.g. Id=…)`).
|
||||
This lookup runs only on the failure path.
|
||||
|
||||
## Consequences
|
||||
- Changing a `mfr__tt__*` type means DROP + CREATE (types cannot be ALTERed). The procs that
|
||||
DECLARE the type must be dropped first and recreated. See
|
||||
`Fuchs_Database/Scripts/2026-09-30_mfr_truncation_fix.sql` for the pattern, including grants.
|
||||
- After fixing a rejected merge, queue the affected Ids in `fds__mfr_updaterequests`. The short
|
||||
sync will not revisit them, and the nightly full reset is disabled in `fds__getUpdateableTables`.
|
||||
- `NVARCHAR(MAX)` columns cannot be index key columns. None of the widened columns is indexed.
|
||||
|
||||
## Alternatives considered
|
||||
- **Truncate to the column width in `SetRowValue`**: this would keep syncing, but it loses user text
|
||||
silently. For keys such as `ExternalId` (DATEV customer number) it would corrupt data. Rejected.
|
||||
- **Widen to a larger fixed size (e.g. 1000, like `mfr__contacts.Note`)**: this just moves the
|
||||
cliff. mfr enforces no limit. Rejected for free text.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-30
|
||||
applyTo:
|
||||
- "Fuchs_Database/dbo/Functions/fds__fn_IntermediateIsAllocatedToOther.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__setInvoiceFinal.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__prepInvoice.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__prepStorno_recreate.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__createStorno_*.sql"
|
||||
- "Fuchs_Database/Scripts/2026-09-30_backfill_intermediate_invoice_allocations.sql"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0034 — An Abschlagsrechnung credit follows the live invoice
|
||||
|
||||
## Context
|
||||
|
||||
A Schlussrechnung credits the Abschlagsrechnungen (intermediate invoices, `InvoiceType = 'i'`)
|
||||
already paid for the same service requests via `IntInvoiceAllocation` lines. Finalising it records
|
||||
the claim in `fds__invoice_details.AllocatedTo_InvId` of each credited intermediate so that
|
||||
`fds__prepInvoice` does not offer the same Abschlagsrechnung again.
|
||||
|
||||
Until 2026-09-30 that claim was never actually written (the editor dropped `reference_InvId`, the
|
||||
MERGE aborted on the NULL). The moment it is written, the question "what happens to the claim when
|
||||
the crediting invoice is cancelled?" becomes real: a Storno (simple/copy) or a Storno + recreate
|
||||
leaves `AllocatedTo_InvId` pointing at the cancelled invoice, and the next invoice for the same
|
||||
service requests would silently bill the customer again for money already paid.
|
||||
|
||||
## Decision
|
||||
|
||||
- **A claim counts only while the crediting invoice is alive.** An `AllocatedTo_InvId` that points
|
||||
at a cancelled invoice (`IsCanceled = 1`), at a non-existent invoice, or at the invoice that the
|
||||
one being finalised replaces (`CanceledInvoice` line → `@Replaces`) is void: the Abschlagsrechnung
|
||||
is offered again by `fds__prepInvoice` / `fds__prepStorno_recreate` and may be taken over by the
|
||||
next finalisation. The single source of truth for "already credited elsewhere" is
|
||||
`[dbo].[fds__fn_IntermediateIsAllocatedToOther]`; `fds__setInvoiceFinal`'s `existing` CTE is its
|
||||
set-based twin and must apply the same rule.
|
||||
- **The link is not cleared on cancellation.** Cancellation has several paths (storno simple/copy,
|
||||
storno + recreate, the retired `fds__setInvoiceCancelled`); rather than releasing the link in each,
|
||||
the readers ignore dead links. The last claimant therefore stays visible for auditing.
|
||||
- **A Stornorechnung (`InvoiceType = 'c'`) never claims.** `fds__createStorno_copy` mirrors the
|
||||
cancelled invoice's `IntInvoiceAllocation` lines (with their references) so the storno document
|
||||
shows them, but finalising the storno must not move the allocation onto it.
|
||||
- **Storno + recreate carries the references.** `fds__prepStorno_recreate` emits its credit lines
|
||||
with the same type as `fds__prepInvoice` (`IntInvoiceAllocation`, not `PartialInvoice`) and the
|
||||
credited invoice in `reference_InvId`, and its `CanceledInvoice` line references the replaced
|
||||
invoice — otherwise the replacement neither takes over the credit nor records what it replaces.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The recreate flow now produces `Replaces_InvId` / `DateCancelled` on the replaced invoice via the
|
||||
regular `fds__setInvoiceFinal` bookkeeping (it did not before: no reference reached the proc).
|
||||
- A second recreate (cancel the replacement, recreate again) keeps the credit because the claim on
|
||||
the replaced invoice is treated as void during the replacement's finalisation.
|
||||
- The one-off backfill script records the *earliest* live claimant; it is consistent with this rule.
|
||||
- An intermediate that is itself cancelled is not offered (unchanged).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Release `AllocatedTo_InvId` when the crediting invoice is cancelled** — rejected: four write
|
||||
paths to keep in sync, and the audit trail of who last credited the Abschlagsrechnung is lost.
|
||||
- **Keep `PartialInvoice` for recreated credit lines and teach `fds__setInvoiceFinal` both types** —
|
||||
rejected: one line type per meaning; `PartialInvoice` remains the Abschlagsbetrag charge line of an
|
||||
intermediate invoice.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-30
|
||||
applyTo:
|
||||
- "Fuchs_Database/dbo/Functions/fds__fn_invoiceDate.sql"
|
||||
- "Fuchs_Database/dbo/Functions/fds__r_getBalanceThisMonth.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__getDatevExports.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__getInvoices_list*.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__getFDSDocument.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__getReportDocument.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__getRequest_details.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__r_*.sql"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__*Reminder*.sql"
|
||||
- "Fuchs/code/InvoiceDates.cs"
|
||||
- "Fuchs/Services/ReminderService.cs"
|
||||
- "Fuchs/Services/*DraftEditService.cs"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0035 — The invoice date is `DateFinalized` everywhere
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0032 made `DateFinalized` the invoice date for the PDF, the e-invoice (BT-2) and the number
|
||||
sequence, and introduced backdating (which only moves `DateFinalized`). Everything else — invoice
|
||||
lists, revenue reports and charts, the DATEV export, document lookups, the request details — still
|
||||
dated and grouped invoices by `DateCreated`, the moment the *draft* was created. A backdated invoice
|
||||
therefore showed September on the document and October in every list, report and in DATEV, and any
|
||||
invoice drafted in one month and finalised in the next had the same split.
|
||||
|
||||
## Decision
|
||||
|
||||
- **One invoice date, everywhere:** the German calendar day of `DateFinalized`, falling back to
|
||||
`DateCreated` only for drafts. Both columns are stored in UTC.
|
||||
- **One implementation per side:** `[dbo].[fds__fn_invoiceDate](DateFinalized, DateCreated)` in SQL and
|
||||
`InvoiceDates.IssueDate` in C#. No proc converts or picks the date itself.
|
||||
- Applies to: the DATEV export (period filter, `Belegdatum`, `Belegfeld 2`), the invoice lists
|
||||
(`fds__getInvoices_list`, `list2`, `list_vario`: period filter, `Belegdatum`, `DateOfCreation`,
|
||||
ordering, overdue fallback), the revenue reports (`fds__r_*`: year/month filters and grouping,
|
||||
`fds__r_getBalanceThisMonth`), document lookups (`fds__getFDSDocument`, `fds__getReportDocument`),
|
||||
`fds__getRequest_details` (new column `InvoiceDate`, shown in the request's invoice list), and the
|
||||
invoice date shown in reminders (`fds__prepReminder`, `fds__createReminder`, `fds__getReminder`).
|
||||
- **Reminders use the same rule for their own date:** the reminder PDF's date is the reminder's
|
||||
`DateFinalized` (German day), draft: `DateCreated`.
|
||||
- **No transition safeguard.** The switch applies to all periods, past and future; already exported
|
||||
DATEV periods are not reconciled (confirmed by the business owner, 2026-09-30).
|
||||
- `DateCreated` keeps its meaning — the creation timestamp of the row — and is still returned where
|
||||
callers read it as such. Draft previews stamp it with `DateTime.UtcNow` to match the stored column.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Reports and exports for a past period can differ from what they showed before this change: an
|
||||
invoice drafted in month M and finalised in M+1 now counts in M+1.
|
||||
- Filters on the invoice date are not SARGable (they were not before either: `CAST(DateCreated as date)`).
|
||||
Acceptable at the current table size; an indexed computed column is the follow-up if it matters.
|
||||
- Any new list, report or export that shows or groups invoices by date must use
|
||||
`fds__fn_invoiceDate` — never `DateCreated` or a raw `DateFinalized`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep `DateCreated` in lists/reports, `DateFinalized` only on documents** — rejected: the same
|
||||
invoice would carry two dates, and backdating would never reach the books.
|
||||
- **Transition safeguard for already exported DATEV periods** — rejected by the business owner.
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-30
|
||||
applyTo:
|
||||
- "Fuchs_Database/**"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0036 — The database project build is the T-SQL gate
|
||||
|
||||
## Context
|
||||
|
||||
A rewrite of `fds__getDatevExports` added a `JOIN @inv as i` next to `fds__invoices as iv`
|
||||
and left an unqualified `[file_guid]` in the select list. SQL Server rejects that statement
|
||||
with Msg 209 (ambiguous column name) — but only when it runs, and the working-tree SQL runs
|
||||
nowhere: the schema lives in the `Fuchs_Database` SSDT project, the dev database only gets
|
||||
what is explicitly published to it, and the xUnit suite cannot bind T-SQL. The defect reached
|
||||
code review as the only check.
|
||||
|
||||
The SSDT build would have caught it: it reported the line as `SQL71502` ("unresolved reference
|
||||
… or ambiguous"). Nobody saw that because
|
||||
|
||||
- the project did not build at all — the two `CREATE USER … FOR LOGIN` files referenced logins
|
||||
that were not in the model, which SSDT treats as error `SQL71501`;
|
||||
- the genuine warning sat among ~160 identical `SQL71502` warnings caused by references to
|
||||
`sys.*`, `INFORMATION_SCHEMA`, `sp_executesql`, `sp_getapplock` (no `master` reference) and
|
||||
to `ocms_fn_generatePassword`, an OCMS helper the project never contained;
|
||||
- a warning does not fail anything.
|
||||
|
||||
The same sweep found that `ocms_fn_generatePassword` (and its dependency `ocms_fn_randbetween`)
|
||||
is missing on the dev database as well, so every id-generating function (`fds__fn_invoice_id`,
|
||||
`fds__fn_reminder_id`, `fis_fn_id`, …) fails there with Msg 4121.
|
||||
|
||||
## Decision
|
||||
|
||||
1. `FuchsDatabase.sqlproj` references the `master` system dacpac
|
||||
(`$(DacPacRootPath)\…\SqlServer\170\SQLSchemas\master.dacpac`), so system objects bind.
|
||||
2. Everything the SQL references is in the project. The OCMS helpers `ocms_fn_generatePassword`
|
||||
and `ocms_fn_randbetween` are vendored copies of the OCMSsharp `ocms_mt_database` files.
|
||||
`Security/Logins.sql` scripts the two application logins with a placeholder,
|
||||
`MUST_CHANGE` password so the users bind; real passwords stay in Key Vault and SSDT never
|
||||
compares passwords, so existing logins are untouched on publish.
|
||||
3. `TreatTSqlWarningsAsErrors` is on. Suppressed: `71501` (dynamic SQL / cross-database
|
||||
references) and `71558` (identifier differs only by case — harmless under the `CI` model
|
||||
collation). Every other T-SQL warning, in particular `SQL71502`, fails the build.
|
||||
4. Building the database project is part of the workflow whenever `Fuchs_Database/**` changes
|
||||
(`msbuild Fuchs_Database/FuchsDatabase.sqlproj -p:Configuration=Debug`, from the Visual
|
||||
Studio MSBuild — `dotnet build` cannot build the legacy SSDT project format). A red database
|
||||
build is a failure like a red C# build.
|
||||
5. The C# side of the contract is a test: `Fuchs.Tests/SqlContractTests` scans every
|
||||
`[dbo].[…]` named in C# strings (`Fuchs`, `Fuchs_DataService`) and checks it against the
|
||||
SSDT files — the object exists, every `EXECUTE` binds (no unknown names, no surplus
|
||||
positional arguments, every parameter without a default supplied), every function call
|
||||
passes the declared argument count, and every `mfr__updt__*` proc declares the
|
||||
`@tblname, @referencetable, @tgtid` signature `FdsMfrClient` calls dynamically.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An unqualified column that becomes ambiguous, a typo in a column name, or a call to a
|
||||
function that is not in the project is a build error before review, not a Msg 209/4121 at
|
||||
a customer.
|
||||
- `SELECT *` inside `EXISTS (…)` over joined tables trips `SQL71502` in SSDT even though SQL
|
||||
Server accepts it; write `SELECT 1` there (done for `fds__fn_IntermediateIsAllocatedToOther`
|
||||
and `fis_admin_testGroupMembership`).
|
||||
- New procedures that use `sys.*`/`sp_*` need no suppression any more. A new reference that is
|
||||
genuinely outside the model (another database, dynamic SQL) is suppressed per statement or
|
||||
by adding the object to the project — never by widening `SuppressTSqlWarnings` for `71502`.
|
||||
- A proc signature change that breaks a C# caller fails `dotnet test`, not the first request.
|
||||
Only literal SQL is covered: dynamically built names (`FuchsVisualization`'s report procs,
|
||||
resolved from `sys.parameters` at runtime) and result-set column names are not.
|
||||
- The build proves binding, not behaviour: publishing to the dev database (`SqlPackage
|
||||
/Action:Publish` with `ExcludeObjectTypes=Logins;Users;Permissions;RoleMembership`, or the
|
||||
schema compare) and exercising the code path remains the runtime check.
|
||||
- The dev database needs the two `ocms_*` helpers published before any id-generating path
|
||||
works there.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Run the changed procedures against the dev database before review.** Needs a publish of
|
||||
every working-tree change first, and does not cover code paths the reviewer does not think
|
||||
to exercise; the compile-time check is free and total.
|
||||
- **Suppress `SQL71502` and keep the build warning-free that way.** Removes exactly the signal
|
||||
that catches this class of bug.
|
||||
- **A C#-side test that parses the SQL for unqualified columns.** A parser-level heuristic
|
||||
without a schema cannot decide ambiguity; the SSDT model can.
|
||||
@@ -181,6 +181,36 @@ Confirming the preview modal posts to **`req/sconf`**
|
||||
`[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.
|
||||
- **Invoice date** = `DateFinalized` — printed on the PDF and used as the
|
||||
e-invoice issue date (`InvoiceDates.IssueDate`, ADR 0032).
|
||||
- **Backdating (ADR 0032):** users with `fds_inv > 2` see a checkbox in the
|
||||
preview dialog (`inv/dpreview` returns `backdate`) to date the invoice to
|
||||
the last working day (Mon–Fri) of the previous month, as long as no
|
||||
invoice has been finalised after that day. `req/sconf` then passes
|
||||
`@backdate = 1`; `fds__fn_invoiceBackdate` is re-checked and the proc
|
||||
throws 60001 (nothing finalised) if it is no longer allowed. Number year and
|
||||
`DueDate` follow the backdated day. The proc serializes all finalisations
|
||||
(transaction + `sp_getapplock`) so the check and the numbering cannot
|
||||
interleave with a concurrent finalisation. The controller maps 60001 to
|
||||
the "create again without backdating" message; a 0-row result (already
|
||||
final/sent, e.g. double submit) is reported as "already finalised".
|
||||
- **Same date everywhere (ADR 0035):** invoice lists, revenue reports,
|
||||
the DATEV export (period + `Belegdatum`), request details and reminders
|
||||
all use `[dbo].[fds__fn_invoiceDate]` — `DateFinalized` as German
|
||||
calendar day, drafts fall back to `DateCreated` — so a backdated invoice
|
||||
appears in the previous month everywhere, including DATEV. The reminder
|
||||
PDF's own date follows the same rule (reminder `DateFinalized`).
|
||||
- **Abschlagsrechnungen (ADR 0034):** `IntInvoiceAllocation` lines carry the
|
||||
credited intermediate invoice in `reference_InvId` (seeded by
|
||||
`fds__prepInvoice` and `fds__prepStorno_recreate`, round-tripped by the
|
||||
editor as item `ref`); finalisation records it in
|
||||
`fds__invoice_details.AllocatedTo_InvId` so the same Abschlagsrechnung is
|
||||
not offered again. The claim only counts while the crediting invoice is
|
||||
alive: once it is cancelled (storno, or replaced via storno + recreate)
|
||||
`fds__fn_IntermediateIsAllocatedToOther` treats the Abschlagsrechnung as
|
||||
free again and the next finalisation takes the allocation over. A
|
||||
Stornorechnung (`'c'`) never claims an allocation, even though a storno
|
||||
copy mirrors the credit lines.
|
||||
2. On success (`IsFinal == true` returned), the controller:
|
||||
- Reloads the invoice via `LoadInvoiceAsync`.
|
||||
- Calls `StoreInvoiceDocumentFileAsync` → renders the **final** PDF
|
||||
@@ -283,7 +313,7 @@ Once an invoice is `IsFinal`, the request/invoice list menus
|
||||
| **View / display PDF** | `$inv.disp(id, 'inv')` | `inv/rdoc` | Renders page images for on-screen display only. If a finalised, non-external invoice has no stored PDF, it is regenerated and persisted first (ADR 0025) |
|
||||
| **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 |
|
||||
| **Storno (cancel)** | `$inv.storno` → `$inv.cSt` (first `inv/pget`: refreshes mfr entities only for numeric mfr ids, no-op for Intranet ids) | `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 |
|
||||
|
||||
|
||||
@@ -49,6 +49,11 @@ Serviceaufträge kommen aus dem MFR-ERP-System und werden regelmäßig automatis
|
||||
### Erstellen / Bearbeiten
|
||||
1. Aus einem Serviceauftrag eine Rechnung **vorbereiten** → Vorschau prüfen.
|
||||
2. **Speichern** (Entwurf) oder weiter **bearbeiten**. Entwürfe tragen ein Wasserzeichen.
|
||||
3. **Rechnung erstellen** in der Vorschau vergibt Rechnungsnummer und Rechnungsdatum (= Tag der Erstellung).
|
||||
- **Rechnungsdatum Vormonat (nur Admins):** Solange im neuen Monat noch keine Rechnung erstellt wurde,
|
||||
erscheint in der Vorschau das Häkchen „Rechnungsdatum auf den letzten Werktag des Vormonats setzen
|
||||
(TT.MM.JJJJ)“. Gesetzt, erhält die Rechnung dieses Datum; Rechnungsnummer und Fälligkeit richten sich
|
||||
danach. Werktage sind Mo–Fr (Feiertage werden nicht berücksichtigt).
|
||||
|
||||
### Sets (Positionen zusammenfassen)
|
||||
- Ein **Set** aus mfr fasst die darauf folgenden Positionen zusammen; mit **„Auf Setpreis
|
||||
|
||||
@@ -57,6 +57,9 @@ public static class FuchsTelemetry
|
||||
public static readonly Counter<long> BlobBackupCopiesFailed =
|
||||
Meter.CreateCounter<long>("fuchs.blobstorage.backupcopies.failed", "{copy}",
|
||||
"Number of blob backup-copy attempts that failed.");
|
||||
public static readonly Counter<long> InvoicesBackdated =
|
||||
Meter.CreateCounter<long>("fuchs.invoices.backdated", "{invoice}",
|
||||
"Invoice finalisations with the admin backdate option (ADR 0032), tagged by outcome.");
|
||||
public static readonly Counter<long> InvoiceFileBackups =
|
||||
Meter.CreateCounter<long>("fuchs.invoice.filebackups", "{backup}",
|
||||
"Admin-triggered invoice file backup+clear actions (Do_Process_Admin backupclearinvoicefile), tagged by outcome.");
|
||||
|
||||
@@ -135,6 +135,12 @@ public class AzureBlobStorageService : IBlobStorageService
|
||||
Response<bool> response = await blobClient.ExistsAsync(cancellationToken);
|
||||
return response.Value;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Caller cancelled (e.g. app shutdown during the startup backfill) — not a storage failure;
|
||||
// reporting "not archived" here made the backfill go on to a doomed upload logged as an error.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Blob existence check failed for {Container}/{Blob} — treating as not archived.",
|
||||
@@ -270,6 +276,11 @@ public class AzureBlobStorageService : IBlobStorageService
|
||||
category, documentId, containerName, blobName);
|
||||
return blobClient.Uri;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug("Blob upload cancelled for {Category} {Id} (caller cancelled).", category, documentId);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FuchsTelemetry.BlobUploadsFailed.Add(1, new KeyValuePair<string, object?>("category", category));
|
||||
|
||||
@@ -259,13 +259,7 @@ public static class ERechnungMapper
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
private static DateOnly IssueDate(GenericObjectDictionary? reg)
|
||||
{
|
||||
foreach (var key in new[] { "DateFinalized", "DateCreated" })
|
||||
if (reg?.getString(key) is { Length: > 0 } s && DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt))
|
||||
return DateOnly.FromDateTime(dt);
|
||||
return DateOnly.FromDateTime(DateTime.Today);
|
||||
}
|
||||
private static DateOnly IssueDate(GenericObjectDictionary? reg) => InvoiceDates.IssueDate(reg);
|
||||
|
||||
/// <summary>Normalises a raw country value (ISO alpha-2, or a German/English name) to a country code.</summary>
|
||||
internal static CountryCode NormalizeCountry(string? raw)
|
||||
|
||||
@@ -12,7 +12,8 @@ public interface IBlobStorageService
|
||||
/// 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.
|
||||
/// or the upload failed — failures never break the primary DB-storage flow. Only a cancelled
|
||||
/// <paramref name="cancellationToken"/> surfaces, as <see cref="OperationCanceledException"/>.
|
||||
/// </summary>
|
||||
Task<Uri?> UploadInvoicePdfAsync(string invoiceId, string fileName, byte[] content,
|
||||
IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
|
||||
@@ -50,7 +51,8 @@ public interface IBlobStorageService
|
||||
/// <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.
|
||||
/// Returns <c>false</c> when storage is disabled/unconfigured or the check fails; throws only
|
||||
/// <see cref="OperationCanceledException"/> when <paramref name="cancellationToken"/> is cancelled.
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -616,7 +616,8 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
["SendToAddressJson"] = InvoiceRecipientAddress.FromCustomValues(Str(session.New["CustomValues"]))?
|
||||
.ToJson().ToString(Newtonsoft.Json.Formatting.None) ?? "",
|
||||
["InvoiceOptions"] = BuildInvoiceOptions(session),
|
||||
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
// UTC like the stored column — InvoiceDates.IssueDate converts it to the German day
|
||||
["DateCreated"] = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
int idx = 0;
|
||||
|
||||
@@ -205,6 +205,17 @@ public class InvoiceService : IInvoiceService
|
||||
/// The whole set is cleared and rewritten on every call — simpler and safer than diffing —
|
||||
/// mirroring the legacy <c>fds__invoice_data.RegisterInvoice_V1</c> (VB) behaviour.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Normalises an item's posted <c>ref</c> (the invoice a CanceledInvoice / IntInvoiceAllocation line
|
||||
/// points at) for <c>fds__invoice_items.reference_InvId</c> (varchar(20)); null when absent or not an id.
|
||||
/// </summary>
|
||||
internal static string? ItemReferenceInvId(string? raw)
|
||||
{
|
||||
string s = (raw ?? "").Trim();
|
||||
if (s.Length == 0 || s.Length > 20 || s.Equals("null", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
return s.All(char.IsAsciiLetterOrDigit) ? s : null;
|
||||
}
|
||||
|
||||
private async Task PersistInvoiceLineItemsAsync(FdsInvoiceData invoice, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
string invoiceId = invoice.Id;
|
||||
@@ -269,6 +280,7 @@ public class InvoiceService : IInvoiceService
|
||||
float vs = 0;
|
||||
if (TryCastSingle(itmDic.getItem("vs"), ref vs)) row["value_service"] = (decimal)vs;
|
||||
row["det"] = itmDic.no("det", false) is true;
|
||||
if (ItemReferenceInvId(itmDic.nz("ref")) is { } refInvId) row["reference_InvId"] = refInvId;
|
||||
row["SortOrder"] = (byte)Math.Clamp(itemSort, 0, 255);
|
||||
itemsTable.Rows.Add(row);
|
||||
}
|
||||
@@ -302,7 +314,9 @@ public class InvoiceService : IInvoiceService
|
||||
: Array.Empty<string>(),
|
||||
AdminUser = reg?.getString("UserNameFinalized") ?? "",
|
||||
AdminUserEmail = reg?.getString("UserEmailFinalized") ?? "",
|
||||
AdminDatumValue = reg?.getString("DateCreated") is { Length: > 0 } dc ? DateTime.Parse(dc) : DateTime.Now
|
||||
// Rechnungsdatum = DateFinalized (incl. backdating), draft: DateCreated — same source as the
|
||||
// e-invoice issue date (ERechnungMapper), see InvoiceDates / ADR 0032.
|
||||
AdminDatumValue = InvoiceDates.IssueDate(reg).ToDateTime(TimeOnly.MinValue)
|
||||
};
|
||||
var inlineImages = await ResolveInlineImagesAsync(invoice);
|
||||
var doc = await _pdf.WriteLetterAsync(tb, draft);
|
||||
|
||||
@@ -271,7 +271,8 @@ public sealed class ReminderDraftEditService : IReminderDraftService
|
||||
["invoices"] = invoices,
|
||||
["CustomValues"] = Str(session.New["CustomValues"]),
|
||||
["IsFinal"] = false,
|
||||
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
// UTC like the stored column — InvoiceDates.IssueDate converts it to the German day
|
||||
["DateCreated"] = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
};
|
||||
return new GenericObjectDictionary(d);
|
||||
}
|
||||
|
||||
@@ -111,7 +111,8 @@ public class ReminderService : IReminderService
|
||||
Address = reminder.InvoiceAddress,
|
||||
AdminUser = reminder.UserNameFinalized,
|
||||
AdminUserEmail = reminder.UserEmailFinalized,
|
||||
AdminDatumValue = reminder.DateCreated ?? DateTime.Now
|
||||
// Reminder date = DateFinalized, draft: DateCreated — same rule as the invoice date (ADR 0035)
|
||||
AdminDatumValue = InvoiceDates.IssueDate(reminder.ReminderRegistration).ToDateTime(TimeOnly.MinValue)
|
||||
};
|
||||
if (!string.IsNullOrEmpty(reminder.RawCustomValues))
|
||||
{
|
||||
|
||||
@@ -32,7 +32,6 @@ public class FdsReminderData
|
||||
internal string RawCustomValues => NewValues?.nz("CustomValues").Trim() ?? "";
|
||||
|
||||
// -- Computed props from registration -------------------------------------
|
||||
public DateTime? DateCreated => ReminderRegistration?.getString("DateCreated") is { Length: > 0 } d ? DateTime.Parse(d) : null;
|
||||
public string ReminderType => ReminderRegistration?.getString("type").Substr(0, 1) ?? "";
|
||||
public string UserNameFinalized => ReminderRegistration?.getString("UserNameFinalized") ?? "";
|
||||
public string UserEmailFinalized => ReminderRegistration?.getString("UserEmailFinalized") ?? "";
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>Result of a <c>fds__setInvoiceFinal</c> call as seen by <c>req/sconf</c>.</summary>
|
||||
public enum InvoiceFinalizeOutcome
|
||||
{
|
||||
/// <summary>The draft was finalized (one row returned).</summary>
|
||||
Finalized,
|
||||
/// <summary>SQL error 60001: no longer allowed to backdate; nothing was finalized.</summary>
|
||||
BackdateNotAllowed,
|
||||
/// <summary>No error but no row: the invoice is already final/sent (or unknown); nothing was changed.</summary>
|
||||
AlreadyFinal,
|
||||
/// <summary>Any other SQL error (authorization 60000, constraint, timeout ...).</summary>
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The invoice date ("Rechnungsdatum") printed on the PDF and carried as the e-invoice issue date
|
||||
/// (BT-2). One source for both so they can never disagree (ADR 0032):
|
||||
/// <c>DateFinalized</c> (the finalisation — or, for a backdated invoice, the backdated — date),
|
||||
/// falling back to <c>DateCreated</c> for drafts. Both columns are written with <c>GETUTCDATE()</c>,
|
||||
/// so the value is converted to the German calendar day.
|
||||
/// </summary>
|
||||
public static class InvoiceDates
|
||||
{
|
||||
private static readonly TimeZoneInfo German = ResolveGermanZone();
|
||||
|
||||
public static DateOnly IssueDate(GenericObjectDictionary? registration, DateTime? utcNow = null)
|
||||
{
|
||||
DateTime? stored = registration?.getDate_null("DateFinalized") ?? registration?.getDate_null("DateCreated");
|
||||
DateTime utc = stored ?? utcNow ?? DateTime.UtcNow;
|
||||
return DateOnly.FromDateTime(ToGermanTime(utc));
|
||||
}
|
||||
|
||||
/// <summary>Parses the ISO date returned by <c>fds__fn_invoiceBackdate</c>; null when empty/invalid.</summary>
|
||||
public static DateOnly? ParseBackdate(string? iso)
|
||||
=> DateOnly.TryParseExact((iso ?? "").Trim(), "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None, out var d) ? d : null;
|
||||
|
||||
/// <summary>True when the finalise request asked for backdating (checkbox value "1"/"true").</summary>
|
||||
public static bool IsBackdateRequested(string? formValue)
|
||||
=> (formValue ?? "").Trim().ToLowerInvariant() is "1" or "true" or "on";
|
||||
|
||||
/// <summary>SQL error number of <c>THROW 60001, 'backdating not allowed'</c> in <c>fds__setInvoiceFinal</c>.</summary>
|
||||
public const int BackdateNotAllowedErrorNumber = 60001;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies what <c>fds__setInvoiceFinal</c> did, from the OCORE result: an exception (with the SQL
|
||||
/// error number), or the number of rows the guarded <c>UPDATE ... WHERE isFinal = 0 AND isSent = 0</c>
|
||||
/// returned. Only the dedicated 60001 means "backdating no longer allowed"; a 0-row result means the
|
||||
/// invoice was already final/sent (e.g. a double submit) — reporting that as a backdate rejection
|
||||
/// would invite the user to create a duplicate.
|
||||
/// </summary>
|
||||
public static InvoiceFinalizeOutcome ClassifyFinalizeResult(string? sqlException, int? sqlErrorNumber, int rowCount)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(sqlException))
|
||||
return sqlErrorNumber == BackdateNotAllowedErrorNumber
|
||||
? InvoiceFinalizeOutcome.BackdateNotAllowed
|
||||
: InvoiceFinalizeOutcome.Error;
|
||||
return rowCount > 0 ? InvoiceFinalizeOutcome.Finalized : InvoiceFinalizeOutcome.AlreadyFinal;
|
||||
}
|
||||
|
||||
/// <summary>Tag value for the <c>fuchs.invoices.backdated</c> counter's <c>outcome</c> dimension.</summary>
|
||||
public static string OutcomeTag(InvoiceFinalizeOutcome outcome) => outcome switch
|
||||
{
|
||||
InvoiceFinalizeOutcome.Finalized => "ok",
|
||||
InvoiceFinalizeOutcome.BackdateNotAllowed => "rejected",
|
||||
InvoiceFinalizeOutcome.AlreadyFinal => "already_final",
|
||||
_ => "error"
|
||||
};
|
||||
|
||||
internal static DateTime ToGermanTime(DateTime utc)
|
||||
=> TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(utc, DateTimeKind.Utc), German);
|
||||
|
||||
private static TimeZoneInfo ResolveGermanZone()
|
||||
{
|
||||
foreach (var id in new[] { "Europe/Berlin", "W. Europe Standard Time" })
|
||||
{
|
||||
try { return TimeZoneInfo.FindSystemTimeZoneById(id); }
|
||||
catch (TimeZoneNotFoundException) { }
|
||||
catch (InvalidTimeZoneException) { }
|
||||
}
|
||||
return TimeZoneInfo.Local;
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,21 @@ div.modal {
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
/* admin "Rechnungsdatum Vormonat" option in the invoice preview dialog (ADR 0032) */
|
||||
> .imagecollection > label.backdate {
|
||||
display: block;
|
||||
margin: 0 0 1rem 0;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #fff8e1;
|
||||
border: 1px solid #f0c36d;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
|
||||
> input {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
> .choicefrm {
|
||||
padding: 1rem;
|
||||
|
||||
|
||||
@@ -394,6 +394,14 @@ $inv.d = {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
|
||||
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
|
||||
/* Admin-only (fds_inv > 2, decided server-side): date the invoice to the last working day of
|
||||
the previous month, offered only until an invoice has been created in the new month (ADR 0032). */
|
||||
let bdChk = null;
|
||||
if ((response.backdate || '') !== '') {
|
||||
let bdRow = $('<label></label>').addClass('backdate').attr('title', $ict.bdTT).appendTo(c);
|
||||
bdChk = $('<input type="checkbox" />').appendTo(bdRow);
|
||||
$$.s(string($ict.bdLbl, [response.backdate.split('-').reverse().join('.')])).appendTo(bdRow);
|
||||
}
|
||||
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
|
||||
for (let ic = (response.img || []).length + 1; ic <= total; ic++) { $$.dc('pdfp ph', c).append($$.dc('note', $ict.pna)); }
|
||||
$ocms.dlg(c, {
|
||||
@@ -403,7 +411,7 @@ $inv.d = {
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/sconf'), data: { id: sv.invid }, success: (cresp) => {
|
||||
url: $ocms.url('req/sconf'), data: { id: sv.invid, backdate: (bdChk && bdChk.is(':checked')) ? 1 : 0 }, success: (cresp) => {
|
||||
ct.trigger('modal_close');
|
||||
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
|
||||
$inv.d.close();
|
||||
@@ -703,7 +711,7 @@ $inv.cInv2 = function (data) {
|
||||
lihd.append($$.sc('dli').click(function () { $inv.disp(ix.Id, 'inv'); }));
|
||||
}
|
||||
lihd.append($$.sc('nme', ix.DocumentName || ix.Id));
|
||||
$$.dc('lidt', ili).append([$$.dc('wda').append([$$.s(fdt(ix.DateCreated, 'dd.MM.yyyy'))]), $$.d().text($ict.iSt[ix.invstatus] || ix.invstatus)]);
|
||||
$$.dc('lidt', ili).append([$$.dc('wda').append([$$.s(fdt(ix.InvoiceDate || ix.DateCreated, 'dd.MM.yyyy'))]), $$.d().text($ict.iSt[ix.invstatus] || ix.invstatus)]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -878,13 +886,16 @@ $inv.ccStInv = function (ev) { // Stornorechnung
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
|
||||
}, error: (xhr) => {
|
||||
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
});
|
||||
|
||||
}, error: () => {
|
||||
}, error: (xhr) => {
|
||||
if (o) { o.c.trigger('modal_close'); }
|
||||
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1011,13 +1022,16 @@ $inv.cSt = function (data) {
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
|
||||
}, error: (xhr) => {
|
||||
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
});
|
||||
|
||||
}, error: () => {
|
||||
}, error: (xhr) => {
|
||||
if (o) { o.c.trigger('modal_close'); }
|
||||
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1331,7 +1345,7 @@ $inv.rrw = function () {
|
||||
} else if (rw.is('.itm.osum') === true) {
|
||||
co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false };
|
||||
} else {
|
||||
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
|
||||
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0), ref: dta.reference_InvId || null };
|
||||
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
|
||||
|
||||
/* dta.position is '' (not unset) for a Text/Title row once invSumUpdate has numbered the
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
ivE: 'Die Email-Adresse ist vermutlich nicht gültig.',
|
||||
ivEc: '\nMöchten Sie fortfahren?',
|
||||
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
|
||||
bdLbl: 'Rechnungsdatum auf den letzten Werktag des Vormonats setzen ({0})',
|
||||
bdTT: 'Nur möglich, solange im neuen Monat noch keine Rechnung erstellt wurde. Rechnungsnummer und Fälligkeit richten sich nach diesem Datum.',
|
||||
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
|
||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
||||
pvw: 'Das Dokument wird abgerufen und für die Vorschau aufbereitet.\nDer Vorgang kann bis zu 2 Minuten dauern.',
|
||||
|
||||
@@ -1309,6 +1309,21 @@ div.modal > .modal-dialog > .modal-content .modal-header, div.modal > .modal-dia
|
||||
div.modal > .modal-dialog > .modal-content .modal-header + div, div.modal > .modal-dialog > .modal-content .modal-body + div, div.modal > .modal-dialog > .modal-content .modal-footer + div, div.modal > .modal-dialog > .modal-content .modal-note + div {
|
||||
border-top: 1px solid #e5e5e5;
|
||||
}
|
||||
div.modal > .modal-dialog > .modal-content .modal-body {
|
||||
/* admin "Rechnungsdatum Vormonat" option in the invoice preview dialog (ADR 0032) */
|
||||
}
|
||||
div.modal > .modal-dialog > .modal-content .modal-body > .imagecollection > label.backdate {
|
||||
display: block;
|
||||
margin: 0 0 1rem 0;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #fff8e1;
|
||||
border: 1px solid #f0c36d;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
div.modal > .modal-dialog > .modal-content .modal-body > .imagecollection > label.backdate > input {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
div.modal > .modal-dialog > .modal-content .modal-body > .choicefrm {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@@ -160,6 +160,8 @@ let $ict = {
|
||||
ivE: 'Die Email-Adresse ist vermutlich nicht gültig.',
|
||||
ivEc: '\nMöchten Sie fortfahren?',
|
||||
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
|
||||
bdLbl: 'Rechnungsdatum auf den letzten Werktag des Vormonats setzen ({0})',
|
||||
bdTT: 'Nur möglich, solange im neuen Monat noch keine Rechnung erstellt wurde. Rechnungsnummer und Fälligkeit richten sich nach diesem Datum.',
|
||||
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
|
||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
||||
pvw: 'Das Dokument wird abgerufen und für die Vorschau aufbereitet.\nDer Vorgang kann bis zu 2 Minuten dauern.',
|
||||
@@ -974,6 +976,14 @@ $inv.d = {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
|
||||
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
|
||||
/* Admin-only (fds_inv > 2, decided server-side): date the invoice to the last working day of
|
||||
the previous month, offered only until an invoice has been created in the new month (ADR 0032). */
|
||||
let bdChk = null;
|
||||
if ((response.backdate || '') !== '') {
|
||||
let bdRow = $('<label></label>').addClass('backdate').attr('title', $ict.bdTT).appendTo(c);
|
||||
bdChk = $('<input type="checkbox" />').appendTo(bdRow);
|
||||
$$.s(string($ict.bdLbl, [response.backdate.split('-').reverse().join('.')])).appendTo(bdRow);
|
||||
}
|
||||
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
|
||||
for (let ic = (response.img || []).length + 1; ic <= total; ic++) { $$.dc('pdfp ph', c).append($$.dc('note', $ict.pna)); }
|
||||
$ocms.dlg(c, {
|
||||
@@ -983,7 +993,7 @@ $inv.d = {
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/sconf'), data: { id: sv.invid }, success: (cresp) => {
|
||||
url: $ocms.url('req/sconf'), data: { id: sv.invid, backdate: (bdChk && bdChk.is(':checked')) ? 1 : 0 }, success: (cresp) => {
|
||||
ct.trigger('modal_close');
|
||||
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
|
||||
$inv.d.close();
|
||||
@@ -1283,7 +1293,7 @@ $inv.cInv2 = function (data) {
|
||||
lihd.append($$.sc('dli').click(function () { $inv.disp(ix.Id, 'inv'); }));
|
||||
}
|
||||
lihd.append($$.sc('nme', ix.DocumentName || ix.Id));
|
||||
$$.dc('lidt', ili).append([$$.dc('wda').append([$$.s(fdt(ix.DateCreated, 'dd.MM.yyyy'))]), $$.d().text($ict.iSt[ix.invstatus] || ix.invstatus)]);
|
||||
$$.dc('lidt', ili).append([$$.dc('wda').append([$$.s(fdt(ix.InvoiceDate || ix.DateCreated, 'dd.MM.yyyy'))]), $$.d().text($ict.iSt[ix.invstatus] || ix.invstatus)]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1458,13 +1468,16 @@ $inv.ccStInv = function (ev) { // Stornorechnung
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
|
||||
}, error: (xhr) => {
|
||||
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
});
|
||||
|
||||
}, error: () => {
|
||||
}, error: (xhr) => {
|
||||
if (o) { o.c.trigger('modal_close'); }
|
||||
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1591,13 +1604,16 @@ $inv.cSt = function (data) {
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
|
||||
}, error: (xhr) => {
|
||||
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
});
|
||||
|
||||
}, error: () => {
|
||||
}, error: (xhr) => {
|
||||
if (o) { o.c.trigger('modal_close'); }
|
||||
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1911,7 +1927,7 @@ $inv.rrw = function () {
|
||||
} else if (rw.is('.itm.osum') === true) {
|
||||
co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false };
|
||||
} else {
|
||||
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
|
||||
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0), ref: dta.reference_InvId || null };
|
||||
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
|
||||
|
||||
/* dta.position is '' (not unset) for a Text/Title row once invSumUpdate has numbered the
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -160,6 +160,8 @@ let $ict = {
|
||||
ivE: 'Die Email-Adresse ist vermutlich nicht gültig.',
|
||||
ivEc: '\nMöchten Sie fortfahren?',
|
||||
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
|
||||
bdLbl: 'Rechnungsdatum auf den letzten Werktag des Vormonats setzen ({0})',
|
||||
bdTT: 'Nur möglich, solange im neuen Monat noch keine Rechnung erstellt wurde. Rechnungsnummer und Fälligkeit richten sich nach diesem Datum.',
|
||||
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
|
||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
||||
pvw: 'Das Dokument wird abgerufen und für die Vorschau aufbereitet.\nDer Vorgang kann bis zu 2 Minuten dauern.',
|
||||
@@ -955,6 +957,14 @@ $inv.d = {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
|
||||
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
|
||||
/* Admin-only (fds_inv > 2, decided server-side): date the invoice to the last working day of
|
||||
the previous month, offered only until an invoice has been created in the new month (ADR 0032). */
|
||||
let bdChk = null;
|
||||
if ((response.backdate || '') !== '') {
|
||||
let bdRow = $('<label></label>').addClass('backdate').attr('title', $ict.bdTT).appendTo(c);
|
||||
bdChk = $('<input type="checkbox" />').appendTo(bdRow);
|
||||
$$.s(string($ict.bdLbl, [response.backdate.split('-').reverse().join('.')])).appendTo(bdRow);
|
||||
}
|
||||
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
|
||||
for (let ic = (response.img || []).length + 1; ic <= total; ic++) { $$.dc('pdfp ph', c).append($$.dc('note', $ict.pna)); }
|
||||
$ocms.dlg(c, {
|
||||
@@ -964,7 +974,7 @@ $inv.d = {
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/sconf'), data: { id: sv.invid }, success: (cresp) => {
|
||||
url: $ocms.url('req/sconf'), data: { id: sv.invid, backdate: (bdChk && bdChk.is(':checked')) ? 1 : 0 }, success: (cresp) => {
|
||||
ct.trigger('modal_close');
|
||||
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
|
||||
$inv.d.close();
|
||||
@@ -1264,7 +1274,7 @@ $inv.cInv2 = function (data) {
|
||||
lihd.append($$.sc('dli').click(function () { $inv.disp(ix.Id, 'inv'); }));
|
||||
}
|
||||
lihd.append($$.sc('nme', ix.DocumentName || ix.Id));
|
||||
$$.dc('lidt', ili).append([$$.dc('wda').append([$$.s(fdt(ix.DateCreated, 'dd.MM.yyyy'))]), $$.d().text($ict.iSt[ix.invstatus] || ix.invstatus)]);
|
||||
$$.dc('lidt', ili).append([$$.dc('wda').append([$$.s(fdt(ix.InvoiceDate || ix.DateCreated, 'dd.MM.yyyy'))]), $$.d().text($ict.iSt[ix.invstatus] || ix.invstatus)]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1439,13 +1449,16 @@ $inv.ccStInv = function (ev) { // Stornorechnung
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
|
||||
}, error: (xhr) => {
|
||||
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
});
|
||||
|
||||
}, error: () => {
|
||||
}, error: (xhr) => {
|
||||
if (o) { o.c.trigger('modal_close'); }
|
||||
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1572,13 +1585,16 @@ $inv.cSt = function (data) {
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
|
||||
}, error: (xhr) => {
|
||||
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
});
|
||||
|
||||
}, error: () => {
|
||||
}, error: (xhr) => {
|
||||
if (o) { o.c.trigger('modal_close'); }
|
||||
alert($t.f1 + (xhr && xhr.status ? xhr.status : ''));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1892,7 +1908,7 @@ $inv.rrw = function () {
|
||||
} else if (rw.is('.itm.osum') === true) {
|
||||
co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false };
|
||||
} else {
|
||||
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
|
||||
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0), ref: dta.reference_InvId || null };
|
||||
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
|
||||
|
||||
/* dta.position is '' (not unset) for a Text/Title row once invSumUpdate has numbered the
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -243,6 +243,21 @@ div.modal > .modal-dialog > .modal-content .modal-header, div.modal > .modal-dia
|
||||
div.modal > .modal-dialog > .modal-content .modal-header + div, div.modal > .modal-dialog > .modal-content .modal-body + div, div.modal > .modal-dialog > .modal-content .modal-footer + div, div.modal > .modal-dialog > .modal-content .modal-note + div {
|
||||
border-top: 1px solid #e5e5e5;
|
||||
}
|
||||
div.modal > .modal-dialog > .modal-content .modal-body {
|
||||
/* admin "Rechnungsdatum Vormonat" option in the invoice preview dialog (ADR 0032) */
|
||||
}
|
||||
div.modal > .modal-dialog > .modal-content .modal-body > .imagecollection > label.backdate {
|
||||
display: block;
|
||||
margin: 0 0 1rem 0;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #fff8e1;
|
||||
border: 1px solid #f0c36d;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
div.modal > .modal-dialog > .modal-content .modal-body > .imagecollection > label.backdate > input {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
div.modal > .modal-dialog > .modal-content .modal-body > .choicefrm {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user