Add unit tests for Fuchs_DataService and related components
- Introduced comprehensive unit tests for the Fuchs_DataService library, covering DATEV header formatting, CSV/XML generation, and FdsMfrClient construction. - Implemented tests for FdsMfr.UpdateNeed parsing and FdsShared utility helpers, ensuring correct functionality and stability. - Added tests for FdsConfig and FdsMfrClient to validate configuration resolution and client construction. Document decisions on backend-authoritative invoice and reminder handling - Created ADR 0008 to clarify that all invoice types and reminder stages are backend-authoritative during drafting and previewing. - Established that all calculations and settings must be processed server-side, ensuring consistency between online editor and PDF outputs. Define irreversible mutations for set-price modes in invoices - Documented ADR 0009 to specify that the "Set mit Preis" and "Nur Set mit Preis" operations are irreversible mutations affecting service request blocks. - Clarified that these operations are not display toggles but actual data changes, ensuring clear expectations for invoice handling. Transition MFR ERP sync to in-process execution within the web app - Created ADR 0010 to outline the migration of Fuchs_DataService from a standalone service to an in-process library within the Fuchs web application. - Updated configuration and logging management to be handled by the host application, streamlining the sync process. Add publish profile and periodic hosted service for job scheduling - Introduced a publish profile for deployment to a specified folder. - Implemented PeriodicHostedService to manage multiple independent jobs, including the MFR ERP sync, with configurable execution intervals. Add dotnet-tools.json for EF Core CLI tools - Included dotnet-tools.json to manage the version of dotnet-ef for Entity Framework Core migrations and commands.
This commit is contained in:
@@ -12,7 +12,7 @@ The **Fuchs Intranet** solution is a line-of-business web application for **Seba
|
||||
| Project | Type | Purpose |
|
||||
|---|---|---|
|
||||
| **Fuchs** | ASP.NET Core Web (MVC) | Main web application — the intranet |
|
||||
| **Fuchs_DataService** | Console / Windows Service (Topshelf) | Background data sync service (MFR ERP polling) |
|
||||
| **Fuchs_DataService** | Class Library | MFR ERP sync logic (entity polling, invoice/DATEV files). Hosted in-process by **Fuchs** as a `PeriodicHostedService` (see ADR 0010) — no longer a standalone process. |
|
||||
| **MFR_RESTClient** | Class Library | REST/OData client for the MFR ERP system. The REST/OData contract is documented in `MFR_RESTClient/Docs/mfr_interface_description.md`. |
|
||||
| **Fuchs_Database** | SSDT (SQL project) | Source of truth for the `fuchs_fds` SQL schema (tables, table types, functions, stored procedures the backend calls). |
|
||||
| **OCORE** | Class Library (shared) | Core utilities: SQL, crypto, email, IO, logging |
|
||||
@@ -96,17 +96,18 @@ The **Fuchs Intranet** solution is a line-of-business web application for **Seba
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Fuchs_DataService (Windows Service / Console) │
|
||||
│ Fuchs_DataService (Class Library, hosted in-process) │
|
||||
│ │
|
||||
│ FdsMain.cs — Topshelf host, job definitions │
|
||||
│ PeriodicHostedService — BackgroundService with PeriodicTimer │
|
||||
│ PeriodicHostedService — in Fuchs/Services; BackgroundService + │
|
||||
│ PeriodicTimer, registered in Program.cs │
|
||||
│ when Fds:SyncEnabled (ADR 0010) │
|
||||
│ FdsMfr.cs (IFdsMfr) — MFR sync orchestration │
|
||||
│ FdsMfrClient.cs — MFR REST client wrapper │
|
||||
│ FdsShared.cs — FdsConfig (appsettings.json reader) │
|
||||
│ FdsZip.cs — 7-Zip archive handling (DATEV export) │
|
||||
│ FdsShared.cs — FdsConfig (reads the host's IConfiguration) │
|
||||
│ (DATEV zip) — OCORE.zip (System.IO.Compression), no 7-Zip │
|
||||
│ FdsDebug.cs — Debug/file logging │
|
||||
│ │
|
||||
│ Jobs: MfrSync (every N min) │
|
||||
│ Jobs: MfrSync (every Fds:ExecutionFrequency_Minutes) │
|
||||
│ → UpdateIfNecessary_async (entity table sync) │
|
||||
│ → UpdateRequested_async (on-demand entity refresh) │
|
||||
│ → GetInvoiceFiles_async (invoice PDF download) │
|
||||
@@ -176,7 +177,7 @@ Stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are s
|
||||
There is no ORM (no EF Core). All data access uses **ADO.NET via OCORE SQL helpers** (`getSQLDatatable_async`, `getSQLDataSet_async`, `setSQLValue_async`) calling stored procedures and inline SQL. `DataTable`/`DataRow` is the primary data transfer mechanism.
|
||||
|
||||
### 4.5 Background Service
|
||||
`Fuchs_DataService` runs as a Windows Service (Topshelf) with a `PeriodicHostedService` that polls the MFR ERP system on a timer, syncing entities and downloading invoice files.
|
||||
The MFR ERP sync runs **in-process inside the web app** as a `PeriodicHostedService` (`Fuchs/Services/PeriodicHostedService.cs`), registered in `Program.cs` when `Fds:SyncEnabled` is true. It polls the MFR ERP on a timer (`Fds:ExecutionFrequency_Minutes`), syncing entities and downloading invoice files via the sync logic in the `Fuchs_DataService` library. See ADR [0010](Decisions/0010-mfr-sync-hosted-in-web-app.md).
|
||||
|
||||
### 4.6 Authentication
|
||||
Cookie-based authentication (`CookieAuthenticationDefaults`) with custom claims (`FuchsUserIdentity`). SQL-based user/password verification.
|
||||
@@ -409,7 +410,7 @@ public class MfrClientFactory : IMfrClientFactory, IDisposable
|
||||
2. ✅ **Resolved** — `FdsInvoiceData`/`FdsReminderData` are now pure data holders; DB + PDF logic moved to `IInvoiceService`/`IReminderService`.
|
||||
3. ✅ **Resolved** — `FdsMfrClient` is created via `IMfrClientFactory` (no `new` in controllers).
|
||||
4. ✅ **Resolved** — `OCORE_Charting` is now used (transitively, via `OCORE_web`'s chart engine) by the report renderer (`FuchsVisualization`).
|
||||
5. ⏳ **Open** — **Topshelf** in `Fuchs_DataService` could be replaced with native `dotnet` Worker Service hosting for .NET 10 alignment.
|
||||
5. ✅ **Resolved** — **Topshelf** removed; `Fuchs_DataService` is now a class library and the MFR sync is hosted in-process by the web app as a `PeriodicHostedService` (ADR 0010).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-07-10
|
||||
applyTo:
|
||||
@@ -55,13 +55,19 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
|
||||
exact editor JSON (`admin` / `new` / `req` blocks with `items`), plus server-computed
|
||||
`Sums`, `ValidationMessages`, `History`, `Version`, `Token`, `InvId`, `LastAccessUtc`.
|
||||
- **Calculation** (`InvoiceDraftCalculator`, static/pure) ports the former client math:
|
||||
`RecomputeItem` (quantity × price × VAT, the `quantChange` port), `RecomputeTotals`
|
||||
`RecomputeLineValues` (net/VAT/service-net/service-VAT per line from raw quantity ×
|
||||
price × VAT rate, the `quantChange`/`setVat` port), `RecomputeTotals`
|
||||
(the `invSumUpdate`/`csms` aggregation + §13b reverse-charge), `RecomputePositions`
|
||||
(numbers every line except heading/free-text lines continuously across the whole invoice —
|
||||
mirroring the editor's `invSumUpdate`, so the editor and the PDF show identical `Pos.` numbers,
|
||||
including after a reorder),
|
||||
and `Validate` (email/address/items/VAT-rate/negative-total checks). Being pure, it is
|
||||
exhaustively unit-tested.
|
||||
exhaustively unit-tested. **The online editor performs no arithmetic of any kind** — not
|
||||
header, footer, sums, totals, taxes, nor a single line's own net/VAT value: `$inv.quantChange`
|
||||
and `$inv.setVat` only post the raw field the user changed (qty/price/vat rate), and
|
||||
`$inv.invSumUpdate` only reassembles the row-contract array needed to post `req` — it computes
|
||||
no totals, no VAT breakdown, and no service-refund note figures. All of those are rendered
|
||||
exclusively from `dstate.sums` via `$inv.d.footer`.
|
||||
- **Sanitisation & reorder.** Scalar text deltas (`title`/`email`/`address`/`provisionperiod`/
|
||||
`provisionlocation`) and the section heading (`block.replace`) are stripped of the editor's
|
||||
TinyMCE HTML (`<p>…</p>`, `<br>`) to plain text in `ApplyDelta` (`HtmlToPlain`) — the backend
|
||||
@@ -85,7 +91,8 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
|
||||
`draftClosed`. Business success/failure still flows through `IEventService`/`NotificationHub`.
|
||||
- **Frontend** (`$fis.draft` in `fis_main.js`, editor in `fis.inv_shared.js`) opens/joins,
|
||||
posts one delta per change, shows a loading state whenever awaiting a signal, and offers
|
||||
"Änderungen verwerfen" and "Änderungshistorie" menu actions. It no longer computes totals.
|
||||
"Änderungen verwerfen" and "Änderungshistorie" menu actions. It computes nothing — no
|
||||
header, footer, sums, totals, taxes, or per-line values.
|
||||
|
||||
## Key files
|
||||
- `Fuchs/code/InvoiceDraftSession.cs` — session + `ChangeHistoryEntry` + `InvoiceDraftSums`.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-10
|
||||
applyTo:
|
||||
@@ -36,9 +36,11 @@ singleton `IInvoiceDraftCache` and orchestrated by the scoped `IInvoiceDraftServ
|
||||
(`InvoiceDraftEditService`). The browser is a pure view/input layer.
|
||||
|
||||
- **Truth & calculation on the server.** `InvoiceDraftCalculator` is the pure,
|
||||
unit-tested port of the former client-side math (`quantChange` + `invSumUpdate`),
|
||||
including the §13b reverse-charge rule and VAT-per-rate grouping. The browser never
|
||||
computes totals; it renders the server's `sums`.
|
||||
unit-tested port of the former client-side math (`quantChange` + `setVat` +
|
||||
`invSumUpdate`), including per-line net/VAT/service-value multiplication
|
||||
(`RecomputeLineValues`), the §13b reverse-charge rule and VAT-per-rate grouping. The
|
||||
browser performs **no arithmetic whatsoever** — not even a single line's
|
||||
`net = qty × price` — it only renders the server's `req`/`sums`.
|
||||
- **Commands are ordinary POSTs; signals are SignalR.** The editor posts single edits
|
||||
to `inv/dpatch` (and `dopen`/`dstate`/`dpreview`/`dsave`/`dhistory`/`ddiscard`/`dclose`).
|
||||
The server mutates the session, recomputes, validates, bumps a version, and pings the
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-15
|
||||
applyTo:
|
||||
- "Fuchs/Services/InvoiceDraft*"
|
||||
- "Fuchs/Services/IInvoiceDraft*"
|
||||
- "Fuchs/Services/ReminderDraft*"
|
||||
- "Fuchs/Services/IReminderDraft*"
|
||||
- "Fuchs/code/InvoiceDraftSession.cs"
|
||||
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
||||
- "Fuchs/code/InvoiceSetPricing.cs"
|
||||
- "Fuchs/code/ReminderDraftSession.cs"
|
||||
- "Fuchs/code/ReminderDraftCalculator.cs"
|
||||
- "Fuchs/code/FuchsPdf.cs"
|
||||
- "Fuchs/js/intranet/**"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0008 — Invoices and reminders (all kinds) are fully backend-authoritative; PDF and online editor must render identical content
|
||||
|
||||
## Context
|
||||
ADR [0006](0006-backend-authoritative-draft-editing.md) established the backend-authoritative
|
||||
draft-editing model for invoices and noted reminders were "intended to follow the identical
|
||||
pattern as a second phase". Both are now implemented (`InvoiceDraftEditService` /
|
||||
`ReminderDraftEditService`). In practice, ambiguity kept resurfacing about *which* invoice/
|
||||
reminder kinds this covers and *which* kinds of change qualify as "must be computed server-side":
|
||||
e.g. whether a purely presentational client-side re-render (set-price display toggle, item
|
||||
reordering, position renumbering) was allowed to keep any client-side math, and whether this
|
||||
applies uniformly to every invoice type (regular `r`, partial/Abschlag `i`, final `f`, storno
|
||||
`c`) and every reminder stage, not just the pilot "regular invoice" flow. This decision closes
|
||||
that ambiguity explicitly.
|
||||
|
||||
## Decision
|
||||
**Every invoice (all `InvoiceType` kinds: regular, partial/Abschlagsrechnung, final/
|
||||
Schlussrechnung, Storno/credit) and every reminder (all reminder stages/Mahnstufen) is
|
||||
backend-authoritative while being drafted or previewed.** This generalises and makes explicit
|
||||
what ADR 0006 already implied for the pilot flow:
|
||||
|
||||
- **Any calculation** (net/VAT/gross totals, per-rate VAT grouping, service-refund figures,
|
||||
§13b reverse-charge suppression, set-price sums, open-amount for reminders, position/line
|
||||
numbering) is performed exclusively by the server (`InvoiceDraftCalculator`,
|
||||
`ReminderDraftCalculator`, `InvoiceSetPricing`). The browser never sums, subtracts, or
|
||||
otherwise derives a monetary or positional value — it only displays server-computed values.
|
||||
This includes the single-line arithmetic that used to run in `quantChange`/`setVat`
|
||||
(`net_val = qty × price`, `vat_val = net_val × rate`, service-net/-VAT splits): those
|
||||
handlers now only post the raw, unmultiplied field the user typed (`qn`/`v`/`vat`) and the
|
||||
server (`InvoiceDraftCalculator.RecomputeLineValues`) computes every derived line value.
|
||||
Likewise the invoice footer (net/VAT-by-rate/gross), the per-block "isum" cell, and the
|
||||
service-refund note figures are rendered exclusively from `dstate.sums` (`$inv.d.footer`);
|
||||
`$inv.invSumUpdate` no longer accumulates any of these — it only reassembles the row
|
||||
contract array needed to post `req` to the server and (on first load) seeds the session.
|
||||
- **Any setting** (§13b flag, set-pricing display mode, payment terms, contact, custom values,
|
||||
…) is applied server-side via a named `InvoiceDraftDelta`/`ReminderDraftDelta` target and
|
||||
reflected back through `dstate`. The client never mutates its local model as the source of
|
||||
truth for a setting; it optimistically reflects the *request* but always re-renders from the
|
||||
next `dstate`/`draftReady` refresh.
|
||||
- **Any text change** (recipient email/address, invoice title, provision location/period,
|
||||
section headings, item name/description/notes) is sanitised and stored server-side
|
||||
(`InvoiceDraftEditService.HtmlToPlain` et al.); the server's stored value is the one that
|
||||
reaches the PDF and any reloaded draft.
|
||||
- **Any reordering** (drag-reorder of service-request blocks/sections, drag-reorder of item
|
||||
rows within a block) is committed as a `block.order` (or equivalent) delta; the server
|
||||
performs the actual reorder and renumbers positions (`InvoiceDraftCalculator.RecomputePositions`).
|
||||
The client's drag interaction is input only — the rendered order after a refresh is the
|
||||
server's order, not whatever the browser left in the DOM mid-drag.
|
||||
- **Irreversible one-way conversions** (e.g. "Auf Setpreis umstellen" — switching a set's
|
||||
member items from individual prices to a single set price) are likewise backend-only
|
||||
operations (`item.setprice` delta / `InvoiceDraftEditService.ApplyItemSetPrice`), never
|
||||
computed or applied in the browser.
|
||||
- **The PDF must render 100% the same information and content as the online editor at any
|
||||
given moment.** Both consume the identical authoritative session data:
|
||||
- The online editor renders `dstate`'s `req`/`sums`/`setDisplay`/`notes` — all server-computed.
|
||||
- The PDF preview (`inv/dpreview`, `rem/dpreview`) renders straight from the same cached
|
||||
session via a synthesised registration (`InvoiceDraftEditService.RenderPreview` /
|
||||
`ReminderDraftEditService`'s reminder equivalent) — **not** from a separate client upload
|
||||
or a re-derived model.
|
||||
- `FuchsPdf.BuildInvoiceNotes` (notice paragraphs) is called identically for both the
|
||||
editor's `notes` array and the PDF body, so intro/closing texts can never drift between
|
||||
the two renderings.
|
||||
- Any new editor-visible fact (a new total, a new flag, a new note) must be added to the
|
||||
shared session/service layer once, not duplicated as separate editor-only and PDF-only
|
||||
logic.
|
||||
- This applies for the full lifecycle while a document is a draft (open → edit → preview →
|
||||
Zwischenspeichern) up to finalise; a finalised, persisted invoice/reminder is immutable
|
||||
and is rendered straight from its stored DB data (no draft session involved) — that path
|
||||
already has no client-side math to begin with.
|
||||
|
||||
## Consequences
|
||||
- New invoice/reminder editor features must be modelled as a server-side delta + calculator
|
||||
change, exactly as ADR 0006 already requires; this decision removes any residual excuse to
|
||||
special-case a "just this one is presentational, do it in JS" shortcut for reordering,
|
||||
display-mode toggles, or one-way conversions.
|
||||
- Any PDF-only or editor-only special-casing found in review is a bug against this decision —
|
||||
the shared session/service must be extended so both renderers read the same value/flag.
|
||||
- Reminder "Mahnstufen" and every invoice type share this obligation; there is no partial/
|
||||
Abschlagsrechnung, Schlussrechnung, or Storno exemption while such a document is still a
|
||||
draft going through the same `dopen`/`dpatch`/`dpreview`/`dsave` flow.
|
||||
- Test coverage for the cache/session layer (`InvoiceDraftEditService`, `ReminderDraftEditService`,
|
||||
`InvoiceDraftCalculator`, `InvoiceSetPricing`) must exercise every mutating operation
|
||||
(text edits, reordering, all three set-pricing display modes, the set-price conversion,
|
||||
multi-rate VAT sums, full recompute) against mock datasets, since this is now the single
|
||||
place all of these behaviours are guaranteed correct — see `Fuchs.Tests/InvoiceDraftServiceTests.cs`,
|
||||
`Fuchs.Tests/ReminderDraftServiceTests.cs`, `Fuchs.Tests/InvoiceDraftCalculatorTests.cs`,
|
||||
`Fuchs.Tests/InvoiceSetPricingTests.cs`.
|
||||
|
||||
## Alternatives considered
|
||||
- **Scope this only to the invoice pilot flow** (leave reminders/other invoice kinds
|
||||
ambiguous): rejected — the ambiguity itself was the problem being fixed; the underlying
|
||||
session/service code already treats all kinds uniformly, so documenting anything narrower
|
||||
would misrepresent the code.
|
||||
- **Allow "purely cosmetic" client-side math for reordering/display toggles**: rejected —
|
||||
history showed exactly this exception is where drift crept in (e.g. the set-price toggle
|
||||
originally computed sums in the browser before being moved server-side); no exception is
|
||||
granted.
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-14
|
||||
applyTo:
|
||||
- "Fuchs/code/InvoiceSetPricing.cs"
|
||||
- "Fuchs/Services/InvoiceDraft*"
|
||||
- "Fuchs/Services/IInvoiceDraft*"
|
||||
- "Fuchs/code/InvoiceDraftSession.cs"
|
||||
- "Fuchs/code/InvoiceDraftCalculator.cs"
|
||||
- "Fuchs/code/FuchsPdf.cs"
|
||||
- "Fuchs/js/intranet/**"
|
||||
- "Fuchs/Docs/INVOICE_SET_PRICING.md"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0009 — The two menu set-price modes are per-service-request-block, irreversible cache mutations that insert a dedicated set row
|
||||
|
||||
## Context
|
||||
ADRs [0006](0006-backend-authoritative-draft-editing.md) and
|
||||
[0008](0008-invoices-and-reminders-fully-backend-authoritative.md) made draft editing
|
||||
backend-authoritative. Under that model the set-price feature had **three** functions, of
|
||||
which the two menu-driven ones ("Set mit Preis" / "Nur Set mit Preis") were framed as
|
||||
whole-invoice **display modes** (`SetDisplayMode.SetPrice`/`SetOnly`): a non-mutating,
|
||||
render-time transform (`InvoiceSetPricing.Build`) over explicit `type == "set"` header items
|
||||
and their `SetItmId` members, persisted only as an `admin.setmode` flag. ADR 0008 calls them
|
||||
"display-mode toggles" and treats them as presentational.
|
||||
|
||||
The product owner has redefined those two menu functions. They are **not** display toggles and
|
||||
they are **not** keyed on set-item membership:
|
||||
|
||||
- Their grouping is the **service request** (`ServiceRequestId` = the editor's tbody block),
|
||||
never `SetItmId`. Every block is treated as one set, whether or not it contains any
|
||||
`type == "set"` item.
|
||||
- Applying a mode is an **irreversible data change** written hard into the cached draft
|
||||
dataset — not a reversible view flag. There is no toggle back; the user adjusts the result
|
||||
by hand afterwards.
|
||||
|
||||
This decision records that redefinition. It **refines** ADR 0008's characterisation of these
|
||||
two operations (from "non-mutating display toggle" to "mutating, one-way conversion"); ADR
|
||||
0008's broader rule — every calculation server-side, and the PDF renders 100% the same content
|
||||
as the editor — remains fully in force and is not superseded.
|
||||
|
||||
## Decision
|
||||
There are three distinct set-price operations, kept clearly separated:
|
||||
|
||||
1. **Inline set-item switch — unchanged.** The row context button on a single `type == "set"`
|
||||
item (`$inv.toSetPrice` → `item.setprice` delta → `InvoiceDraftEditService.ApplyItemSetPrice`).
|
||||
It is `SetItmId`-based, sums the header's members onto the header, sets the members' prices to
|
||||
`null` (empty cell, excluded from the sum — consistent with modes 2 & 3, not `0`), and is
|
||||
one-way. This is the **only** set-price operation that reads `SetItmId`. The button is shown —
|
||||
and the operation available — **only** for items that are `type == "set"` **and** carry a
|
||||
`SetItmId` (and are still unconverted, i.e. own price `0`); an item missing either condition
|
||||
never offers it.
|
||||
|
||||
2. **"Set mit Preis" (menu) — per-block, irreversible mutation.** Applied server-side to the
|
||||
cached `InvoiceDraftSession`, grouped by `ServiceRequestId`. For **every** service-request
|
||||
block:
|
||||
- Insert one dedicated, emphasised **set row** at the top of the block, carrying the block's
|
||||
aggregated value (net + VAT + service-net/-VAT splits) as its price. This row is a real,
|
||||
editable line item with its own id, so the user can manually change the set value afterwards
|
||||
as an ordinary item edit.
|
||||
- **Null out** the price of every existing item row in the block (set the price fields to
|
||||
`null`, **not** `0`) so the row renders with an **empty** price/total cell. `null` and `0`
|
||||
are semantically distinct here: `null` means "no price — render an empty cell and exclude
|
||||
from the block sum", whereas `0` would legitimately print `0,00 €`. The rows themselves are
|
||||
retained.
|
||||
|
||||
3. **"Nur Set mit Preis" (menu) — per-block, irreversible mutation.** As above, grouped by
|
||||
`ServiceRequestId`. For every block:
|
||||
- Insert the same dedicated, emphasised set row carrying the block's aggregated value.
|
||||
- **Remove** every existing item row in the block from the dataset entirely (the lines are
|
||||
gone, not merely hidden).
|
||||
|
||||
Properties common to the two menu modes (2 and 3):
|
||||
|
||||
- **Mutation, not display.** The change is written into `InvoiceDraftSession.Req` (the "cache
|
||||
dataset") as a mutating `InvoiceDraftDelta`, computed on the server (never in the browser).
|
||||
There is no render-time `admin.setmode` grouping flag driving how lines are shown, and no
|
||||
reversible toggle.
|
||||
- **Irreversible.** There is no patch to undo it. The only ways back are discarding the draft
|
||||
(reloads the DB state) or hand-editing the resulting rows.
|
||||
- **`SetItmId` is irrelevant.** Membership is the block, full stop.
|
||||
- **Total unchanged.** The inserted set row's value equals the sum of the block's original items,
|
||||
which are then excluded from the sum — either because their price is `null` (mode 2, `null`
|
||||
counts as no contribution) or because they are gone (mode 3). So `InvoiceBalance`/
|
||||
`InvoiceBalance_net` are unaffected.
|
||||
- **Editor and PDF render identically** (ADR 0008): the dedicated set row is emphasised in both,
|
||||
and both read the same mutated session.
|
||||
|
||||
## Consequences
|
||||
- For the two menu modes, `InvoiceSetPricing` stops being a non-mutating render transform over
|
||||
`type == "set"` groups; the grouping/insert/blank/remove is a real mutation in
|
||||
`InvoiceDraftEditService`, keyed on the block. The inline `item.setprice` switch (operation 1)
|
||||
remains the sole `SetItmId`-based, set-item-scoped operation.
|
||||
- The previous `admin.setmode` display-flag model for these two modes — persisted `setmode:`
|
||||
`InvoiceOptions` token, "Set-Preisanzeige menu entry disappears while unset", `Build(...)`
|
||||
choosing `ShowPrice` per member at render time — is retired. Because the operation is a
|
||||
one-shot irreversible mutation, there is no persisted display state to toggle. Any residual
|
||||
`setmode:` token must degrade safely (ignored) and is no longer (re-)persisted.
|
||||
- New/changed behaviour must be modelled as a server-side delta + calculator/service change and
|
||||
covered by tests (`InvoiceDraftServiceTests`, `InvoiceSetPricingTests`): for each menu mode,
|
||||
assert the inserted set row's value equals the block sum, the total is unchanged, mode 2
|
||||
nulls-but-keeps member rows while mode 3 removes them, and an empty block is a no-op.
|
||||
- Reminders follow the identical pattern when/if the same feature is offered there (ADR 0006/0008
|
||||
reminder mirror).
|
||||
|
||||
## Alternatives considered
|
||||
Each of the following was raised and **explicitly decided against** as part of accepting this
|
||||
decision — they are rejected choices, not open options to revisit without a superseding ADR:
|
||||
|
||||
- **Keep them as non-mutating display toggles** (the prior design): **explicitly rejected** by the
|
||||
product owner — the set price must be a real, hand-editable value baked into the document, and
|
||||
"Nur Set mit Preis" must actually drop the member lines, not just hide them.
|
||||
- **Zero the blanked members' prices instead of nulling them**: **explicitly rejected** — `0` is
|
||||
ambiguous (it prints `0,00 €`), so the frontend could not tell an empty cell from a genuine
|
||||
zero price. Blanked members are set to `null` precisely to make "no price" unambiguous.
|
||||
- **Carry the set price on the existing block heading row** instead of a dedicated row:
|
||||
**explicitly rejected** — a separate, individually-editable set row keeps the section-heading
|
||||
semantics intact and gives the user a concrete line to adjust afterwards.
|
||||
- **Group by `SetItmId`/`type == "set"` headers like the inline switch:** **explicitly rejected**
|
||||
— the menu modes present each *service request* as one set, independent of any mfr set-item;
|
||||
conflating the two groupings is exactly the ambiguity this decision removes.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-15
|
||||
applyTo:
|
||||
- "Fuchs_DataService/**"
|
||||
- "Fuchs/Services/PeriodicHostedService.cs"
|
||||
- "Fuchs/Program.cs"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0010 — MFR ERP sync runs in-process in the web app; Fuchs_DataService is a library
|
||||
|
||||
## Context
|
||||
`Fuchs_DataService` was a standalone console/Windows Service hosted by **Topshelf**.
|
||||
It carried its own `appsettings.json`, its own file-based configuration bootstrap
|
||||
(`FdsConfig.Initialize()` reading the file), its own logging provider
|
||||
(`FdsLoggerProvider`/`AddFdsLogging`), and a machine-name guard in `Main()` that
|
||||
disabled the service on developer PCs. In practice the web app (`Fuchs`) already
|
||||
referenced the project, already called `fds.FdsConfig.Initialize(builder.Configuration)`,
|
||||
already registered `IFdsMfr`, and already created `FdsMfrClient` via
|
||||
`IMfrClientFactory` — so the sync logic and the web app were sharing the same code
|
||||
and the same connection strings while the service kept a second, parallel copy of
|
||||
configuration/logging/hosting.
|
||||
|
||||
Maintaining a separate process, a second `appsettings.json` (duplicating connection
|
||||
strings + MFR credentials), Topshelf, and a machine-name guard added drift risk and
|
||||
operational overhead for no benefit the web host couldn't provide.
|
||||
|
||||
## Decision
|
||||
- **`Fuchs_DataService` is now a class library** (no `OutputType Exe`, no Topshelf,
|
||||
no own `appsettings.json`, no `install.bat`/`un-install.bat`, no
|
||||
`System.Configuration.ConfigurationManager`). It contains only the MFR sync
|
||||
logic (`FdsMfr`/`IFdsMfr`, `FdsMfrClient`), the DATEV/zip helpers, `FdsShared`,
|
||||
`FdsDebug`, and `FdsConfig`.
|
||||
- **The host owns configuration.** `FdsConfig` keeps only
|
||||
`Initialize(IConfiguration)` (the file-based overload is gone). The Fuchs web app
|
||||
injects its `IConfiguration`; connection strings (`fuchs_fds_ConnectionString`)
|
||||
and MFR credentials (`Fds:MFR_*`, Key Vault-managed) come from Fuchs.
|
||||
- **The host owns logging.** `FdsLoggerProvider`/`AddFdsLogging` were removed; the
|
||||
library uses only `ILogger`/`ILoggerFactory` injected from Fuchs's logging
|
||||
(`AddFuchsLogging`). The library depends only on
|
||||
`Microsoft.Extensions.Logging.Abstractions` + `Microsoft.Extensions.Configuration.Binder`.
|
||||
- **The sync runs in-process.** `PeriodicHostedService` (the generic
|
||||
multi-job `BackgroundService`) moved to `Fuchs/Services/` and is registered in
|
||||
`Program.cs` as a hosted service. The single `MfrSync` job calls
|
||||
`UpdateIfNecessary_async` → `UpdateRequested_async` → `GetInvoiceFiles_async`.
|
||||
- **A config flag replaces the machine-name guard.** Registration is gated by
|
||||
`Fds:SyncEnabled` (default `false` when unset): `true` in production
|
||||
`appsettings.json`, `false` in `appsettings.Development.json`, so developer
|
||||
machines never poll the ERP. Interval (`Fds:ExecutionFrequency_Minutes`, default
|
||||
15) and debug verbosity (`Fds:DebugDetails`) also come from the `Fds` section.
|
||||
|
||||
## Consequences
|
||||
- One process, one configuration surface, one logging pipeline. The sync inherits
|
||||
the web app's OpenTelemetry, DI, and lifetime automatically.
|
||||
- **Instance fan-out is a consideration:** the sync now runs in *every* web instance
|
||||
where `Fds:SyncEnabled` is true. The intranet is deployed single-instance, so this
|
||||
is acceptable; if Fuchs is ever scaled out, gate the sync to a single instance
|
||||
(leader election / dedicated instance flag) to avoid concurrent MFR polling.
|
||||
- Enabling/disabling the sync per environment is now a config change, not a
|
||||
redeploy of a separate service.
|
||||
- `Fuchs_DataService` is intentionally kept as a separate project (not folded into
|
||||
`Fuchs`) so the sync logic stays isolated and unit-testable; `Fuchs.Tests` covers
|
||||
it via `InternalsVisibleTo`.
|
||||
- **The `Squid-Box.SevenZipSharp` native dependency (and the bundled `7z.dll`) was
|
||||
removed** from both `Fuchs_DataService` and `Fuchs`. The only live archive use — the
|
||||
DATEV export — is a plain, unencrypted zip, now produced via the native
|
||||
`OCORE.zip.filesToZipArchive` (`System.IO.Compression`). The 7-Zip-only paths
|
||||
(`.7z`/LZMA2, AES-encrypted archives, extraction, `FastAppend`) had no callers.
|
||||
Trade-off accepted: `System.IO.Compression` cannot produce `.7z` or password/AES
|
||||
archives; if that is ever required, a compression library must be reintroduced.
|
||||
|
||||
## Alternatives considered
|
||||
- **Native `dotnet` Worker Service (separate process).** Would modernize off
|
||||
Topshelf but keep the duplicate-config/duplicate-logging/second-process problem.
|
||||
Rejected because the web app already hosts everything the sync needs.
|
||||
- **Fold the code directly into `Fuchs`.** Rejected to preserve a clean, separately
|
||||
testable sync library and avoid enlarging the web project.
|
||||
@@ -94,8 +94,10 @@ payload; see `EVAL_live_invoice_editing.md` for the rationale.
|
||||
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.
|
||||
(default) / `SetOnly`; see `INVOICE_SET_PRICING.md`. Purely presentational
|
||||
while the set header itself has no own price; once a set is converted via
|
||||
the item switch (`item.setprice`), the conversion is one-way and totals
|
||||
recompute from that point on.
|
||||
- **Contact person** for the invoice (`$inv.sctp`, stored in `CustomValues`).
|
||||
|
||||
All of this recalculates client-side totals live via the `fds.inv` event
|
||||
@@ -122,7 +124,7 @@ 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`).
|
||||
e.g. `setmode:setonly`, `§13b`).
|
||||
- **Existing draft**: `fds__setInvoice` (same parameter set, updates in place)
|
||||
→ `fds__createInvoice_Details` again.
|
||||
|
||||
|
||||
@@ -1,24 +1,54 @@
|
||||
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
|
||||
# Invoice "Set" Pricing — Design & Front-/Back-end Contract
|
||||
|
||||
Customer requirement: items declared as a **set** in `[dbo].[mfr__items]`
|
||||
(`[Type] = 'set'`) should normally be shown as a single **set price** on the
|
||||
invoice instead of being broken up into their member items and summed.
|
||||
> Governed by ADR [0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)
|
||||
> (the two menu modes) and ADR [0008](Decisions/0008-invoices-and-reminders-fully-backend-authoritative.md)
|
||||
> (everything server-side, PDF == editor). ADR 0009 **redefined** the two menu modes from the
|
||||
> reversible, non-mutating display toggles this document previously described into irreversible,
|
||||
> per-service-request-block mutations — the text below reflects the redefinition.
|
||||
|
||||
Three display modes (switchable in the invoice editor):
|
||||
There are **three** separate set-price operations. They fall into two families that must not be
|
||||
confused, because they group items by different keys and differ in whether they mutate the data:
|
||||
|
||||
| Mode | Set line | Member items | Use as |
|
||||
|---|---|---|---|
|
||||
| **SetPrice** (default) | shown **with price** | shown **without price** | the new default |
|
||||
| **ItemPrices** | shown as a heading **without price** | shown **with price** | the previous behaviour |
|
||||
| **SetOnly** | shown **with price** | **removed** | compact |
|
||||
| # | Operation | Trigger | Grouped by | Effect |
|
||||
|---|---|---|---|---|
|
||||
| **1** | **Set-item switch** | row context button on a single `type == "set"` item (`$inv.toSetPrice`), shown only when it has a `SetItmId` | `SetItmId` (the mfr set-item and its members) | sums members onto the set header, sets the members' prices to `null` — **one-way mutation** |
|
||||
| **2** | **"Set mit Preis"** | editor menu ("Set-Preisanzeige") | **`ServiceRequestId`** (the whole block) | inserts a dedicated **set row** per block (block sum as its price) and sets every item's price to **`null`** (items shown **without price**) — **irreversible mutation** |
|
||||
| **3** | **"Nur Set mit Preis"** | editor menu ("Set-Preisanzeige") | **`ServiceRequestId`** (the whole block) | inserts the dedicated **set row** per block and **removes** every item line from the block — **irreversible mutation** |
|
||||
|
||||
> **Totals are unaffected.** The invoice total is taken from the registration
|
||||
> balance (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered
|
||||
> lines, so switching modes is purely presentational. The set price always
|
||||
> equals the sum of its members (computed as a fallback when the set header
|
||||
> carries no own price).
|
||||
Only operation **1** reads `SetItmId`. Operations **2** and **3** ignore it entirely; their only
|
||||
grouping key is the service request (the editor tbody block). See "All set-price functions" below
|
||||
for the full breakdown.
|
||||
|
||||
## Back-end (implemented + unit-tested)
|
||||
> **The two menu modes are irreversible data changes, not display toggles.** Choosing "Set mit
|
||||
> Preis" or "Nur Set mit Preis" rewrites the block's items in the authoritative, server-cached
|
||||
> draft session (`InvoiceDraftSession.Req` — the "cache dataset"): a dedicated set row is inserted
|
||||
> and members have their price set to `null` (mode 2) or are deleted (mode 3). There is **no**
|
||||
> reversible toggle and **no** persisted `admin.setmode` render flag driving grouping. The only
|
||||
> ways back are discarding the draft (reloads the DB state) or hand-editing the resulting rows —
|
||||
> the set row is a real, editable line item precisely so the user can adjust the set value
|
||||
> afterwards.
|
||||
|
||||
> **`ItemPrices` / `admin.setmode` display-flag model was removed.** The earlier design persisted
|
||||
> a `setmode:<mode>` token in `InvoiceOptions` and re-rendered set-item groups per that flag at
|
||||
> render time (`InvoiceSetPricing.Build`). Under ADR 0009 the two menu modes are one-shot
|
||||
> mutations, so there is no display state to persist or toggle. A stale `setmode:`/`itemprices`
|
||||
> token degrades safely (ignored) and is never (re-)persisted.
|
||||
|
||||
> **Totals are unaffected.** The invoice total is taken from the registration balance
|
||||
> (`InvoiceBalance` / `InvoiceBalance_net`), not by summing the rendered lines. Each operation
|
||||
> conserves the total: the inserted set row's value equals the sum of the items it blanks (mode 2)
|
||||
> or removes (mode 3), and the set-item switch (mode 1) writes exactly the members' sum onto the
|
||||
> header.
|
||||
|
||||
## Back-end
|
||||
|
||||
> **Migration status (ADR 0009).** The code below still reflects the previous
|
||||
> `admin.setmode` + `InvoiceSetPricing.Build` **display-mode** implementation for
|
||||
> functions 2 & 3. Under ADR 0009 those two functions become per-block mutations in
|
||||
> `InvoiceDraftEditService` (insert set row + blank/remove members); the render-time
|
||||
> `Build`/`ModeFromInvoiceOptions`/`setmode` display path for them is being retired.
|
||||
> Function 1 (`ApplyItemSetPrice`) is unaffected. Update this section as the
|
||||
> migration lands so it stays a faithful description of the code.
|
||||
|
||||
- `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation:
|
||||
`SetDisplayMode` + `Build(items, mode)` → ordered `InvoiceSetLine`s, each with
|
||||
@@ -34,15 +64,15 @@ Three display modes (switchable in the invoice editor):
|
||||
Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
|
||||
`wwwroot/web/fis.inv.de.js` via gulp `min:js`):
|
||||
|
||||
1. **Mode** — a 3-way switch (`$inv.ssetmode`, menu entry `setm`, label
|
||||
`$ict.setm`) writes the choice onto `admin.setmode`
|
||||
(`setprice` | `itemprices` | `setonly`). The back-end
|
||||
`FdsInvoiceData.BuildInvoiceOptions` turns that into the
|
||||
`setmode:<mode>` token inside `@InvoiceOptions` (default `setprice` omitted),
|
||||
persisted by `fds__createInvoice_Details` and read back by
|
||||
`InvoiceSetPricing.ModeFromInvoiceOptions`. This rides the **same `admin`
|
||||
channel as `§13b`** (the posted payload is `{admin, req, sms, new}` — `inv`
|
||||
is not sent).
|
||||
1. **Menu modes** — the "Set-Preisanzeige" menu (`$inv.ssetmode` → `$inv.setSetmode`,
|
||||
menu entry `setm`, label `$ict.setm`) offers "Set mit Preis" and "Nur Set mit
|
||||
Preis". Selecting one posts a **mutating delta** (grouped by service-request
|
||||
block) to `inv/dpatch`; the server rewrites the block's items in the cached
|
||||
session (inserts the set row, blanks or removes members) and pushes the new
|
||||
state back via `draftReady`/`dstate`. The browser performs **no** grouping or
|
||||
pricing math (ADR 0008/0009) — it only posts the chosen mode and re-renders the
|
||||
server's `req`/`sums`. There is no persisted `admin.setmode` display flag for
|
||||
these two modes.
|
||||
|
||||
2. **Item shape** — `$inv.invSumUpdate` now posts each request block's
|
||||
`items[]` in the back-end contract shape via `$inv.itemToContract`:
|
||||
@@ -51,12 +81,20 @@ Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
|
||||
`FdsInvoiceData.InvoiceItems` does not read — so line items never reached the
|
||||
C# PDF. This change closes that gap for **all** invoices, not just sets.)
|
||||
|
||||
3. **Set flags** — `invSumUpdate` tags items as it builds `items[]`: an item with
|
||||
`type === 'set'` is a header (`id` = its set id); the **following items in the
|
||||
same block become its members** (`setId` = the header's id) until the next set
|
||||
header. `mfr__items` has a `Type='set'` header but **no explicit member link**,
|
||||
so this "header claims the following items in its block" rule is the convention
|
||||
— adjust in `invSumUpdate` if mfr later exposes a real grouping.
|
||||
3. **Set flags (function 1 only)** — `invSumUpdate` tags items as it builds `items[]`:
|
||||
an item with `type === 'set'` is a header (`id` = its set id); a member item's
|
||||
`setId` is taken directly from the server-computed `SetItmId` field on the row
|
||||
(`rrx.SetItmId`, populated by `fds__prepInvoice`'s `[SetItmID]` window function,
|
||||
anchored on the still-unconverted, zero-priced `'set'` header that owns it) —
|
||||
**not** re-derived from row order in the browser. `mfr__items` itself still has no
|
||||
explicit member link; `fds__prepInvoice` computes `SetItmId` per request from the
|
||||
item list, so only items the server actually attributes to a set are tagged, and
|
||||
unrelated items following a set in the list are never swept in. The header row's
|
||||
own `SetItmId` self-references its own id (rather than being `null`); it is
|
||||
explicitly excluded from being its own member both here (`sid !== citem.id`) and
|
||||
in `InvoiceDraftEditService.ApplyItemSetPrice`. These flags feed **only** the
|
||||
set-item switch (function 1); the two menu modes (functions 2 & 3) ignore
|
||||
`SetItmId` and group by service-request block.
|
||||
|
||||
### Editor → backend field normalization (`$inv.invcPayload`)
|
||||
The editor's internal model keeps the long-standing key names, but the migrated C#
|
||||
@@ -80,20 +118,95 @@ no per-rate `vat_*` keys.
|
||||
`sms.vat`, so non-19 % rates are stored correctly. Single-rate procs still store only
|
||||
the highest rate.
|
||||
|
||||
The editor's running **total stays the member sum in every mode**, matching the
|
||||
registration balance — switching modes is purely presentational.
|
||||
The editor's running **total is unaffected by any set-price operation**, matching
|
||||
the registration balance — each operation conserves the total (the set row's value
|
||||
equals the members it blanks/removes; the set-item switch writes exactly the
|
||||
members' sum onto the header).
|
||||
|
||||
### Why the switch lives in the editor
|
||||
Set grouping is only known where the request/item tree is rendered (front-end).
|
||||
The back-end intentionally stays the single, tested authority for *how* a chosen
|
||||
mode maps to printed lines, so the editor only needs to pick the mode and tag the
|
||||
items — it does not re-implement the pricing rules.
|
||||
### Why the trigger lives in the editor
|
||||
The choice of *when* to apply a set-price operation is only known where the invoice
|
||||
is being composed (front-end), but the operation itself is executed **server-side**
|
||||
against the cached draft session — the editor merely names the target (a set-item
|
||||
`Ref` for function 1, or the chosen menu mode for functions 2 & 3) and re-renders
|
||||
the server's result. The back-end stays the single, tested authority for how each
|
||||
operation rewrites the lines; the editor never re-implements the grouping, the
|
||||
per-block aggregation, or the pricing rules (ADR 0008/0009).
|
||||
|
||||
## All set-price functions: before/after comparison
|
||||
|
||||
There are **three** distinct functions, and all three are **mutations** of the
|
||||
authoritative cached draft session (`InvoiceDraftSession.Req`) — none is a
|
||||
transient, freely-reversible view flag. They differ in what they group by and
|
||||
what they touch:
|
||||
|
||||
- **Function 1 — the set-item switch** (`item.setprice`) groups by `SetItmId`
|
||||
(one mfr set-item and its members) and is triggered per set row.
|
||||
- **Functions 2 & 3 — the two menu modes** ("Set mit Preis" / "Nur Set mit
|
||||
Preis") group by `ServiceRequestId` (the whole block), ignore `SetItmId`
|
||||
entirely, and are triggered once from the "Set-Preisanzeige" menu.
|
||||
|
||||
Only function 1 reads `SetItmId`. All three are one-way; the only escape hatch
|
||||
is discarding the draft or hand-editing the resulting rows.
|
||||
|
||||
### 1. The set-item switch (`item.setprice` patch, single set, mutating)
|
||||
|
||||
Triggered from the invoice editor's row context menu (`$inv.toSetPrice`), applied
|
||||
server-side by `InvoiceDraftEditService.ApplyItemSetPrice`. The context button is
|
||||
shown — and the operation available — **only** on a row that is `type == "set"`
|
||||
**and** carries a `SetItmId` (and is still unconverted, own price `0`); a row
|
||||
missing either condition never offers it. It gives a set-item its "own price": the
|
||||
members' values are summed onto the header and the members' prices are set to
|
||||
`null` (empty cell, excluded from the sum — not `0`). This
|
||||
conversion is **one-way** — there is no patch to move a converted set back to
|
||||
separately-priced members; the user would re-edit the individual line prices by
|
||||
hand. It is the **only** function keyed on `SetItmId`.
|
||||
|
||||
| Aspect | Before the switch | After the switch |
|
||||
|---|---|---|
|
||||
| Set header item (`type == "set"`, `id == Ref`) price | `0` (zero-priced, as delivered by `fds__prepInvoice`) | Price fields (`total_net`/`v`/`vt` + VAT amounts `vv`/`vs`/`vsv`) replaced by the sum of all its members' corresponding values |
|
||||
| Member items (`SetItmId == Ref`, excluding the header itself) | Each shows its own individual `total_net` / VAT amounts | Each price field is set to **`null`** (`v`/`vt`/`vv`/`vs`/`vsv` all `null`, not `0`) → renders an **empty** price/total cell and is excluded from the sum; the row itself stays in the list |
|
||||
| Membership determination | N/A — membership already fixed by the server (`fds__prepInvoice`'s `[SetItmID]` window function) | **Unchanged** — the switch only sums/nulls the items the server already tagged; it never re-derives or reassigns `SetItmId` |
|
||||
| Items **not** tagged with this header's `SetItmId` (e.g. unrelated items following the set in the same block) | Untouched | **Still untouched** — never swept in, regardless of row order/position |
|
||||
| Draft version / history | — | Version bumped by one; an `item.setprice` history entry recorded with old/new header value |
|
||||
| Invoice total (`Sums.TotalNet`/`TotalGross`) | Sum of all individual item prices (header 0 + each member's own price) | **Unchanged** — same total, because the header received exactly the sum of its members |
|
||||
| Idempotency / no-ops | `Ref` unknown, or `Ref` does not point at a `type == 'set'` header → **no-op**: no version bump, no history entry | Same guard still applies after conversion — re-issuing the patch against a non-header `Ref` remains a no-op |
|
||||
|
||||
### 2 & 3. The two menu modes (per service-request block, mutating)
|
||||
|
||||
Triggered once from the editor's "Set-Preisanzeige" menu (`$inv.ssetmode` →
|
||||
`$inv.setSetmode`) and applied server-side per **service-request block**
|
||||
(`ServiceRequestId`), independent of any `type == "set"` item or `SetItmId`.
|
||||
Both are **irreversible** and rewrite the block's items in the cached session.
|
||||
|
||||
For each block, a dedicated, emphasised **set row** is inserted (see "The
|
||||
dedicated set row" below) carrying the block's aggregated value as its price;
|
||||
then, depending on the mode, the block's original items are either blanked or
|
||||
removed:
|
||||
|
||||
| Aspect | **"Set mit Preis"** (mode 2) | **"Nur Set mit Preis"** (mode 3) |
|
||||
|---|---|---|
|
||||
| Grouping key | `ServiceRequestId` (block) | `ServiceRequestId` (block) |
|
||||
| Inserted set row | one per block, price = block's aggregated net (+ VAT/service splits) | one per block, same value |
|
||||
| Original item rows | **kept**, but each price field (`v`/`vt`/`vv`/`vs`/`vsv`) is set to **`null`** (not `0`) → renders an **empty** price/total cell and is excluded from the block sum | **removed** from the block entirely |
|
||||
| `SetItmId` | ignored | ignored |
|
||||
| Reversibility | irreversible (discard draft or hand-edit) | irreversible (discard draft or hand-edit) |
|
||||
| Invoice total | **unchanged** — the set row's value equals the sum of the block's members it blanks | **unchanged** — the set row's value equals the sum of the removed lines |
|
||||
| Empty block | no-op | no-op |
|
||||
|
||||
### The dedicated set row
|
||||
|
||||
Both menu modes insert a **real, editable line item** (its own id, rendered
|
||||
emphasised in the editor and the PDF), not a reused block-heading row and not a
|
||||
render-only overlay. Because it is a genuine row in the cached dataset, the user
|
||||
can adjust the set value afterwards with an ordinary item edit — that hand-edit
|
||||
is the intended and only "undo" for the conversion (ADR
|
||||
[0009](Decisions/0009-block-setprice-modes-are-irreversible-mutations.md)).
|
||||
|
||||
## Persistence note
|
||||
Draft/preview PDFs render straight from the posted `invc` JSON, so the contract
|
||||
works end-to-end for previews and creation. `setmode` persists via
|
||||
`InvoiceOptions`; the finalised document is rendered once and stored as a file, so
|
||||
re-rendering from line items is not needed for correctness. Persisting the
|
||||
per-item `type`/`setId` flags (an SSDT + `fds__createInvoice_Details` change) is
|
||||
only required if a finalised invoice must be **re-generated** from stored items in
|
||||
a different mode later — not done here.
|
||||
Draft/preview PDFs render straight from the cached draft session, so the contract
|
||||
works end-to-end for previews and creation. The two menu modes bake their result
|
||||
directly into the session's items (a set row plus blanked/removed members), so no
|
||||
`setmode:` display token is needed or persisted; the finalised document is
|
||||
rendered once and stored as a file. Persisting the per-item `type`/`setId` flags
|
||||
(an SSDT + `fds__createInvoice_Details` change) is only required if a finalised
|
||||
invoice must be **re-generated** from stored items later — not done here.
|
||||
|
||||
+3
-7
@@ -24,7 +24,6 @@
|
||||
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="code\7z.dll" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="Data\**" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="favicon.ico" />
|
||||
</ItemGroup>
|
||||
@@ -45,7 +44,6 @@
|
||||
<PackageReference Include="PDFsharp" Version="6.2.4" />
|
||||
<PackageReference Include="PDFsharp-MigraDoc" Version="6.2.4" />
|
||||
<PackageReference Include="Spire.PDF" Version="[8.10.5,8.10.5]" allowedVersions="[8.10.5,8.10.5]" />
|
||||
<PackageReference Include="Squid-Box.SevenZipSharp" Version="1.6.2.24" />
|
||||
<!-- Updated packages -->
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="MimeKit" Version="4.17.0" />
|
||||
@@ -53,8 +51,8 @@
|
||||
<!-- New packages (needed for .NET 10) -->
|
||||
<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.9" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.9" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="10.0.10" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.10" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.29.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
@@ -66,14 +64,12 @@
|
||||
|
||||
|
||||
<!-- UsingTask für plattformneutrales Zippen nach Publish -->
|
||||
<UsingTask TaskName="ZipDir" TaskFactory="CodeTaskFactory" AssemblyName="Microsoft.Build.Tasks.Core">
|
||||
<UsingTask TaskName="ZipDir" TaskFactory="RoslynCodeTaskFactory" AssemblyName="Microsoft.Build.Tasks.Core">
|
||||
<ParameterGroup>
|
||||
<Source ParameterType="System.String" Required="true" />
|
||||
<Destination ParameterType="System.String" Required="true" />
|
||||
</ParameterGroup>
|
||||
<Task>
|
||||
<Reference Include="System.IO.Compression" />
|
||||
<Reference Include="System.IO.Compression.FileSystem" />
|
||||
<Code Type="Fragment" Language="cs">
|
||||
<![CDATA[
|
||||
if (System.IO.File.Exists(Destination)) {
|
||||
|
||||
@@ -59,6 +59,31 @@ public class Program
|
||||
// FDS MFR singleton — ILogger<FdsMfr> and ILoggerFactory are supplied by the ASP.NET Core DI container
|
||||
builder.Services.AddSingleton<fds.IFdsMfr, fds.FdsMfr>();
|
||||
|
||||
// In-process MFR ERP sync — formerly the standalone Fuchs_DataService Windows Service
|
||||
// (Topshelf), now hosted here as a BackgroundService. Gated by Fds:SyncEnabled so
|
||||
// developer machines (appsettings.Development.json sets it false) never poll the ERP;
|
||||
// interval and debug verbosity come from the Fds config section.
|
||||
if (builder.Configuration.GetValue("Fds:SyncEnabled", false))
|
||||
{
|
||||
builder.Services.AddHostedService(sp =>
|
||||
{
|
||||
var mfr = sp.GetRequiredService<fds.IFdsMfr>();
|
||||
var interval = TimeSpan.FromMinutes(
|
||||
builder.Configuration.GetValue("Fds:ExecutionFrequency_Minutes", 15d));
|
||||
bool debug = builder.Configuration.GetValue("Fds:DebugDetails", false);
|
||||
var jobs = new[]
|
||||
{
|
||||
new PeriodicJobDefinition("MfrSync", interval, async ct =>
|
||||
{
|
||||
await mfr.UpdateIfNecessary_async(debug, ct);
|
||||
await mfr.UpdateRequested_async(debug, ct);
|
||||
await mfr.GetInvoiceFiles_async(debug, ct);
|
||||
})
|
||||
};
|
||||
return new PeriodicHostedService(jobs, sp.GetRequiredService<ILogger<PeriodicHostedService>>());
|
||||
});
|
||||
}
|
||||
|
||||
// MVC with Razor view support
|
||||
builder.Services.AddControllersWithViews();
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<DeleteExistingFiles>true</DeleteExistingFiles>
|
||||
<ExcludeApp_Data>false</ExcludeApp_Data>
|
||||
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
||||
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||
<LastUsedPlatform>x64</LastUsedPlatform>
|
||||
<PublishProvider>FileSystem</PublishProvider>
|
||||
<PublishUrl>Q:\PWProjects\Fuchs_Intranet</PublishUrl>
|
||||
<WebPublishMethod>FileSystem</WebPublishMethod>
|
||||
<_TargetId>Folder</_TargetId>
|
||||
<SiteUrlToLaunchAfterPublish />
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<ProjectGuid>2856176d-cda6-1be2-0ce0-d72c26fb4f35</ProjectGuid>
|
||||
<SelfContained>false</SelfContained>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -116,6 +116,9 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
case "block.replace": return ReplaceBlock(s, d, ref oldValue, ref newValue);
|
||||
case "block.remove": return RemoveBlock(s, d, ref oldValue, ref newValue);
|
||||
case "block.order": return ReorderBlocks(s, d, ref oldValue, ref newValue);
|
||||
case "item.setprice": return ApplyItemSetPrice(s, d, ref oldValue, ref newValue);
|
||||
case "block.setprice": return ApplyBlockSetPricing(s, removeMembers: false, ref oldValue, ref newValue);
|
||||
case "block.setonly": return ApplyBlockSetPricing(s, removeMembers: true, ref oldValue, ref newValue);
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
@@ -185,6 +188,183 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "Auf Setpreis umstellen" (backend-authoritative, ADR 0006): irreversibly converts a Set
|
||||
/// header item (<c>Ref</c> = the header's item id) from individually-priced members to a
|
||||
/// single set price. Sums the <c>net</c>/<c>net_val</c>/<c>vat_val</c>/<c>svcnet_val</c>/
|
||||
/// <c>svcvat_val</c> of every member line in the same block whose <c>SetItmId</c> equals the
|
||||
/// header id (the grouping <c>fds__prepInvoice</c> computes, anchored on the still-unconverted,
|
||||
/// zero-priced Set header — see the <c>[SetItmID]</c> window function there), writes that sum
|
||||
/// onto the header line and zeroes each member's price fields — matching the visual
|
||||
/// <c>SetPrice</c> display mode (<see cref="InvoiceSetPricing"/>), but as an actual, irreversible
|
||||
/// data change rather than a display toggle. The user can still change the resulting header price
|
||||
/// manually afterwards (ordinary item edit), which is why this is one-way.
|
||||
/// <para>
|
||||
/// The header row's own <c>SetItmId</c> now self-references its own id (rather than being
|
||||
/// <c>null</c>) once <c>fds__prepInvoice</c> stopped special-casing it, so membership is
|
||||
/// determined by <c>id == Ref</c> first (captured as <paramref name="header"/> below and
|
||||
/// <c>continue</c>d past) — a header is never mistaken for its own member.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static bool ApplyItemSetPrice(InvoiceDraftSession s, InvoiceDraftDelta d, ref string oldValue, ref string newValue)
|
||||
{
|
||||
if (string.IsNullOrEmpty(d.Ref)) return false;
|
||||
foreach (var blockTok in s.Req)
|
||||
{
|
||||
if (blockTok is not JObject block || block["itm"] is not JArray lines) continue;
|
||||
JObject? header = null;
|
||||
var members = new List<JObject>();
|
||||
foreach (var lineTok in lines)
|
||||
{
|
||||
if (lineTok is not JObject co) continue;
|
||||
if (Str(co["id"]) == d.Ref) { header = co; continue; } // the header itself is never a member, even if its SetItmId self-references
|
||||
if (Str(co["SetItmId"]) == d.Ref) members.Add(co);
|
||||
}
|
||||
if (header == null) continue;
|
||||
if (!string.Equals(Str(header["typ"]), "set", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
|
||||
oldValue = InvoiceDraftCalculator.Dec(header["vt"]).ToString(CultureInfo.InvariantCulture);
|
||||
decimal sumNet = 0, sumVat = 0, sumSvcNet = 0, sumSvcVat = 0;
|
||||
foreach (var m in members)
|
||||
{
|
||||
sumNet += InvoiceDraftCalculator.Dec(m["vt"]);
|
||||
sumVat += InvoiceDraftCalculator.Dec(m["vv"]);
|
||||
sumSvcNet += InvoiceDraftCalculator.Dec(m["vs"]);
|
||||
sumSvcVat += InvoiceDraftCalculator.Dec(m["vsv"]);
|
||||
NullLinePrice(m); // ADR 0009: null (empty cell), not 0 — excluded from the sum, distinct from a real 0,00 €
|
||||
}
|
||||
header["v"] = sumNet; header["vt"] = sumNet; header["vv"] = sumVat; header["vs"] = sumSvcNet; header["vsv"] = sumSvcVat;
|
||||
newValue = sumNet.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
// Mirror the conversion onto the "items" contract shape (InvoiceSetPricing/BuildSetDisplay
|
||||
// read total_net from here, not from "itm") so the online editor's set-display immediately
|
||||
// reflects that the set is now converted, instead of waiting for a reload.
|
||||
if (block["items"] is JArray items)
|
||||
{
|
||||
foreach (var itemTok in items)
|
||||
{
|
||||
if (itemTok is not JObject io) continue;
|
||||
string id = Str(io["id"]);
|
||||
if (id == d.Ref) { io["total_net"] = sumNet; }
|
||||
else if (members.Any(m => Str(m["id"]) == id)) { NullItemPrice(io); }
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The two menu set-price modes (ADR 0009), grouped by service-request <b>block</b>
|
||||
/// (not <c>SetItmId</c>) and applied server-side as an <b>irreversible mutation</b> of the
|
||||
/// cached session — never a display flag. For every block that carries any priced line:
|
||||
/// a dedicated, emphasised set row (<c>typ == "set"</c>) is inserted at the top carrying the
|
||||
/// block's aggregated value (net + VAT + service-net/-VAT splits); then the block's original
|
||||
/// items are either price-<b>nulled</b> (<paramref name="removeMembers"/> = false → "Set mit
|
||||
/// Preis": empty price cells, kept) or <b>removed</b> (<paramref name="removeMembers"/> = true →
|
||||
/// "Nur Set mit Preis"). <c>null</c> (not <c>0</c>) marks "no price" — an empty cell excluded
|
||||
/// from the sum, distinct from a genuine 0,00 €. Any pre-existing set membership is neutralised
|
||||
/// (existing set rows demoted, <c>setId</c>/<c>SetItmId</c> cleared) so the block becomes one
|
||||
/// flat set and the two groupings (this vs. the <c>SetItmId</c>-based item switch) never collide.
|
||||
/// The invoice total is conserved: the set row's value equals the sum of the members it blanks
|
||||
/// or removes.
|
||||
/// </summary>
|
||||
private static bool ApplyBlockSetPricing(InvoiceDraftSession s, bool removeMembers, ref string oldValue, ref string newValue)
|
||||
{
|
||||
bool mutated = false;
|
||||
int converted = 0;
|
||||
foreach (var blockTok in s.Req)
|
||||
{
|
||||
if (blockTok is not JObject block || block["itm"] is not JArray lines || lines.Count == 0) continue;
|
||||
|
||||
// Aggregate the block from its current line values (nulled/absent = 0 contribution).
|
||||
decimal net = 0, vat = 0, svcNet = 0, svcVat = 0;
|
||||
string rate = "";
|
||||
foreach (var lineTok in lines)
|
||||
{
|
||||
if (lineTok is not JObject co) continue;
|
||||
net += InvoiceDraftCalculator.Dec(co["vt"]);
|
||||
vat += InvoiceDraftCalculator.Dec(co["vv"]);
|
||||
svcNet += InvoiceDraftCalculator.Dec(co["vs"]);
|
||||
svcVat += InvoiceDraftCalculator.Dec(co["vsv"]);
|
||||
string r = InvoiceDraftCalculator.NormalizeRate(Str(co["vat"]));
|
||||
if (r.Length > 0) rate = r; // collapsed set line carries the block's (highest/last) rate
|
||||
}
|
||||
if (net == 0 && vat == 0 && svcNet == 0 && svcVat == 0) continue; // nothing priced -> skip block
|
||||
|
||||
string blockId = Str(block["Id"]);
|
||||
string setId = "bset_" + (blockId.Length > 0 ? blockId : Guid.NewGuid().ToString("N")[..8]);
|
||||
const string title = "Gesamtumfang pauschal"; // neutral label for the block set row (ADR 0009) — not the service-request title
|
||||
|
||||
// The dedicated, emphasised set row, in both line shapes (itm = editor co; items = contract).
|
||||
var setItm = new JObject
|
||||
{
|
||||
["id"] = setId, ["typ"] = "set", ["p"] = "", ["q"] = "", ["t"] = title, ["tt"] = "",
|
||||
["v"] = JValue.CreateNull(), ["vt"] = net, ["vv"] = vat, ["vs"] = svcNet, ["vsv"] = svcVat,
|
||||
["vat"] = rate, ["det"] = false
|
||||
};
|
||||
var setItem = new JObject
|
||||
{
|
||||
["id"] = setId, ["type"] = "set", ["title"] = title, ["desc"] = "", ["qty"] = "",
|
||||
["price_net"] = net, ["total_net"] = net, ["vat"] = rate, ["setId"] = JValue.CreateNull()
|
||||
};
|
||||
|
||||
// Mutate the itm array in place (avoids Newtonsoft re-parenting): drop members (setonly)
|
||||
// or null their prices + neutralise set membership (setprice), then prepend the set row.
|
||||
if (removeMembers) lines.Clear();
|
||||
else
|
||||
foreach (var lineTok in lines)
|
||||
{
|
||||
if (lineTok is not JObject co) continue;
|
||||
DemoteSet(co, "typ"); co["SetItmId"] = JValue.CreateNull();
|
||||
NullLinePrice(co);
|
||||
}
|
||||
lines.Insert(0, setItm);
|
||||
|
||||
if (block["items"] is JArray items)
|
||||
{
|
||||
if (removeMembers) items.Clear();
|
||||
else
|
||||
foreach (var itemTok in items)
|
||||
{
|
||||
if (itemTok is not JObject io) continue;
|
||||
DemoteSet(io, "type"); io["setId"] = JValue.CreateNull();
|
||||
NullItemPrice(io);
|
||||
}
|
||||
items.Insert(0, setItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
block["items"] = new JArray { setItem };
|
||||
}
|
||||
|
||||
mutated = true; converted++;
|
||||
}
|
||||
oldValue = "";
|
||||
newValue = (removeMembers ? "setonly" : "setprice") + ":" + converted;
|
||||
return mutated;
|
||||
}
|
||||
|
||||
/// <summary>Demotes a pre-existing set row to a plain line so only the freshly inserted block set row stays "set".</summary>
|
||||
private static void DemoteSet(JObject line, string typeKey)
|
||||
{
|
||||
if (line[typeKey] is { } t && string.Equals(Str(t), "set", StringComparison.OrdinalIgnoreCase))
|
||||
line[typeKey] = "other";
|
||||
}
|
||||
|
||||
/// <summary>Sets the editor <c>co</c> price fields to JSON null (empty cell, excluded from sums — ADR 0009).</summary>
|
||||
private static void NullLinePrice(JObject co)
|
||||
{
|
||||
co["v"] = JValue.CreateNull(); co["vt"] = JValue.CreateNull(); co["vv"] = JValue.CreateNull();
|
||||
co["vs"] = JValue.CreateNull(); co["vsv"] = JValue.CreateNull();
|
||||
}
|
||||
|
||||
/// <summary>Sets the items-contract price fields to JSON null (empty cell — ADR 0009).</summary>
|
||||
private static void NullItemPrice(JObject io)
|
||||
{
|
||||
io["price_net"] = JValue.CreateNull(); io["total_net"] = JValue.CreateNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reorders the service-request blocks to the id sequence the editor posts after a section
|
||||
/// drag (<c>Value</c> = ["id",…]). Named ids move into the given order; any not named are kept
|
||||
@@ -269,7 +449,9 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
{
|
||||
if (blockTok is not JObject block || block["items"] is not JArray itemsArr) continue;
|
||||
List<Dictionary<string, object?>>? items = itemsArr.ToObject<List<Dictionary<string, object?>>>();
|
||||
if (items == null || !InvoiceSetPricing.ContainsSets(items)) continue;
|
||||
// Only function-1 (SetItmId-linked) sets get display flags; block-mode sets (ADR 0009)
|
||||
// clear setId and render flat, so BuildSetDisplay must not sweep them into Build here.
|
||||
if (items == null || !InvoiceSetPricing.HasSetMembers(items)) continue;
|
||||
|
||||
foreach (var line in InvoiceSetPricing.Build(items, mode))
|
||||
{
|
||||
@@ -331,6 +513,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
// ── Internals ──────────────────────────────────────────────────────────────
|
||||
private static void Refresh(InvoiceDraftSession session)
|
||||
{
|
||||
InvoiceDraftCalculator.RecomputeLineValues(session);
|
||||
InvoiceDraftCalculator.RecomputeTotals(session);
|
||||
InvoiceDraftCalculator.RecomputePositions(session);
|
||||
InvoiceDraftCalculator.Validate(session);
|
||||
@@ -430,7 +613,7 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
string setmode = Str(session.Admin["setmode"]).Trim().ToLowerInvariant();
|
||||
// Mirrors FdsInvoiceData.BuildInvoiceOptions: persist any explicitly-chosen mode
|
||||
// (including the default "setprice") so it is distinguishable from "never touched".
|
||||
if (setmode is "setprice" or "itemprices" or "setonly") tokens.Add("setmode:" + setmode);
|
||||
if (setmode is "setprice" or "setonly") tokens.Add("setmode:" + setmode);
|
||||
return string.Join(",", tokens);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a named job with its own execution schedule for use with <see cref="PeriodicHostedService"/>.
|
||||
/// </summary>
|
||||
public sealed record PeriodicJobDefinition(
|
||||
string Name,
|
||||
TimeSpan Interval,
|
||||
Func<CancellationToken, Task> Execute);
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="BackgroundService"/> that runs multiple independent jobs, each on its own <see cref="PeriodicTimer"/>.
|
||||
/// Hosts the MFR ERP sync that formerly ran as the standalone <c>Fuchs_DataService</c> Windows Service
|
||||
/// (Topshelf); it is now registered in-process from <c>Program.cs</c>, gated by <c>Fds:SyncEnabled</c>.
|
||||
/// </summary>
|
||||
public sealed class PeriodicHostedService : BackgroundService
|
||||
{
|
||||
private readonly IReadOnlyList<PeriodicJobDefinition> _jobs;
|
||||
private readonly ILogger<PeriodicHostedService> _logger;
|
||||
|
||||
public PeriodicHostedService(IEnumerable<PeriodicJobDefinition> jobs, ILogger<PeriodicHostedService> logger)
|
||||
{
|
||||
_jobs = jobs.ToList();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var jobTasks = _jobs.Select(job => RunJobAsync(job, stoppingToken)).ToList();
|
||||
await Task.WhenAll(jobTasks);
|
||||
}
|
||||
|
||||
private async Task RunJobAsync(PeriodicJobDefinition job, CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(job.Interval);
|
||||
_logger.LogInformation("Job '{Name}' scheduled with interval {Interval}.", job.Name, job.Interval);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
_logger.LogDebug("Job '{Name}' starting.", job.Name);
|
||||
try
|
||||
{
|
||||
await job.Execute(stoppingToken);
|
||||
_logger.LogDebug("Job '{Name}' completed.", job.Name);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Job '{Name}' failed.", job.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@
|
||||
string appName = ViewData["AppName"] as string ?? "Fuchs Intranet";
|
||||
string fullName = ViewData["FullName"] as string ?? "";
|
||||
string pageTitle = ViewData["Title"] as string ?? "Intranet";
|
||||
// When the deployment is flagged as a test/staging instance (Fuchs:IsTestDeployment = true),
|
||||
// a distinctive green background is injected below so a test instance is never mistaken for
|
||||
// production. Defaults to false (production) in appsettings.json.
|
||||
bool isTestDeployment = Configuration.GetValue<bool>("Fuchs:IsTestDeployment");
|
||||
string? debugDbTarget = null;
|
||||
if (HostEnvironment.IsDevelopment())
|
||||
{
|
||||
@@ -56,6 +60,15 @@
|
||||
|
||||
@await RenderSectionAsync("CustomHeader", required: false)
|
||||
|
||||
@if (isTestDeployment)
|
||||
{
|
||||
<style>
|
||||
body, html, body > header > nav, body > main > #topbar > nav {
|
||||
background-color: #13a143 !important;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
|
||||
<script type="text/javascript">
|
||||
$ocms.auth = @Html.Raw(JsonConvert.SerializeObject(authinfo));
|
||||
</script>
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
}
|
||||
},
|
||||
"Fds": {
|
||||
"SyncEnabled": false,
|
||||
"ExecutionFrequency_Minutes": 15,
|
||||
"DebugDetails": true,
|
||||
"MFR_host": "portal.mobilefieldreport.com",
|
||||
"MFR_UserName": "system@sebastian-fuchs---bad-und-heizung-gmbh-und-co-kg.com",
|
||||
"MFR_Password": "0oT4G3H2"
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"AzureBlobStorage_ConnectionString": "MANAGED_BY_KEYVAULT"
|
||||
},
|
||||
"Fuchs": {
|
||||
"IsTestDeployment": true,
|
||||
"ocms_guid": "00094b8f-a822-4e9c-b627-87802f93fca8",
|
||||
"ocms_valid_locales_csv": "de,en",
|
||||
"ocms_default_locale": "de",
|
||||
@@ -48,7 +49,7 @@
|
||||
"Enabled": false
|
||||
},
|
||||
"Email": {
|
||||
"OverrideRecipient": ""
|
||||
"OverrideRecipient": "info@processweb.de"
|
||||
},
|
||||
"StartupChecks": {
|
||||
"Enabled": false,
|
||||
@@ -60,9 +61,9 @@
|
||||
"StartupEmailRecipientName": "Startup Monitor"
|
||||
},
|
||||
"AzureStorage": {
|
||||
"Enabled": false,
|
||||
"InvoiceContainer": "fuchs-invoices",
|
||||
"ReminderContainer": "fuchs-reminders",
|
||||
"Enabled": true,
|
||||
"InvoiceContainer": "dev-fuchs-invoices",
|
||||
"ReminderContainer": "dev-fuchs-reminders",
|
||||
"MetadataFields": [ "Id", "Version", "InvoiceId", "InvoiceTitle", "InvId", "DocumentName", "file_guid" ]
|
||||
},
|
||||
"Telemetry": {
|
||||
@@ -71,6 +72,9 @@
|
||||
}
|
||||
},
|
||||
"Fds": {
|
||||
"SyncEnabled": true,
|
||||
"ExecutionFrequency_Minutes": 15,
|
||||
"DebugDetails": false,
|
||||
"MFR_host": "portal.mobilefieldreport.com",
|
||||
"MFR_UserName": "MANAGED_BY_KEYVAULT",
|
||||
"MFR_Password": "MANAGED_BY_KEYVAULT"
|
||||
|
||||
Binary file not shown.
@@ -175,7 +175,7 @@ public class FdsInvoiceData
|
||||
// explicit user choice of the default is indistinguishable from an invoice that was never
|
||||
// switched to set-pricing at all (needed so the editor can hide the "Set-Preisanzeige"
|
||||
// menu entry once a mode has been chosen; see InvoiceDraftEditService.BuildInvoiceOptions).
|
||||
if (setmode is "setprice" or "itemprices" or "setonly") tokens.Add("setmode:" + setmode);
|
||||
if (setmode is "setprice" or "setonly") tokens.Add("setmode:" + setmode);
|
||||
return string.Join(",", tokens);
|
||||
}
|
||||
|
||||
|
||||
+13
-6
@@ -168,6 +168,9 @@ public static class FuchsPdf
|
||||
bool isText = type is "text" or "title";
|
||||
ParseDec(i.no("price_net", 0), out decimal price);
|
||||
ParseDec(i.no("total_net", 0), out decimal total);
|
||||
// ADR 0009: a null/absent total_net means "no price" — render an empty cell (not 0,00 €),
|
||||
// distinct from a genuine 0. This is how block-mode (Set mit Preis) nulled members print.
|
||||
bool hasPrice = i.TryGetValue("total_net", out var tv) && tv != null;
|
||||
return new InvoiceSetLine
|
||||
{
|
||||
Title = i.nz("title", ""),
|
||||
@@ -175,7 +178,8 @@ public static class FuchsPdf
|
||||
Qty = i.nz("qty", ""),
|
||||
PriceNet = price,
|
||||
TotalNet = total,
|
||||
ShowPrice = !isText,
|
||||
ShowPrice = !isText && hasPrice,
|
||||
Numbered = !isText, // numbered like the editor: every line except free-text/heading, even price-blanked set members
|
||||
IsSetHeader = type == "set"
|
||||
};
|
||||
}
|
||||
@@ -764,8 +768,8 @@ public static class FuchsPdf
|
||||
// Each section prints its heading; positions are numbered the same way the editor numbers
|
||||
// them (every line except free-text/heading lines, including a set header). The chosen
|
||||
// set-display mode (see INVOICE_SET_PRICING.md) governs how "set" items and their members
|
||||
// are shown: SetPrice (default) prices the set, blanks members; ItemPrices prices the
|
||||
// members, blanks the set heading; SetOnly prices the set and drops the members entirely.
|
||||
// are shown: SetPrice (default) prices the set, blanks members; SetOnly prices the set
|
||||
// and drops the members entirely.
|
||||
// Blocks without any set items are unaffected and always render flat.
|
||||
var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions"));
|
||||
int pos = 0;
|
||||
@@ -779,13 +783,16 @@ public static class FuchsPdf
|
||||
hr.Cells[1].AddParagraph().WithStyle("TblCell_RTitle").AddFormattedText(block.Heading, TextFormat.Bold);
|
||||
}
|
||||
|
||||
var lines = InvoiceSetPricing.ContainsSets(block.Items)
|
||||
// Function-1 (SetItmId-linked) sets still collapse via Build; block-mode sets (ADR 0009)
|
||||
// and plain blocks render flat — their set row is emphasised on its own and nulled
|
||||
// members map to blank cells (see MapItemToLine).
|
||||
var lines = InvoiceSetPricing.HasSetMembers(block.Items)
|
||||
? InvoiceSetPricing.Build(block.Items, setMode)
|
||||
: block.Items.Select(MapItemToLine).ToList(); // no sets: flat, faithful mirror of the editor
|
||||
: block.Items.Select(MapItemToLine).ToList();
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
bool numbered = line.IsSetHeader || line.ShowPrice; // free-text/heading lines carry no number
|
||||
bool numbered = line.Numbered; // every line except free-text/heading (mirrors the editor's RecomputePositions), even price-blanked set members
|
||||
var row = tbl.AddRow();
|
||||
row.HeightRule = RowHeightRule.Auto;
|
||||
row.Cells[0].AddParagraph(numbered ? (++pos).ToString() : "").Style = "TblCell_Base";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
@@ -16,6 +16,45 @@ namespace Fuchs.intranet;
|
||||
/// </summary>
|
||||
public static class InvoiceDraftCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Recomputes every line's own net/VAT/service values from its raw quantity (<c>qn</c>),
|
||||
/// unit price (<c>v</c>) and VAT rate (<c>vat</c>) — the authoritative, server-side port of
|
||||
/// the former client-side <c>quantChange</c>/<c>setVat</c> multiplication (ADR 0006/0008: the
|
||||
/// online editor performs no arithmetic at all, not even a single line's net = qty × price).
|
||||
/// Only lines that actually carry a raw quantity and a positive unit price are recomputed
|
||||
/// (mirrors <c>quantChange</c>'s own guard); lines without both (headings, free text,
|
||||
/// combined-sum rows, and set members that have been zeroed by
|
||||
/// <see cref="Fuchs.Services.InvoiceDraftEditService"/>'s "Auf Setpreis umstellen" conversion)
|
||||
/// keep whatever value they already carry, so a set header's synthesised sum is never
|
||||
/// clobbered by a subsequent recompute.
|
||||
/// </summary>
|
||||
public static void RecomputeLineValues(InvoiceDraftSession session)
|
||||
{
|
||||
foreach (var blockTok in session.Req)
|
||||
{
|
||||
if (blockTok is not JObject block || block["itm"] is not JArray lines) continue;
|
||||
foreach (var lineTok in lines)
|
||||
{
|
||||
if (lineTok is not JObject co) continue;
|
||||
decimal qty = Dec(co["qn"]);
|
||||
decimal price = Dec(co["v"]);
|
||||
if (qty <= 0 || price <= 0) continue; // no raw qty/price posted -> leave the value as delivered
|
||||
|
||||
string rate = NormalizeRate(Str(co["vat"]));
|
||||
decimal vatFactor = rate.Length > 0 && decimal.TryParse(rate, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal r)
|
||||
? r / 100m : 0m;
|
||||
decimal netVal = Math.Round(qty * price, 2, MidpointRounding.AwayFromZero);
|
||||
decimal vatVal = Math.Round(netVal * vatFactor, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
co["vt"] = netVal;
|
||||
co["vv"] = vatVal;
|
||||
bool isService = string.Equals(Str(co["typ"]), "Service", StringComparison.OrdinalIgnoreCase);
|
||||
co["vs"] = isService ? netVal : 0;
|
||||
co["vsv"] = isService ? vatVal : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates every block's line values into the draft's totals — the port of
|
||||
/// <c>invSumUpdate</c>'s <c>csms</c> accumulation plus §13b (VAT suppressed → gross = net).
|
||||
|
||||
@@ -13,8 +13,6 @@ public enum SetDisplayMode
|
||||
{
|
||||
/// <summary>Default: show the set as one priced line; member items listed without price.</summary>
|
||||
SetPrice,
|
||||
/// <summary>Show each member item with its own price; the set line is a header without price.</summary>
|
||||
ItemPrices,
|
||||
/// <summary>Show only the set as one priced line; member items are removed entirely.</summary>
|
||||
SetOnly
|
||||
}
|
||||
@@ -32,6 +30,14 @@ public sealed class InvoiceSetLine
|
||||
public decimal TotalNet { get; init; }
|
||||
/// <summary>When false, the price/total cells are rendered blank (e.g. set members in SetPrice mode).</summary>
|
||||
public bool ShowPrice { get; init; } = true;
|
||||
/// <summary>
|
||||
/// Whether this line carries a position number. Mirrors the editor's numbering
|
||||
/// (<c>InvoiceDraftCalculator.RecomputePositions</c>): every line is numbered <b>except</b>
|
||||
/// free-text/heading lines — independent of whether a price is shown, so a price-blanked set
|
||||
/// member (SetPrice mode / block-mode nulled member) is still numbered exactly like in the
|
||||
/// online editor.
|
||||
/// </summary>
|
||||
public bool Numbered { get; init; } = true;
|
||||
/// <summary>True for the set header line (rendered emphasised).</summary>
|
||||
public bool IsSetHeader { get; init; }
|
||||
}
|
||||
@@ -43,17 +49,24 @@ public sealed class InvoiceSetLine
|
||||
/// header's <c>id</c>. Items that belong to no set pass through unchanged.
|
||||
///
|
||||
/// The invoice total is taken from the registration balance, not from these
|
||||
/// lines, so switching modes is purely presentational and never changes the
|
||||
/// invoice sum — set price always equals the sum of its members.
|
||||
/// lines, so calling <see cref="Build"/> is a pure, non-mutating transformation
|
||||
/// of the items it is given and never changes the invoice sum. A set header
|
||||
/// only adopts the chosen <see cref="SetDisplayMode"/> once it has actually been
|
||||
/// converted (its own <c>total_net</c> is non-zero, via the one-way set-item
|
||||
/// switch, <c>InvoiceDraftEditService.ApplyItemSetPrice</c>) — until then the
|
||||
/// header renders blank and each member keeps its own individual price, exactly
|
||||
/// like a non-set item. (The choice of which mode to apply is itself a
|
||||
/// persisted, effectively one-way decision on the draft session — see
|
||||
/// <c>Fuchs/Docs/INVOICE_SET_PRICING.md</c> — but that persistence lives outside
|
||||
/// this class.)
|
||||
/// </summary>
|
||||
public static class InvoiceSetPricing
|
||||
{
|
||||
public static SetDisplayMode ParseMode(string? raw) =>
|
||||
(raw ?? "").Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"itemprices" or "items" or "item" => SetDisplayMode.ItemPrices,
|
||||
"setonly" or "set_only" => SetDisplayMode.SetOnly,
|
||||
_ => SetDisplayMode.SetPrice
|
||||
"setonly" or "set_only" => SetDisplayMode.SetOnly,
|
||||
_ => SetDisplayMode.SetPrice
|
||||
};
|
||||
|
||||
/// <summary>Reads the set mode from an InvoiceOptions CSV token like "setmode:itemprices".</summary>
|
||||
@@ -71,9 +84,21 @@ public static class InvoiceSetPricing
|
||||
public static bool ContainsSets(IEnumerable<Dictionary<string, object?>> items) =>
|
||||
items.Any(IsSetHeader) || items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
|
||||
|
||||
/// <summary>
|
||||
/// True if the block carries a <c>SetItmId</c>-linked set (a member points at a header via
|
||||
/// <c>setId</c>) — i.e. the function-1 "set-item switch" grouping. This is the only case that
|
||||
/// still routes through <see cref="Build"/>; the two menu modes (ADR 0009) clear <c>setId</c>
|
||||
/// and render flat (their inserted set row is emphasised on its own, members null out to blank).
|
||||
/// </summary>
|
||||
public static bool HasSetMembers(IEnumerable<Dictionary<string, object?>> items) =>
|
||||
items.Any(i => !string.IsNullOrEmpty(SetIdOf(i)));
|
||||
|
||||
/// <summary>
|
||||
/// Produces the ordered display lines for the given items and mode.
|
||||
/// Standalone items are always shown with their price.
|
||||
/// Standalone items are always shown with their price. A set header whose
|
||||
/// own <c>total_net</c> is still zero (not yet converted via the set-item
|
||||
/// switch) is rendered blank with its members individually priced,
|
||||
/// regardless of <paramref name="mode"/>.
|
||||
/// </summary>
|
||||
public static List<InvoiceSetLine> Build(IReadOnlyList<Dictionary<string, object?>> items, SetDisplayMode mode)
|
||||
{
|
||||
@@ -90,22 +115,29 @@ public static class InvoiceSetPricing
|
||||
{
|
||||
if (IsSetHeader(item))
|
||||
{
|
||||
string setId = HeaderIdOf(item);
|
||||
var members = membersBySet.TryGetValue(setId, out var m) ? m : new List<Dictionary<string, object?>>();
|
||||
decimal setTot = HeaderTotal(item, members);
|
||||
string setId = HeaderIdOf(item);
|
||||
var members = membersBySet.TryGetValue(setId, out var m) ? m : new List<Dictionary<string, object?>>();
|
||||
FuchsPdf.ParseDec(item.no("total_net", 0), out decimal headerOwnTotal);
|
||||
|
||||
if (headerOwnTotal == 0)
|
||||
{
|
||||
// Not yet converted (the set-item switch, ApplyItemSetPrice, hasn't run): the
|
||||
// header still carries its as-delivered zero price, so the chosen display mode
|
||||
// does not apply yet — show the header blank and each member with its own,
|
||||
// individual price, exactly as if there were no set grouping at all.
|
||||
result.Add(HeaderLine(item, 0, showPrice: false));
|
||||
foreach (var mem in members) result.Add(MemberLine(mem, showPrice: true));
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (mode)
|
||||
{
|
||||
case SetDisplayMode.SetPrice:
|
||||
result.Add(HeaderLine(item, setTot, showPrice: true));
|
||||
result.Add(HeaderLine(item, headerOwnTotal, showPrice: true));
|
||||
foreach (var mem in members) result.Add(MemberLine(mem, showPrice: false));
|
||||
break;
|
||||
case SetDisplayMode.ItemPrices:
|
||||
result.Add(HeaderLine(item, setTot, showPrice: false)); // grouping title, no price (avoid double count)
|
||||
foreach (var mem in members) result.Add(MemberLine(mem, showPrice: true));
|
||||
break;
|
||||
case SetDisplayMode.SetOnly:
|
||||
result.Add(HeaderLine(item, setTot, showPrice: true));
|
||||
result.Add(HeaderLine(item, headerOwnTotal, showPrice: true));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -148,15 +180,6 @@ public static class InvoiceSetPricing
|
||||
return string.IsNullOrEmpty(s) ? i.nz("id", "") : s;
|
||||
}
|
||||
|
||||
private static decimal HeaderTotal(Dictionary<string, object?> header, List<Dictionary<string, object?>> members)
|
||||
{
|
||||
FuchsPdf.ParseDec(header.no("total_net", 0), out decimal headerTot);
|
||||
if (headerTot != 0) return headerTot;
|
||||
decimal sum = 0;
|
||||
foreach (var m in members) { FuchsPdf.ParseDec(m.no("total_net", 0), out decimal t); sum += t; }
|
||||
return sum;
|
||||
}
|
||||
|
||||
private static InvoiceSetLine HeaderLine(Dictionary<string, object?> i, decimal setTotal, bool showPrice) => new()
|
||||
{
|
||||
Id = i.nz("id", ""),
|
||||
@@ -166,6 +189,7 @@ public static class InvoiceSetPricing
|
||||
PriceNet = setTotal,
|
||||
TotalNet = setTotal,
|
||||
ShowPrice = showPrice,
|
||||
Numbered = true, // a set header is always numbered
|
||||
IsSetHeader = true
|
||||
};
|
||||
|
||||
@@ -182,6 +206,7 @@ public static class InvoiceSetPricing
|
||||
PriceNet = price,
|
||||
TotalNet = total,
|
||||
ShowPrice = showPrice && !IsNoPriceLine(i), // headings/free text print no price
|
||||
Numbered = !IsNoPriceLine(i), // …but are still numbered unless heading/free-text
|
||||
IsSetHeader = false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.10",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,10 +130,13 @@ $inv.d = {
|
||||
return h;
|
||||
},
|
||||
/* Seed the authoritative server session from the assembled editor payload. Called
|
||||
once from invSumUpdate on open; thereafter the backend is the source of truth. */
|
||||
once from invSumUpdate on open; thereafter the backend is the source of truth.
|
||||
The in-flight ajax promise is stashed on 'dseedpromise' so a delta sent (e.g. via
|
||||
a context-menu action) before seeding has actually completed can wait for the token
|
||||
instead of silently no-op'ing (see $inv.d.sync). */
|
||||
seed: function (payload) {
|
||||
let l = $inv.d.layout(); l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
let p = $ocms.postXT({
|
||||
url: $ocms.url('inv/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
|
||||
$inv.d.tbl().data('dtoken', r.token).data('dver', r.version).data('dhashes', $inv.d.hashes()).data('dorder', $inv.d.order());
|
||||
$fis.draft.bind(r.token, {
|
||||
@@ -142,8 +145,10 @@ $inv.d = {
|
||||
onClosed: (reason) => $inv.d.closed(reason)
|
||||
});
|
||||
$inv.d.refresh();
|
||||
}, error: () => { l.rC('freeze'); }, complete: () => { $inv.d.tbl().removeData('dseeding'); }
|
||||
}, error: () => { l.rC('freeze'); }, complete: () => { $inv.d.tbl().removeData('dseeding').removeData('dseedpromise'); }
|
||||
});
|
||||
$inv.d.tbl().data('dseedpromise', p);
|
||||
return p;
|
||||
},
|
||||
/* Re-fetch the authoritative state and render the totals footer + validation from it. */
|
||||
refresh: function (cb) {
|
||||
@@ -157,11 +162,21 @@ $inv.d = {
|
||||
},
|
||||
applyState: function (state) {
|
||||
let tbl = $inv.d.tbl(); if (tbl.length < 1) { return; }
|
||||
/* Concurrent refreshes (one per flushed block plus one for a follow-up delta such as
|
||||
item.setprice, and SignalR draftReady on top) can have their dstate responses arrive
|
||||
out of order. Applying a response older than the version already rendered would
|
||||
silently roll back a just-applied server-side mutation (e.g. a set-price conversion
|
||||
reverting to its pre-conversion 0/individually-priced state) — so stale responses are
|
||||
dropped here instead of rendered. */
|
||||
let curVer = tbl.data('dver');
|
||||
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
|
||||
return;
|
||||
}
|
||||
tbl.data('dver', state.version).data('serverSums', state.sums);
|
||||
$inv.d.footer(tbl, state.sums || {}, state.admin || {});
|
||||
$inv.d.notes(state.notes || []);
|
||||
$inv.d.validation(state.validation || []);
|
||||
$inv.d.applyPositions(tbl, state.req || []);
|
||||
$inv.d.applyItems(tbl, state.req || []);
|
||||
$inv.d.applySetDisplay(tbl, state.setDisplay || {});
|
||||
/* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode:
|
||||
hides it right after a mode switch, and restores it if a discard reverted the draft to
|
||||
@@ -181,19 +196,60 @@ $inv.d = {
|
||||
/* Push the server's authoritative position numbers back onto the rendered rows so the online
|
||||
editor and the PDF preview always agree (the server numbers priced lines continuously; the
|
||||
browser must not keep its own numbering). Only the position cell is touched — no re-render. */
|
||||
applyPositions: function (tbl, req) {
|
||||
(req || []).forEach((b) => (b && b.itm || []).forEach((co) => {
|
||||
if (!co || (co.id || '') === '') { return; }
|
||||
let cell = tbl.find('#itm' + co.id + ' td.keep').first();
|
||||
if (cell.length) { cell.text(co.p != null ? co.p : ''); }
|
||||
}));
|
||||
/* Reconciles each block's rendered rows against the authoritative server line list
|
||||
(`req[].itm`, the co shape). The server may not only change a line's values but also
|
||||
ADD lines (the block set row inserted by "Set mit Preis"/block.setprice) and REMOVE lines
|
||||
("Nur Set mit Preis"/block.setonly), and reorder them — the browser never computes any of
|
||||
that (ADR 0006/0008/0009), it just mirrors the server. For each server line: patch/insert
|
||||
the matching row, preserving null price fields verbatim (ADR 0009: null = empty cell, kept
|
||||
distinct from 0). Rows the server no longer lists are removed; order follows the server.
|
||||
Position numbers, net/VAT and the set-row emphasis all come from rrw off the mirrored data. */
|
||||
applyItems: function (tbl, req) {
|
||||
let val = (x) => (x === undefined ? null : x); // JSON null stays null; absent -> null
|
||||
(req || []).forEach((b) => {
|
||||
if (!b) { return; }
|
||||
let bid = (b.Id || '').toString(); if (bid === '') { return; }
|
||||
let bdy = tbl.children('tbody').filter((i, e) => (($(e).data() || {}).Id || '').toString() === bid).first();
|
||||
if (bdy.length < 1) { return; }
|
||||
let itms = b.itm || [], serverIds = {};
|
||||
let title = bdy.find('tr.title').first();
|
||||
let prev = title.length ? title : null;
|
||||
itms.forEach((co) => {
|
||||
let id = (co.id || '').toString(); if (id === '') { return; }
|
||||
serverIds[id] = true;
|
||||
let rw = bdy.find('#itm' + id).first(), isNew = rw.length < 1;
|
||||
if (isNew) { rw = $$.tr(bdy, { id: 'itm' + id, class: 'itm' }); }
|
||||
let dta = rw.data() || {};
|
||||
let changed = isNew || (dta.Type || '') !== (co.typ || '')
|
||||
|| dta.net !== val(co.v) || dta.net_val !== val(co.vt) || dta.vat_val !== val(co.vv)
|
||||
|| dta.svcnet_val !== val(co.vs) || dta.svcvat_val !== val(co.vsv)
|
||||
|| (dta.position || '') !== (co.p != null ? co.p : '');
|
||||
$.extend(dta, {
|
||||
Id: id, Type: co.typ,
|
||||
net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv),
|
||||
svcnet_val: val(co.vs), svcvat_val: val(co.vsv),
|
||||
vat: co.vat, position: (co.p != null ? co.p : ''),
|
||||
SetItmId: (co.SetItmId != null ? co.SetItmId : null)
|
||||
});
|
||||
if (isNew) { $.extend(dta, { htmltext: co.t, quantity: co.q }); } // text only for server-created rows (e.g. the set row)
|
||||
rw.data(dta);
|
||||
if (prev) { if (rw.prev()[0] !== prev[0]) { rw.insertAfter(prev); } } // keep server order, but don't churn already-ordered rows
|
||||
else if (bdy.children().first()[0] !== rw[0]) { bdy.prepend(rw); }
|
||||
prev = rw;
|
||||
if (changed) { $inv.rrw.call(rw); }
|
||||
});
|
||||
bdy.find('tr.itm').each((i, tr) => {
|
||||
let id = (($(tr).data() || {}).Id || '').toString();
|
||||
if (id !== '' && !serverIds[id]) { $(tr).remove(); } // dropped server-side (block.setonly)
|
||||
});
|
||||
});
|
||||
},
|
||||
/* Applies the backend-authoritative set-pricing display flags (see BuildSetDisplay /
|
||||
InvoiceSetPricing) onto the rendered rows: blanks the price/total cells for lines the
|
||||
server says should show no price (set members in SetPrice mode, the set header in
|
||||
ItemPrices mode), and emphasises the set header line — so the online editor always
|
||||
shows exactly what the PDF will print, without duplicating the pricing rules client-side.
|
||||
Rows not present in the map (no set in that block, or standalone items) are left untouched. */
|
||||
server says should show no price (set members in SetPrice/SetOnly mode), and emphasises
|
||||
the set header line — so the online editor always shows exactly what the PDF will print,
|
||||
without duplicating the pricing rules client-side. Rows not present in the map (no set in
|
||||
that block, or standalone items) are left untouched. */
|
||||
applySetDisplay: function (tbl, setDisplay) {
|
||||
tbl.find('tr.itm').each((i, tr) => {
|
||||
let rw = $(tr), dta = rw.data() || {}, id = (dta.Id || '').toString();
|
||||
@@ -205,11 +261,23 @@ $inv.d = {
|
||||
}
|
||||
});
|
||||
},
|
||||
/* Send one change to the server; the draftReady signal and this success both refresh. */
|
||||
/* Send one change to the server; the draftReady signal and this success both refresh.
|
||||
Returns the underlying ajax promise so callers that must not race a follow-up delta
|
||||
against this one (e.g. $inv.toSetPrice after a flush) can chain on it.
|
||||
If the initial seeding (inv/dopen) is still in flight (no token yet, but a
|
||||
'dseedpromise' is pending), the delta previously vanished silently — the user saw no
|
||||
network activity at all when clicking a context-menu action right after opening the
|
||||
editor. Now the delta waits for the seed to finish and retries against the freshly
|
||||
issued token instead of being dropped. */
|
||||
sync: function (delta) {
|
||||
let t = $inv.d.token(); if (t === '') { return; }
|
||||
let t = $inv.d.token();
|
||||
if (t === '') {
|
||||
let seeding = $inv.d.tbl().data('dseedpromise');
|
||||
if (seeding) { return $.when(seeding).then(() => $inv.d.sync(delta)); }
|
||||
return $.when();
|
||||
}
|
||||
$inv.d.layout().aC('freeze');
|
||||
$ocms.postXT({
|
||||
return $ocms.postXT({
|
||||
url: $ocms.url('inv/dpatch'), data: { token: t, delta: JSON.stringify(delta) },
|
||||
success: () => { $inv.d.refresh(); },
|
||||
error: (xhr) => { $inv.d.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } }
|
||||
@@ -220,22 +288,29 @@ $inv.d = {
|
||||
/* Diff the freshly-rebuilt blocks against the last-synced state and send only the
|
||||
changed/removed blocks as granular block.replace / block.remove deltas. A pure section
|
||||
reorder (same blocks, new sequence) changes no block hash, so it is sent separately as a
|
||||
block.order delta; the server reorders the cache, renumbers positions and pushes them back. */
|
||||
block.order delta; the server reorders the cache, renumbers positions and pushes them back.
|
||||
No early return on an empty token here: sync() itself waits for an in-flight seed
|
||||
(dseedpromise) instead of silently dropping the delta, so edits made in the brief window
|
||||
before seeding completes are not lost. */
|
||||
syncChanged: function (tbl) {
|
||||
if (($inv.d.token()) === '') { return; }
|
||||
let bai = tbl.data('bai') || [], prev = tbl.data('dhashes') || {}, next = {}, changed = [], removed = [];
|
||||
let bai = tbl.data('bai') || [], prev = tbl.data('dhashes') || {}, next = {}, changed = [], removed = [], pending = [];
|
||||
$.each(bai, (i, b) => { let id = (b.Id || '').toString(), h = JSON.stringify(b); next[id] = h; if (prev[id] !== h) { changed.push(b); } });
|
||||
$.each(prev, (id) => { if (next[id] === undefined) { removed.push(id); } });
|
||||
let order = $inv.d.order(), prevOrder = tbl.data('dorder') || [];
|
||||
tbl.data('dhashes', next).data('dorder', order);
|
||||
changed.forEach((b) => $inv.d.sync({ Target: 'block.replace', Ref: (b.Id || '').toString(), Value: b }));
|
||||
removed.forEach((id) => $inv.d.sync({ Target: 'block.remove', Ref: id }));
|
||||
changed.forEach((b) => pending.push($inv.d.sync({ Target: 'block.replace', Ref: (b.Id || '').toString(), Value: b })));
|
||||
removed.forEach((id) => pending.push($inv.d.sync({ Target: 'block.remove', Ref: id })));
|
||||
let sameSet = prevOrder.length === order.length && prevOrder.slice().sort().join(',') === order.slice().sort().join(',');
|
||||
if (sameSet && prevOrder.join(',') !== order.join(',')) { $inv.d.sync({ Target: 'block.order', Value: order }); }
|
||||
if (sameSet && prevOrder.join(',') !== order.join(',')) { pending.push($inv.d.sync({ Target: 'block.order', Value: order })); }
|
||||
/* $.when(...pending) so a caller (e.g. toSetPrice) can wait until every flushed block
|
||||
has actually reached the server before sending a further delta that depends on it —
|
||||
otherwise a delayed block.replace carrying pre-conversion values can land after and
|
||||
silently overwrite a just-applied server-side mutation (e.g. item.setprice). */
|
||||
return $.when.apply($, pending).promise();
|
||||
},
|
||||
/* Map an inline recipient field to its delta target and send it. */
|
||||
/* Map an inline recipient field to its delta target and send it. No token-guard here for the
|
||||
same reason as syncChanged: sync() waits for an in-flight seed instead of dropping the edit. */
|
||||
syncField: function (nme, val) {
|
||||
if ($inv.d.token() === '') { return; }
|
||||
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
|
||||
let target = map[nme]; if (!target) { return; }
|
||||
$inv.d.sync({ Target: target, Value: val });
|
||||
@@ -390,6 +465,12 @@ $inv.rd = {
|
||||
},
|
||||
applyState: function (state) {
|
||||
let tbl = $inv.rd.tbl(); if (tbl.length < 1) { return; }
|
||||
/* See $inv.d.applyState — drop stale/out-of-order dstate responses so an older refresh
|
||||
can never roll back a newer one's totals/validation. */
|
||||
let curVer = tbl.data('rdver');
|
||||
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
|
||||
return;
|
||||
}
|
||||
tbl.data('rdver', state.version).data('serverSums', state.sums).data('remid', state.remid || '');
|
||||
$inv.rd.footer(tbl, state.sums || {});
|
||||
$inv.rd.notes(state.notes || {});
|
||||
@@ -949,6 +1030,8 @@ $inv.eHtml = function (ev) {
|
||||
}
|
||||
$ocms.dlgform(flds, sets);
|
||||
};
|
||||
/* No client-side arithmetic (ADR 0006/0008): only the chosen VAT rate is stored on each row;
|
||||
the server (InvoiceDraftCalculator.RecomputeLineValues) recomputes vat_val/svcvat_val from it. */
|
||||
$inv.setVat = function (ev) {
|
||||
let t = $(this), thisrow = ev.data;
|
||||
let vat = prompt($rct.rqV);
|
||||
@@ -961,12 +1044,6 @@ $inv.setVat = function (ev) {
|
||||
thisrow.siblings('.itm').each(function () {
|
||||
let rwi = $(this), dta = rwi.data();
|
||||
dta.vat = fnum(vat, { style: 'percent' }).replace(' ', '');
|
||||
if ((dta.net_val || 0) > 0) {
|
||||
dta.vat_val = dta.net_val * vat;
|
||||
}
|
||||
if ((dta.svcnet_val || 0) > 0) {
|
||||
dta.svcvat_val = dta.svcnet_val * vat;
|
||||
}
|
||||
});
|
||||
$inv.t_fds_inv();
|
||||
}
|
||||
@@ -1026,12 +1103,14 @@ $inv.rrw = function () {
|
||||
if (ph === false) {
|
||||
bc.unshift($$.dc('ibtn edit', { title: $rct.cP }).append(gi('pencil')).click(rw, $inv.eRow));
|
||||
bc.push($$.dc('ibtn del', { title: $rct.dR }).append(gi('trash')).click(function (e) { if (confirm($rct.cD)) { rw.remove(); $inv.t_fds_inv(); } }));
|
||||
/* "Auf Setpreis umstellen": only offered on a Set header item that is not yet
|
||||
priced from its members (fds__prepInvoice's [SetItmId] is null exactly for a
|
||||
Set header — either already set-priced, or not yet converted). Irreversible:
|
||||
sets this row's price to the sum of its members (rows whose [SetItmId] equals
|
||||
this row's id) and clears each member's price. */
|
||||
if ((dta.Type || '').toLowerCase() === 'set' && (dta.SetItmId || '') === '') {
|
||||
/* "Auf Setpreis umstellen": offered on a Set header item that does not yet carry a
|
||||
price. fds__prepInvoice's [SetItmId] now anchors on the still-unconverted (price 0)
|
||||
header, so the header row's own SetItmId self-references its own id rather than being
|
||||
null — the real signal for showing this button is still the header's own price/net_val
|
||||
being 0 (once converted it carries a nonzero price and the button disappears).
|
||||
Irreversible: the actual sum/clear mutation happens server-side (backend-authoritative,
|
||||
see InvoiceDraftEditService.ApplyItemSetPrice) via the "item.setprice" patch target. */
|
||||
if ((dta.Type || '').toLowerCase() === 'set' && (dta.net_val || 0) === 0) {
|
||||
bc.push($$.dc('ibtn tosetp', { title: $rct.toSetP }).append(gi('scale')).click(rw, $inv.toSetPrice));
|
||||
}
|
||||
}
|
||||
@@ -1041,7 +1120,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 };
|
||||
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 };
|
||||
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
|
||||
|
||||
co.p = dta.position || (dta.SortOrder || '');
|
||||
@@ -1051,7 +1130,7 @@ $inv.rrw = function () {
|
||||
co.t = dta.htmltext || (((dta.NameOrNumber || '').substr(0, 1) !== '#' ? oHtml($$[0]('p').text(dta.NameOrNumber)) : '') + (dta.Note || ''));
|
||||
} else {
|
||||
co.tt = co.det ? '' : $$.s(dta.Note || '').text();
|
||||
co.q = dta.quantity || (fnum(dta.quantityhours) + ' ' + (dta.UnitString || ''));
|
||||
co.q = dta.quantity || ((dta.quantityhours || 0) !== 0 ? (fnum(dta.quantityhours) + ' ' + (dta.UnitString || '')) : ''); /* guard: no quantityhours -> blank, not fnum(undefined)="NaN" (e.g. the block set row) */
|
||||
co.t = dta.htmltext ||(co.det ? (oHtml($$.s(dta.NameOrNumber || '')) + oHtml($$.dc('desc').html(dta.Note))) : oHtml($$.s(dta.NameOrNumber || '')));
|
||||
co.v = dta.net
|
||||
co.vt = dta.net_val
|
||||
@@ -1070,75 +1149,62 @@ $inv.rrw = function () {
|
||||
tda.push($$.td(rw, { colspan: 4 }).append(co.t));
|
||||
} else {
|
||||
Array.prototype.push.apply(tda, co.q ? [$$.tdc('keep').text(co.q)] : []);
|
||||
/* ADR 0009: a null/undefined price renders an EMPTY cell (block-mode "Set mit Preis"
|
||||
members, and the set row's own unit-price), distinct from a real 0,00 €. */
|
||||
let cur = (x) => (x == null ? '' : fnum(x, $rct.cst));
|
||||
Array.prototype.push.apply(tda, [
|
||||
$$.tdc('txt', { colspan: !co.q ? 2 : 1, title: co.tt }).append(co.t),
|
||||
$$.tdc('currency').text(fnum(co.v, $rct.cst)),
|
||||
$$.tdc('currency inetval').text(fnum(co.vt, $rct.cst)).attr('title', $rct.svcPart + ': ' + fnum(co.vs, $rct.cst))
|
||||
$$.tdc('currency').text(cur(co.v)),
|
||||
$$.tdc('currency inetval').text(cur(co.vt)).attr('title', $rct.svcPart + ': ' + cur(co.vs))
|
||||
]);
|
||||
}
|
||||
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ).tC('hidenote', hn).append(tda);
|
||||
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
|
||||
.tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows (fn1 header + block set row) */
|
||||
.tC('hidenote', hn).append(tda);
|
||||
dta.co = co;
|
||||
};
|
||||
/* "Auf Setpreis umstellen" (see $inv.rrw): sets this Set-header row's price to the sum of
|
||||
its member rows' totals (rows in the same block whose [SetItmId] equals this row's id,
|
||||
the authoritative grouping from fds__prepInvoice), then clears each member's price so the
|
||||
member items show without a price — matching the SetPrice presentation, but as an actual,
|
||||
irreversible data change instead of a display-mode toggle (INVOICE_SET_PRICING.md's
|
||||
setmode remains display-only and is unaffected by this). */
|
||||
/* "Auf Setpreis umstellen" (see $inv.rrw): backend-authoritative (ADR 0006) — the browser only
|
||||
posts the target set-header item id; the server (InvoiceDraftEditService.ApplyItemSetPrice)
|
||||
sums the member rows (same block, [SetItmId] equal to this row's id), writes that sum onto
|
||||
the set header and clears each member's price, then the normal draftReady refresh re-renders
|
||||
both the header and its members from the authoritative session state. This mirrors the PDF's
|
||||
read of the same cached session, so the online editor and PDF can never drift apart. */
|
||||
$inv.toSetPrice = function (ev) {
|
||||
let rw = ev.data, dta = rw.data(), id = (dta.Id || '').toString();
|
||||
if (id === '') { return; }
|
||||
if (confirm($rct.toSetPc) === false) { return; }
|
||||
let bdy = rw.closest('tbody'), members = bdy.find('tr.itm').filter(function () {
|
||||
return (($(this).data('SetItmId') || '').toString()) === id;
|
||||
});
|
||||
let sum_net_val = 0, sum_vat_val = 0, sum_svcnet_val = 0, sum_svcvat_val = 0;
|
||||
members.each(function () {
|
||||
let m = $(this).data();
|
||||
sum_net_val += (m.net_val || 0);
|
||||
sum_vat_val += (m.vat_val || 0);
|
||||
sum_svcnet_val += (m.svcnet_val || 0);
|
||||
sum_svcvat_val += (m.svcvat_val || 0);
|
||||
$.extend(m, { net: 0, net_val: 0, vat_val: 0, svcnet_val: 0, svcvat_val: 0, Discount: 0 });
|
||||
$inv.rrw.call($(this));
|
||||
});
|
||||
$.extend(dta, {
|
||||
net: sum_net_val, quantityhours: 1, Discount: 0,
|
||||
net_val: sum_net_val, vat_val: sum_vat_val, svcnet_val: sum_svcnet_val, svcvat_val: sum_svcvat_val
|
||||
});
|
||||
$inv.rrw.call(rw);
|
||||
$inv.t_fds_inv();
|
||||
/* Wait for the flush of any pending local edits to actually reach the server before sending
|
||||
the conversion delta — sending both in parallel let a delayed block.replace (still carrying
|
||||
the pre-conversion, zero member/header values) land after item.setprice and silently
|
||||
overwrite the just-converted totals back to 0. */
|
||||
$.when($inv.t_fds_inv()).done(() => { $inv.d.sync({ Target: 'item.setprice', Ref: id }); });
|
||||
};
|
||||
/* No client-side totals/footer/tax computation (ADR 0006/0008): this only re-assembles each
|
||||
block's row contract array (needed to post the "req" shape to the server) and, on the very
|
||||
first pass, seeds the authoritative backend session. Footer, VAT breakdown, service-refund
|
||||
note figures and the per-block sum cell are never computed here — they are rendered exclusively
|
||||
from the server's `dstate` response by $inv.d.footer / $inv.d.applyState once the draft has a
|
||||
token. Before a token exists the footer/notes/isum cells are simply left empty; there is no
|
||||
local approximation to display. */
|
||||
$inv.invSumUpdate = function () {
|
||||
let tbl = $(this), ft = tbl.children('tfoot').empty(), p13b = bool((tbl.data().admin || {}).p13b || '', false);
|
||||
tbl.nextAll('.fnote').remove();
|
||||
let sms = { ttn: 0, ttb: 0, ttvat: 0, tscn: 0, tscvat: 0, vat: {}, itmnet: {} }, ba = [];
|
||||
let rwcy = (lbl, val, cls) => $$.tdc('currency', $$.tr(ft, { class: cls || 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text(lbl)]), fnum(val, $rct.cst)), fn = (t) => $$.dc('fnote').insertAfter(tbl).rwText(t);
|
||||
let csms = function (rrx, sms, sid) {
|
||||
sms.tscn += (rrx.svcnet_val || 0);
|
||||
sms.tscvat += (rrx.svcvat_val || 0);
|
||||
sms.ttn += (rrx.net_val || 0);
|
||||
sms.ttvat += (rrx.vat_val || 0);
|
||||
sms.ttb += ((rrx.net_val || 0) + (rrx.vat_val || 0));
|
||||
if ((rrx.vat || '') !== '') {
|
||||
sms.vat[rrx.vat] = (sms.vat[rrx.vat] || 0) + (rrx.vat_val || 0);
|
||||
}
|
||||
//sms.itmnet[sid] = (sms.itmnet[sid] || 0) + (rrx.net_val || 0);
|
||||
};
|
||||
let tbl = $(this);
|
||||
let ba = [];
|
||||
let bds = tbl.children('tbody');
|
||||
bds.each((bi, bdy) => {
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], cset = null, bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
b.tC('empty', itm.length < 1);
|
||||
itm.each((ti, tx) => {
|
||||
|
||||
let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co);
|
||||
//console.debug('rrx %o', rrx);
|
||||
let rrx = $(tx).data() || {};
|
||||
i.push(rrx.co);
|
||||
/* backend item contract (title/desc/qty/price_net/total_net + set flags), see InvoiceSetPricing.
|
||||
Set grouping: an item of Type 'set' is a header that claims the following items in this
|
||||
block as its members until the next set header (mfr__items has no explicit member link). */
|
||||
Set grouping: fds__prepInvoice computes [SetItmId] per item, anchored on the still-unconverted
|
||||
(price 0) Set header that owns it — this is the authoritative membership signal (not row order),
|
||||
so items not tagged with a SetItmId by the server are never swept into a preceding set. The
|
||||
header row's own SetItmId self-references its own id, but it is never its own member (excluded
|
||||
by the id !== '' && !== citem.id check below). */
|
||||
let citem = $inv.itemToContract(rrx);
|
||||
if (citem.type === 'set' && citem.id !== '') { cset = citem.id; }
|
||||
else if (cset !== null && (citem.id || '') !== '') { citem.setId = cset; }
|
||||
let sid = (rrx.SetItmId || '').toString();
|
||||
if (citem.type !== 'set' && sid !== '' && sid !== citem.id) { citem.setId = sid; }
|
||||
citems.push(citem);
|
||||
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
|
||||
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
|
||||
@@ -1147,41 +1213,10 @@ $inv.invSumUpdate = function () {
|
||||
$inv.rrw.call(tx);
|
||||
}
|
||||
});
|
||||
//console.debug('%o', {
|
||||
// f: b.find('tr.isum > td.isumval'), t: fnum(bnet, $rct.cst), n: bnet
|
||||
//});
|
||||
b.find('tr.isum > td.isumval').text(fnum(bnet, $rct.cst));
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems, netval: bnet });
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems });
|
||||
});
|
||||
let nonempty = tbl.find('tbody:not(.empty)').length;
|
||||
bds.find('tr.isum').tC('hidden', nonempty < 2);
|
||||
//let fnet = $$.tdc('currency', $$.tr(ft, { class: 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text('Netto')]), fnum(sms.ttn, $rct.cst));
|
||||
rwcy('Netto', sms.ttn);
|
||||
if (p13b === false) {
|
||||
$.each(sms.vat, (vi, vx) => {
|
||||
//$$.tdc('currency vat', $$.tr(ft, { class: 'tvat' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text('Umsatzsteuer ' + vi)]), fnum(vx, $rct.cst));
|
||||
rwcy($rct.vat + ' ' + vi, vx, 'tvat');
|
||||
});
|
||||
} else {
|
||||
sms.ttb = sms.ttn;
|
||||
}
|
||||
//let fsum = $$.tdc('currency', $$.tr(ft, { class: 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text('Summe')]), fnum(sms.ttb, $rct.cst));
|
||||
rwcy('Summe', sms.ttb);
|
||||
let itype = tbl.data().admin.type;
|
||||
if (itype === 'i') {
|
||||
fn($rct.note2);
|
||||
fn($rct.note4);
|
||||
} else if (itype === 'c') {
|
||||
fn($rct.note2);
|
||||
} else {
|
||||
fn(string($rct.note3, [fnum((sms.tscn + sms.tscvat) * (tbl.data().admin.tax_servicerefund || 0), $rct.cst)])).aC('ntax');
|
||||
fn($rct.note2);
|
||||
fn(string($rct.note1, [fnum(sms.tscn + sms.tscvat, $rct.cst), fnum(sms.tscn, $rct.cst), fnum(sms.tscvat, $rct.cst)]));
|
||||
}
|
||||
if (p13b === true) {
|
||||
fn($rct.note13b);
|
||||
}
|
||||
tbl.data('sms', sms);
|
||||
tbl.data('bai', ba);
|
||||
/* Backend-authoritative seeding (ADR 0006): on the first calculation of an invoice
|
||||
draft (invSumUpdate is only bound for invoices, never reminders), hand the assembled
|
||||
@@ -1281,8 +1316,16 @@ $inv.t_fds_inv = () => {
|
||||
let tbl = $('div.invoice_layout table.invi');
|
||||
tbl.trigger('fds.inv');
|
||||
/* After any local item mutation, push the changed block(s) to the authoritative
|
||||
server session as granular deltas (invoice drafts only — reminders have no token). */
|
||||
if ((tbl.data('dtoken') || '') !== '') { $inv.d.syncChanged(tbl); }
|
||||
server session as granular deltas (invoice drafts only — reminders have no token,
|
||||
nor a 'dseedpromise', so they correctly fall through to the no-op). While the initial
|
||||
seed (inv/dopen) is still in flight there is no token yet either, but a 'dseedpromise'
|
||||
is pending — wait for it instead of skipping the flush, otherwise an edit made in that
|
||||
brief window is silently never sent. Returns the flush promise so callers that must
|
||||
sequence a follow-up server delta after this flush (e.g. $inv.toSetPrice) can wait for
|
||||
it instead of racing it. */
|
||||
if ((tbl.data('dtoken') || '') !== '') { return $inv.d.syncChanged(tbl); }
|
||||
let seeding = tbl.data('dseedpromise');
|
||||
return seeding ? $.when(seeding).then(() => $inv.d.syncChanged(tbl)) : $.when();
|
||||
};
|
||||
$inv.sedit = () => {
|
||||
$inv.sprev(true);
|
||||
@@ -1349,7 +1392,10 @@ $inv.itemToContract = function (rrx) {
|
||||
rrx = rrx || {};
|
||||
let oHtml = (e) => $$.d().append(e).html();
|
||||
let type = (rrx.Type || '').toString().toLowerCase();
|
||||
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: (rrx.net_val || 0), vat: rrx.vat || '' };
|
||||
/* ADR 0009: preserve null prices verbatim (null = empty cell, kept distinct from a real 0) so
|
||||
block-mode nulled members and the set row round-trip unchanged when the block is re-posted. */
|
||||
let nz = (x) => (x == null ? null : x);
|
||||
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: nz(rrx.net_val), vat: rrx.vat || '' };
|
||||
if (rrx.co && rrx.co.typ === 'osum') {
|
||||
/* combined single-sum line — the on-screen "title" is an HTML sub-table; render it as desc */
|
||||
ci.desc = rrx.co.t || '';
|
||||
@@ -1370,30 +1416,35 @@ $inv.itemToContract = function (rrx) {
|
||||
ci.desc = rrx.Note || '';
|
||||
}
|
||||
ci.qty = rrx.quantity || ((rrx.quantityhours || 0) !== 0 ? (fnum(rrx.quantityhours) + (rrx.UnitString ? ' ' + rrx.UnitString : '')) : '');
|
||||
ci.price_net = (rrx.net || 0);
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
ci.price_net = nz(rrx.net);
|
||||
ci.total_net = nz(rrx.net_val);
|
||||
}
|
||||
return ci;
|
||||
};
|
||||
/* 3-way set-pricing display switch. Mirrors §13b: writes the choice onto admin.setmode,
|
||||
which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token the PDF reads. */
|
||||
/* 2-way set-pricing display switch (SetPrice / SetOnly). Mirrors §13b: writes the choice onto
|
||||
admin.setmode, which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token
|
||||
the PDF reads. ItemPrices ("Positionen mit Preis – Set als Überschrift") was removed: it was
|
||||
always just the implicit pre-conversion default and is not a state the user can switch back to
|
||||
once a set has been converted via the "Auf Setpreis umstellen" item switch (see $inv.toSetPrice /
|
||||
InvoiceDraftEditService.ApplyItemSetPrice) — that conversion is one-way. */
|
||||
$inv.ssetmode = () => {
|
||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
||||
d.admin = d.admin || {};
|
||||
let cur = (d.admin.setmode || 'setprice'), o;
|
||||
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
|
||||
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('itemprices'), btn('setonly')]);
|
||||
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('setonly')]);
|
||||
o = $ocms.dlg(fr, { width: 800 });
|
||||
};
|
||||
/* "Set mit Preis" / "Nur Set mit Preis" (ADR 0009): NOT a display toggle — an irreversible,
|
||||
backend-authoritative mutation grouped by service-request block. The server inserts a dedicated
|
||||
set row per block (block sum) and either nulls each member's price (setprice) or removes the
|
||||
members (setonly); the normal draftReady refresh re-renders both via $inv.d.applyItems. The
|
||||
browser only posts the chosen mode. Flush pending local edits first so a delayed block.replace
|
||||
can't land after the conversion and overwrite it (same race guard as $inv.toSetPrice). */
|
||||
$inv.setSetmode = (mode) => {
|
||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
||||
d.admin = d.admin || {};
|
||||
d.admin.setmode = mode; /* posted in admin -> BuildInvoiceParams writes setmode: into InvoiceOptions */
|
||||
d.inv = d.inv || {}; /* keep a local InvoiceOptions reflection in sync (cosmetic) */
|
||||
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
|
||||
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
|
||||
d.inv.InvoiceOptions = opts.join(',');
|
||||
$inv.d.sync({ Target: 'setmode', Value: mode });
|
||||
if (confirm($rct.toSetMc || $rct.toSetPc) === false) { return; }
|
||||
let target = (mode === 'setonly') ? 'block.setonly' : 'block.setprice';
|
||||
$.when($inv.t_fds_inv()).done(() => { $inv.d.sync({ Target: target }); });
|
||||
};
|
||||
$inv.sctp = () => {
|
||||
let flds = $invcol.ctp;
|
||||
@@ -1416,24 +1467,21 @@ $inv.sctp = () => {
|
||||
});
|
||||
};
|
||||
/* Normalises the editor's working model into the exact field names the C# backend
|
||||
(FdsInvoiceData.BuildInvoiceParams) reads, then returns the `invc` payload:
|
||||
- balances/service sums come from `sms` (ttn/ttb), exposed on `new` as total_net/total_gross;
|
||||
- every VAT rate's net amount is exposed as new.vat_<rate>_net (the backend reads the highest);
|
||||
(FdsInvoiceData.BuildInvoiceParams) reads, then returns the `invc` payload. No totals/VAT
|
||||
are computed here (ADR 0006/0008: the online editor performs no arithmetic at all) — the
|
||||
backend session computes total_net/total_gross/vat itself (InvoiceDraftCalculator) once
|
||||
opened from this payload, and BuildFdsData/BuildInvoiceParams reads them from there:
|
||||
- new.invoicetitle -> new.title, new.loc -> new.provisionlocation, admin.paymentterms ->
|
||||
new.paymentterm, admin.CustomerId -> admin.customerid.
|
||||
Originals are kept alongside; the source objects are not mutated. */
|
||||
$inv.invcPayload = function (d) {
|
||||
d = d || {};
|
||||
let sms = d.sms || {}, nw = $.extend({}, d.new), adm = $.extend({}, d.admin);
|
||||
nw.total_net = sms.ttn || 0;
|
||||
nw.total_gross = sms.ttb || 0;
|
||||
/* VAT (rate + amount) is taken by the backend straight from the posted sms.vat map
|
||||
(FdsInvoiceData.HighestVat), so no per-rate new.vat_* keys are needed here. */
|
||||
let nw = $.extend({}, d.new), adm = $.extend({}, d.admin);
|
||||
nw.title = (nw.invoicetitle != null ? nw.invoicetitle : (nw.title || ''));
|
||||
nw.provisionlocation = (nw.loc != null ? nw.loc : (nw.provisionlocation || ''));
|
||||
nw.paymentterm = (adm.paymentterms != null ? adm.paymentterms : (nw.paymentterm || ''));
|
||||
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
|
||||
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
|
||||
return { admin: adm, req: d.bai, new: nw };
|
||||
};
|
||||
/* Zwischenspeichern and preview now run against the backend-authoritative session
|
||||
(ADR 0006): no full-invoice re-upload — the cached draft is flushed / rendered by
|
||||
@@ -1447,20 +1495,11 @@ $inv.rReload = () => {
|
||||
$inv.cInv2({ id: s.search });
|
||||
} catch (e) { }
|
||||
};
|
||||
$inv.quantChange = function (i) {
|
||||
//console.debug({ t: this, i: i });
|
||||
let t = $(this), f = t.closest('form'), fi = {}, pf = (i) => parseFloat(i.toString().replace('%', '').replace(',', '.')), rtp = (num) => num.toFixed(2);
|
||||
f.find(':input').each((i, e) => { fi[$(e).attr('name')] = $(e); });
|
||||
let qv = parseInt(fi.quantityhours.val() || '0'), nv = pf(fi.net.val() || '0'), vat = pf(fi.vat.val()) * 0.01;
|
||||
if (qv > 0 && nv > 0) {
|
||||
fi.net_val.val(rtp(qv * nv));
|
||||
fi.vat_val.val(rtp(qv * nv * vat));
|
||||
if (['Service'].includes(fi.Type.val())) {
|
||||
fi.svcnet_val.val(rtp(qv * nv));
|
||||
fi.svcvat_val.val(rtp(qv * nv * vat));
|
||||
}
|
||||
}
|
||||
};
|
||||
/* No client-side arithmetic (ADR 0006/0008): quantity/price/VAT edits are posted as raw values
|
||||
and the server (InvoiceDraftCalculator.RecomputeLineValues) computes net_val/vat_val/
|
||||
svcnet_val/svcvat_val. The fields are left as entered here; the authoritative values are
|
||||
pushed back onto the row by $inv.d.applyItems once the change reaches the backend. */
|
||||
$inv.quantChange = function (i) { };
|
||||
$inv.storno = function (id, fds) {
|
||||
let o, fr = $$.dc('choicefrm').append([
|
||||
$$.dc('btn', 'Storno ohne Details').click({ id: id, mode: 'simple' }, (ev) => { o.c.trigger('modal_close'); $inv.cSt(ev.data); })
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
setm: 'Set-Preisanzeige',
|
||||
setmo: {
|
||||
setprice: 'Set mit Preis – Positionen ohne Preis',
|
||||
itemprices: 'Positionen mit Preis – Set als Überschrift',
|
||||
setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
|
||||
},
|
||||
ctp: 'Ansprechpartner festlegen',
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
combP: 'Positionen zusammenfassen',
|
||||
toSetP: 'Auf Setpreis umstellen (Positionen ohne Preis)',
|
||||
toSetPc: 'Der Set-Preis wird auf die Summe seiner Positionen gesetzt und die Positionen selbst verlieren ihren Preis. Das ist nicht rückgängig zu machen (der Betrag kann danach nur noch manuell geändert werden). Fortfahren?',
|
||||
toSetMc: 'Jeder Leistungsauftrag wird zu einer Set-Position mit der Summe als Preis zusammengefasst. Das ist nicht rückgängig zu machen (die Set-Preise können danach nur noch manuell geändert werden). Fortfahren?',
|
||||
iSum: 'Zwischensumme',
|
||||
dtRel: 'Freigegeben am: ',
|
||||
dtCr: 'Erstellt am: ',
|
||||
|
||||
@@ -577,7 +577,7 @@ $ocms.postXT = function (options) {
|
||||
if ((typeof options.contentType === 'boolean' ? options.contentType === false : false) === true) {
|
||||
ajo.contentType = false; /* needed for file uploads */
|
||||
}
|
||||
$.ajax(ajo);
|
||||
return $.ajax(ajo);
|
||||
};
|
||||
$ocms.cex_timer = function () {
|
||||
if (!$ocms.cexi) {
|
||||
|
||||
@@ -198,7 +198,15 @@ if (!Element.prototype.closest) {
|
||||
_onPress: function (e) {
|
||||
if (!e) { return; }
|
||||
function op(tgt) {
|
||||
if (tgt && tgt.parentNode === this._container && (this._dragHandleClass === '' || e.target.className.indexOf(this._dragHandleClass) > -1) && tgt.className.indexOf("nosort") < 0) {
|
||||
// Match the drag handle by real CSS class token, not substring. The old
|
||||
// e.target.className.indexOf('ico') matched every Bootstrap "glyph[ico]n" icon,
|
||||
// so clicking any icon button (edit/delete/set-price…) inside a sortable row
|
||||
// started a zero-distance drag that swallowed the button's own click. Using
|
||||
// closest('.<handle>') restricts dragging to the dedicated handle element (e.g.
|
||||
// the ".ico" move button) and its descendants only.
|
||||
var isHandle = this._dragHandleClass === '' ||
|
||||
(e.target.closest && e.target.closest('.' + this._dragHandleClass) != null);
|
||||
if (tgt && tgt.parentNode === this._container && isHandle && tgt.className.indexOf("nosort") < 0) {
|
||||
e.preventDefault();
|
||||
this._dragging = true;
|
||||
this._click = getPoint(e);
|
||||
|
||||
+201
-162
@@ -36,6 +36,7 @@ let $rct = {
|
||||
combP: 'Positionen zusammenfassen',
|
||||
toSetP: 'Auf Setpreis umstellen (Positionen ohne Preis)',
|
||||
toSetPc: 'Der Set-Preis wird auf die Summe seiner Positionen gesetzt und die Positionen selbst verlieren ihren Preis. Das ist nicht rückgängig zu machen (der Betrag kann danach nur noch manuell geändert werden). Fortfahren?',
|
||||
toSetMc: 'Jeder Leistungsauftrag wird zu einer Set-Position mit der Summe als Preis zusammengefasst. Das ist nicht rückgängig zu machen (die Set-Preise können danach nur noch manuell geändert werden). Fortfahren?',
|
||||
iSum: 'Zwischensumme',
|
||||
dtRel: 'Freigegeben am: ',
|
||||
dtCr: 'Erstellt am: ',
|
||||
@@ -151,7 +152,6 @@ let $ict = {
|
||||
setm: 'Set-Preisanzeige',
|
||||
setmo: {
|
||||
setprice: 'Set mit Preis – Positionen ohne Preis',
|
||||
itemprices: 'Positionen mit Preis – Set als Überschrift',
|
||||
setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
|
||||
},
|
||||
ctp: 'Ansprechpartner festlegen',
|
||||
@@ -673,10 +673,13 @@ $inv.d = {
|
||||
return h;
|
||||
},
|
||||
/* Seed the authoritative server session from the assembled editor payload. Called
|
||||
once from invSumUpdate on open; thereafter the backend is the source of truth. */
|
||||
once from invSumUpdate on open; thereafter the backend is the source of truth.
|
||||
The in-flight ajax promise is stashed on 'dseedpromise' so a delta sent (e.g. via
|
||||
a context-menu action) before seeding has actually completed can wait for the token
|
||||
instead of silently no-op'ing (see $inv.d.sync). */
|
||||
seed: function (payload) {
|
||||
let l = $inv.d.layout(); l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
let p = $ocms.postXT({
|
||||
url: $ocms.url('inv/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
|
||||
$inv.d.tbl().data('dtoken', r.token).data('dver', r.version).data('dhashes', $inv.d.hashes()).data('dorder', $inv.d.order());
|
||||
$fis.draft.bind(r.token, {
|
||||
@@ -685,8 +688,10 @@ $inv.d = {
|
||||
onClosed: (reason) => $inv.d.closed(reason)
|
||||
});
|
||||
$inv.d.refresh();
|
||||
}, error: () => { l.rC('freeze'); }, complete: () => { $inv.d.tbl().removeData('dseeding'); }
|
||||
}, error: () => { l.rC('freeze'); }, complete: () => { $inv.d.tbl().removeData('dseeding').removeData('dseedpromise'); }
|
||||
});
|
||||
$inv.d.tbl().data('dseedpromise', p);
|
||||
return p;
|
||||
},
|
||||
/* Re-fetch the authoritative state and render the totals footer + validation from it. */
|
||||
refresh: function (cb) {
|
||||
@@ -700,11 +705,21 @@ $inv.d = {
|
||||
},
|
||||
applyState: function (state) {
|
||||
let tbl = $inv.d.tbl(); if (tbl.length < 1) { return; }
|
||||
/* Concurrent refreshes (one per flushed block plus one for a follow-up delta such as
|
||||
item.setprice, and SignalR draftReady on top) can have their dstate responses arrive
|
||||
out of order. Applying a response older than the version already rendered would
|
||||
silently roll back a just-applied server-side mutation (e.g. a set-price conversion
|
||||
reverting to its pre-conversion 0/individually-priced state) — so stale responses are
|
||||
dropped here instead of rendered. */
|
||||
let curVer = tbl.data('dver');
|
||||
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
|
||||
return;
|
||||
}
|
||||
tbl.data('dver', state.version).data('serverSums', state.sums);
|
||||
$inv.d.footer(tbl, state.sums || {}, state.admin || {});
|
||||
$inv.d.notes(state.notes || []);
|
||||
$inv.d.validation(state.validation || []);
|
||||
$inv.d.applyPositions(tbl, state.req || []);
|
||||
$inv.d.applyItems(tbl, state.req || []);
|
||||
$inv.d.applySetDisplay(tbl, state.setDisplay || {});
|
||||
/* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode:
|
||||
hides it right after a mode switch, and restores it if a discard reverted the draft to
|
||||
@@ -724,19 +739,60 @@ $inv.d = {
|
||||
/* Push the server's authoritative position numbers back onto the rendered rows so the online
|
||||
editor and the PDF preview always agree (the server numbers priced lines continuously; the
|
||||
browser must not keep its own numbering). Only the position cell is touched — no re-render. */
|
||||
applyPositions: function (tbl, req) {
|
||||
(req || []).forEach((b) => (b && b.itm || []).forEach((co) => {
|
||||
if (!co || (co.id || '') === '') { return; }
|
||||
let cell = tbl.find('#itm' + co.id + ' td.keep').first();
|
||||
if (cell.length) { cell.text(co.p != null ? co.p : ''); }
|
||||
}));
|
||||
/* Reconciles each block's rendered rows against the authoritative server line list
|
||||
(`req[].itm`, the co shape). The server may not only change a line's values but also
|
||||
ADD lines (the block set row inserted by "Set mit Preis"/block.setprice) and REMOVE lines
|
||||
("Nur Set mit Preis"/block.setonly), and reorder them — the browser never computes any of
|
||||
that (ADR 0006/0008/0009), it just mirrors the server. For each server line: patch/insert
|
||||
the matching row, preserving null price fields verbatim (ADR 0009: null = empty cell, kept
|
||||
distinct from 0). Rows the server no longer lists are removed; order follows the server.
|
||||
Position numbers, net/VAT and the set-row emphasis all come from rrw off the mirrored data. */
|
||||
applyItems: function (tbl, req) {
|
||||
let val = (x) => (x === undefined ? null : x); // JSON null stays null; absent -> null
|
||||
(req || []).forEach((b) => {
|
||||
if (!b) { return; }
|
||||
let bid = (b.Id || '').toString(); if (bid === '') { return; }
|
||||
let bdy = tbl.children('tbody').filter((i, e) => (($(e).data() || {}).Id || '').toString() === bid).first();
|
||||
if (bdy.length < 1) { return; }
|
||||
let itms = b.itm || [], serverIds = {};
|
||||
let title = bdy.find('tr.title').first();
|
||||
let prev = title.length ? title : null;
|
||||
itms.forEach((co) => {
|
||||
let id = (co.id || '').toString(); if (id === '') { return; }
|
||||
serverIds[id] = true;
|
||||
let rw = bdy.find('#itm' + id).first(), isNew = rw.length < 1;
|
||||
if (isNew) { rw = $$.tr(bdy, { id: 'itm' + id, class: 'itm' }); }
|
||||
let dta = rw.data() || {};
|
||||
let changed = isNew || (dta.Type || '') !== (co.typ || '')
|
||||
|| dta.net !== val(co.v) || dta.net_val !== val(co.vt) || dta.vat_val !== val(co.vv)
|
||||
|| dta.svcnet_val !== val(co.vs) || dta.svcvat_val !== val(co.vsv)
|
||||
|| (dta.position || '') !== (co.p != null ? co.p : '');
|
||||
$.extend(dta, {
|
||||
Id: id, Type: co.typ,
|
||||
net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv),
|
||||
svcnet_val: val(co.vs), svcvat_val: val(co.vsv),
|
||||
vat: co.vat, position: (co.p != null ? co.p : ''),
|
||||
SetItmId: (co.SetItmId != null ? co.SetItmId : null)
|
||||
});
|
||||
if (isNew) { $.extend(dta, { htmltext: co.t, quantity: co.q }); } // text only for server-created rows (e.g. the set row)
|
||||
rw.data(dta);
|
||||
if (prev) { if (rw.prev()[0] !== prev[0]) { rw.insertAfter(prev); } } // keep server order, but don't churn already-ordered rows
|
||||
else if (bdy.children().first()[0] !== rw[0]) { bdy.prepend(rw); }
|
||||
prev = rw;
|
||||
if (changed) { $inv.rrw.call(rw); }
|
||||
});
|
||||
bdy.find('tr.itm').each((i, tr) => {
|
||||
let id = (($(tr).data() || {}).Id || '').toString();
|
||||
if (id !== '' && !serverIds[id]) { $(tr).remove(); } // dropped server-side (block.setonly)
|
||||
});
|
||||
});
|
||||
},
|
||||
/* Applies the backend-authoritative set-pricing display flags (see BuildSetDisplay /
|
||||
InvoiceSetPricing) onto the rendered rows: blanks the price/total cells for lines the
|
||||
server says should show no price (set members in SetPrice mode, the set header in
|
||||
ItemPrices mode), and emphasises the set header line — so the online editor always
|
||||
shows exactly what the PDF will print, without duplicating the pricing rules client-side.
|
||||
Rows not present in the map (no set in that block, or standalone items) are left untouched. */
|
||||
server says should show no price (set members in SetPrice/SetOnly mode), and emphasises
|
||||
the set header line — so the online editor always shows exactly what the PDF will print,
|
||||
without duplicating the pricing rules client-side. Rows not present in the map (no set in
|
||||
that block, or standalone items) are left untouched. */
|
||||
applySetDisplay: function (tbl, setDisplay) {
|
||||
tbl.find('tr.itm').each((i, tr) => {
|
||||
let rw = $(tr), dta = rw.data() || {}, id = (dta.Id || '').toString();
|
||||
@@ -748,11 +804,23 @@ $inv.d = {
|
||||
}
|
||||
});
|
||||
},
|
||||
/* Send one change to the server; the draftReady signal and this success both refresh. */
|
||||
/* Send one change to the server; the draftReady signal and this success both refresh.
|
||||
Returns the underlying ajax promise so callers that must not race a follow-up delta
|
||||
against this one (e.g. $inv.toSetPrice after a flush) can chain on it.
|
||||
If the initial seeding (inv/dopen) is still in flight (no token yet, but a
|
||||
'dseedpromise' is pending), the delta previously vanished silently — the user saw no
|
||||
network activity at all when clicking a context-menu action right after opening the
|
||||
editor. Now the delta waits for the seed to finish and retries against the freshly
|
||||
issued token instead of being dropped. */
|
||||
sync: function (delta) {
|
||||
let t = $inv.d.token(); if (t === '') { return; }
|
||||
let t = $inv.d.token();
|
||||
if (t === '') {
|
||||
let seeding = $inv.d.tbl().data('dseedpromise');
|
||||
if (seeding) { return $.when(seeding).then(() => $inv.d.sync(delta)); }
|
||||
return $.when();
|
||||
}
|
||||
$inv.d.layout().aC('freeze');
|
||||
$ocms.postXT({
|
||||
return $ocms.postXT({
|
||||
url: $ocms.url('inv/dpatch'), data: { token: t, delta: JSON.stringify(delta) },
|
||||
success: () => { $inv.d.refresh(); },
|
||||
error: (xhr) => { $inv.d.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } }
|
||||
@@ -763,22 +831,29 @@ $inv.d = {
|
||||
/* Diff the freshly-rebuilt blocks against the last-synced state and send only the
|
||||
changed/removed blocks as granular block.replace / block.remove deltas. A pure section
|
||||
reorder (same blocks, new sequence) changes no block hash, so it is sent separately as a
|
||||
block.order delta; the server reorders the cache, renumbers positions and pushes them back. */
|
||||
block.order delta; the server reorders the cache, renumbers positions and pushes them back.
|
||||
No early return on an empty token here: sync() itself waits for an in-flight seed
|
||||
(dseedpromise) instead of silently dropping the delta, so edits made in the brief window
|
||||
before seeding completes are not lost. */
|
||||
syncChanged: function (tbl) {
|
||||
if (($inv.d.token()) === '') { return; }
|
||||
let bai = tbl.data('bai') || [], prev = tbl.data('dhashes') || {}, next = {}, changed = [], removed = [];
|
||||
let bai = tbl.data('bai') || [], prev = tbl.data('dhashes') || {}, next = {}, changed = [], removed = [], pending = [];
|
||||
$.each(bai, (i, b) => { let id = (b.Id || '').toString(), h = JSON.stringify(b); next[id] = h; if (prev[id] !== h) { changed.push(b); } });
|
||||
$.each(prev, (id) => { if (next[id] === undefined) { removed.push(id); } });
|
||||
let order = $inv.d.order(), prevOrder = tbl.data('dorder') || [];
|
||||
tbl.data('dhashes', next).data('dorder', order);
|
||||
changed.forEach((b) => $inv.d.sync({ Target: 'block.replace', Ref: (b.Id || '').toString(), Value: b }));
|
||||
removed.forEach((id) => $inv.d.sync({ Target: 'block.remove', Ref: id }));
|
||||
changed.forEach((b) => pending.push($inv.d.sync({ Target: 'block.replace', Ref: (b.Id || '').toString(), Value: b })));
|
||||
removed.forEach((id) => pending.push($inv.d.sync({ Target: 'block.remove', Ref: id })));
|
||||
let sameSet = prevOrder.length === order.length && prevOrder.slice().sort().join(',') === order.slice().sort().join(',');
|
||||
if (sameSet && prevOrder.join(',') !== order.join(',')) { $inv.d.sync({ Target: 'block.order', Value: order }); }
|
||||
if (sameSet && prevOrder.join(',') !== order.join(',')) { pending.push($inv.d.sync({ Target: 'block.order', Value: order })); }
|
||||
/* $.when(...pending) so a caller (e.g. toSetPrice) can wait until every flushed block
|
||||
has actually reached the server before sending a further delta that depends on it —
|
||||
otherwise a delayed block.replace carrying pre-conversion values can land after and
|
||||
silently overwrite a just-applied server-side mutation (e.g. item.setprice). */
|
||||
return $.when.apply($, pending).promise();
|
||||
},
|
||||
/* Map an inline recipient field to its delta target and send it. */
|
||||
/* Map an inline recipient field to its delta target and send it. No token-guard here for the
|
||||
same reason as syncChanged: sync() waits for an in-flight seed instead of dropping the edit. */
|
||||
syncField: function (nme, val) {
|
||||
if ($inv.d.token() === '') { return; }
|
||||
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
|
||||
let target = map[nme]; if (!target) { return; }
|
||||
$inv.d.sync({ Target: target, Value: val });
|
||||
@@ -933,6 +1008,12 @@ $inv.rd = {
|
||||
},
|
||||
applyState: function (state) {
|
||||
let tbl = $inv.rd.tbl(); if (tbl.length < 1) { return; }
|
||||
/* See $inv.d.applyState — drop stale/out-of-order dstate responses so an older refresh
|
||||
can never roll back a newer one's totals/validation. */
|
||||
let curVer = tbl.data('rdver');
|
||||
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
|
||||
return;
|
||||
}
|
||||
tbl.data('rdver', state.version).data('serverSums', state.sums).data('remid', state.remid || '');
|
||||
$inv.rd.footer(tbl, state.sums || {});
|
||||
$inv.rd.notes(state.notes || {});
|
||||
@@ -1492,6 +1573,8 @@ $inv.eHtml = function (ev) {
|
||||
}
|
||||
$ocms.dlgform(flds, sets);
|
||||
};
|
||||
/* No client-side arithmetic (ADR 0006/0008): only the chosen VAT rate is stored on each row;
|
||||
the server (InvoiceDraftCalculator.RecomputeLineValues) recomputes vat_val/svcvat_val from it. */
|
||||
$inv.setVat = function (ev) {
|
||||
let t = $(this), thisrow = ev.data;
|
||||
let vat = prompt($rct.rqV);
|
||||
@@ -1504,12 +1587,6 @@ $inv.setVat = function (ev) {
|
||||
thisrow.siblings('.itm').each(function () {
|
||||
let rwi = $(this), dta = rwi.data();
|
||||
dta.vat = fnum(vat, { style: 'percent' }).replace(' ', '');
|
||||
if ((dta.net_val || 0) > 0) {
|
||||
dta.vat_val = dta.net_val * vat;
|
||||
}
|
||||
if ((dta.svcnet_val || 0) > 0) {
|
||||
dta.svcvat_val = dta.svcnet_val * vat;
|
||||
}
|
||||
});
|
||||
$inv.t_fds_inv();
|
||||
}
|
||||
@@ -1569,12 +1646,14 @@ $inv.rrw = function () {
|
||||
if (ph === false) {
|
||||
bc.unshift($$.dc('ibtn edit', { title: $rct.cP }).append(gi('pencil')).click(rw, $inv.eRow));
|
||||
bc.push($$.dc('ibtn del', { title: $rct.dR }).append(gi('trash')).click(function (e) { if (confirm($rct.cD)) { rw.remove(); $inv.t_fds_inv(); } }));
|
||||
/* "Auf Setpreis umstellen": only offered on a Set header item that is not yet
|
||||
priced from its members (fds__prepInvoice's [SetItmId] is null exactly for a
|
||||
Set header — either already set-priced, or not yet converted). Irreversible:
|
||||
sets this row's price to the sum of its members (rows whose [SetItmId] equals
|
||||
this row's id) and clears each member's price. */
|
||||
if ((dta.Type || '').toLowerCase() === 'set' && (dta.SetItmId || '') === '') {
|
||||
/* "Auf Setpreis umstellen": offered on a Set header item that does not yet carry a
|
||||
price. fds__prepInvoice's [SetItmId] now anchors on the still-unconverted (price 0)
|
||||
header, so the header row's own SetItmId self-references its own id rather than being
|
||||
null — the real signal for showing this button is still the header's own price/net_val
|
||||
being 0 (once converted it carries a nonzero price and the button disappears).
|
||||
Irreversible: the actual sum/clear mutation happens server-side (backend-authoritative,
|
||||
see InvoiceDraftEditService.ApplyItemSetPrice) via the "item.setprice" patch target. */
|
||||
if ((dta.Type || '').toLowerCase() === 'set' && (dta.net_val || 0) === 0) {
|
||||
bc.push($$.dc('ibtn tosetp', { title: $rct.toSetP }).append(gi('scale')).click(rw, $inv.toSetPrice));
|
||||
}
|
||||
}
|
||||
@@ -1584,7 +1663,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 };
|
||||
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 };
|
||||
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
|
||||
|
||||
co.p = dta.position || (dta.SortOrder || '');
|
||||
@@ -1594,7 +1673,7 @@ $inv.rrw = function () {
|
||||
co.t = dta.htmltext || (((dta.NameOrNumber || '').substr(0, 1) !== '#' ? oHtml($$[0]('p').text(dta.NameOrNumber)) : '') + (dta.Note || ''));
|
||||
} else {
|
||||
co.tt = co.det ? '' : $$.s(dta.Note || '').text();
|
||||
co.q = dta.quantity || (fnum(dta.quantityhours) + ' ' + (dta.UnitString || ''));
|
||||
co.q = dta.quantity || ((dta.quantityhours || 0) !== 0 ? (fnum(dta.quantityhours) + ' ' + (dta.UnitString || '')) : ''); /* guard: no quantityhours -> blank, not fnum(undefined)="NaN" (e.g. the block set row) */
|
||||
co.t = dta.htmltext ||(co.det ? (oHtml($$.s(dta.NameOrNumber || '')) + oHtml($$.dc('desc').html(dta.Note))) : oHtml($$.s(dta.NameOrNumber || '')));
|
||||
co.v = dta.net
|
||||
co.vt = dta.net_val
|
||||
@@ -1613,75 +1692,62 @@ $inv.rrw = function () {
|
||||
tda.push($$.td(rw, { colspan: 4 }).append(co.t));
|
||||
} else {
|
||||
Array.prototype.push.apply(tda, co.q ? [$$.tdc('keep').text(co.q)] : []);
|
||||
/* ADR 0009: a null/undefined price renders an EMPTY cell (block-mode "Set mit Preis"
|
||||
members, and the set row's own unit-price), distinct from a real 0,00 €. */
|
||||
let cur = (x) => (x == null ? '' : fnum(x, $rct.cst));
|
||||
Array.prototype.push.apply(tda, [
|
||||
$$.tdc('txt', { colspan: !co.q ? 2 : 1, title: co.tt }).append(co.t),
|
||||
$$.tdc('currency').text(fnum(co.v, $rct.cst)),
|
||||
$$.tdc('currency inetval').text(fnum(co.vt, $rct.cst)).attr('title', $rct.svcPart + ': ' + fnum(co.vs, $rct.cst))
|
||||
$$.tdc('currency').text(cur(co.v)),
|
||||
$$.tdc('currency inetval').text(cur(co.vt)).attr('title', $rct.svcPart + ': ' + cur(co.vs))
|
||||
]);
|
||||
}
|
||||
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ).tC('hidenote', hn).append(tda);
|
||||
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
|
||||
.tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows (fn1 header + block set row) */
|
||||
.tC('hidenote', hn).append(tda);
|
||||
dta.co = co;
|
||||
};
|
||||
/* "Auf Setpreis umstellen" (see $inv.rrw): sets this Set-header row's price to the sum of
|
||||
its member rows' totals (rows in the same block whose [SetItmId] equals this row's id,
|
||||
the authoritative grouping from fds__prepInvoice), then clears each member's price so the
|
||||
member items show without a price — matching the SetPrice presentation, but as an actual,
|
||||
irreversible data change instead of a display-mode toggle (INVOICE_SET_PRICING.md's
|
||||
setmode remains display-only and is unaffected by this). */
|
||||
/* "Auf Setpreis umstellen" (see $inv.rrw): backend-authoritative (ADR 0006) — the browser only
|
||||
posts the target set-header item id; the server (InvoiceDraftEditService.ApplyItemSetPrice)
|
||||
sums the member rows (same block, [SetItmId] equal to this row's id), writes that sum onto
|
||||
the set header and clears each member's price, then the normal draftReady refresh re-renders
|
||||
both the header and its members from the authoritative session state. This mirrors the PDF's
|
||||
read of the same cached session, so the online editor and PDF can never drift apart. */
|
||||
$inv.toSetPrice = function (ev) {
|
||||
let rw = ev.data, dta = rw.data(), id = (dta.Id || '').toString();
|
||||
if (id === '') { return; }
|
||||
if (confirm($rct.toSetPc) === false) { return; }
|
||||
let bdy = rw.closest('tbody'), members = bdy.find('tr.itm').filter(function () {
|
||||
return (($(this).data('SetItmId') || '').toString()) === id;
|
||||
});
|
||||
let sum_net_val = 0, sum_vat_val = 0, sum_svcnet_val = 0, sum_svcvat_val = 0;
|
||||
members.each(function () {
|
||||
let m = $(this).data();
|
||||
sum_net_val += (m.net_val || 0);
|
||||
sum_vat_val += (m.vat_val || 0);
|
||||
sum_svcnet_val += (m.svcnet_val || 0);
|
||||
sum_svcvat_val += (m.svcvat_val || 0);
|
||||
$.extend(m, { net: 0, net_val: 0, vat_val: 0, svcnet_val: 0, svcvat_val: 0, Discount: 0 });
|
||||
$inv.rrw.call($(this));
|
||||
});
|
||||
$.extend(dta, {
|
||||
net: sum_net_val, quantityhours: 1, Discount: 0,
|
||||
net_val: sum_net_val, vat_val: sum_vat_val, svcnet_val: sum_svcnet_val, svcvat_val: sum_svcvat_val
|
||||
});
|
||||
$inv.rrw.call(rw);
|
||||
$inv.t_fds_inv();
|
||||
/* Wait for the flush of any pending local edits to actually reach the server before sending
|
||||
the conversion delta — sending both in parallel let a delayed block.replace (still carrying
|
||||
the pre-conversion, zero member/header values) land after item.setprice and silently
|
||||
overwrite the just-converted totals back to 0. */
|
||||
$.when($inv.t_fds_inv()).done(() => { $inv.d.sync({ Target: 'item.setprice', Ref: id }); });
|
||||
};
|
||||
/* No client-side totals/footer/tax computation (ADR 0006/0008): this only re-assembles each
|
||||
block's row contract array (needed to post the "req" shape to the server) and, on the very
|
||||
first pass, seeds the authoritative backend session. Footer, VAT breakdown, service-refund
|
||||
note figures and the per-block sum cell are never computed here — they are rendered exclusively
|
||||
from the server's `dstate` response by $inv.d.footer / $inv.d.applyState once the draft has a
|
||||
token. Before a token exists the footer/notes/isum cells are simply left empty; there is no
|
||||
local approximation to display. */
|
||||
$inv.invSumUpdate = function () {
|
||||
let tbl = $(this), ft = tbl.children('tfoot').empty(), p13b = bool((tbl.data().admin || {}).p13b || '', false);
|
||||
tbl.nextAll('.fnote').remove();
|
||||
let sms = { ttn: 0, ttb: 0, ttvat: 0, tscn: 0, tscvat: 0, vat: {}, itmnet: {} }, ba = [];
|
||||
let rwcy = (lbl, val, cls) => $$.tdc('currency', $$.tr(ft, { class: cls || 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text(lbl)]), fnum(val, $rct.cst)), fn = (t) => $$.dc('fnote').insertAfter(tbl).rwText(t);
|
||||
let csms = function (rrx, sms, sid) {
|
||||
sms.tscn += (rrx.svcnet_val || 0);
|
||||
sms.tscvat += (rrx.svcvat_val || 0);
|
||||
sms.ttn += (rrx.net_val || 0);
|
||||
sms.ttvat += (rrx.vat_val || 0);
|
||||
sms.ttb += ((rrx.net_val || 0) + (rrx.vat_val || 0));
|
||||
if ((rrx.vat || '') !== '') {
|
||||
sms.vat[rrx.vat] = (sms.vat[rrx.vat] || 0) + (rrx.vat_val || 0);
|
||||
}
|
||||
//sms.itmnet[sid] = (sms.itmnet[sid] || 0) + (rrx.net_val || 0);
|
||||
};
|
||||
let tbl = $(this);
|
||||
let ba = [];
|
||||
let bds = tbl.children('tbody');
|
||||
bds.each((bi, bdy) => {
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], cset = null, bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
b.tC('empty', itm.length < 1);
|
||||
itm.each((ti, tx) => {
|
||||
|
||||
let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co);
|
||||
//console.debug('rrx %o', rrx);
|
||||
let rrx = $(tx).data() || {};
|
||||
i.push(rrx.co);
|
||||
/* backend item contract (title/desc/qty/price_net/total_net + set flags), see InvoiceSetPricing.
|
||||
Set grouping: an item of Type 'set' is a header that claims the following items in this
|
||||
block as its members until the next set header (mfr__items has no explicit member link). */
|
||||
Set grouping: fds__prepInvoice computes [SetItmId] per item, anchored on the still-unconverted
|
||||
(price 0) Set header that owns it — this is the authoritative membership signal (not row order),
|
||||
so items not tagged with a SetItmId by the server are never swept into a preceding set. The
|
||||
header row's own SetItmId self-references its own id, but it is never its own member (excluded
|
||||
by the id !== '' && !== citem.id check below). */
|
||||
let citem = $inv.itemToContract(rrx);
|
||||
if (citem.type === 'set' && citem.id !== '') { cset = citem.id; }
|
||||
else if (cset !== null && (citem.id || '') !== '') { citem.setId = cset; }
|
||||
let sid = (rrx.SetItmId || '').toString();
|
||||
if (citem.type !== 'set' && sid !== '' && sid !== citem.id) { citem.setId = sid; }
|
||||
citems.push(citem);
|
||||
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
|
||||
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
|
||||
@@ -1690,41 +1756,10 @@ $inv.invSumUpdate = function () {
|
||||
$inv.rrw.call(tx);
|
||||
}
|
||||
});
|
||||
//console.debug('%o', {
|
||||
// f: b.find('tr.isum > td.isumval'), t: fnum(bnet, $rct.cst), n: bnet
|
||||
//});
|
||||
b.find('tr.isum > td.isumval').text(fnum(bnet, $rct.cst));
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems, netval: bnet });
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems });
|
||||
});
|
||||
let nonempty = tbl.find('tbody:not(.empty)').length;
|
||||
bds.find('tr.isum').tC('hidden', nonempty < 2);
|
||||
//let fnet = $$.tdc('currency', $$.tr(ft, { class: 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text('Netto')]), fnum(sms.ttn, $rct.cst));
|
||||
rwcy('Netto', sms.ttn);
|
||||
if (p13b === false) {
|
||||
$.each(sms.vat, (vi, vx) => {
|
||||
//$$.tdc('currency vat', $$.tr(ft, { class: 'tvat' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text('Umsatzsteuer ' + vi)]), fnum(vx, $rct.cst));
|
||||
rwcy($rct.vat + ' ' + vi, vx, 'tvat');
|
||||
});
|
||||
} else {
|
||||
sms.ttb = sms.ttn;
|
||||
}
|
||||
//let fsum = $$.tdc('currency', $$.tr(ft, { class: 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text('Summe')]), fnum(sms.ttb, $rct.cst));
|
||||
rwcy('Summe', sms.ttb);
|
||||
let itype = tbl.data().admin.type;
|
||||
if (itype === 'i') {
|
||||
fn($rct.note2);
|
||||
fn($rct.note4);
|
||||
} else if (itype === 'c') {
|
||||
fn($rct.note2);
|
||||
} else {
|
||||
fn(string($rct.note3, [fnum((sms.tscn + sms.tscvat) * (tbl.data().admin.tax_servicerefund || 0), $rct.cst)])).aC('ntax');
|
||||
fn($rct.note2);
|
||||
fn(string($rct.note1, [fnum(sms.tscn + sms.tscvat, $rct.cst), fnum(sms.tscn, $rct.cst), fnum(sms.tscvat, $rct.cst)]));
|
||||
}
|
||||
if (p13b === true) {
|
||||
fn($rct.note13b);
|
||||
}
|
||||
tbl.data('sms', sms);
|
||||
tbl.data('bai', ba);
|
||||
/* Backend-authoritative seeding (ADR 0006): on the first calculation of an invoice
|
||||
draft (invSumUpdate is only bound for invoices, never reminders), hand the assembled
|
||||
@@ -1824,8 +1859,16 @@ $inv.t_fds_inv = () => {
|
||||
let tbl = $('div.invoice_layout table.invi');
|
||||
tbl.trigger('fds.inv');
|
||||
/* After any local item mutation, push the changed block(s) to the authoritative
|
||||
server session as granular deltas (invoice drafts only — reminders have no token). */
|
||||
if ((tbl.data('dtoken') || '') !== '') { $inv.d.syncChanged(tbl); }
|
||||
server session as granular deltas (invoice drafts only — reminders have no token,
|
||||
nor a 'dseedpromise', so they correctly fall through to the no-op). While the initial
|
||||
seed (inv/dopen) is still in flight there is no token yet either, but a 'dseedpromise'
|
||||
is pending — wait for it instead of skipping the flush, otherwise an edit made in that
|
||||
brief window is silently never sent. Returns the flush promise so callers that must
|
||||
sequence a follow-up server delta after this flush (e.g. $inv.toSetPrice) can wait for
|
||||
it instead of racing it. */
|
||||
if ((tbl.data('dtoken') || '') !== '') { return $inv.d.syncChanged(tbl); }
|
||||
let seeding = tbl.data('dseedpromise');
|
||||
return seeding ? $.when(seeding).then(() => $inv.d.syncChanged(tbl)) : $.when();
|
||||
};
|
||||
$inv.sedit = () => {
|
||||
$inv.sprev(true);
|
||||
@@ -1892,7 +1935,10 @@ $inv.itemToContract = function (rrx) {
|
||||
rrx = rrx || {};
|
||||
let oHtml = (e) => $$.d().append(e).html();
|
||||
let type = (rrx.Type || '').toString().toLowerCase();
|
||||
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: (rrx.net_val || 0), vat: rrx.vat || '' };
|
||||
/* ADR 0009: preserve null prices verbatim (null = empty cell, kept distinct from a real 0) so
|
||||
block-mode nulled members and the set row round-trip unchanged when the block is re-posted. */
|
||||
let nz = (x) => (x == null ? null : x);
|
||||
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: nz(rrx.net_val), vat: rrx.vat || '' };
|
||||
if (rrx.co && rrx.co.typ === 'osum') {
|
||||
/* combined single-sum line — the on-screen "title" is an HTML sub-table; render it as desc */
|
||||
ci.desc = rrx.co.t || '';
|
||||
@@ -1913,30 +1959,35 @@ $inv.itemToContract = function (rrx) {
|
||||
ci.desc = rrx.Note || '';
|
||||
}
|
||||
ci.qty = rrx.quantity || ((rrx.quantityhours || 0) !== 0 ? (fnum(rrx.quantityhours) + (rrx.UnitString ? ' ' + rrx.UnitString : '')) : '');
|
||||
ci.price_net = (rrx.net || 0);
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
ci.price_net = nz(rrx.net);
|
||||
ci.total_net = nz(rrx.net_val);
|
||||
}
|
||||
return ci;
|
||||
};
|
||||
/* 3-way set-pricing display switch. Mirrors §13b: writes the choice onto admin.setmode,
|
||||
which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token the PDF reads. */
|
||||
/* 2-way set-pricing display switch (SetPrice / SetOnly). Mirrors §13b: writes the choice onto
|
||||
admin.setmode, which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token
|
||||
the PDF reads. ItemPrices ("Positionen mit Preis – Set als Überschrift") was removed: it was
|
||||
always just the implicit pre-conversion default and is not a state the user can switch back to
|
||||
once a set has been converted via the "Auf Setpreis umstellen" item switch (see $inv.toSetPrice /
|
||||
InvoiceDraftEditService.ApplyItemSetPrice) — that conversion is one-way. */
|
||||
$inv.ssetmode = () => {
|
||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
||||
d.admin = d.admin || {};
|
||||
let cur = (d.admin.setmode || 'setprice'), o;
|
||||
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
|
||||
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('itemprices'), btn('setonly')]);
|
||||
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('setonly')]);
|
||||
o = $ocms.dlg(fr, { width: 800 });
|
||||
};
|
||||
/* "Set mit Preis" / "Nur Set mit Preis" (ADR 0009): NOT a display toggle — an irreversible,
|
||||
backend-authoritative mutation grouped by service-request block. The server inserts a dedicated
|
||||
set row per block (block sum) and either nulls each member's price (setprice) or removes the
|
||||
members (setonly); the normal draftReady refresh re-renders both via $inv.d.applyItems. The
|
||||
browser only posts the chosen mode. Flush pending local edits first so a delayed block.replace
|
||||
can't land after the conversion and overwrite it (same race guard as $inv.toSetPrice). */
|
||||
$inv.setSetmode = (mode) => {
|
||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
||||
d.admin = d.admin || {};
|
||||
d.admin.setmode = mode; /* posted in admin -> BuildInvoiceParams writes setmode: into InvoiceOptions */
|
||||
d.inv = d.inv || {}; /* keep a local InvoiceOptions reflection in sync (cosmetic) */
|
||||
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
|
||||
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
|
||||
d.inv.InvoiceOptions = opts.join(',');
|
||||
$inv.d.sync({ Target: 'setmode', Value: mode });
|
||||
if (confirm($rct.toSetMc || $rct.toSetPc) === false) { return; }
|
||||
let target = (mode === 'setonly') ? 'block.setonly' : 'block.setprice';
|
||||
$.when($inv.t_fds_inv()).done(() => { $inv.d.sync({ Target: target }); });
|
||||
};
|
||||
$inv.sctp = () => {
|
||||
let flds = $invcol.ctp;
|
||||
@@ -1959,24 +2010,21 @@ $inv.sctp = () => {
|
||||
});
|
||||
};
|
||||
/* Normalises the editor's working model into the exact field names the C# backend
|
||||
(FdsInvoiceData.BuildInvoiceParams) reads, then returns the `invc` payload:
|
||||
- balances/service sums come from `sms` (ttn/ttb), exposed on `new` as total_net/total_gross;
|
||||
- every VAT rate's net amount is exposed as new.vat_<rate>_net (the backend reads the highest);
|
||||
(FdsInvoiceData.BuildInvoiceParams) reads, then returns the `invc` payload. No totals/VAT
|
||||
are computed here (ADR 0006/0008: the online editor performs no arithmetic at all) — the
|
||||
backend session computes total_net/total_gross/vat itself (InvoiceDraftCalculator) once
|
||||
opened from this payload, and BuildFdsData/BuildInvoiceParams reads them from there:
|
||||
- new.invoicetitle -> new.title, new.loc -> new.provisionlocation, admin.paymentterms ->
|
||||
new.paymentterm, admin.CustomerId -> admin.customerid.
|
||||
Originals are kept alongside; the source objects are not mutated. */
|
||||
$inv.invcPayload = function (d) {
|
||||
d = d || {};
|
||||
let sms = d.sms || {}, nw = $.extend({}, d.new), adm = $.extend({}, d.admin);
|
||||
nw.total_net = sms.ttn || 0;
|
||||
nw.total_gross = sms.ttb || 0;
|
||||
/* VAT (rate + amount) is taken by the backend straight from the posted sms.vat map
|
||||
(FdsInvoiceData.HighestVat), so no per-rate new.vat_* keys are needed here. */
|
||||
let nw = $.extend({}, d.new), adm = $.extend({}, d.admin);
|
||||
nw.title = (nw.invoicetitle != null ? nw.invoicetitle : (nw.title || ''));
|
||||
nw.provisionlocation = (nw.loc != null ? nw.loc : (nw.provisionlocation || ''));
|
||||
nw.paymentterm = (adm.paymentterms != null ? adm.paymentterms : (nw.paymentterm || ''));
|
||||
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
|
||||
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
|
||||
return { admin: adm, req: d.bai, new: nw };
|
||||
};
|
||||
/* Zwischenspeichern and preview now run against the backend-authoritative session
|
||||
(ADR 0006): no full-invoice re-upload — the cached draft is flushed / rendered by
|
||||
@@ -1990,20 +2038,11 @@ $inv.rReload = () => {
|
||||
$inv.cInv2({ id: s.search });
|
||||
} catch (e) { }
|
||||
};
|
||||
$inv.quantChange = function (i) {
|
||||
//console.debug({ t: this, i: i });
|
||||
let t = $(this), f = t.closest('form'), fi = {}, pf = (i) => parseFloat(i.toString().replace('%', '').replace(',', '.')), rtp = (num) => num.toFixed(2);
|
||||
f.find(':input').each((i, e) => { fi[$(e).attr('name')] = $(e); });
|
||||
let qv = parseInt(fi.quantityhours.val() || '0'), nv = pf(fi.net.val() || '0'), vat = pf(fi.vat.val()) * 0.01;
|
||||
if (qv > 0 && nv > 0) {
|
||||
fi.net_val.val(rtp(qv * nv));
|
||||
fi.vat_val.val(rtp(qv * nv * vat));
|
||||
if (['Service'].includes(fi.Type.val())) {
|
||||
fi.svcnet_val.val(rtp(qv * nv));
|
||||
fi.svcvat_val.val(rtp(qv * nv * vat));
|
||||
}
|
||||
}
|
||||
};
|
||||
/* No client-side arithmetic (ADR 0006/0008): quantity/price/VAT edits are posted as raw values
|
||||
and the server (InvoiceDraftCalculator.RecomputeLineValues) computes net_val/vat_val/
|
||||
svcnet_val/svcvat_val. The fields are left as entered here; the authoritative values are
|
||||
pushed back onto the row by $inv.d.applyItems once the change reaches the backend. */
|
||||
$inv.quantChange = function (i) { };
|
||||
$inv.storno = function (id, fds) {
|
||||
let o, fr = $$.dc('choicefrm').append([
|
||||
$$.dc('btn', 'Storno ohne Details').click({ id: id, mode: 'simple' }, (ev) => { o.c.trigger('modal_close'); $inv.cSt(ev.data); })
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -814,7 +814,7 @@ $ocms.postXT = function (options) {
|
||||
if ((typeof options.contentType === 'boolean' ? options.contentType === false : false) === true) {
|
||||
ajo.contentType = false; /* needed for file uploads */
|
||||
}
|
||||
$.ajax(ajo);
|
||||
return $.ajax(ajo);
|
||||
};
|
||||
$ocms.cex_timer = function () {
|
||||
if (!$ocms.cexi) {
|
||||
@@ -2670,7 +2670,15 @@ if (!Element.prototype.closest) {
|
||||
_onPress: function (e) {
|
||||
if (!e) { return; }
|
||||
function op(tgt) {
|
||||
if (tgt && tgt.parentNode === this._container && (this._dragHandleClass === '' || e.target.className.indexOf(this._dragHandleClass) > -1) && tgt.className.indexOf("nosort") < 0) {
|
||||
// Match the drag handle by real CSS class token, not substring. The old
|
||||
// e.target.className.indexOf('ico') matched every Bootstrap "glyph[ico]n" icon,
|
||||
// so clicking any icon button (edit/delete/set-price…) inside a sortable row
|
||||
// started a zero-distance drag that swallowed the button's own click. Using
|
||||
// closest('.<handle>') restricts dragging to the dedicated handle element (e.g.
|
||||
// the ".ico" move button) and its descendants only.
|
||||
var isHandle = this._dragHandleClass === '' ||
|
||||
(e.target.closest && e.target.closest('.' + this._dragHandleClass) != null);
|
||||
if (tgt && tgt.parentNode === this._container && isHandle && tgt.className.indexOf("nosort") < 0) {
|
||||
e.preventDefault();
|
||||
this._dragging = true;
|
||||
this._click = getPoint(e);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+201
-162
@@ -36,6 +36,7 @@ let $rct = {
|
||||
combP: 'Positionen zusammenfassen',
|
||||
toSetP: 'Auf Setpreis umstellen (Positionen ohne Preis)',
|
||||
toSetPc: 'Der Set-Preis wird auf die Summe seiner Positionen gesetzt und die Positionen selbst verlieren ihren Preis. Das ist nicht rückgängig zu machen (der Betrag kann danach nur noch manuell geändert werden). Fortfahren?',
|
||||
toSetMc: 'Jeder Leistungsauftrag wird zu einer Set-Position mit der Summe als Preis zusammengefasst. Das ist nicht rückgängig zu machen (die Set-Preise können danach nur noch manuell geändert werden). Fortfahren?',
|
||||
iSum: 'Zwischensumme',
|
||||
dtRel: 'Freigegeben am: ',
|
||||
dtCr: 'Erstellt am: ',
|
||||
@@ -151,7 +152,6 @@ let $ict = {
|
||||
setm: 'Set-Preisanzeige',
|
||||
setmo: {
|
||||
setprice: 'Set mit Preis – Positionen ohne Preis',
|
||||
itemprices: 'Positionen mit Preis – Set als Überschrift',
|
||||
setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
|
||||
},
|
||||
ctp: 'Ansprechpartner festlegen',
|
||||
@@ -654,10 +654,13 @@ $inv.d = {
|
||||
return h;
|
||||
},
|
||||
/* Seed the authoritative server session from the assembled editor payload. Called
|
||||
once from invSumUpdate on open; thereafter the backend is the source of truth. */
|
||||
once from invSumUpdate on open; thereafter the backend is the source of truth.
|
||||
The in-flight ajax promise is stashed on 'dseedpromise' so a delta sent (e.g. via
|
||||
a context-menu action) before seeding has actually completed can wait for the token
|
||||
instead of silently no-op'ing (see $inv.d.sync). */
|
||||
seed: function (payload) {
|
||||
let l = $inv.d.layout(); l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
let p = $ocms.postXT({
|
||||
url: $ocms.url('inv/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
|
||||
$inv.d.tbl().data('dtoken', r.token).data('dver', r.version).data('dhashes', $inv.d.hashes()).data('dorder', $inv.d.order());
|
||||
$fis.draft.bind(r.token, {
|
||||
@@ -666,8 +669,10 @@ $inv.d = {
|
||||
onClosed: (reason) => $inv.d.closed(reason)
|
||||
});
|
||||
$inv.d.refresh();
|
||||
}, error: () => { l.rC('freeze'); }, complete: () => { $inv.d.tbl().removeData('dseeding'); }
|
||||
}, error: () => { l.rC('freeze'); }, complete: () => { $inv.d.tbl().removeData('dseeding').removeData('dseedpromise'); }
|
||||
});
|
||||
$inv.d.tbl().data('dseedpromise', p);
|
||||
return p;
|
||||
},
|
||||
/* Re-fetch the authoritative state and render the totals footer + validation from it. */
|
||||
refresh: function (cb) {
|
||||
@@ -681,11 +686,21 @@ $inv.d = {
|
||||
},
|
||||
applyState: function (state) {
|
||||
let tbl = $inv.d.tbl(); if (tbl.length < 1) { return; }
|
||||
/* Concurrent refreshes (one per flushed block plus one for a follow-up delta such as
|
||||
item.setprice, and SignalR draftReady on top) can have their dstate responses arrive
|
||||
out of order. Applying a response older than the version already rendered would
|
||||
silently roll back a just-applied server-side mutation (e.g. a set-price conversion
|
||||
reverting to its pre-conversion 0/individually-priced state) — so stale responses are
|
||||
dropped here instead of rendered. */
|
||||
let curVer = tbl.data('dver');
|
||||
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
|
||||
return;
|
||||
}
|
||||
tbl.data('dver', state.version).data('serverSums', state.sums);
|
||||
$inv.d.footer(tbl, state.sums || {}, state.admin || {});
|
||||
$inv.d.notes(state.notes || []);
|
||||
$inv.d.validation(state.validation || []);
|
||||
$inv.d.applyPositions(tbl, state.req || []);
|
||||
$inv.d.applyItems(tbl, state.req || []);
|
||||
$inv.d.applySetDisplay(tbl, state.setDisplay || {});
|
||||
/* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode:
|
||||
hides it right after a mode switch, and restores it if a discard reverted the draft to
|
||||
@@ -705,19 +720,60 @@ $inv.d = {
|
||||
/* Push the server's authoritative position numbers back onto the rendered rows so the online
|
||||
editor and the PDF preview always agree (the server numbers priced lines continuously; the
|
||||
browser must not keep its own numbering). Only the position cell is touched — no re-render. */
|
||||
applyPositions: function (tbl, req) {
|
||||
(req || []).forEach((b) => (b && b.itm || []).forEach((co) => {
|
||||
if (!co || (co.id || '') === '') { return; }
|
||||
let cell = tbl.find('#itm' + co.id + ' td.keep').first();
|
||||
if (cell.length) { cell.text(co.p != null ? co.p : ''); }
|
||||
}));
|
||||
/* Reconciles each block's rendered rows against the authoritative server line list
|
||||
(`req[].itm`, the co shape). The server may not only change a line's values but also
|
||||
ADD lines (the block set row inserted by "Set mit Preis"/block.setprice) and REMOVE lines
|
||||
("Nur Set mit Preis"/block.setonly), and reorder them — the browser never computes any of
|
||||
that (ADR 0006/0008/0009), it just mirrors the server. For each server line: patch/insert
|
||||
the matching row, preserving null price fields verbatim (ADR 0009: null = empty cell, kept
|
||||
distinct from 0). Rows the server no longer lists are removed; order follows the server.
|
||||
Position numbers, net/VAT and the set-row emphasis all come from rrw off the mirrored data. */
|
||||
applyItems: function (tbl, req) {
|
||||
let val = (x) => (x === undefined ? null : x); // JSON null stays null; absent -> null
|
||||
(req || []).forEach((b) => {
|
||||
if (!b) { return; }
|
||||
let bid = (b.Id || '').toString(); if (bid === '') { return; }
|
||||
let bdy = tbl.children('tbody').filter((i, e) => (($(e).data() || {}).Id || '').toString() === bid).first();
|
||||
if (bdy.length < 1) { return; }
|
||||
let itms = b.itm || [], serverIds = {};
|
||||
let title = bdy.find('tr.title').first();
|
||||
let prev = title.length ? title : null;
|
||||
itms.forEach((co) => {
|
||||
let id = (co.id || '').toString(); if (id === '') { return; }
|
||||
serverIds[id] = true;
|
||||
let rw = bdy.find('#itm' + id).first(), isNew = rw.length < 1;
|
||||
if (isNew) { rw = $$.tr(bdy, { id: 'itm' + id, class: 'itm' }); }
|
||||
let dta = rw.data() || {};
|
||||
let changed = isNew || (dta.Type || '') !== (co.typ || '')
|
||||
|| dta.net !== val(co.v) || dta.net_val !== val(co.vt) || dta.vat_val !== val(co.vv)
|
||||
|| dta.svcnet_val !== val(co.vs) || dta.svcvat_val !== val(co.vsv)
|
||||
|| (dta.position || '') !== (co.p != null ? co.p : '');
|
||||
$.extend(dta, {
|
||||
Id: id, Type: co.typ,
|
||||
net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv),
|
||||
svcnet_val: val(co.vs), svcvat_val: val(co.vsv),
|
||||
vat: co.vat, position: (co.p != null ? co.p : ''),
|
||||
SetItmId: (co.SetItmId != null ? co.SetItmId : null)
|
||||
});
|
||||
if (isNew) { $.extend(dta, { htmltext: co.t, quantity: co.q }); } // text only for server-created rows (e.g. the set row)
|
||||
rw.data(dta);
|
||||
if (prev) { if (rw.prev()[0] !== prev[0]) { rw.insertAfter(prev); } } // keep server order, but don't churn already-ordered rows
|
||||
else if (bdy.children().first()[0] !== rw[0]) { bdy.prepend(rw); }
|
||||
prev = rw;
|
||||
if (changed) { $inv.rrw.call(rw); }
|
||||
});
|
||||
bdy.find('tr.itm').each((i, tr) => {
|
||||
let id = (($(tr).data() || {}).Id || '').toString();
|
||||
if (id !== '' && !serverIds[id]) { $(tr).remove(); } // dropped server-side (block.setonly)
|
||||
});
|
||||
});
|
||||
},
|
||||
/* Applies the backend-authoritative set-pricing display flags (see BuildSetDisplay /
|
||||
InvoiceSetPricing) onto the rendered rows: blanks the price/total cells for lines the
|
||||
server says should show no price (set members in SetPrice mode, the set header in
|
||||
ItemPrices mode), and emphasises the set header line — so the online editor always
|
||||
shows exactly what the PDF will print, without duplicating the pricing rules client-side.
|
||||
Rows not present in the map (no set in that block, or standalone items) are left untouched. */
|
||||
server says should show no price (set members in SetPrice/SetOnly mode), and emphasises
|
||||
the set header line — so the online editor always shows exactly what the PDF will print,
|
||||
without duplicating the pricing rules client-side. Rows not present in the map (no set in
|
||||
that block, or standalone items) are left untouched. */
|
||||
applySetDisplay: function (tbl, setDisplay) {
|
||||
tbl.find('tr.itm').each((i, tr) => {
|
||||
let rw = $(tr), dta = rw.data() || {}, id = (dta.Id || '').toString();
|
||||
@@ -729,11 +785,23 @@ $inv.d = {
|
||||
}
|
||||
});
|
||||
},
|
||||
/* Send one change to the server; the draftReady signal and this success both refresh. */
|
||||
/* Send one change to the server; the draftReady signal and this success both refresh.
|
||||
Returns the underlying ajax promise so callers that must not race a follow-up delta
|
||||
against this one (e.g. $inv.toSetPrice after a flush) can chain on it.
|
||||
If the initial seeding (inv/dopen) is still in flight (no token yet, but a
|
||||
'dseedpromise' is pending), the delta previously vanished silently — the user saw no
|
||||
network activity at all when clicking a context-menu action right after opening the
|
||||
editor. Now the delta waits for the seed to finish and retries against the freshly
|
||||
issued token instead of being dropped. */
|
||||
sync: function (delta) {
|
||||
let t = $inv.d.token(); if (t === '') { return; }
|
||||
let t = $inv.d.token();
|
||||
if (t === '') {
|
||||
let seeding = $inv.d.tbl().data('dseedpromise');
|
||||
if (seeding) { return $.when(seeding).then(() => $inv.d.sync(delta)); }
|
||||
return $.when();
|
||||
}
|
||||
$inv.d.layout().aC('freeze');
|
||||
$ocms.postXT({
|
||||
return $ocms.postXT({
|
||||
url: $ocms.url('inv/dpatch'), data: { token: t, delta: JSON.stringify(delta) },
|
||||
success: () => { $inv.d.refresh(); },
|
||||
error: (xhr) => { $inv.d.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } }
|
||||
@@ -744,22 +812,29 @@ $inv.d = {
|
||||
/* Diff the freshly-rebuilt blocks against the last-synced state and send only the
|
||||
changed/removed blocks as granular block.replace / block.remove deltas. A pure section
|
||||
reorder (same blocks, new sequence) changes no block hash, so it is sent separately as a
|
||||
block.order delta; the server reorders the cache, renumbers positions and pushes them back. */
|
||||
block.order delta; the server reorders the cache, renumbers positions and pushes them back.
|
||||
No early return on an empty token here: sync() itself waits for an in-flight seed
|
||||
(dseedpromise) instead of silently dropping the delta, so edits made in the brief window
|
||||
before seeding completes are not lost. */
|
||||
syncChanged: function (tbl) {
|
||||
if (($inv.d.token()) === '') { return; }
|
||||
let bai = tbl.data('bai') || [], prev = tbl.data('dhashes') || {}, next = {}, changed = [], removed = [];
|
||||
let bai = tbl.data('bai') || [], prev = tbl.data('dhashes') || {}, next = {}, changed = [], removed = [], pending = [];
|
||||
$.each(bai, (i, b) => { let id = (b.Id || '').toString(), h = JSON.stringify(b); next[id] = h; if (prev[id] !== h) { changed.push(b); } });
|
||||
$.each(prev, (id) => { if (next[id] === undefined) { removed.push(id); } });
|
||||
let order = $inv.d.order(), prevOrder = tbl.data('dorder') || [];
|
||||
tbl.data('dhashes', next).data('dorder', order);
|
||||
changed.forEach((b) => $inv.d.sync({ Target: 'block.replace', Ref: (b.Id || '').toString(), Value: b }));
|
||||
removed.forEach((id) => $inv.d.sync({ Target: 'block.remove', Ref: id }));
|
||||
changed.forEach((b) => pending.push($inv.d.sync({ Target: 'block.replace', Ref: (b.Id || '').toString(), Value: b })));
|
||||
removed.forEach((id) => pending.push($inv.d.sync({ Target: 'block.remove', Ref: id })));
|
||||
let sameSet = prevOrder.length === order.length && prevOrder.slice().sort().join(',') === order.slice().sort().join(',');
|
||||
if (sameSet && prevOrder.join(',') !== order.join(',')) { $inv.d.sync({ Target: 'block.order', Value: order }); }
|
||||
if (sameSet && prevOrder.join(',') !== order.join(',')) { pending.push($inv.d.sync({ Target: 'block.order', Value: order })); }
|
||||
/* $.when(...pending) so a caller (e.g. toSetPrice) can wait until every flushed block
|
||||
has actually reached the server before sending a further delta that depends on it —
|
||||
otherwise a delayed block.replace carrying pre-conversion values can land after and
|
||||
silently overwrite a just-applied server-side mutation (e.g. item.setprice). */
|
||||
return $.when.apply($, pending).promise();
|
||||
},
|
||||
/* Map an inline recipient field to its delta target and send it. */
|
||||
/* Map an inline recipient field to its delta target and send it. No token-guard here for the
|
||||
same reason as syncChanged: sync() waits for an in-flight seed instead of dropping the edit. */
|
||||
syncField: function (nme, val) {
|
||||
if ($inv.d.token() === '') { return; }
|
||||
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
|
||||
let target = map[nme]; if (!target) { return; }
|
||||
$inv.d.sync({ Target: target, Value: val });
|
||||
@@ -914,6 +989,12 @@ $inv.rd = {
|
||||
},
|
||||
applyState: function (state) {
|
||||
let tbl = $inv.rd.tbl(); if (tbl.length < 1) { return; }
|
||||
/* See $inv.d.applyState — drop stale/out-of-order dstate responses so an older refresh
|
||||
can never roll back a newer one's totals/validation. */
|
||||
let curVer = tbl.data('rdver');
|
||||
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
|
||||
return;
|
||||
}
|
||||
tbl.data('rdver', state.version).data('serverSums', state.sums).data('remid', state.remid || '');
|
||||
$inv.rd.footer(tbl, state.sums || {});
|
||||
$inv.rd.notes(state.notes || {});
|
||||
@@ -1473,6 +1554,8 @@ $inv.eHtml = function (ev) {
|
||||
}
|
||||
$ocms.dlgform(flds, sets);
|
||||
};
|
||||
/* No client-side arithmetic (ADR 0006/0008): only the chosen VAT rate is stored on each row;
|
||||
the server (InvoiceDraftCalculator.RecomputeLineValues) recomputes vat_val/svcvat_val from it. */
|
||||
$inv.setVat = function (ev) {
|
||||
let t = $(this), thisrow = ev.data;
|
||||
let vat = prompt($rct.rqV);
|
||||
@@ -1485,12 +1568,6 @@ $inv.setVat = function (ev) {
|
||||
thisrow.siblings('.itm').each(function () {
|
||||
let rwi = $(this), dta = rwi.data();
|
||||
dta.vat = fnum(vat, { style: 'percent' }).replace(' ', '');
|
||||
if ((dta.net_val || 0) > 0) {
|
||||
dta.vat_val = dta.net_val * vat;
|
||||
}
|
||||
if ((dta.svcnet_val || 0) > 0) {
|
||||
dta.svcvat_val = dta.svcnet_val * vat;
|
||||
}
|
||||
});
|
||||
$inv.t_fds_inv();
|
||||
}
|
||||
@@ -1550,12 +1627,14 @@ $inv.rrw = function () {
|
||||
if (ph === false) {
|
||||
bc.unshift($$.dc('ibtn edit', { title: $rct.cP }).append(gi('pencil')).click(rw, $inv.eRow));
|
||||
bc.push($$.dc('ibtn del', { title: $rct.dR }).append(gi('trash')).click(function (e) { if (confirm($rct.cD)) { rw.remove(); $inv.t_fds_inv(); } }));
|
||||
/* "Auf Setpreis umstellen": only offered on a Set header item that is not yet
|
||||
priced from its members (fds__prepInvoice's [SetItmId] is null exactly for a
|
||||
Set header — either already set-priced, or not yet converted). Irreversible:
|
||||
sets this row's price to the sum of its members (rows whose [SetItmId] equals
|
||||
this row's id) and clears each member's price. */
|
||||
if ((dta.Type || '').toLowerCase() === 'set' && (dta.SetItmId || '') === '') {
|
||||
/* "Auf Setpreis umstellen": offered on a Set header item that does not yet carry a
|
||||
price. fds__prepInvoice's [SetItmId] now anchors on the still-unconverted (price 0)
|
||||
header, so the header row's own SetItmId self-references its own id rather than being
|
||||
null — the real signal for showing this button is still the header's own price/net_val
|
||||
being 0 (once converted it carries a nonzero price and the button disappears).
|
||||
Irreversible: the actual sum/clear mutation happens server-side (backend-authoritative,
|
||||
see InvoiceDraftEditService.ApplyItemSetPrice) via the "item.setprice" patch target. */
|
||||
if ((dta.Type || '').toLowerCase() === 'set' && (dta.net_val || 0) === 0) {
|
||||
bc.push($$.dc('ibtn tosetp', { title: $rct.toSetP }).append(gi('scale')).click(rw, $inv.toSetPrice));
|
||||
}
|
||||
}
|
||||
@@ -1565,7 +1644,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 };
|
||||
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 };
|
||||
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
|
||||
|
||||
co.p = dta.position || (dta.SortOrder || '');
|
||||
@@ -1575,7 +1654,7 @@ $inv.rrw = function () {
|
||||
co.t = dta.htmltext || (((dta.NameOrNumber || '').substr(0, 1) !== '#' ? oHtml($$[0]('p').text(dta.NameOrNumber)) : '') + (dta.Note || ''));
|
||||
} else {
|
||||
co.tt = co.det ? '' : $$.s(dta.Note || '').text();
|
||||
co.q = dta.quantity || (fnum(dta.quantityhours) + ' ' + (dta.UnitString || ''));
|
||||
co.q = dta.quantity || ((dta.quantityhours || 0) !== 0 ? (fnum(dta.quantityhours) + ' ' + (dta.UnitString || '')) : ''); /* guard: no quantityhours -> blank, not fnum(undefined)="NaN" (e.g. the block set row) */
|
||||
co.t = dta.htmltext ||(co.det ? (oHtml($$.s(dta.NameOrNumber || '')) + oHtml($$.dc('desc').html(dta.Note))) : oHtml($$.s(dta.NameOrNumber || '')));
|
||||
co.v = dta.net
|
||||
co.vt = dta.net_val
|
||||
@@ -1594,75 +1673,62 @@ $inv.rrw = function () {
|
||||
tda.push($$.td(rw, { colspan: 4 }).append(co.t));
|
||||
} else {
|
||||
Array.prototype.push.apply(tda, co.q ? [$$.tdc('keep').text(co.q)] : []);
|
||||
/* ADR 0009: a null/undefined price renders an EMPTY cell (block-mode "Set mit Preis"
|
||||
members, and the set row's own unit-price), distinct from a real 0,00 €. */
|
||||
let cur = (x) => (x == null ? '' : fnum(x, $rct.cst));
|
||||
Array.prototype.push.apply(tda, [
|
||||
$$.tdc('txt', { colspan: !co.q ? 2 : 1, title: co.tt }).append(co.t),
|
||||
$$.tdc('currency').text(fnum(co.v, $rct.cst)),
|
||||
$$.tdc('currency inetval').text(fnum(co.vt, $rct.cst)).attr('title', $rct.svcPart + ': ' + fnum(co.vs, $rct.cst))
|
||||
$$.tdc('currency').text(cur(co.v)),
|
||||
$$.tdc('currency inetval').text(cur(co.vt)).attr('title', $rct.svcPart + ': ' + cur(co.vs))
|
||||
]);
|
||||
}
|
||||
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ).tC('hidenote', hn).append(tda);
|
||||
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
|
||||
.tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows (fn1 header + block set row) */
|
||||
.tC('hidenote', hn).append(tda);
|
||||
dta.co = co;
|
||||
};
|
||||
/* "Auf Setpreis umstellen" (see $inv.rrw): sets this Set-header row's price to the sum of
|
||||
its member rows' totals (rows in the same block whose [SetItmId] equals this row's id,
|
||||
the authoritative grouping from fds__prepInvoice), then clears each member's price so the
|
||||
member items show without a price — matching the SetPrice presentation, but as an actual,
|
||||
irreversible data change instead of a display-mode toggle (INVOICE_SET_PRICING.md's
|
||||
setmode remains display-only and is unaffected by this). */
|
||||
/* "Auf Setpreis umstellen" (see $inv.rrw): backend-authoritative (ADR 0006) — the browser only
|
||||
posts the target set-header item id; the server (InvoiceDraftEditService.ApplyItemSetPrice)
|
||||
sums the member rows (same block, [SetItmId] equal to this row's id), writes that sum onto
|
||||
the set header and clears each member's price, then the normal draftReady refresh re-renders
|
||||
both the header and its members from the authoritative session state. This mirrors the PDF's
|
||||
read of the same cached session, so the online editor and PDF can never drift apart. */
|
||||
$inv.toSetPrice = function (ev) {
|
||||
let rw = ev.data, dta = rw.data(), id = (dta.Id || '').toString();
|
||||
if (id === '') { return; }
|
||||
if (confirm($rct.toSetPc) === false) { return; }
|
||||
let bdy = rw.closest('tbody'), members = bdy.find('tr.itm').filter(function () {
|
||||
return (($(this).data('SetItmId') || '').toString()) === id;
|
||||
});
|
||||
let sum_net_val = 0, sum_vat_val = 0, sum_svcnet_val = 0, sum_svcvat_val = 0;
|
||||
members.each(function () {
|
||||
let m = $(this).data();
|
||||
sum_net_val += (m.net_val || 0);
|
||||
sum_vat_val += (m.vat_val || 0);
|
||||
sum_svcnet_val += (m.svcnet_val || 0);
|
||||
sum_svcvat_val += (m.svcvat_val || 0);
|
||||
$.extend(m, { net: 0, net_val: 0, vat_val: 0, svcnet_val: 0, svcvat_val: 0, Discount: 0 });
|
||||
$inv.rrw.call($(this));
|
||||
});
|
||||
$.extend(dta, {
|
||||
net: sum_net_val, quantityhours: 1, Discount: 0,
|
||||
net_val: sum_net_val, vat_val: sum_vat_val, svcnet_val: sum_svcnet_val, svcvat_val: sum_svcvat_val
|
||||
});
|
||||
$inv.rrw.call(rw);
|
||||
$inv.t_fds_inv();
|
||||
/* Wait for the flush of any pending local edits to actually reach the server before sending
|
||||
the conversion delta — sending both in parallel let a delayed block.replace (still carrying
|
||||
the pre-conversion, zero member/header values) land after item.setprice and silently
|
||||
overwrite the just-converted totals back to 0. */
|
||||
$.when($inv.t_fds_inv()).done(() => { $inv.d.sync({ Target: 'item.setprice', Ref: id }); });
|
||||
};
|
||||
/* No client-side totals/footer/tax computation (ADR 0006/0008): this only re-assembles each
|
||||
block's row contract array (needed to post the "req" shape to the server) and, on the very
|
||||
first pass, seeds the authoritative backend session. Footer, VAT breakdown, service-refund
|
||||
note figures and the per-block sum cell are never computed here — they are rendered exclusively
|
||||
from the server's `dstate` response by $inv.d.footer / $inv.d.applyState once the draft has a
|
||||
token. Before a token exists the footer/notes/isum cells are simply left empty; there is no
|
||||
local approximation to display. */
|
||||
$inv.invSumUpdate = function () {
|
||||
let tbl = $(this), ft = tbl.children('tfoot').empty(), p13b = bool((tbl.data().admin || {}).p13b || '', false);
|
||||
tbl.nextAll('.fnote').remove();
|
||||
let sms = { ttn: 0, ttb: 0, ttvat: 0, tscn: 0, tscvat: 0, vat: {}, itmnet: {} }, ba = [];
|
||||
let rwcy = (lbl, val, cls) => $$.tdc('currency', $$.tr(ft, { class: cls || 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text(lbl)]), fnum(val, $rct.cst)), fn = (t) => $$.dc('fnote').insertAfter(tbl).rwText(t);
|
||||
let csms = function (rrx, sms, sid) {
|
||||
sms.tscn += (rrx.svcnet_val || 0);
|
||||
sms.tscvat += (rrx.svcvat_val || 0);
|
||||
sms.ttn += (rrx.net_val || 0);
|
||||
sms.ttvat += (rrx.vat_val || 0);
|
||||
sms.ttb += ((rrx.net_val || 0) + (rrx.vat_val || 0));
|
||||
if ((rrx.vat || '') !== '') {
|
||||
sms.vat[rrx.vat] = (sms.vat[rrx.vat] || 0) + (rrx.vat_val || 0);
|
||||
}
|
||||
//sms.itmnet[sid] = (sms.itmnet[sid] || 0) + (rrx.net_val || 0);
|
||||
};
|
||||
let tbl = $(this);
|
||||
let ba = [];
|
||||
let bds = tbl.children('tbody');
|
||||
bds.each((bi, bdy) => {
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], cset = null, bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
b.tC('empty', itm.length < 1);
|
||||
itm.each((ti, tx) => {
|
||||
|
||||
let rrx = $(tx).data() || {}; csms(rrx, sms, rx.Id); bnet += (rrx.net_val || 0); i.push(rrx.co);
|
||||
//console.debug('rrx %o', rrx);
|
||||
let rrx = $(tx).data() || {};
|
||||
i.push(rrx.co);
|
||||
/* backend item contract (title/desc/qty/price_net/total_net + set flags), see InvoiceSetPricing.
|
||||
Set grouping: an item of Type 'set' is a header that claims the following items in this
|
||||
block as its members until the next set header (mfr__items has no explicit member link). */
|
||||
Set grouping: fds__prepInvoice computes [SetItmId] per item, anchored on the still-unconverted
|
||||
(price 0) Set header that owns it — this is the authoritative membership signal (not row order),
|
||||
so items not tagged with a SetItmId by the server are never swept into a preceding set. The
|
||||
header row's own SetItmId self-references its own id, but it is never its own member (excluded
|
||||
by the id !== '' && !== citem.id check below). */
|
||||
let citem = $inv.itemToContract(rrx);
|
||||
if (citem.type === 'set' && citem.id !== '') { cset = citem.id; }
|
||||
else if (cset !== null && (citem.id || '') !== '') { citem.setId = cset; }
|
||||
let sid = (rrx.SetItmId || '').toString();
|
||||
if (citem.type !== 'set' && sid !== '' && sid !== citem.id) { citem.setId = sid; }
|
||||
citems.push(citem);
|
||||
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
|
||||
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
|
||||
@@ -1671,41 +1737,10 @@ $inv.invSumUpdate = function () {
|
||||
$inv.rrw.call(tx);
|
||||
}
|
||||
});
|
||||
//console.debug('%o', {
|
||||
// f: b.find('tr.isum > td.isumval'), t: fnum(bnet, $rct.cst), n: bnet
|
||||
//});
|
||||
b.find('tr.isum > td.isumval').text(fnum(bnet, $rct.cst));
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems, netval: bnet });
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems });
|
||||
});
|
||||
let nonempty = tbl.find('tbody:not(.empty)').length;
|
||||
bds.find('tr.isum').tC('hidden', nonempty < 2);
|
||||
//let fnet = $$.tdc('currency', $$.tr(ft, { class: 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text('Netto')]), fnum(sms.ttn, $rct.cst));
|
||||
rwcy('Netto', sms.ttn);
|
||||
if (p13b === false) {
|
||||
$.each(sms.vat, (vi, vx) => {
|
||||
//$$.tdc('currency vat', $$.tr(ft, { class: 'tvat' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text('Umsatzsteuer ' + vi)]), fnum(vx, $rct.cst));
|
||||
rwcy($rct.vat + ' ' + vi, vx, 'tvat');
|
||||
});
|
||||
} else {
|
||||
sms.ttb = sms.ttn;
|
||||
}
|
||||
//let fsum = $$.tdc('currency', $$.tr(ft, { class: 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text('Summe')]), fnum(sms.ttb, $rct.cst));
|
||||
rwcy('Summe', sms.ttb);
|
||||
let itype = tbl.data().admin.type;
|
||||
if (itype === 'i') {
|
||||
fn($rct.note2);
|
||||
fn($rct.note4);
|
||||
} else if (itype === 'c') {
|
||||
fn($rct.note2);
|
||||
} else {
|
||||
fn(string($rct.note3, [fnum((sms.tscn + sms.tscvat) * (tbl.data().admin.tax_servicerefund || 0), $rct.cst)])).aC('ntax');
|
||||
fn($rct.note2);
|
||||
fn(string($rct.note1, [fnum(sms.tscn + sms.tscvat, $rct.cst), fnum(sms.tscn, $rct.cst), fnum(sms.tscvat, $rct.cst)]));
|
||||
}
|
||||
if (p13b === true) {
|
||||
fn($rct.note13b);
|
||||
}
|
||||
tbl.data('sms', sms);
|
||||
tbl.data('bai', ba);
|
||||
/* Backend-authoritative seeding (ADR 0006): on the first calculation of an invoice
|
||||
draft (invSumUpdate is only bound for invoices, never reminders), hand the assembled
|
||||
@@ -1805,8 +1840,16 @@ $inv.t_fds_inv = () => {
|
||||
let tbl = $('div.invoice_layout table.invi');
|
||||
tbl.trigger('fds.inv');
|
||||
/* After any local item mutation, push the changed block(s) to the authoritative
|
||||
server session as granular deltas (invoice drafts only — reminders have no token). */
|
||||
if ((tbl.data('dtoken') || '') !== '') { $inv.d.syncChanged(tbl); }
|
||||
server session as granular deltas (invoice drafts only — reminders have no token,
|
||||
nor a 'dseedpromise', so they correctly fall through to the no-op). While the initial
|
||||
seed (inv/dopen) is still in flight there is no token yet either, but a 'dseedpromise'
|
||||
is pending — wait for it instead of skipping the flush, otherwise an edit made in that
|
||||
brief window is silently never sent. Returns the flush promise so callers that must
|
||||
sequence a follow-up server delta after this flush (e.g. $inv.toSetPrice) can wait for
|
||||
it instead of racing it. */
|
||||
if ((tbl.data('dtoken') || '') !== '') { return $inv.d.syncChanged(tbl); }
|
||||
let seeding = tbl.data('dseedpromise');
|
||||
return seeding ? $.when(seeding).then(() => $inv.d.syncChanged(tbl)) : $.when();
|
||||
};
|
||||
$inv.sedit = () => {
|
||||
$inv.sprev(true);
|
||||
@@ -1873,7 +1916,10 @@ $inv.itemToContract = function (rrx) {
|
||||
rrx = rrx || {};
|
||||
let oHtml = (e) => $$.d().append(e).html();
|
||||
let type = (rrx.Type || '').toString().toLowerCase();
|
||||
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: (rrx.net_val || 0), vat: rrx.vat || '' };
|
||||
/* ADR 0009: preserve null prices verbatim (null = empty cell, kept distinct from a real 0) so
|
||||
block-mode nulled members and the set row round-trip unchanged when the block is re-posted. */
|
||||
let nz = (x) => (x == null ? null : x);
|
||||
let ci = { id: (rrx.Id || '').toString(), type: type, title: '', desc: '', qty: '', price_net: '', total_net: nz(rrx.net_val), vat: rrx.vat || '' };
|
||||
if (rrx.co && rrx.co.typ === 'osum') {
|
||||
/* combined single-sum line — the on-screen "title" is an HTML sub-table; render it as desc */
|
||||
ci.desc = rrx.co.t || '';
|
||||
@@ -1894,30 +1940,35 @@ $inv.itemToContract = function (rrx) {
|
||||
ci.desc = rrx.Note || '';
|
||||
}
|
||||
ci.qty = rrx.quantity || ((rrx.quantityhours || 0) !== 0 ? (fnum(rrx.quantityhours) + (rrx.UnitString ? ' ' + rrx.UnitString : '')) : '');
|
||||
ci.price_net = (rrx.net || 0);
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
ci.price_net = nz(rrx.net);
|
||||
ci.total_net = nz(rrx.net_val);
|
||||
}
|
||||
return ci;
|
||||
};
|
||||
/* 3-way set-pricing display switch. Mirrors §13b: writes the choice onto admin.setmode,
|
||||
which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token the PDF reads. */
|
||||
/* 2-way set-pricing display switch (SetPrice / SetOnly). Mirrors §13b: writes the choice onto
|
||||
admin.setmode, which BuildInvoiceParams turns into the "setmode:<mode>" InvoiceOptions token
|
||||
the PDF reads. ItemPrices ("Positionen mit Preis – Set als Überschrift") was removed: it was
|
||||
always just the implicit pre-conversion default and is not a state the user can switch back to
|
||||
once a set has been converted via the "Auf Setpreis umstellen" item switch (see $inv.toSetPrice /
|
||||
InvoiceDraftEditService.ApplyItemSetPrice) — that conversion is one-way. */
|
||||
$inv.ssetmode = () => {
|
||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
||||
d.admin = d.admin || {};
|
||||
let cur = (d.admin.setmode || 'setprice'), o;
|
||||
let btn = (mode) => $$.dc('btn', $ict.setmo[mode]).tC('selected', cur === mode).click(() => { o.c.trigger('modal_close'); $inv.setSetmode(mode); });
|
||||
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('itemprices'), btn('setonly')]);
|
||||
let fr = $$.dc('choicefrm').append([btn('setprice'), btn('setonly')]);
|
||||
o = $ocms.dlg(fr, { width: 800 });
|
||||
};
|
||||
/* "Set mit Preis" / "Nur Set mit Preis" (ADR 0009): NOT a display toggle — an irreversible,
|
||||
backend-authoritative mutation grouped by service-request block. The server inserts a dedicated
|
||||
set row per block (block sum) and either nulls each member's price (setprice) or removes the
|
||||
members (setonly); the normal draftReady refresh re-renders both via $inv.d.applyItems. The
|
||||
browser only posts the chosen mode. Flush pending local edits first so a delayed block.replace
|
||||
can't land after the conversion and overwrite it (same race guard as $inv.toSetPrice). */
|
||||
$inv.setSetmode = (mode) => {
|
||||
let l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
|
||||
d.admin = d.admin || {};
|
||||
d.admin.setmode = mode; /* posted in admin -> BuildInvoiceParams writes setmode: into InvoiceOptions */
|
||||
d.inv = d.inv || {}; /* keep a local InvoiceOptions reflection in sync (cosmetic) */
|
||||
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
|
||||
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
|
||||
d.inv.InvoiceOptions = opts.join(',');
|
||||
$inv.d.sync({ Target: 'setmode', Value: mode });
|
||||
if (confirm($rct.toSetMc || $rct.toSetPc) === false) { return; }
|
||||
let target = (mode === 'setonly') ? 'block.setonly' : 'block.setprice';
|
||||
$.when($inv.t_fds_inv()).done(() => { $inv.d.sync({ Target: target }); });
|
||||
};
|
||||
$inv.sctp = () => {
|
||||
let flds = $invcol.ctp;
|
||||
@@ -1940,24 +1991,21 @@ $inv.sctp = () => {
|
||||
});
|
||||
};
|
||||
/* Normalises the editor's working model into the exact field names the C# backend
|
||||
(FdsInvoiceData.BuildInvoiceParams) reads, then returns the `invc` payload:
|
||||
- balances/service sums come from `sms` (ttn/ttb), exposed on `new` as total_net/total_gross;
|
||||
- every VAT rate's net amount is exposed as new.vat_<rate>_net (the backend reads the highest);
|
||||
(FdsInvoiceData.BuildInvoiceParams) reads, then returns the `invc` payload. No totals/VAT
|
||||
are computed here (ADR 0006/0008: the online editor performs no arithmetic at all) — the
|
||||
backend session computes total_net/total_gross/vat itself (InvoiceDraftCalculator) once
|
||||
opened from this payload, and BuildFdsData/BuildInvoiceParams reads them from there:
|
||||
- new.invoicetitle -> new.title, new.loc -> new.provisionlocation, admin.paymentterms ->
|
||||
new.paymentterm, admin.CustomerId -> admin.customerid.
|
||||
Originals are kept alongside; the source objects are not mutated. */
|
||||
$inv.invcPayload = function (d) {
|
||||
d = d || {};
|
||||
let sms = d.sms || {}, nw = $.extend({}, d.new), adm = $.extend({}, d.admin);
|
||||
nw.total_net = sms.ttn || 0;
|
||||
nw.total_gross = sms.ttb || 0;
|
||||
/* VAT (rate + amount) is taken by the backend straight from the posted sms.vat map
|
||||
(FdsInvoiceData.HighestVat), so no per-rate new.vat_* keys are needed here. */
|
||||
let nw = $.extend({}, d.new), adm = $.extend({}, d.admin);
|
||||
nw.title = (nw.invoicetitle != null ? nw.invoicetitle : (nw.title || ''));
|
||||
nw.provisionlocation = (nw.loc != null ? nw.loc : (nw.provisionlocation || ''));
|
||||
nw.paymentterm = (adm.paymentterms != null ? adm.paymentterms : (nw.paymentterm || ''));
|
||||
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
|
||||
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
|
||||
return { admin: adm, req: d.bai, new: nw };
|
||||
};
|
||||
/* Zwischenspeichern and preview now run against the backend-authoritative session
|
||||
(ADR 0006): no full-invoice re-upload — the cached draft is flushed / rendered by
|
||||
@@ -1971,20 +2019,11 @@ $inv.rReload = () => {
|
||||
$inv.cInv2({ id: s.search });
|
||||
} catch (e) { }
|
||||
};
|
||||
$inv.quantChange = function (i) {
|
||||
//console.debug({ t: this, i: i });
|
||||
let t = $(this), f = t.closest('form'), fi = {}, pf = (i) => parseFloat(i.toString().replace('%', '').replace(',', '.')), rtp = (num) => num.toFixed(2);
|
||||
f.find(':input').each((i, e) => { fi[$(e).attr('name')] = $(e); });
|
||||
let qv = parseInt(fi.quantityhours.val() || '0'), nv = pf(fi.net.val() || '0'), vat = pf(fi.vat.val()) * 0.01;
|
||||
if (qv > 0 && nv > 0) {
|
||||
fi.net_val.val(rtp(qv * nv));
|
||||
fi.vat_val.val(rtp(qv * nv * vat));
|
||||
if (['Service'].includes(fi.Type.val())) {
|
||||
fi.svcnet_val.val(rtp(qv * nv));
|
||||
fi.svcvat_val.val(rtp(qv * nv * vat));
|
||||
}
|
||||
}
|
||||
};
|
||||
/* No client-side arithmetic (ADR 0006/0008): quantity/price/VAT edits are posted as raw values
|
||||
and the server (InvoiceDraftCalculator.RecomputeLineValues) computes net_val/vat_val/
|
||||
svcnet_val/svcvat_val. The fields are left as entered here; the authoritative values are
|
||||
pushed back onto the row by $inv.d.applyItems once the change reaches the backend. */
|
||||
$inv.quantChange = function (i) { };
|
||||
$inv.storno = function (id, fds) {
|
||||
let o, fr = $$.dc('choicefrm').append([
|
||||
$$.dc('btn', 'Storno ohne Details').click({ id: id, mode: 'simple' }, (ev) => { o.c.trigger('modal_close'); $inv.cSt(ev.data); })
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -631,7 +631,7 @@ $ocms.postXT = function (options) {
|
||||
if ((typeof options.contentType === 'boolean' ? options.contentType === false : false) === true) {
|
||||
ajo.contentType = false; /* needed for file uploads */
|
||||
}
|
||||
$.ajax(ajo);
|
||||
return $.ajax(ajo);
|
||||
};
|
||||
$ocms.cex_timer = function () {
|
||||
if (!$ocms.cexi) {
|
||||
|
||||
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