Invoice set pricing: wire editor + align editor/backend contract
Front-end (fis.inv_shared.js / fis.inv_txt_de.js, rebuilt bundles): - 3-way set display switch (setprice/itemprices/setonly) via admin.setmode, emitted into InvoiceOptions by FdsInvoiceData.BuildInvoiceOptions. - Each request block now posts items[] in the backend contract shape (title/desc/qty/price_net/total_net + set type/setId tags) via itemToContract. - invcPayload normalises the editor model to the field names BuildInvoiceParams reads (sms totals -> new.total_net/total_gross, invoicetitle->title, loc->provisionlocation, admin.paymentterms->new.paymentterm, CustomerId->customerid). Back-end: - BuildInvoiceOptions adds the setmode token alongside §13b. - VAT rate+amount now taken from sms.vat (HighestVat) instead of the broken items 'is List<object>' detection that pinned the rate to 19. - InvoiceSetPricing blanks price/total cells for text/title heading lines. Tests: set-pricing text-line blanking, HighestVat selection/parsing, updated the VAT param test to the sms.vat contract. 180 passing. Note: the second VAT slot (InvoiceVAT_2) stays unused by design; mixed-rate invoices store only the highest rate (pre-existing, accepted). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -42,11 +42,11 @@ public class FdsDataTests
|
||||
[Fact]
|
||||
public void FdsInvoiceData_BuildInvoiceParams_PicksHighestVatAndMapsFields()
|
||||
{
|
||||
// two line items with different VAT rates → highest (19) selected
|
||||
// VAT comes from the sms.vat map (rate → amount); highest rate (19) selected.
|
||||
var jo = JObject.Parse(
|
||||
"{ \"admin\": { \"type\": \"R\", \"customerid\": \"7\", \"p13b\": false }, " +
|
||||
" \"new\": { \"title\": \"Bad\", \"total_gross\": \"119\", \"total_net\": \"100\", \"vat_19_net\": \"100\", \"paymentterm\": \"10d\" }, " +
|
||||
" \"req\": [ { \"items\": [ { \"vat\": \"7%\" }, { \"vat\": \"19%\" } ] } ] }");
|
||||
" \"new\": { \"title\": \"Bad\", \"total_gross\": \"119\", \"total_net\": \"100\", \"paymentterm\": \"10d\" }, " +
|
||||
" \"sms\": { \"vat\": { \"7,0%\": 7.0, \"19,0%\": 19.0 } } }");
|
||||
var inv = new FdsInvoiceData(jo);
|
||||
|
||||
var pl = inv.BuildInvoiceParams(change: false, invId: "");
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Fuchs.intranet;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the posted <c>admin</c> flags map to the <c>@InvoiceOptions</c>
|
||||
/// CSV the backend persists — the channel both §13b and the set-pricing
|
||||
/// <c>setmode</c> token ride. The PDF reads the mode back via
|
||||
/// <see cref="InvoiceSetPricing.ModeFromInvoiceOptions"/>.
|
||||
/// </summary>
|
||||
public class InvoiceOptionsTests
|
||||
{
|
||||
private static string InvoiceOptionsFor(object? admin)
|
||||
{
|
||||
var root = new JObject();
|
||||
if (admin != null) root["admin"] = JObject.FromObject(admin);
|
||||
var data = new FdsInvoiceData(root);
|
||||
var p = data.BuildInvoiceParams(change: false, invId: "")
|
||||
.First(x => x.ParameterName == "@InvoiceOptions");
|
||||
return p.Value is DBNull or null ? "" : p.Value!.ToString()!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoFlags_EmptyOptions()
|
||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r" }));
|
||||
|
||||
[Fact]
|
||||
public void DefaultSetPrice_OmitsToken()
|
||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "setprice" }));
|
||||
|
||||
[Theory]
|
||||
[InlineData("itemprices", "setmode:itemprices")]
|
||||
[InlineData("setonly", "setmode:setonly")]
|
||||
[InlineData("ITEMPRICES", "setmode:itemprices")] // case-insensitive
|
||||
public void SetMode_EmitsToken(string mode, string expected)
|
||||
=> Assert.Equal(expected, InvoiceOptionsFor(new { type = "r", setmode = mode }));
|
||||
|
||||
[Fact]
|
||||
public void UnknownSetMode_OmitsToken()
|
||||
=> Assert.Equal("", InvoiceOptionsFor(new { type = "r", setmode = "garbage" }));
|
||||
|
||||
[Fact]
|
||||
public void P13bOnly_EmitsLegacyToken()
|
||||
=> Assert.Equal("§13b", InvoiceOptionsFor(new { type = "r", p13b = true }));
|
||||
|
||||
[Fact]
|
||||
public void P13bAndSetMode_EmitsBoth()
|
||||
=> Assert.Equal("§13b,setmode:setonly", InvoiceOptionsFor(new { type = "r", p13b = true, setmode = "setonly" }));
|
||||
|
||||
// ── VAT: highest rate + amount taken from the sms.vat map ─────────────────
|
||||
private static GenericObjectDictionary SmsWithVat(params (string rate, double amount)[] vat)
|
||||
{
|
||||
var map = new Dictionary<string, double>();
|
||||
foreach (var (r, a) in vat) map[r] = a;
|
||||
return new GenericObjectDictionary(new Dictionary<string, object> { ["vat"] = JObject.FromObject(map) });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HighestVat_PicksHighestRate_GermanFormatted()
|
||||
{
|
||||
var (rate, amt) = FdsInvoiceData.HighestVat(SmsWithVat(("19,0%", 38.0), ("7,0%", 7.0)));
|
||||
Assert.Equal("19", rate); // integer, invariant
|
||||
Assert.Equal("38", amt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HighestVat_NonIntegerRate_Preserved()
|
||||
{
|
||||
var (rate, amt) = FdsInvoiceData.HighestVat(SmsWithVat(("10,5%", 5.25)));
|
||||
Assert.Equal("10.5", rate); // invariant decimal point — safe for numeric(5,2)
|
||||
Assert.Equal("5.25", amt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HighestVat_EmptyOrMissing_ReturnsZero()
|
||||
{
|
||||
Assert.Equal(("0", "0"), FdsInvoiceData.HighestVat(null));
|
||||
Assert.Equal(("0", "0"), FdsInvoiceData.HighestVat(new GenericObjectDictionary(new Dictionary<string, object>())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HighestVat_FlowsIntoParams()
|
||||
{
|
||||
var root = new JObject
|
||||
{
|
||||
["admin"] = JObject.FromObject(new { type = "r" }),
|
||||
["sms"] = JObject.FromObject(new Dictionary<string, object>
|
||||
{
|
||||
["vat"] = new Dictionary<string, double> { ["19,0%"] = 38.0 }
|
||||
})
|
||||
};
|
||||
var pars = new FdsInvoiceData(root).BuildInvoiceParams(false, "");
|
||||
Assert.Equal("19", pars.First(p => p.ParameterName == "@InvoiceVAT_1").Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModeFromInvoiceOptions_RoundTripsBackendEmission()
|
||||
{
|
||||
// The token this side emits must parse back to the same mode on the PDF side.
|
||||
Assert.Equal(SetDisplayMode.ItemPrices,
|
||||
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", setmode = "itemprices" })));
|
||||
Assert.Equal(SetDisplayMode.SetOnly,
|
||||
InvoiceSetPricing.ModeFromInvoiceOptions(InvoiceOptionsFor(new { type = "r", p13b = true, setmode = "setonly" })));
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,40 @@ public class InvoiceSetPricingTests
|
||||
Assert.All(lines, l => Assert.False(l.IsSetHeader));
|
||||
}
|
||||
|
||||
// ── Text/Title lines never show a price (any mode) ────────────────────────
|
||||
private static Dictionary<string, object?> TextLine(string title) => new()
|
||||
{
|
||||
["type"] = "title", ["title"] = title, ["qty"] = "", ["price_net"] = "", ["total_net"] = ""
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Build_TextLine_Standalone_NoPrice()
|
||||
{
|
||||
var items = new List<Dictionary<string, object?>>
|
||||
{
|
||||
TextLine("Leistungszeitraum 2026"),
|
||||
Standalone("Anfahrt", "50.00", "50.00")
|
||||
};
|
||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
|
||||
Assert.False(lines[0].ShowPrice); // heading — blank price
|
||||
Assert.True(lines[1].ShowPrice); // real item — priced
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_TextLine_AsSetMember_NoPriceEvenInItemPrices()
|
||||
{
|
||||
var items = new List<Dictionary<string, object?>>
|
||||
{
|
||||
SetHeader("10", "Bad-Komplettset", "1000.00"),
|
||||
new() { ["type"] = "title", ["setId"] = "10", ["title"] = "Hinweis", ["total_net"] = "" },
|
||||
Member("10", "Waschbecken", "600.00", "600.00")
|
||||
};
|
||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.ItemPrices);
|
||||
var note = lines.First(l => l.Title == "Hinweis");
|
||||
Assert.False(note.ShowPrice); // text member stays blank
|
||||
Assert.True(lines.First(l => l.Title == "Waschbecken").ShowPrice);
|
||||
}
|
||||
|
||||
// ── Set price equals sum of member prices across modes (no double counting) ─
|
||||
[Fact]
|
||||
public void SetPrice_EqualsSumOfMembers()
|
||||
|
||||
@@ -29,23 +29,59 @@ Three display modes (switchable in the invoice editor):
|
||||
set header line is rendered emphasised. Invoices without sets pass through
|
||||
unchanged.
|
||||
|
||||
## Front-end contract (to wire in the invoice editor)
|
||||
## Front-end contract (implemented in the invoice editor)
|
||||
|
||||
The editor already knows the set structure (from `fds__prepInvoice`) and assembles
|
||||
the `invc` JSON. To drive the modes it must, when sending the invoice:
|
||||
Wired in `Fuchs/js/intranet/modules/fis.inv_shared.js` (bundled to
|
||||
`wwwroot/web/fis.inv.de.js` via gulp `min:js`):
|
||||
|
||||
1. **Mode** — add a token to `inv.InvoiceOptions` (CSV, alongside `§13b`):
|
||||
- `setmode:setprice` (default — may be omitted), `setmode:itemprices`, or `setmode:setonly`.
|
||||
A 3-way switch in the editor sets this token.
|
||||
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).
|
||||
|
||||
2. **Item flags** — in each service request's `items[]`:
|
||||
- the **set header** item: `type: "set"`, `id: "<setId>"`, and `total_net` =
|
||||
the set price (or `0` to let the back-end sum the members);
|
||||
- each **member** item: `setId: "<setId>"` (matching the header's id).
|
||||
- standalone items need no extra fields.
|
||||
2. **Item shape** — `$inv.invSumUpdate` now posts each request block's
|
||||
`items[]` in the back-end contract shape via `$inv.itemToContract`:
|
||||
`{ id, type, title, desc, qty, price_net, total_net, vat }`. (Previously the
|
||||
editor only posted the legacy on-screen `itm`/`co` objects, which
|
||||
`FdsInvoiceData.InvoiceItems` does not read — so line items never reached the
|
||||
C# PDF. This change closes that gap for **all** invoices, not just sets.)
|
||||
|
||||
That is the entire contract — the back-end does the rest. The editor's running
|
||||
**total stays the member sum in every mode**, matching the registration balance.
|
||||
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.
|
||||
|
||||
### Editor → backend field normalization (`$inv.invcPayload`)
|
||||
The editor's internal model keeps the long-standing key names, but the migrated C#
|
||||
`BuildInvoiceParams` reads different ones. At post time `$inv.invcPayload(d)` maps the
|
||||
working model onto the exact field names the back-end reads (non-destructively):
|
||||
`sms.ttn → new.total_net`, `sms.ttb → new.total_gross`, each `sms.vat` rate →
|
||||
`new.vat_<rate>_net`, `new.invoicetitle → new.title`, `new.loc → new.provisionlocation`,
|
||||
`admin.paymentterms → new.paymentterm`, `admin.CustomerId → admin.customerid`. Both
|
||||
`req/save` and `req/sprep|sedit` post through it, so titles/balances/VAT now reach the
|
||||
backend correctly.
|
||||
|
||||
VAT (rate **and** amount) is taken by the backend directly from the posted `sms.vat`
|
||||
map via `FdsInvoiceData.HighestVat` (highest rate wins) — `invcPayload` therefore emits
|
||||
no per-rate `vat_*` keys.
|
||||
|
||||
**Back-end fixes applied alongside the wiring:**
|
||||
- Heading/free-text lines (`type` `text`/`title`) now render a **blank** price/total
|
||||
(`InvoiceSetPricing.IsNoPriceLine`), instead of `0,00 €`.
|
||||
- VAT rate detection no longer reads line items (the old `items is List<object>` test
|
||||
failed on Newtonsoft `JArray` and pinned `@InvoiceVAT_1` to `19`); it now comes from
|
||||
`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.
|
||||
|
||||
### Why the switch lives in the editor
|
||||
Set grouping is only known where the request/item tree is rendered (front-end).
|
||||
@@ -55,9 +91,9 @@ items — it does not re-implement the pricing rules.
|
||||
|
||||
## Persistence note
|
||||
Draft/preview PDFs render straight from the posted `invc` JSON, so the contract
|
||||
works end-to-end for previews immediately. For a **finalised** invoice to re-render
|
||||
in a chosen mode later, the per-item `type`/`setId` flags (and the `setmode`
|
||||
option) must be persisted with the stored invoice items
|
||||
(`fds__createInvoice_Details` / `fds__invoice_items`). `setmode` already persists
|
||||
via `InvoiceOptions`; persisting the per-item set tags is a small SSDT + create-proc
|
||||
change to make once the editor wiring is confirmed.
|
||||
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.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
@@ -113,23 +114,10 @@ public class FdsInvoiceData
|
||||
internal List<SqlParameter> BuildInvoiceParams(bool change, string invId)
|
||||
{
|
||||
_ = change; _ = invId;
|
||||
var vatsDic = new Dictionary<string, string>();
|
||||
if (Req != null)
|
||||
{
|
||||
foreach (var rq in Req)
|
||||
{
|
||||
if (rq.TryGetValue("items", out var itmsObj) && itmsObj is List<object> itms)
|
||||
{
|
||||
foreach (var itm in itms.OfType<Dictionary<string, object?>>())
|
||||
{
|
||||
string vatKey = itm.nz("vat", "").Replace("%", "").Trim();
|
||||
if (!string.IsNullOrEmpty(vatKey) && vatKey != "0")
|
||||
vatsDic.TryAdd(vatKey, vatsDic.TryGetValue(vatKey, out var ve) ? ve : "0");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
string vathigh = vatsDic.Keys.OrderByDescending(k => double.TryParse(k, out var d) ? d : 0).FirstOrDefault() ?? "19";
|
||||
// VAT rate + amount come from the editor's computed sms.vat map (rate → amount),
|
||||
// matching the legacy contract. Item-level VAT strings are German-formatted and
|
||||
// single-rate procs only store one rate, so the highest rate wins.
|
||||
var (vatRate, vatNet) = HighestVat(Sms);
|
||||
|
||||
return new List<SqlParameter>
|
||||
{
|
||||
@@ -137,8 +125,8 @@ public class FdsInvoiceData
|
||||
SQL_NVarChar("@InvoiceTitle", NewValues?.nz("title") ?? ""),
|
||||
SQL_Float("@InvoiceBalance", stringvalue: NewValues?.nz("total_gross") ?? "0"),
|
||||
SQL_Float("@InvoiceBalance_net", stringvalue: NewValues?.nz("total_net") ?? "0"),
|
||||
SQL_Float("@InvoiceVAT_net1", stringvalue: NewValues?.nz($"vat_{vathigh}_net") ?? "0"),
|
||||
SQL_VarChar("@InvoiceVAT_1", vathigh),
|
||||
SQL_Float("@InvoiceVAT_net1", stringvalue: vatNet),
|
||||
SQL_VarChar("@InvoiceVAT_1", vatRate),
|
||||
SQL_VarChar("@PaymentTerm", NewValues?.nz("paymentterm") ?? "", dbNull_IfEmpty: true),
|
||||
SQL_BigInt("@CustomerId", Admin?.nz("customerid") ?? ""),
|
||||
SQL_VarChar("@SendToAddress", RawInvoiceAddress),
|
||||
@@ -147,8 +135,64 @@ public class FdsInvoiceData
|
||||
SQL_NVarChar("@CustomValues", RawCustomValues, dbNull_IfEmpty: true),
|
||||
SQL_Float("@InvoiceService_net", stringvalue: Sms?.nz("tscn") ?? "0"),
|
||||
SQL_Float("@InvoiceService_VAT", stringvalue: Sms?.nz("tscvat") ?? "0"),
|
||||
SQL_VarChar("@InvoiceOptions",
|
||||
Admin?.no("p13b", false) is true ? "§13b" : "", dbNull_IfEmpty: true)
|
||||
SQL_VarChar("@InvoiceOptions", BuildInvoiceOptions(), dbNull_IfEmpty: true)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the InvoiceOptions CSV from the posted admin flags.
|
||||
/// <c>§13b</c> is the existing reverse-charge flag; <c>setmode:<mode></c>
|
||||
/// selects the set-pricing display mode (the default <c>setprice</c> is omitted).
|
||||
/// Read back by <see cref="InvoiceSetPricing.ModeFromInvoiceOptions"/> (set mode)
|
||||
/// and the §13b PDF note. Both ride the <c>admin</c> object the editor posts.
|
||||
/// </summary>
|
||||
internal string BuildInvoiceOptions()
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
if (Admin?.no("p13b", false) is true) tokens.Add("§13b");
|
||||
string setmode = (Admin?.nz("setmode") ?? "").Trim().ToLowerInvariant();
|
||||
if (setmode is "itemprices" or "setonly") tokens.Add("setmode:" + setmode);
|
||||
return string.Join(",", tokens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the highest VAT rate and its net VAT amount from the editor's
|
||||
/// computed <c>sms.vat</c> map (rate string → amount), e.g. {"19,0%": 123.45}.
|
||||
/// Mirrors the legacy contract (VAT comes from the totals block, not line items).
|
||||
/// Returns invariant numeric strings — rate ("19") and amount ("123.45") — ready
|
||||
/// for the <c>numeric</c> proc params. Empty/absent map → ("0","0").
|
||||
/// </summary>
|
||||
internal static (string rate, string netAmount) HighestVat(GenericObjectDictionary? sms)
|
||||
{
|
||||
if (sms == null || !sms.TryGetValue("vat", out var vobj) || vobj is null)
|
||||
return ("0", "0");
|
||||
|
||||
bool found = false; double bestRate = 0, bestAmount = 0;
|
||||
void Consider(string key, double amount)
|
||||
{
|
||||
double r = ParseRate(key);
|
||||
if (!found || r > bestRate) { found = true; bestRate = r; bestAmount = amount; }
|
||||
}
|
||||
|
||||
if (vobj is JObject jo)
|
||||
foreach (var p in jo)
|
||||
Consider(p.Key, p.Value is { } t && t.Type is JTokenType.Float or JTokenType.Integer ? t.Value<double>() : 0);
|
||||
else if (vobj is IDictionary<string, object?> dict)
|
||||
foreach (var kv in dict)
|
||||
Consider(kv.Key, double.TryParse(Convert.ToString(kv.Value, CultureInfo.InvariantCulture),
|
||||
NumberStyles.Any, CultureInfo.InvariantCulture, out var a) ? a : 0);
|
||||
|
||||
if (!found) return ("0", "0");
|
||||
string rate = bestRate == Math.Floor(bestRate)
|
||||
? ((long)bestRate).ToString(CultureInfo.InvariantCulture)
|
||||
: bestRate.ToString(CultureInfo.InvariantCulture);
|
||||
return (rate, bestAmount.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
/// <summary>Parses a VAT rate string ("19,0%", "7%", "19") to a number (German or invariant).</summary>
|
||||
private static double ParseRate(string? key)
|
||||
{
|
||||
string s = (key ?? "").Replace("%", "").Trim().Replace(',', '.');
|
||||
return double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,17 @@ public static class InvoiceSetPricing
|
||||
private static bool IsSetHeader(Dictionary<string, object?> i) =>
|
||||
string.Equals(i.nz("type", ""), "set", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Text/Title lines are headings/free text with no price — their price/total
|
||||
/// cells render blank (matching the editor and the legacy invoice), regardless
|
||||
/// of the chosen mode.
|
||||
/// </summary>
|
||||
private static bool IsNoPriceLine(Dictionary<string, object?> i)
|
||||
{
|
||||
string t = i.nz("type", "").ToLowerInvariant();
|
||||
return t is "text" or "title";
|
||||
}
|
||||
|
||||
private static string? SetIdOf(Dictionary<string, object?> i)
|
||||
{
|
||||
string s = i.nz("setId", "");
|
||||
@@ -165,7 +176,7 @@ public static class InvoiceSetPricing
|
||||
Qty = i.nz("qty", "1"),
|
||||
PriceNet = price,
|
||||
TotalNet = total,
|
||||
ShowPrice = showPrice,
|
||||
ShowPrice = showPrice && !IsNoPriceLine(i), // headings/free text print no price
|
||||
IsSetHeader = false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,6 +96,9 @@ $inv.eM = (r, re, opt) => {
|
||||
if ((opt || '').split(',').includes('p13b') === true) {
|
||||
m.push({ lbl: $ict.p13b, fnc: $inv.sp13b });
|
||||
}
|
||||
if ((opt || '').split(',').includes('setm') === true) {
|
||||
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
||||
}
|
||||
if (booln(r, false) === true) {
|
||||
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
|
||||
}
|
||||
@@ -258,7 +261,7 @@ $inv.ccInv = function (ev) { //normale rechnung
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
|
||||
$inv.eM(false, true, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -403,7 +406,7 @@ $inv.cntInv = function (data) { //invoice continuation
|
||||
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
$inv.eM(false, true, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -646,12 +649,19 @@ $inv.invSumUpdate = function () {
|
||||
};
|
||||
let bds = tbl.children('tbody');
|
||||
bds.each((bi, bdy) => {
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], cset = null, bnet = 0, 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);
|
||||
/* 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). */
|
||||
let citem = $inv.itemToContract(rrx);
|
||||
if (citem.type === 'set' && citem.id !== '') { cset = citem.id; }
|
||||
else if (cset !== null && (citem.id || '') !== '') { citem.setId = cset; }
|
||||
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++; }
|
||||
rrx.SortOrder = iso;
|
||||
@@ -663,7 +673,7 @@ $inv.invSumUpdate = function () {
|
||||
// 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, netval: bnet });
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems, netval: bnet });
|
||||
});
|
||||
let nonempty = tbl.find('tbody:not(.empty)').length;
|
||||
bds.find('tr.isum').tC('hidden', nonempty < 2);
|
||||
@@ -838,6 +848,53 @@ $inv.sp13b = () => {
|
||||
}
|
||||
tbl.trigger('fds.inv');
|
||||
};
|
||||
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
|
||||
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
|
||||
total_net, vat }. Set membership (type:'set' header + setId on members) is added by
|
||||
the caller. The invoice total comes from the registration balance, so per-item
|
||||
totals here are purely presentational. */
|
||||
$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 || '' };
|
||||
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 || '';
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
} else if (['text', 'title'].includes(type) && (rrx.net_val || 0) === 0) {
|
||||
/* heading / free-text line, no price */
|
||||
ci.desc = rrx.htmltext || ((((rrx.NameOrNumber || '').substr(0, 1) !== '#') ? oHtml($$[0]('p').text(rrx.NameOrNumber || '')) : '') + (rrx.Note || ''));
|
||||
ci.total_net = '';
|
||||
} else {
|
||||
/* normal priced item (incl. set headers, which carry their own set price or 0) */
|
||||
ci.title = rrx.NameOrNumber || '';
|
||||
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);
|
||||
}
|
||||
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. */
|
||||
$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')]);
|
||||
o = $ocms.dlg(fr, { width: 800 });
|
||||
};
|
||||
$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.sctp = () => {
|
||||
let flds = $invcol.ctp;
|
||||
$ocms.dlgform(flds, {
|
||||
@@ -857,12 +914,32 @@ $inv.sctp = () => {
|
||||
}, typedvalues: true
|
||||
});
|
||||
};
|
||||
/* 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);
|
||||
- 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. */
|
||||
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 };
|
||||
};
|
||||
$inv.ssave = () => {
|
||||
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
|
||||
$inv.t_fds_inv();
|
||||
l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid || '' }, success: (response) => {
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
|
||||
$inv.cntInv({ id: response.id });
|
||||
}, error: () => {
|
||||
alert($ict.eis);
|
||||
@@ -884,7 +961,7 @@ $inv.sprev = (change) => {
|
||||
}
|
||||
}
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid ||'' }, success: (response) => {
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
|
||||
if (invtp > 10) {
|
||||
|
||||
@@ -40,6 +40,12 @@
|
||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
||||
iss: 'Zwischenstand speichern.',
|
||||
p13b: 'USt -> §13b',
|
||||
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',
|
||||
mfr: 'Von MFR neu abrufen',
|
||||
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
|
||||
|
||||
@@ -146,6 +146,12 @@ let $ict = {
|
||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
||||
iss: 'Zwischenstand speichern.',
|
||||
p13b: 'USt -> §13b',
|
||||
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',
|
||||
mfr: 'Von MFR neu abrufen',
|
||||
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
|
||||
@@ -637,6 +643,9 @@ $inv.eM = (r, re, opt) => {
|
||||
if ((opt || '').split(',').includes('p13b') === true) {
|
||||
m.push({ lbl: $ict.p13b, fnc: $inv.sp13b });
|
||||
}
|
||||
if ((opt || '').split(',').includes('setm') === true) {
|
||||
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
||||
}
|
||||
if (booln(r, false) === true) {
|
||||
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
|
||||
}
|
||||
@@ -799,7 +808,7 @@ $inv.ccInv = function (ev) { //normale rechnung
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
|
||||
$inv.eM(false, true, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -944,7 +953,7 @@ $inv.cntInv = function (data) { //invoice continuation
|
||||
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
$inv.eM(false, true, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -1187,12 +1196,19 @@ $inv.invSumUpdate = function () {
|
||||
};
|
||||
let bds = tbl.children('tbody');
|
||||
bds.each((bi, bdy) => {
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], cset = null, bnet = 0, 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);
|
||||
/* 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). */
|
||||
let citem = $inv.itemToContract(rrx);
|
||||
if (citem.type === 'set' && citem.id !== '') { cset = citem.id; }
|
||||
else if (cset !== null && (citem.id || '') !== '') { citem.setId = cset; }
|
||||
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++; }
|
||||
rrx.SortOrder = iso;
|
||||
@@ -1204,7 +1220,7 @@ $inv.invSumUpdate = function () {
|
||||
// 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, netval: bnet });
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems, netval: bnet });
|
||||
});
|
||||
let nonempty = tbl.find('tbody:not(.empty)').length;
|
||||
bds.find('tr.isum').tC('hidden', nonempty < 2);
|
||||
@@ -1379,6 +1395,53 @@ $inv.sp13b = () => {
|
||||
}
|
||||
tbl.trigger('fds.inv');
|
||||
};
|
||||
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
|
||||
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
|
||||
total_net, vat }. Set membership (type:'set' header + setId on members) is added by
|
||||
the caller. The invoice total comes from the registration balance, so per-item
|
||||
totals here are purely presentational. */
|
||||
$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 || '' };
|
||||
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 || '';
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
} else if (['text', 'title'].includes(type) && (rrx.net_val || 0) === 0) {
|
||||
/* heading / free-text line, no price */
|
||||
ci.desc = rrx.htmltext || ((((rrx.NameOrNumber || '').substr(0, 1) !== '#') ? oHtml($$[0]('p').text(rrx.NameOrNumber || '')) : '') + (rrx.Note || ''));
|
||||
ci.total_net = '';
|
||||
} else {
|
||||
/* normal priced item (incl. set headers, which carry their own set price or 0) */
|
||||
ci.title = rrx.NameOrNumber || '';
|
||||
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);
|
||||
}
|
||||
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. */
|
||||
$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')]);
|
||||
o = $ocms.dlg(fr, { width: 800 });
|
||||
};
|
||||
$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.sctp = () => {
|
||||
let flds = $invcol.ctp;
|
||||
$ocms.dlgform(flds, {
|
||||
@@ -1398,12 +1461,32 @@ $inv.sctp = () => {
|
||||
}, typedvalues: true
|
||||
});
|
||||
};
|
||||
/* 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);
|
||||
- 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. */
|
||||
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 };
|
||||
};
|
||||
$inv.ssave = () => {
|
||||
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
|
||||
$inv.t_fds_inv();
|
||||
l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid || '' }, success: (response) => {
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
|
||||
$inv.cntInv({ id: response.id });
|
||||
}, error: () => {
|
||||
alert($ict.eis);
|
||||
@@ -1425,7 +1508,7 @@ $inv.sprev = (change) => {
|
||||
}
|
||||
}
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid ||'' }, success: (response) => {
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
|
||||
if (invtp > 10) {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -146,6 +146,12 @@ let $ict = {
|
||||
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
|
||||
iss: 'Zwischenstand speichern.',
|
||||
p13b: 'USt -> §13b',
|
||||
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',
|
||||
mfr: 'Von MFR neu abrufen',
|
||||
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
|
||||
@@ -618,6 +624,9 @@ $inv.eM = (r, re, opt) => {
|
||||
if ((opt || '').split(',').includes('p13b') === true) {
|
||||
m.push({ lbl: $ict.p13b, fnc: $inv.sp13b });
|
||||
}
|
||||
if ((opt || '').split(',').includes('setm') === true) {
|
||||
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
||||
}
|
||||
if (booln(r, false) === true) {
|
||||
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
|
||||
}
|
||||
@@ -780,7 +789,7 @@ $inv.ccInv = function (ev) { //normale rechnung
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
|
||||
$inv.eM(false, true, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -925,7 +934,7 @@ $inv.cntInv = function (data) { //invoice continuation
|
||||
|
||||
rif.tbl.children('tbody').each($inv.bdysort);
|
||||
rif.tbl.trigger('fds.inv'); /* trigger calculations */
|
||||
$inv.eM(false, true, 'iss,p13b,ctp');
|
||||
$inv.eM(false, true, 'iss,p13b,setm,ctp');
|
||||
}, complete: () => {
|
||||
o.c.trigger('modal_close');
|
||||
}
|
||||
@@ -1168,12 +1177,19 @@ $inv.invSumUpdate = function () {
|
||||
};
|
||||
let bds = tbl.children('tbody');
|
||||
bds.each((bi, bdy) => {
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], bnet = 0, itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], cset = null, bnet = 0, 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);
|
||||
/* 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). */
|
||||
let citem = $inv.itemToContract(rrx);
|
||||
if (citem.type === 'set' && citem.id !== '') { cset = citem.id; }
|
||||
else if (cset !== null && (citem.id || '') !== '') { citem.setId = cset; }
|
||||
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++; }
|
||||
rrx.SortOrder = iso;
|
||||
@@ -1185,7 +1201,7 @@ $inv.invSumUpdate = function () {
|
||||
// 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, netval: bnet });
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems, netval: bnet });
|
||||
});
|
||||
let nonempty = tbl.find('tbody:not(.empty)').length;
|
||||
bds.find('tr.isum').tC('hidden', nonempty < 2);
|
||||
@@ -1360,6 +1376,53 @@ $inv.sp13b = () => {
|
||||
}
|
||||
tbl.trigger('fds.inv');
|
||||
};
|
||||
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
|
||||
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
|
||||
total_net, vat }. Set membership (type:'set' header + setId on members) is added by
|
||||
the caller. The invoice total comes from the registration balance, so per-item
|
||||
totals here are purely presentational. */
|
||||
$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 || '' };
|
||||
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 || '';
|
||||
ci.total_net = (rrx.net_val || 0);
|
||||
} else if (['text', 'title'].includes(type) && (rrx.net_val || 0) === 0) {
|
||||
/* heading / free-text line, no price */
|
||||
ci.desc = rrx.htmltext || ((((rrx.NameOrNumber || '').substr(0, 1) !== '#') ? oHtml($$[0]('p').text(rrx.NameOrNumber || '')) : '') + (rrx.Note || ''));
|
||||
ci.total_net = '';
|
||||
} else {
|
||||
/* normal priced item (incl. set headers, which carry their own set price or 0) */
|
||||
ci.title = rrx.NameOrNumber || '';
|
||||
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);
|
||||
}
|
||||
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. */
|
||||
$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')]);
|
||||
o = $ocms.dlg(fr, { width: 800 });
|
||||
};
|
||||
$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.sctp = () => {
|
||||
let flds = $invcol.ctp;
|
||||
$ocms.dlgform(flds, {
|
||||
@@ -1379,12 +1442,32 @@ $inv.sctp = () => {
|
||||
}, typedvalues: true
|
||||
});
|
||||
};
|
||||
/* 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);
|
||||
- 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. */
|
||||
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 };
|
||||
};
|
||||
$inv.ssave = () => {
|
||||
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
|
||||
$inv.t_fds_inv();
|
||||
l.aC('freeze');
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid || '' }, success: (response) => {
|
||||
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
|
||||
$inv.cntInv({ id: response.id });
|
||||
}, error: () => {
|
||||
alert($ict.eis);
|
||||
@@ -1406,7 +1489,7 @@ $inv.sprev = (change) => {
|
||||
}
|
||||
}
|
||||
$ocms.postXT({
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new }), id: d.invid ||'' }, success: (response) => {
|
||||
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
|
||||
l.rC('freeze');
|
||||
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
|
||||
if (invtp > 10) {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user