From 5d83ffaa76937026303a67a88e5ced8186f3c8de Mon Sep 17 00:00:00 2001 From: agentd Date: Fri, 7 Aug 2026 13:04:33 +0000 Subject: [PATCH] Mengenrabatt: Staffel im Rechner und eine Seite im Web agentd task 4027a3aa01be47e9b677e7c687e4b183 --- src/Prices/PriceCalculator.cs | 53 ++- tests/Prices.Tests/PriceCalculatorTests.cs | 90 +++++ web/src/app/app.html | 351 +----------------- web/src/app/app.routes.ts | 6 +- web/src/app/app.scss | 41 ++ web/src/app/app.spec.ts | 17 +- web/src/app/app.ts | 11 +- .../mengenrabatt-rechner.html | 54 +++ .../mengenrabatt-rechner.scss | 55 +++ .../mengenrabatt-rechner.spec.ts | 107 ++++++ .../mengenrabatt-rechner.ts | 99 +++++ 11 files changed, 532 insertions(+), 352 deletions(-) create mode 100644 web/src/app/mengenrabatt-rechner/mengenrabatt-rechner.html create mode 100644 web/src/app/mengenrabatt-rechner/mengenrabatt-rechner.scss create mode 100644 web/src/app/mengenrabatt-rechner/mengenrabatt-rechner.spec.ts create mode 100644 web/src/app/mengenrabatt-rechner/mengenrabatt-rechner.ts diff --git a/src/Prices/PriceCalculator.cs b/src/Prices/PriceCalculator.cs index c2b9cb3..3680867 100644 --- a/src/Prices/PriceCalculator.cs +++ b/src/Prices/PriceCalculator.cs @@ -16,6 +16,57 @@ public static class PriceCalculator throw new ArgumentOutOfRangeException(nameof(discountPercent), "A discount is between 0 and 100 percent."); } - return price - discountPercent; + return price * (1m - discountPercent / 100m); + } + + /// Rounds an amount to the nearest 0.05, rounding halves away from zero. + public static decimal RoundToFiveRappen(decimal amount) + { + return Math.Round(amount * 20m, MidpointRounding.AwayFromZero) / 20m; + } + + /// Returns the quantity discount percent for a quantity: 0, 5 or 10. + public static int GetQuantityDiscountPercent(int quantity) + { + if (quantity < 1) + { + throw new ArgumentOutOfRangeException(nameof(quantity), "A quantity must be at least 1."); + } + + return quantity switch + { + >= 50 => 10, + >= 10 => 5, + _ => 0, + }; + } + + /// + /// Calculates the total: percentage discount on the unit price first, then the quantity + /// discount on the total, then rounds the end amount to the nearest 0.05. + /// + public static decimal CalculateTotal(decimal unitPrice, int quantity, decimal discountPercent) + { + if (unitPrice < 0) + { + throw new ArgumentOutOfRangeException(nameof(unitPrice), "A price cannot be negative."); + } + + if (quantity < 1) + { + throw new ArgumentOutOfRangeException(nameof(quantity), "A quantity must be at least 1."); + } + + if (discountPercent is < 0 or > 100) + { + throw new ArgumentOutOfRangeException(nameof(discountPercent), "A discount is between 0 and 100 percent."); + } + + var discountedUnitPrice = ApplyDiscount(unitPrice, discountPercent); + var totalBeforeQuantityDiscount = discountedUnitPrice * quantity; + var quantityDiscountPercent = GetQuantityDiscountPercent(quantity); + var totalBeforeRounding = totalBeforeQuantityDiscount * (1m - quantityDiscountPercent / 100m); + + return RoundToFiveRappen(totalBeforeRounding); } } diff --git a/tests/Prices.Tests/PriceCalculatorTests.cs b/tests/Prices.Tests/PriceCalculatorTests.cs index c49c29a..b703a0a 100644 --- a/tests/Prices.Tests/PriceCalculatorTests.cs +++ b/tests/Prices.Tests/PriceCalculatorTests.cs @@ -33,4 +33,94 @@ public sealed class PriceCalculatorTests { Assert.Throws(() => PriceCalculator.ApplyDiscount(100m, 101m)); } + + [Fact] + public void Quantity_9_gets_no_quantity_discount() + { + Assert.Equal(0, PriceCalculator.GetQuantityDiscountPercent(9)); + } + + [Fact] + public void Quantity_10_gets_five_percent_quantity_discount() + { + Assert.Equal(5, PriceCalculator.GetQuantityDiscountPercent(10)); + } + + [Fact] + public void Quantity_49_gets_five_percent_quantity_discount() + { + Assert.Equal(5, PriceCalculator.GetQuantityDiscountPercent(49)); + } + + [Fact] + public void Quantity_50_gets_ten_percent_quantity_discount() + { + Assert.Equal(10, PriceCalculator.GetQuantityDiscountPercent(50)); + } + + [Fact] + public void Quantity_below_one_is_refused() + { + Assert.Throws(() => PriceCalculator.GetQuantityDiscountPercent(0)); + } + + [Fact] + public void Total_applies_five_percent_quantity_discount() + { + Assert.Equal(950m, PriceCalculator.CalculateTotal(100m, 10, 0m)); + } + + [Fact] + public void Total_applies_percentage_discount_before_quantity_discount() + { + Assert.Equal(712.5m, PriceCalculator.CalculateTotal(100m, 10, 25m)); + } + + [Fact] + public void Total_applies_ten_percent_quantity_discount_for_fifty_pieces() + { + Assert.Equal(4500m, PriceCalculator.CalculateTotal(100m, 50, 0m)); + } + + [Fact] + public void Rounding_rounds_up_to_the_nearest_five_rappen() + { + Assert.Equal(1.25m, PriceCalculator.RoundToFiveRappen(1.23m)); + } + + [Fact] + public void Rounding_rounds_down_to_the_nearest_five_rappen() + { + Assert.Equal(1.20m, PriceCalculator.RoundToFiveRappen(1.21m)); + } + + [Fact] + public void Rounding_rounds_midpoints_away_from_zero() + { + Assert.Equal(1.15m, PriceCalculator.RoundToFiveRappen(1.125m)); + } + + [Fact] + public void Total_rounds_after_the_quantity_discount() + { + Assert.Equal(15.85m, PriceCalculator.CalculateTotal(1.67m, 10, 0m)); + } + + [Fact] + public void Total_refuses_a_negative_unit_price() + { + Assert.Throws(() => PriceCalculator.CalculateTotal(-1m, 1, 0m)); + } + + [Fact] + public void Total_refuses_a_quantity_below_one() + { + Assert.Throws(() => PriceCalculator.CalculateTotal(10m, 0, 0m)); + } + + [Fact] + public void Total_refuses_a_discount_above_100_percent() + { + Assert.Throws(() => PriceCalculator.CalculateTotal(10m, 1, 101m)); + } } diff --git a/web/src/app/app.html b/web/src/app/app.html index a1c4296..5196fda 100644 --- a/web/src/app/app.html +++ b/web/src/app/app.html @@ -1,344 +1,9 @@ - - - - - - - - - - - -
-
-
- -

