Invoice set pricing: 3 display modes (backend contract + PDF + tests)
Sets (mfr__items Type='set') can be shown three ways on the invoice:
- SetPrice (default): set line priced, member items shown without price
- ItemPrices: member items priced, set line as a heading without price
- SetOnly: only the set line (priced), members removed
- InvoiceSetPricing (new): the authoritative, unit-tested transformation
(SetDisplayMode + Build) that both sides agree on; set price always equals the
sum of members. Mode is read from InvoiceOptions ("setmode:<mode>").
- FuchsPdf.ApplyInvoice renders through it: lines flagged ShowPrice=false print
blank price/total cells; set headers are emphasised. Invoices without sets are
unchanged. Totals come from the registration balance, so modes are purely
presentational and never change the sum.
- InvoiceSetPricingTests (+14): all three modes, set-price = member sum, header
total fallback, no-set pass-through, option parsing.
- Docs/INVOICE_SET_PRICING.md documents the front-end contract (the editor sets
the mode token + tags set header/member items); the back-end does the rest.
Front-end editor wiring is specified in the doc but intentionally not shipped
blind (cannot validate the running editor here).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Fuchs.intranet;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Contract tests for the set-pricing display modes — the agreed interplay
|
||||
/// between the front-end (which picks the mode and groups items) and the
|
||||
/// back-end PDF renderer (which honours ShowPrice / IsSetHeader). Set items use
|
||||
/// type="set"; members carry setId == the set header's id.
|
||||
/// </summary>
|
||||
public class InvoiceSetPricingTests
|
||||
{
|
||||
private static Dictionary<string, object?> SetHeader(string id, string title, string? total = null) => new()
|
||||
{
|
||||
["type"] = "set", ["id"] = id, ["title"] = title, ["qty"] = "1",
|
||||
["total_net"] = total ?? "0", ["price_net"] = total ?? "0"
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> Member(string setId, string title, string price, string total) => new()
|
||||
{
|
||||
["setId"] = setId, ["title"] = title, ["qty"] = "1", ["price_net"] = price, ["total_net"] = total
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> Standalone(string title, string price, string total) => new()
|
||||
{
|
||||
["title"] = title, ["qty"] = "1", ["price_net"] = price, ["total_net"] = total
|
||||
};
|
||||
|
||||
// A set (sum 1000) with two members, plus one standalone item (50).
|
||||
private static List<Dictionary<string, object?>> Sample() => new()
|
||||
{
|
||||
SetHeader("10", "Bad-Komplettset", "1000.00"),
|
||||
Member("10", "Waschbecken", "600.00", "600.00"),
|
||||
Member("10", "Armatur", "400.00", "400.00"),
|
||||
Standalone("Anfahrt", "50.00", "50.00")
|
||||
};
|
||||
|
||||
// ── Mode parsing ────────────────────────────────────────────────────────
|
||||
[Theory]
|
||||
[InlineData("itemprices", SetDisplayMode.ItemPrices)]
|
||||
[InlineData("items", SetDisplayMode.ItemPrices)]
|
||||
[InlineData("setonly", SetDisplayMode.SetOnly)]
|
||||
[InlineData("setprice", SetDisplayMode.SetPrice)]
|
||||
[InlineData("", SetDisplayMode.SetPrice)]
|
||||
[InlineData("garbage", SetDisplayMode.SetPrice)]
|
||||
public void ParseMode_Works(string raw, SetDisplayMode expected)
|
||||
=> Assert.Equal(expected, InvoiceSetPricing.ParseMode(raw));
|
||||
|
||||
[Fact]
|
||||
public void ModeFromInvoiceOptions_ReadsToken()
|
||||
{
|
||||
Assert.Equal(SetDisplayMode.ItemPrices, InvoiceSetPricing.ModeFromInvoiceOptions("§13b,setmode:itemprices"));
|
||||
Assert.Equal(SetDisplayMode.SetOnly, InvoiceSetPricing.ModeFromInvoiceOptions("setmode:setonly"));
|
||||
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions("§13b")); // default
|
||||
Assert.Equal(SetDisplayMode.SetPrice, InvoiceSetPricing.ModeFromInvoiceOptions(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsSets_DetectsHeaderOrMember()
|
||||
{
|
||||
Assert.True(InvoiceSetPricing.ContainsSets(Sample()));
|
||||
Assert.False(InvoiceSetPricing.ContainsSets(new List<Dictionary<string, object?>> { Standalone("X", "1", "1") }));
|
||||
}
|
||||
|
||||
// ── SetPrice (default): set priced, members blank ─────────────────────────
|
||||
[Fact]
|
||||
public void Build_SetPrice_SetLinePricedMembersBlank()
|
||||
{
|
||||
var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetPrice);
|
||||
|
||||
Assert.Equal(4, lines.Count); // header + 2 members + standalone
|
||||
var header = lines[0];
|
||||
Assert.True(header.IsSetHeader);
|
||||
Assert.True(header.ShowPrice);
|
||||
Assert.Equal(1000.00m, header.TotalNet);
|
||||
|
||||
Assert.False(lines[1].ShowPrice); // Waschbecken — no price
|
||||
Assert.False(lines[2].ShowPrice); // Armatur — no price
|
||||
Assert.Equal("Waschbecken", lines[1].Title);
|
||||
|
||||
Assert.True(lines[3].ShowPrice); // standalone keeps price
|
||||
Assert.Equal(50.00m, lines[3].TotalNet);
|
||||
}
|
||||
|
||||
// ── ItemPrices: members priced, set header blank ──────────────────────────
|
||||
[Fact]
|
||||
public void Build_ItemPrices_MembersPricedHeaderBlank()
|
||||
{
|
||||
var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.ItemPrices);
|
||||
|
||||
Assert.Equal(4, lines.Count);
|
||||
Assert.True(lines[0].IsSetHeader);
|
||||
Assert.False(lines[0].ShowPrice); // set header is just a title now
|
||||
Assert.True(lines[1].ShowPrice);
|
||||
Assert.Equal(600.00m, lines[1].TotalNet);
|
||||
Assert.True(lines[2].ShowPrice);
|
||||
Assert.Equal(400.00m, lines[2].TotalNet);
|
||||
Assert.True(lines[3].ShowPrice); // standalone
|
||||
}
|
||||
|
||||
// ── SetOnly: members removed ──────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void Build_SetOnly_RemovesMembers()
|
||||
{
|
||||
var lines = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetOnly);
|
||||
|
||||
Assert.Equal(2, lines.Count); // set header + standalone only
|
||||
Assert.True(lines[0].IsSetHeader);
|
||||
Assert.True(lines[0].ShowPrice);
|
||||
Assert.Equal(1000.00m, lines[0].TotalNet);
|
||||
Assert.False(lines[1].IsSetHeader);
|
||||
Assert.Equal("Anfahrt", lines[1].Title);
|
||||
}
|
||||
|
||||
// ── Set price falls back to sum of members when header total is 0 ─────────
|
||||
[Fact]
|
||||
public void Build_HeaderTotalZero_UsesSumOfMembers()
|
||||
{
|
||||
var items = new List<Dictionary<string, object?>>
|
||||
{
|
||||
SetHeader("7", "Set ohne Preis"), // total 0
|
||||
Member("7", "A", "120.00", "120.00"),
|
||||
Member("7", "B", "80.00", "80.00")
|
||||
};
|
||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
|
||||
Assert.Equal(200.00m, lines[0].TotalNet); // 120 + 80
|
||||
}
|
||||
|
||||
// ── No sets: pass-through unchanged ───────────────────────────────────────
|
||||
[Fact]
|
||||
public void Build_NoSets_PassThroughAllPriced()
|
||||
{
|
||||
var items = new List<Dictionary<string, object?>>
|
||||
{
|
||||
Standalone("A", "10.00", "10.00"),
|
||||
Standalone("B", "20.00", "20.00")
|
||||
};
|
||||
var lines = InvoiceSetPricing.Build(items, SetDisplayMode.SetPrice);
|
||||
Assert.Equal(2, lines.Count);
|
||||
Assert.All(lines, l => Assert.True(l.ShowPrice));
|
||||
Assert.All(lines, l => Assert.False(l.IsSetHeader));
|
||||
}
|
||||
|
||||
// ── Set price equals sum of member prices across modes (no double counting) ─
|
||||
[Fact]
|
||||
public void SetPrice_EqualsSumOfMembers()
|
||||
{
|
||||
var setLine = InvoiceSetPricing.Build(Sample(), SetDisplayMode.SetPrice).First(l => l.IsSetHeader);
|
||||
decimal memberSum = Sample()
|
||||
.Where(i => i.TryGetValue("setId", out var s) && s?.ToString() == "10")
|
||||
.Sum(i => decimal.Parse(i["total_net"]!.ToString()!, System.Globalization.CultureInfo.InvariantCulture));
|
||||
Assert.Equal(memberSum, setLine.TotalNet);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# 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.
|
||||
|
||||
Three display modes (switchable in the invoice editor):
|
||||
|
||||
| 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 |
|
||||
|
||||
> **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).
|
||||
|
||||
## Back-end (implemented + unit-tested)
|
||||
|
||||
- `Fuchs/code/InvoiceSetPricing.cs` — the authoritative transformation:
|
||||
`SetDisplayMode` + `Build(items, mode)` → ordered `InvoiceSetLine`s, each with
|
||||
`ShowPrice` and `IsSetHeader`. `ModeFromInvoiceOptions(...)` reads the mode
|
||||
from the invoice options. Fully covered by `Fuchs.Tests/InvoiceSetPricingTests.cs`.
|
||||
- `FuchsPdf.ApplyInvoice` renders through `InvoiceSetPricing.Build`: lines with
|
||||
`ShowPrice == false` render **blank** price/total cells (not `0,00 €`), and the
|
||||
set header line is rendered emphasised. Invoices without sets pass through
|
||||
unchanged.
|
||||
|
||||
## Front-end contract (to wire 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:
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
## 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.
|
||||
+14
-13
@@ -473,25 +473,26 @@ public static class FuchsPdf
|
||||
hRow.Cells[i].Format.Alignment = i >= 2 ? ParagraphAlignment.Right : ParagraphAlignment.Left;
|
||||
}
|
||||
|
||||
// Data rows — from InvoiceItems
|
||||
// Data rows — resolved through the set-display mode (see InvoiceSetPricing).
|
||||
// For invoices without sets this passes items through unchanged; for sets it
|
||||
// emits set header + members per the chosen mode, blanking price cells where
|
||||
// a line should show no price. Totals come from the registration balance, so
|
||||
// the mode is purely presentational.
|
||||
var setMode = InvoiceSetPricing.ModeFromInvoiceOptions(inv.InvoiceRegistration?.getString("InvoiceOptions"));
|
||||
int pos = 1;
|
||||
foreach (var itm in inv.InvoiceItems)
|
||||
foreach (var line in InvoiceSetPricing.Build(inv.InvoiceItems, setMode))
|
||||
{
|
||||
string title = itm.nz("title", "");
|
||||
string desc = itm.nz("desc", "");
|
||||
string qty = itm.nz("qty", "1");
|
||||
ParseDec(itm.no("price_net", 0), out decimal priceNet);
|
||||
ParseDec(itm.no("total_net", 0), out decimal totalNet);
|
||||
|
||||
var row = tbl.AddRow();
|
||||
row.HeightRule = RowHeightRule.Auto;
|
||||
row.Cells[0].AddParagraph(pos.ToString()).Style = "TblCell_Base";
|
||||
var titleCell = row.Cells[1].AddParagraph();
|
||||
titleCell.Style = "TblCell_RTitle"; titleCell.AddText(title);
|
||||
if (!string.IsNullOrEmpty(desc)) row.Cells[1].AddHtml($"<div>{desc}</div>");
|
||||
row.Cells[2].AddParagraph(qty).Style = "TblCell_Base";
|
||||
row.Cells[3].AddParagraph(Currency(priceNet)).Style = "TblCell_Base";
|
||||
row.Cells[4].AddParagraph(Currency(totalNet)).Style = "TblCell_RSum";
|
||||
titleCell.Style = "TblCell_RTitle";
|
||||
if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold);
|
||||
else titleCell.AddText(line.Title);
|
||||
if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>");
|
||||
row.Cells[2].AddParagraph(line.Qty).Style = "TblCell_Base";
|
||||
row.Cells[3].AddParagraph(line.ShowPrice ? Currency(line.PriceNet) : "").Style = "TblCell_Base";
|
||||
row.Cells[4].AddParagraph(line.ShowPrice ? Currency(line.TotalNet) : "").Style = "TblCell_RSum";
|
||||
row.Cells[2].Format.Alignment = ParagraphAlignment.Right;
|
||||
row.Cells[3].Format.Alignment = ParagraphAlignment.Right;
|
||||
row.Cells[4].Format.Alignment = ParagraphAlignment.Right;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
using static OCORE.commons;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
|
||||
namespace Fuchs.intranet;
|
||||
|
||||
/// <summary>
|
||||
/// How a "set" (mfr__items with <c>Type = "set"</c>) and its member items are
|
||||
/// priced/displayed on the invoice. This is the shared contract between the
|
||||
/// front-end (which lets the user pick the mode and groups the items) and the
|
||||
/// back-end PDF renderer. Unit-tested in <c>InvoiceSetPricingTests</c>.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>A resolved invoice display line (after applying the set display mode).</summary>
|
||||
public sealed class InvoiceSetLine
|
||||
{
|
||||
public string Title { get; init; } = "";
|
||||
public string Desc { get; init; } = "";
|
||||
public string Qty { get; init; } = "1";
|
||||
public decimal PriceNet { get; init; }
|
||||
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>True for the set header line (rendered emphasised).</summary>
|
||||
public bool IsSetHeader { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transforms raw invoice line items into display lines according to a
|
||||
/// <see cref="SetDisplayMode"/>. A "set" item is identified by
|
||||
/// <c>type == "set"</c>; its members carry <c>setId</c> equal to the set
|
||||
/// 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.
|
||||
/// </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
|
||||
};
|
||||
|
||||
/// <summary>Reads the set mode from an InvoiceOptions CSV token like "setmode:itemprices".</summary>
|
||||
public static SetDisplayMode ModeFromInvoiceOptions(string? invoiceOptions)
|
||||
{
|
||||
foreach (var token in (invoiceOptions ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (token.StartsWith("setmode:", StringComparison.OrdinalIgnoreCase))
|
||||
return ParseMode(token["setmode:".Length..]);
|
||||
}
|
||||
return SetDisplayMode.SetPrice;
|
||||
}
|
||||
|
||||
/// <summary>True if any item in the list is a set header or set member.</summary>
|
||||
public static bool ContainsSets(IEnumerable<Dictionary<string, object?>> items) =>
|
||||
items.Any(IsSetHeader) || 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.
|
||||
/// </summary>
|
||||
public static List<InvoiceSetLine> Build(IReadOnlyList<Dictionary<string, object?>> items, SetDisplayMode mode)
|
||||
{
|
||||
var result = new List<InvoiceSetLine>();
|
||||
if (items.Count == 0) return result;
|
||||
|
||||
// Group members by their set id.
|
||||
var membersBySet = items
|
||||
.Where(i => !IsSetHeader(i) && !string.IsNullOrEmpty(SetIdOf(i)))
|
||||
.GroupBy(SetIdOf)
|
||||
.ToDictionary(g => g.Key!, g => g.ToList());
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
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);
|
||||
|
||||
switch (mode)
|
||||
{
|
||||
case SetDisplayMode.SetPrice:
|
||||
result.Add(HeaderLine(item, setTot, 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));
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(SetIdOf(item)))
|
||||
{
|
||||
// Member — already emitted next to its header above; skip standalone emission.
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(MemberLine(item, showPrice: true)); // standalone item
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
private static bool IsSetHeader(Dictionary<string, object?> i) =>
|
||||
string.Equals(i.nz("type", ""), "set", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string? SetIdOf(Dictionary<string, object?> i)
|
||||
{
|
||||
string s = i.nz("setId", "");
|
||||
return string.IsNullOrEmpty(s) ? null : s;
|
||||
}
|
||||
|
||||
private static string HeaderIdOf(Dictionary<string, object?> i)
|
||||
{
|
||||
string s = i.nz("setId", "");
|
||||
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()
|
||||
{
|
||||
Title = i.nz("title", ""),
|
||||
Desc = i.nz("desc", ""),
|
||||
Qty = i.nz("qty", "1"),
|
||||
PriceNet = setTotal,
|
||||
TotalNet = setTotal,
|
||||
ShowPrice = showPrice,
|
||||
IsSetHeader = true
|
||||
};
|
||||
|
||||
private static InvoiceSetLine MemberLine(Dictionary<string, object?> i, bool showPrice)
|
||||
{
|
||||
FuchsPdf.ParseDec(i.no("price_net", 0), out decimal price);
|
||||
FuchsPdf.ParseDec(i.no("total_net", 0), out decimal total);
|
||||
return new InvoiceSetLine
|
||||
{
|
||||
Title = i.nz("title", ""),
|
||||
Desc = i.nz("desc", ""),
|
||||
Qty = i.nz("qty", "1"),
|
||||
PriceNet = price,
|
||||
TotalNet = total,
|
||||
ShowPrice = showPrice,
|
||||
IsSetHeader = false
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user