Browse Source

Fix variant price reset on Import; bump version to 1.3.4

Import creates product variants with price 0. GetVariants() synced the
variant list prices from the products, so it overwrote the predefined
Cena prices with 0 and persisted them to settings on form close,
forcing the user to re-enter every price after each import.

A zero (unset) product price no longer overwrites an existing variant
price; a genuine non-zero price still syncs as before.

- ProductStorage.GetVariants: guard against zero-price overwrite
- Add ProductStorageTests covering import price-preservation
- Bump App AssemblyVersion/FileVersion/Version and Setup ProductVersion
  to 1.3.4 (new ProductCode/PackageCode, UpgradeCode kept)
- Update root README.md and App/README.md changelog, add CLAUDE.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dalibor Votruba 1 month ago
parent
commit
30cb1d2ecd

+ 7 - 2
App/ProductStorage/ProductStorage.cs

@@ -212,8 +212,13 @@ namespace qdr.app.studiou.orders2printpack.ProductStorage
                 }
                 else
                 {
-                    if (_variants.First(x => x.Name == variant.Name).Price != variant.Price)
-                        _variants.First(x => x.Name == variant.Name).Price = variant.Price;
+                    // Keep the existing (e.g. predefined) price when the product variant has
+                    // no price set (0). Freshly imported variants default to price 0 and must
+                    // not overwrite a price the user already configured. Only sync a real,
+                    // non-zero price coming from the products.
+                    var existing = _variants.First(x => x.Name == variant.Name);
+                    if (variant.Price != 0 && existing.Price != variant.Price)
+                        existing.Price = variant.Price;
 
                 }
             }

+ 12 - 0
App/README.md

@@ -2,6 +2,18 @@
 
 Proprietální řešení zpracování WooCommerce objednávek pro online tiskovou službu (WinForm)
 ## Changelog
+### 1.3.4 (2026-06-09)
+- Oprava: import produktů již nepřepisuje ceny v číselníku variant na 0. Předdefinované ceny variant (sloupec "Cena") zůstávají po importu zachovány.
+
+### 1.3.3
+- Přidán tag pro zpracování e-mailu (processing email) při tisku štítků
+
+### 1.3.2
+- Oprava agregace množství (quantity) při generování štítků
+
+### 1.3.1
+- Přidán sloupec "Vlastnost 1 varianta"
+
 ### 1.3.0 (2025-05-26)
 - Přidán TSS REST API client (v.1.0.0)
 - Rozšíření konfigurace o hodnoty pro TSS REST API client

+ 3 - 3
App/qdr.app.studiou.orders2printpack.csproj

@@ -16,9 +16,9 @@
     <SignAssembly>True</SignAssembly>
     <AssemblyOriginatorKeyFile>..\bo_strong_key_pair.snk</AssemblyOriginatorKeyFile>
     <Platforms>AnyCPU;x86;x64</Platforms>
-    <AssemblyVersion>1.3.3.0</AssemblyVersion>
-    <FileVersion>1.3.3.0</FileVersion>
-    <Version>1.3.3</Version>
+    <AssemblyVersion>1.3.4.0</AssemblyVersion>
+    <FileVersion>1.3.4.0</FileVersion>
+    <Version>1.3.4</Version>
 	<ApplicationHighDpiMode>SystemAware</ApplicationHighDpiMode>
     <ForceDesignerDpiUnaware>true</ForceDesignerDpiUnaware>
   </PropertyGroup>

+ 62 - 0
CLAUDE.md