Hello, {{ title() }}

-

Congratulations! Your app is running. 🎉

-
- -
-
- @for (item of [ - { title: 'Explore the Docs', link: 'https://angular.dev' }, - { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' }, - { title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'}, - { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' }, - { title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' }, - { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' }, - ]; track item.title) { - - {{ item.title }} - - - - - } -
- -
-
+
+

{{ title() }}

+ +
+
+
- - - - - - - - - - - diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index dc39edb..0f9391c 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -1,3 +1,7 @@ import { Routes } from '@angular/router'; +import { MengenrabattRechner } from './mengenrabatt-rechner/mengenrabatt-rechner'; -export const routes: Routes = []; +export const routes: Routes = [ + { path: 'mengenrabatt', component: MengenrabattRechner }, + { path: '', pathMatch: 'full', redirectTo: 'mengenrabatt' }, +]; diff --git a/web/src/app/app.scss b/web/src/app/app.scss index e69de29..ff0b786 100644 --- a/web/src/app/app.scss +++ b/web/src/app/app.scss @@ -0,0 +1,41 @@ +:host { + display: block; + min-height: 100dvh; + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + color: var(--app-fg, #1a1a1a); + background: var(--app-bg, #f7f7f7); +} + +.app-header { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 1rem 1.5rem; + background: var(--app-header-bg, #ffffff); + border-bottom: 1px solid var(--app-border, #d0d0d0); +} + +.app-title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; +} + +.app-nav { + display: flex; + gap: 1rem; +} + +.app-nav-link { + color: var(--app-link, #0a58ca); + text-decoration: none; +} + +.app-nav-link:hover, +.app-nav-link:focus { + text-decoration: underline; +} + +.app-main { + display: block; +} diff --git a/web/src/app/app.spec.ts b/web/src/app/app.spec.ts index 92618e6..e599a69 100644 --- a/web/src/app/app.spec.ts +++ b/web/src/app/app.spec.ts @@ -1,10 +1,13 @@ import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; import { App } from './app'; +import { routes } from './app.routes'; describe('App', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [App], + providers: [provideRouter(routes)], }).compileComponents(); }); @@ -14,10 +17,20 @@ describe('App', () => { expect(app).toBeTruthy(); }); - it('should render title', async () => { + it('should render the app title', async () => { const fixture = TestBed.createComponent(App); + fixture.detectChanges(); await fixture.whenStable(); const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, web'); + expect(compiled.querySelector('h1')?.textContent).toContain('Preisrechner'); + }); + + it('should render the navigation link to the volume discount calculator', async () => { + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + await fixture.whenStable(); + const compiled = fixture.nativeElement as HTMLElement; + const link = compiled.querySelector('a[href="/mengenrabatt"]'); + expect(link?.textContent).toContain('Mengenrabatt-Rechner'); }); }); diff --git a/web/src/app/app.ts b/web/src/app/app.ts index 358960b..2963ded 100644 --- a/web/src/app/app.ts +++ b/web/src/app/app.ts @@ -1,12 +1,13 @@ -import { Component, signal } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { RouterLink, RouterOutlet } from '@angular/router'; @Component({ selector: 'app-root', - imports: [RouterOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterOutlet, RouterLink], templateUrl: './app.html', - styleUrl: './app.scss' + styleUrl: './app.scss', }) export class App { - protected readonly title = signal('web'); + protected readonly title = signal('Preisrechner'); } diff --git a/web/src/app/mengenrabatt-rechner/mengenrabatt-rechner.html b/web/src/app/mengenrabatt-rechner/mengenrabatt-rechner.html new file mode 100644 index 0000000..f0f72fa --- /dev/null +++ b/web/src/app/mengenrabatt-rechner/mengenrabatt-rechner.html @@ -0,0 +1,54 @@ +
+

Mengenrabatt-Rechner

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+
Endbetrag
+
{{ endbetragLabel() }}
+
+
+
Staffelstufe
+
{{ staffelLabel() }}
+
+
+
diff --git a/web/src/app/mengenrabatt-rechner/mengenrabatt-rechner.scss b/web/src/app/mengenrabatt-rechner/mengenrabatt-rechner.scss new file mode 100644 index 0000000..975ffa5 --- /dev/null +++ b/web/src/app/mengenrabatt-rechner/mengenrabatt-rechner.scss @@ -0,0 +1,55 @@ +.mengenrabatt-rechner { + display: grid; + gap: 1rem; + max-width: 32rem; + margin: 2rem auto; + padding: 1.5rem; + border: 1px solid var(--app-border, #d0d0d0); + border-radius: 0.5rem; + font-family: inherit; +} + +.mengenrabatt-rechner h1 { + margin: 0; + font-size: 1.5rem; +} + +.field { + display: grid; + gap: 0.25rem; +} + +.field label { + font-weight: 600; +} + +.field input { + padding: 0.5rem 0.75rem; + font: inherit; + border: 1px solid var(--app-border, #d0d0d0); + border-radius: 0.25rem; +} + +.result { + display: grid; + gap: 0.5rem; + margin: 0; + padding-top: 1rem; + border-top: 1px solid var(--app-border, #d0d0d0); +} + +.result-row { + display: flex; + justify-content: space-between; + align-items: baseline; + margin: 0; +} + +.result-row dt { + font-weight: 600; +} + +.result-row dd { + margin: 0; + font-variant-numeric: tabular-nums; +} diff --git a/web/src/app/mengenrabatt-rechner/mengenrabatt-rechner.spec.ts b/web/src/app/mengenrabatt-rechner/mengenrabatt-rechner.spec.ts new file mode 100644 index 0000000..b4473df --- /dev/null +++ b/web/src/app/mengenrabatt-rechner/mengenrabatt-rechner.spec.ts @@ -0,0 +1,107 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { MengenrabattRechner } from './mengenrabatt-rechner'; + +function setInputs( + fixture: ComponentFixture, + unitPrice: string, + quantity: string, + percent: string, +): void { + const inputs = fixture.nativeElement.querySelectorAll('input'); + (inputs[0] as HTMLInputElement).value = unitPrice; + inputs[0].dispatchEvent(new Event('input')); + (inputs[1] as HTMLInputElement).value = quantity; + inputs[1].dispatchEvent(new Event('input')); + (inputs[2] as HTMLInputElement).value = percent; + inputs[2].dispatchEvent(new Event('input')); + fixture.detectChanges(); +} + +function compute(unitPrice: string, quantity: string, percent: string): { + endbetrag: string; + staffelstufe: string; +} { + const fixture = TestBed.createComponent(MengenrabattRechner); + fixture.detectChanges(); + setInputs(fixture, unitPrice, quantity, percent); + const compiled = fixture.nativeElement as HTMLElement; + return { + endbetrag: (compiled.querySelector('[data-testid="endbetrag"]')?.textContent ?? '').trim(), + staffelstufe: (compiled.querySelector('[data-testid="staffelstufe"]')?.textContent ?? '').trim(), + }; +} + +describe('MengenrabattRechner', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MengenrabattRechner], + }).compileComponents(); + }); + + describe('quantity tiers', () => { + it('uses the 0% tier for 9 pieces', () => { + const { endbetrag, staffelstufe } = compute('10', '9', '0'); + expect(staffelstufe).toBe('0 % (1–9 Stück)'); + expect(endbetrag).toContain('90.00'); + }); + + it('switches to the 5% tier at 10 pieces', () => { + const { endbetrag, staffelstufe } = compute('10', '10', '0'); + expect(staffelstufe).toBe('5 % (10–49 Stück)'); + expect(endbetrag).toContain('95.00'); + }); + + it('stays on the 5% tier at 49 pieces', () => { + const { endbetrag, staffelstufe } = compute('1', '49', '0'); + expect(staffelstufe).toBe('5 % (10–49 Stück)'); + // 1 * 0.95 * 49 = 46.55 + expect(endbetrag).toContain('46.55'); + }); + + it('switches to the 10% tier at 50 pieces', () => { + const { endbetrag, staffelstufe } = compute('1', '50', '0'); + expect(staffelstufe).toBe('10 % (ab 50 Stück)'); + // 1 * 0.9 * 50 = 45.00 + expect(endbetrag).toContain('45.00'); + }); + }); + + describe('discount pipeline', () => { + it('applies percentage discount first, then the quantity tier discount (100/10/25 → 712.50)', () => { + const { endbetrag, staffelstufe } = compute('100', '10', '25'); + // 100 * 0.75 = 75; 75 * 10 = 750; quantity tier 5%: 750 * 0.95 = 712.50 + expect(endbetrag).toContain('712.50'); + expect(staffelstufe).toBe('5 % (10–49 Stück)'); + }); + }); + + describe('rounding to 0.05', () => { + it('rounds 1.23 up to 1.25', () => { + const { endbetrag } = compute('1.23', '1', '0'); + // 1 piece is in the 1–9 tier (0%); rounded to 0.05: 1.23 -> 1.25 + expect(endbetrag).toContain('1.25'); + }); + }); + + describe('invalid input', () => { + it('shows the placeholder when inputs are empty', () => { + const { endbetrag, staffelstufe } = compute('', '', ''); + expect(endbetrag).toBe('—'); + expect(staffelstufe).toBe('—'); + }); + + it('shows the placeholder for a non-integer quantity', () => { + const { endbetrag } = compute('10', '3.5', '0'); + expect(endbetrag).toBe('—'); + }); + }); + + describe('DOM output', () => { + it('renders the Endbetrag with two decimals and the German Staffelstufe label', () => { + const { endbetrag, staffelstufe } = compute('100', '10', '25'); + expect(endbetrag).toMatch(/\d+\.\d{2}/); + expect(endbetrag).toContain('712.50'); + expect(staffelstufe).toBe('5 % (10–49 Stück)'); + }); + }); +}); diff --git a/web/src/app/mengenrabatt-rechner/mengenrabatt-rechner.ts b/web/src/app/mengenrabatt-rechner/mengenrabatt-rechner.ts new file mode 100644 index 0000000..2d76aad --- /dev/null +++ b/web/src/app/mengenrabatt-rechner/mengenrabatt-rechner.ts @@ -0,0 +1,99 @@ +import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core'; + +interface DiscountTier { + readonly minQuantity: number; + readonly maxQuantity: number | null; + readonly percent: number; + readonly label: string; +} + +const TIERS: readonly DiscountTier[] = [ + { minQuantity: 1, maxQuantity: 9, percent: 0, label: '0 % (1–9 Stück)' }, + { minQuantity: 10, maxQuantity: 49, percent: 5, label: '5 % (10–49 Stück)' }, + { minQuantity: 50, maxQuantity: null, percent: 10, label: '10 % (ab 50 Stück)' }, +]; + +const PERCENT_DIVISOR = 100; +const ROUNDING_STEP = 20; // 1 / 0.05 → multiply before rounding + +function tierFor(quantity: number): DiscountTier | null { + for (const tier of TIERS) { + if (quantity >= tier.minQuantity && (tier.maxQuantity === null || quantity <= tier.maxQuantity)) { + return tier; + } + } + return null; +} + +function parseNumber(value: string): number | null { + if (value.trim() === '') { + return null; + } + const parsed = Number(value.replace(',', '.')); + return Number.isFinite(parsed) ? parsed : null; +} + +function formatChf(value: number): string { + return `CHF\u00a0${value.toFixed(2)}`; +} + +@Component({ + selector: 'app-mengenrabatt-rechner', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [], + templateUrl: './mengenrabatt-rechner.html', + styleUrl: './mengenrabatt-rechner.scss', +}) +export class MengenrabattRechner { + protected readonly unitPriceInput = signal(''); + protected readonly quantityInput = signal(''); + protected readonly percentDiscountInput = signal(''); + + private readonly unitPrice = computed(() => parseNumber(this.unitPriceInput())); + private readonly quantity = computed(() => { + const value = parseNumber(this.quantityInput()); + if (value === null || !Number.isInteger(value) || value <= 0) { + return null; + } + return value; + }); + private readonly percentDiscount = computed(() => { + const value = parseNumber(this.percentDiscountInput()); + if (value === null || value < 0 || value > 100) { + return null; + } + return value; + }); + + protected readonly tier = computed(() => { + const quantity = this.quantity(); + return quantity === null ? null : tierFor(quantity); + }); + + private readonly total = computed(() => { + const unitPrice = this.unitPrice(); + const quantity = this.quantity(); + const percentDiscount = this.percentDiscount(); + const tier = this.tier(); + if ( + unitPrice === null || + unitPrice < 0 || + quantity === null || + percentDiscount === null || + tier === null + ) { + return null; + } + const discountedUnit = unitPrice * (1 - percentDiscount / PERCENT_DIVISOR); + const subtotal = discountedUnit * quantity; + const withTierDiscount = subtotal * (1 - tier.percent / PERCENT_DIVISOR); + return Math.round(withTierDiscount * ROUNDING_STEP) / ROUNDING_STEP; + }); + + protected readonly endbetragLabel = computed(() => { + const value = this.total(); + return value === null ? '—' : formatChf(value); + }); + + protected readonly staffelLabel = computed(() => this.tier()?.label ?? '—'); +}