@@ -0,0 +1,62 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Overview
+
+Proprietary Windows Forms (.NET 9) desktop tool for **StudioU**, an online photo-print service. It turns WooCommerce order exports into print-ready file packages, product catalogs, printable labels, and emailed digital downloads. UI text, comments, and CSV column names are predominantly **Czech** — match that when editing user-facing strings.
+
+## Build / Test / Run
+
+```powershell
+# Build whole solution
+dotnet build qdr.app.studiou.orders2printpack.sln
+
+# Run the app (WinExe Windows Forms — Windows only)
+dotnet run --project App
+
+# Run all tests (NUnit)
+dotnet test
+
+# Run a single test fixture or test
+dotnet test --filter "FullyQualifiedName~EmailStorageProcessorTests"
+dotnet test --filter "Name=MethodNameHere"
+```
+
+Notes:
+- App targets `net9.0-windows7.0`; Test targets `net9.0-windows`. `Nullable` and `ImplicitUsings` are enabled in both.
+- The App assembly is **signed** with `bo_strong_key_pair.snk` (referenced as `..\bo_strong_key_pair.snk`).
+- `Setup/Setup.vdproj` is a Visual Studio Installer project — only buildable in Visual Studio with the installer-projects extension, not via `dotnet`. Bump `Version`/`AssemblyVersion`/`FileVersion` in `App/...csproj` and the README changelog together when releasing (see commit history convention).
+
+### External (NuGet) dependencies that aren't in this repo
+- `qdr.fnd.core` (Quadarax.Foundation.Core) — shared foundation: `ILogger`/`ILog` logging, `FileSearch`, `FileUtils`, `CsvHelper`/`CsvUtils` (DataTable CSV), and `ParameterizedString` (the `{token}` substitution engine used for output filenames). Source is not in this repo; treat as an opaque library.
+- `qdr.app.tss.client` — REST client for Quadarax **Temporary Shared Storage (TSS)**; used to upload ZIPs and get a download permalink (`FileUploadService`, `UploadFileRequest`).
+- `DocumentFormat.OpenXml` — used to generate `.xlsx` label sheets without Office installed.
+
+## Architecture
+
+`Program.cs` launches `FormLauncher`, a hub with buttons opening four independent workflows plus a config editor. Each workflow is a self-contained `Form` with its own logic — there is no shared controller/service layer; forms read/write the shared `AppSettings` (user settings) directly.
+
+The four workflows:
+
+1. **FormBatch** — the core "orders → print package" flow. Loads a WooCommerce order CSV into a `DataTable`, scans a source RAW-image folder (`FileSearch`), matches each order line's image URL to a local file by filename, then copies/renames files into the output folder. Output paths are built from `AppSettings.OutputFileMask` via `ParameterizedString` (`{order_no}`, `{prod_name}`, `{ordinal}`, `{ext}`, plus every CSV column normalized to lower_snake_case). Quantity expands to multiple numbered copies. Lines whose format matches `FormatDigitalContains` **and** have an email are routed into a per-email subfolder (the "digital" path) instead of print. Can also emit a protocol CSV (`GenerateProtocol`) stamping external order number/date.
+
+2. **FormProductEdit** + `ProductStorage/` — edit/import the product catalog. `ProductStorage` holds a flat list of `ProductDto` rows where a row is either a product (`Type == "variable"`) or a variant (`Type == "variation"`, linked to parent by SKU). Variants and categories are derived views over that list. `ProductDto.IsValid` encodes the catalog validity rules. Import builds URLs/descriptions from `AppSettings.ImportTpl*` templates.
+
+3. **FormLabelPrint** + `Labels/ExcelLabelGenerator.cs` — CSV → printable `.xlsx` label sheet. `ProcessData` first aggregates rows (group-by `LabelAggregateColumns`, building computed columns via `LabelAggegationDataExpression`). `ExcelLabelGenerator` lays labels out in an N×M grid per A4 page using OpenXML, expanding `{columnName}` placeholders and a lightweight markup in `LabelTemplate`: `**bold**`, `__italic__`, `_underline_`, `^^size^text^^`, and `{cr}` → newline.
+
+4. **FormDigitalSend** + `EmailStorage/EmailStorageProcessor.cs` — emails download links for digital orders. `EmailStorageProcessor` is the substantive class here (FormDigitalSend itself is largely a stub). It treats each subdirectory of an input folder as one recipient (directory name = email address), and drives a **state machine** persisted in `metadata.json`: `Ready → Processing → Uploading → Sending → Sent` (or `Error`). Per item it zips the JPGs, uploads to TSS, generates a download password + permalink, personalizes the email template (`{{DOWNLOAD_LINK}}`, `{{PASSWORD}}`, `{{RECIPIENT}}`, `{{ORDERS}}`), sends via SMTP, and moves the ZIP through hidden dirs `.processing/` → `.sending/` → `.sent/`. Order numbers are parsed from filenames matching `^(\d+)_.*\.jpg$`.
+
+### CSV handling — two distinct parsers
+- **`CsvParser`** (`App/Extensions/CsvParserUtility.cs`) — reflection/attribute-based; maps CSV columns to typed properties via `[CsvColumn("Header Name")]`. Used for the strongly-typed product catalog (`ProductDto`).
+- **`CsvHelper` / `CsvUtils`** (DataTable-based, mostly from `qdr.fnd.core`) — used by FormBatch and FormLabelPrint where columns are dynamic and addressed by name at runtime.
+
+Both respect `AppSettings.CSVDelimiter`.
+
+### Configuration (`AppSettings`)
+All operational config lives in the generated `Properties/AppSettings` user-settings class (`AppSettings.settings`), edited at runtime through `dlgSettings` / `dlgEditCfg`. Key groups: `MapCol*` (map logical fields to actual CSV column names — change these, not the code, when an export's headers differ), output/import templates, label layout + template, TSS credentials (`TSSApiKey`/`TSSUri`/`TSSChunkSizeBytes`), and SMTP. "Reset config" on the launcher restores defaults.
+
+### Testability convention
+File I/O goes through `System.IO.Abstractions` (`IFileSystem`), so logic is unit-testable with `MockFileSystem`. New file-touching logic should take/use `IFileSystem` rather than `System.IO.File`/`Directory` directly. Tests use **NUnit + Moq + System.IO.Abstractions.TestingHelpers**; `EmailStorageProcessorTests` is the model to follow.
+</content>
+</invoke>

+ 156 - 0
README.md

@@ -0,0 +1,156 @@
+# StudioU Orders2PrintPack
+
+**Verze 1.3.4** · Quadarax © 2025
+
+Proprietární desktopová aplikace (Windows Forms, .NET 9) pro online tiskovou službu **StudioU**. Zpracovává exporty objednávek z WooCommerce a převádí je na podklady připravené k tisku, katalog produktů, tiskové štítky a e-mailové odesílání digitálního obsahu.
+
+> Uživatelské rozhraní, názvy sloupců CSV i texty jsou v češtině.
+
+---
+
+## Hlavní funkce
+
+Aplikace se spouští úvodní obrazovkou (`FormLauncher`), ze které jsou dostupné čtyři samostatné moduly a editor konfigurace.
+
+### 1. Příprava balíčků k tisku (`FormBatch`)
+Hlavní pracovní postup „objednávky → balíček k tisku":
+- Načte CSV export objednávek z WooCommerce.
+- Prohledá složku se zdrojovými (RAW) soubory a podle názvu souboru spáruje každý řádek objednávky s lokálním obrázkem.
+- Zkopíruje a přejmenuje soubory do výstupní složky podle masky `OutputFileMask` (tokeny `{order_no}`, `{prod_name}`, `{ordinal}`, `{ext}` a libovolný sloupec CSV).
+- Množství (`qty`) se rozkopíruje do číslovaných kopií.
+- Řádky odpovídající formátu digitálního obsahu (`FormatDigitalContains`) s vyplněným e-mailem se směrují do podsložky podle e-mailu místo tisku.
+- Umožňuje vygenerovat protokol (`.csv`) s číslem a datem externí objednávky.
+
+### 2. Editace produktů (`FormProductEdit`)
+Editace a import katalogu produktů a variant:
+- Produkt (`variable`) nebo varianta (`variation`) navázaná na rodiče přes SKU.
+- Generování URL a popisů z šablon (`ImportTpl*`).
+- Kontrola validity a duplicit, editace hodnot Minimum/Maximum množství.
+
+**Číselník variant a ceny.** Pravý panel obsahuje číselník variant (sloupce *Jméno*, *Cena*,
+*Min. Počet*, *Max. Počet*). Tento číselník slouží jako referenční ceník a ukládá se mezi spuštěními
+do uživatelské konfigurace (`PredefinedVariants`); při otevření okna se z ní obnoví.
+
+Doporučený postup pro ceny:
+1. **Import** ze složkové struktury (`kategorie/varianta/soubor`) vytvoří produkty a varianty.
+   Importované varianty vznikají s cenou `0` (cena se z importu nepřebírá).
+2. Cenu nastavíte v číselníku variant a tlačítkem **Použít** ji hromadně přenesete na všechny
+   produktové varianty se shodným názvem.
+
+Číselník variant si ceny drží i při importu – produkt s nulovou (nenastavenou) cenou referenční
+cenu nepřepíše. Skutečná nenulová cena zadaná na produktu se do číselníku naopak promítne.
+
+### 3. Tisk štítků (`FormLabelPrint`)
+Generování tiskových štítků z CSV do souboru `.xlsx` (Excel, přes OpenXML — bez nutnosti Office):
+- Agregace řádků podle `LabelAggregateColumns` a tvorba počítaných sloupců (`LabelAggegationDataExpression`).
+- Rozvržení štítků do mřížky N×M na stránku A4.
+- Šablona `LabelTemplate` podporuje zástupné symboly `{nazevSloupce}` a jednoduché formátování: `**tučně**`, `__kurzíva__`, `_podtržení_`, `^^velikost^text^^` a `{cr}` pro nový řádek.
+
+### 4. Odeslání digitálních dat (`FormDigitalSend` / `EmailStorageProcessor`)
+Odesílání odkazů ke stažení pro digitální objednávky:
+- Každá podsložka vstupního adresáře = jeden příjemce (název složky = e-mail).
+- Stavový automat ukládaný do `metadata.json`: `Ready → Processing → Uploading → Sending → Sent` (případně `Error`).
+- Pro každou položku: zabalí JPG do ZIP, nahraje do **Temporary Shared Storage (TSS)**, vygeneruje heslo a permalink ke stažení, personalizuje e-mailovou šablonu (`{{DOWNLOAD_LINK}}`, `{{PASSWORD}}`, `{{RECIPIENT}}`, `{{ORDERS}}`) a odešle přes SMTP.
+- ZIP soubory procházejí skrytými adresáři `.processing/` → `.sending/` → `.sent/`.
+
+---
+
+## Požadavky
+
+- Windows (Windows Forms, cíl `net9.0-windows7.0`)
+- .NET 9 SDK
+- Externí NuGet závislosti:
+  - `qdr.fnd.core` — sdílená knihovna (logování, prohledávání souborů, CSV, `ParameterizedString`)
+  - `qdr.app.tss.client` — REST klient pro Temporary Shared Storage
+  - `DocumentFormat.OpenXml` — generování `.xlsx` štítků
+
+## Sestavení a spuštění
+
+```powershell
+# Sestavení celého řešení
+dotnet build qdr.app.studiou.orders2printpack.sln
+
+# Spuštění aplikace
+dotnet run --project App
+
+# Spuštění testů (NUnit)
+dotnet test
+```
+
+Instalátor (`Setup/Setup.vdproj`) je projekt Visual Studio Installer — lze sestavit pouze ve Visual Studiu s rozšířením pro instalační projekty, ne přes `dotnet`.
+
+## Konfigurace
+
+Veškeré nastavení je uloženo v uživatelské konfiguraci (`Properties/AppSettings`) a edituje se za běhu přes `dlgSettings` / `dlgEditCfg`. Klíčové skupiny:
+- **Mapování sloupců** (`MapCol*`) — propojení logických polí s názvy sloupců v CSV. Při jiné struktuře exportu se mění zde, ne v kódu.
+- **Šablony** výstupních souborů, importu, štítků.
+- **TSS** — `TSSApiKey`, `TSSUri`, `TSSChunkSizeBytes`.
+- **SMTP** — server, port, přihlášení, SSL.
+
+Tlačítko „Reset konfigurace" na úvodní obrazovce obnoví výchozí hodnoty.
+
+---
+
+## Changelog
+
+### 1.3.4 (2026-06-09)
+- Oprava: import produktů již nepřepisuje ceny v číselníku variant na `0`. Předdefinované ceny variant (sloupec *Cena*) zůstávají po importu zachovány a není nutné je zadávat znovu ručně.
+
+### 1.3.3 (2025-06)
+- Přidán tag pro zpracování e-mailu (processing email) při tisku štítků
+
+### 1.3.2
+- Oprava agregace množství (quantity) při generování štítků
+
+### 1.3.1
+- Přidán sloupec „Vlastnost 1 varianta" (`[CsvColumn("Vlastnost 1 varianta")]`)
+
+### 1.3.0 (2025-05-26)
+- Přidán TSS REST API client (v.1.0.0)
+- Rozšíření konfigurace o hodnoty pro TSS REST API client
+- Přidána obrazovka „Odeslání digitálních dat"
+
+### 1.2.4 (2025-05-19)
+- Rozšíření podpory přípravy balíčků k tisku o generování digitálního obsahu podle cílového e-mailu
+- Přidán do konfigurace e-mail a identifikace formátu digitálního obsahu
+
+### 1.2.3 (2025-04-28)
+- Editace hodnot Minimum/Maximum na Produktu a Variantě
+- Editace RAW hodnot pro Kategorie produktu, variant a mapování Batch CSV sloupců + delimiter
+- Kontrola duplicit source souborů v importu Batch CSV
+
+### 1.2.2 (2025-04-01)
+- Při importu produktů se nastavuje fixní ShortDescription a Description pro produkt
+- Přidány sloupce Maximum/Minimum quantity pro varianty
+
+### 1.2.1 (2025-04-01)
+- Přidána možnost definovat agregaci při generování štítků a přidávání kalkulovaných sloupců
+- Rozšíření nastavení (konfigurace) tisku štítků
+
+### 1.2.0 (2025-03-13)
+- Upgrade na .NET 9.0
+- Přidána podpora pro tisk štítků podle importovaného .CSV do xlsx (Excel)
+- Přidána funkce generování repository v Editaci Produktů
+
+### 1.1.1 (2025-03-13)
+- Opraveno, Nový a Uložit otevře dialog pro uložení
+- Oprava importu produktů, měsíc importu v Url je formátován na dvě číslice
+- Oprava importu produktů, při importu se SKU složí ze jména importovaného souboru bez koncovky
+- Oprava výchozího mapování sloupců
+
+### 1.1.0 (2024-10-16)
+- Přidán launch screen
+- Přidána obrazovka Editace produktů s podporou importu ze struktury
+
+### 1.0.2 (2024-10-05)
+- Úprava mapování na sloupečky CSV, nastavení defaultů
+- Přidáno nastavení pro promazání výstupního adresáře, včetně nastavení
+
+### 1.0.1 (2024-09-27)
+- Přidána podpora generování protokolu (.csv)
+- Rozšíření datové struktury o položky OutputFileName, ExternalOrder, ExternalOrderDate
+- Oprava chyb UI
+
+### 1.0.0 (2024-09-27)
+- První verze
+</content>

+ 3 - 3
Setup/Setup.vdproj

@@ -198,15 +198,15 @@
         {
         "Name" = "8:Microsoft Visual Studio"
         "ProductName" = "8:Order2PrintPack"
-        "ProductCode" = "8:{1931821E-83E3-4602-9EEA-7B36045ADC75}"
-        "PackageCode" = "8:{9042A1B7-DF0D-4969-8574-0B5FF20C5B53}"
+        "ProductCode" = "8:{D42F8167-1B20-4152-9060-F35390D35817}"
+        "PackageCode" = "8:{35847375-6045-4948-BF27-6B8635DC95FA}"
         "UpgradeCode" = "8:{38F8DC1E-8290-49ED-A3D2-32BC6DD74325}"
         "AspNetVersion" = "8:2.0.50727.0"
         "RestartWWWService" = "11:FALSE"
         "RemovePreviousVersions" = "11:TRUE"
         "DetectNewerInstalledVersion" = "11:TRUE"
         "InstallAllUsers" = "11:FALSE"
-        "ProductVersion" = "8:1.3.3"
+        "ProductVersion" = "8:1.3.4"
         "Manufacturer" = "8:Quadarax"
         "ARPHELPTELEPHONE" = "8:"
         "ARPHELPLINK" = "8:"

+ 89 - 0
Test/ProductStorageTests.cs

@@ -0,0 +1,89 @@
+using ProductStore = qdr.app.studiou.orders2printpack.ProductStorage.ProductStorage;
+
+namespace qdr.app.studiou.orders2printpack.Test
+{
+    /// <summary>
+    /// Tests around the variant price behaviour when products are imported.
+    /// Import creates product variants with Price = 0; the predefined variant
+    /// prices (loaded from settings) must survive that, otherwise the user has
+    /// to re-enter every price by hand after each import.
+    /// </summary>
+    [TestFixture]
+    public class ProductStorageTests
+    {
+        // Mirrors how ImportProducts adds a variant: a parent product plus a
+        // variation row whose VariantName matches a predefined variant, Price = 0.
+        private static void SimulateImportedVariant(ProductStore storage, string parentSku, string variantName, decimal price = 0m)
+        {
+            var parent = storage.AddNewProduct();
+            parent.Sku = parentSku;
+
+            var variant = storage.AddNewProductVariant(parentSku);
+            variant.VariantName = variantName;
+            variant.Price = price;
+        }
+
+        [Test]
+        public void Import_DoesNotResetPredefinedVariantPrice()
+        {
+            // Arrange: predefined variants with prices, as restored from settings.
+            var storage = new ProductStore();
+            storage.AppendVariantsFromString("Foto 10x15cm=150;Foto 15x23cm=120");
+
+            // Act: import brings in a matching variant priced at 0.
+            SimulateImportedVariant(storage, "P1", "Foto 10x15cm", price: 0m);
+            var variants = storage.Variants.ToList();
+
+            // Assert: the configured price is kept, not overwritten with 0.
+            var imported = variants.Single(x => x.Name == "Foto 10x15cm");
+            Assert.That(imported.Price, Is.EqualTo(150m));
+
+            // The untouched predefined variant is unaffected too.
+            var other = variants.Single(x => x.Name == "Foto 15x23cm");
+            Assert.That(other.Price, Is.EqualTo(120m));
+        }
+
+        [Test]
+        public void Import_PersistsPredefinedPriceAfterRoundTrip()
+        {
+            // Arrange
+            var storage = new ProductStore();
+            storage.AppendVariantsFromString("Foto 10x15cm=150");
+
+            // Act: import (price 0) then serialise as settings would on form close.
+            SimulateImportedVariant(storage, "P1", "Foto 10x15cm", price: 0m);
+            _ = storage.Variants.ToList();
+            var persisted = storage.GetVariantsAsString();
+
+            // Assert: the price written back to settings is still 150, not 0.
+            Assert.That(persisted, Does.Contain("Foto 10x15cm=150"));
+        }
+
+        [Test]
+        public void Variants_StillSyncsRealNonZeroProductPrice()
+        {
+            // Guard against over-correcting: a genuine non-zero product price
+            // must still update the variant, as it did before the fix.
+            var storage = new ProductStore();
+            storage.AppendVariantsFromString("Foto 10x15cm=150");
+
+            SimulateImportedVariant(storage, "P1", "Foto 10x15cm", price: 99m);
+            var imported = storage.Variants.Single(x => x.Name == "Foto 10x15cm");
+
+            Assert.That(imported.Price, Is.EqualTo(99m));
+        }
+
+        [Test]
+        public void Variants_AddsBrandNewVariantFromImport()
+        {
+            // A variant name that isn't predefined should still appear in the list.
+            var storage = new ProductStore();
+            storage.AppendVariantsFromString("Foto 10x15cm=150");
+
+            SimulateImportedVariant(storage, "P1", "Plakát A2", price: 0m);
+            var variants = storage.Variants.ToList();
+
+            Assert.That(variants.Select(x => x.Name), Does.Contain("Plakát A2"));
+        }
+    }
+}