Adapt inventory to new catalog interface

This also does a major restructuring of the inventory mutate form.

Some things still are not as they should be, but it mostly works like
before. They can be fixed later.

Co-authored-by: Theo Reichert <theo.reichert@mailbox.tu-dresden.de>
This commit is contained in:
Simon Bruder 2023-11-29 17:13:07 +01:00
parent a4099f1de0
commit 2dff2842fc
Signed by: simon
GPG key ID: 8D3C82F9F309F8EC
12 changed files with 415 additions and 238 deletions

View file

@ -18,8 +18,7 @@ package Salespoint {
package catering {
package catalog {
interface ConsumableCatalog
interface RentableCatalog
interface CateringCatalog
}
package inventory {
@ -27,16 +26,21 @@ package catering {
+ InventoryController(inventory : UniqueInventory)
+ list(model : Model) : String
+ edit(model : Model, pid : Product) : String
+ edit(model : Model, form : InventoryMutateForm) : String
+ edit(model : Model, pid : Product, form : InventoryMutateForm) : String
+ editConsumable(form: ConsumableMutateForm, result: Errors, pid : Product, model : Model) : String
+ editRentable(form: RentableMutateForm, result: Errors, pid : Product, model : Model) : String
+ edit(form : InventoryMutateForm, result : Errors, pid : Product, model : Model) : String
+ add(model : Model) : String
+ add(model : Model, type : String) : String
+ add(model : Model, form : InventoryMutateForm) : String
+ addConsumable(form : ConsumableMutateForm, result : Errors, model : Model) : String
+ addRentable(form : RentableMutateForm, result : Errors, model : Model) : String
+ add(form : InventoryMutateForm, result : Errors, model : Model) : String
+ delete(pid : Product) : String
}
InventoryController --> "1" catering.catalog.ConsumableCatalog : "-consumableCatalog"
InventoryController --> "1" catering.catalog.RentableCatalog : "-rentableCatalog"
InventoryController --> "1" catering.catalog.CateringCatalog : "-cateringCatalog"
InventoryController ..> InventoryMutateForm
InventoryController ..> ConsumableMutateForm
InventoryController ..> RentableMutateForm
InventoryController .u.> Salespoint.Product
InventoryController -u-> "1" Salespoint.UniqueInventory : "-inventory"
InventoryController .u.> Salespoint.UniqueInventoryItem
@ -48,13 +52,28 @@ package catering {
+ InventoryInitializer(inventory : UniqueInventory, catalog : CateringCatalog)
+ initialize() : void
}
InventoryInitializer --> "1" catering.catalog.ConsumableCatalog : "-consumableCatalog"
InventoryInitializer --> "1" catering.catalog.RentableCatalog : "-rentableCatalog"
InventoryInitializer --> "1" catering.catalog.CateringCatalog : "-cateringCatalog"
InventoryInitializer .u.|> Salespoint.DataInitializer
InventoryInitializer .u.> Salespoint.Quantity
InventoryInitializer -u-> "1" Salespoint.UniqueInventory : "-inventory"
InventoryInitializer .u.> Salespoint.UniqueInventoryItem
InventoryInitializer .u.> Spring.Assert
class InventoryMutateForm
class ConsumableMutateForm
class RentableMutateForm
ConsumableMutateForm <|-- InventoryMutateForm
RentableMutateForm <|-- InventoryMutateForm
InventoryMutateForm ..> Salespoint.Quantity
InventoryMutateForm ..> Salespoint.Product
ConsumableMutateForm ..> Salespoint.Product
RentableMutateForm ..> Salespoint.Product
InventoryMutateForm ..> Salespoint.UniqueInventoryItem
ConsumableMutateForm ..> Salespoint.UniqueInventoryItem
RentableMutateForm ..> Salespoint.UniqueInventoryItem
}
}
@enduml

Binary file not shown.

View file

@ -18,12 +18,17 @@ package catering.catalog;
import static org.salespointframework.core.Currencies.EURO;
import java.util.Set;
import java.util.Optional;
import org.javamoney.moneta.Money;
import org.salespointframework.core.DataInitializer;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import catering.order.OrderType;
@Component
@Order(20)
class CatalogDataInitializer implements DataInitializer {
@ -41,9 +46,21 @@ class CatalogDataInitializer implements DataInitializer {
return;
}
cateringCatalog.save(new CatalogDummy("Brötchen Vollkorn", CatalogDummyType.CONSUMABLE, Money.of(1, EURO), Money.of(0.5, EURO),
Money.of(0.75, EURO)));
cateringCatalog.save(new CatalogDummy("Kerze Rot", CatalogDummyType.CONSUMABLE, Money.of(2, EURO), Money.of(1.5, EURO)));
cateringCatalog.save(new CatalogDummy("Brotschneidemaschine Power X 3000", CatalogDummyType.RENTABLE, Money.of(25, EURO), Money.of(10000, EURO)));
cateringCatalog.save(new Consumable(
"Brötchen Vollkorn",
Money.of(1, EURO),
Money.of(0.5, EURO),
Optional.of(Money.of(0.75, EURO)),
Set.of(OrderType.EVENT_CATERING, OrderType.MOBILE_BREAKFAST)));
cateringCatalog.save(new Rentable(
"Kerze Rot",
Money.of(2, EURO),
Money.of(1.5, EURO),
Set.of(OrderType.EVENT_CATERING, OrderType.MOBILE_BREAKFAST)));
cateringCatalog.save(new Rentable(
"Brotschneidemaschine Power X 3000",
Money.of(25, EURO),
Money.of(10000, EURO),
Set.of(OrderType.EVENT_CATERING, OrderType.MOBILE_BREAKFAST)));
}
}

View file

@ -1,79 +0,0 @@
/*
* Copyright (C) 2023 Simon Bruder
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package catering.catalog;
import javax.money.MonetaryAmount;
import org.salespointframework.catalog.Product;
import org.springframework.util.Assert;
import jakarta.persistence.Entity;
@Entity
public class CatalogDummy extends Product {
MonetaryAmount wholesalePrice;
// Optional<MonetaryAmount> promotionPrice;
// enterprise java goes brrr
MonetaryAmount promotionPrice;
CatalogDummyType type;
// enterprise java goes brrr
@SuppressWarnings({ "unused", "deprecation" })
private CatalogDummy() {
}
public CatalogDummy(String name, CatalogDummyType type, MonetaryAmount price, MonetaryAmount wholesalePrice,
MonetaryAmount promotionPrice) {
super(name, price);
Assert.notNull(type, "Type must not be null!");
Assert.notNull(wholesalePrice, "WholesalePricee must not be null!");
// PromotionPrice can be null.
this.type = type;
this.wholesalePrice = wholesalePrice;
this.promotionPrice = promotionPrice;
}
public CatalogDummy(String name, CatalogDummyType type, MonetaryAmount price, MonetaryAmount wholesalePrice) {
this(name, type, price, wholesalePrice, null);
}
public MonetaryAmount getWholesalePrice() {
return wholesalePrice;
}
public void setWholesalePrice(MonetaryAmount wholesalePrice) {
Assert.notNull(wholesalePrice, "WholesalePricee must not be null!");
this.wholesalePrice = wholesalePrice;
}
public MonetaryAmount getPromotionPrice() {
return promotionPrice;
}
public void setPromotionPrice(MonetaryAmount promotionPrice) {
this.promotionPrice = promotionPrice;
}
public CatalogDummyType getType() {
return this.type;
}
public void setType(CatalogDummyType type) {
Assert.notNull(type, "Type must not be null!");
this.type = type;
}
}

View file

@ -1,22 +0,0 @@
/*
* Copyright (C) 2023 Simon Bruder
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package catering.catalog;
public enum CatalogDummyType {
CONSUMABLE,
RENTABLE,
}

View file

@ -0,0 +1,87 @@
/*
* Copyright (C) 2023 Simon Bruder
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package catering.inventory;
import static org.salespointframework.core.Currencies.EURO;
import java.util.Optional;
import java.util.Set;
import javax.money.MonetaryAmount;
import javax.money.NumberValue;
import org.javamoney.moneta.Money;
import org.salespointframework.catalog.Product;
import org.salespointframework.inventory.UniqueInventoryItem;
import catering.catalog.Consumable;
import catering.order.OrderType;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero; // NonNegative in enterprise java
class ConsumableMutateForm extends InventoryMutateForm {
private @NotNull @PositiveOrZero Double wholesalePrice;
private @NotNull Optional<@PositiveOrZero Double> promotionPrice = Optional.empty();
public Double getWholesalePrice() {
return wholesalePrice;
}
public Optional<Double> getPromotionPrice() {
return promotionPrice;
}
public void setWholesalePrice(Double wholesalePrice) {
this.wholesalePrice = wholesalePrice;
}
public void setPromotionPrice(Optional<Double> promotionPrice) {
this.promotionPrice = promotionPrice;
}
@Override
public Product toProduct() {
return new Consumable(
getName(),
Money.of(getRetailPrice(), EURO),
Money.of(getWholesalePrice(), EURO),
getPromotionPrice().map(price -> Money.of(price, EURO)),
// FIXME: categories for creation of consumables are hardcoded
Set.of(OrderType.EVENT_CATERING, OrderType.MOBILE_BREAKFAST));
}
public static ConsumableMutateForm of(Consumable product, UniqueInventoryItem item) {
ConsumableMutateForm form = new ConsumableMutateForm();
form.setName(product.getName());
form.setQuantity(item.getQuantity());
form.setWholesalePrice(product.getWholesalePrice().getNumber().doubleValueExact());
form.setRetailPrice(product.getRetailPrice().getNumber().doubleValueExact());
form.setPromotionPrice(
product.getPromotionPrice().map(MonetaryAmount::getNumber).map(NumberValue::doubleValueExact));
return form;
}
@Override
protected void modifyProductPrimitive(Product product) {
if (product instanceof Consumable consumable) {
consumable.setWholesalePrice(Money.of(getWholesalePrice(), EURO));
consumable.setPromotionPrice(getPromotionPrice().map(price -> Money.of(price, EURO)));
} else {
throw new IllegalArgumentException("ConsumableMutateForm can only modify instances of Consumable");
}
}
}

View file

@ -16,14 +16,6 @@
*/
package catering.inventory;
import static org.salespointframework.core.Currencies.EURO;
import java.util.Optional;
import javax.money.MonetaryAmount;
import javax.money.NumberValue;
import org.javamoney.moneta.Money;
import org.salespointframework.catalog.Product;
import org.salespointframework.inventory.UniqueInventory;
import org.salespointframework.inventory.UniqueInventoryItem;
@ -36,11 +28,35 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import catering.catalog.CatalogDummy;
import catering.catalog.CateringCatalog;
import jakarta.validation.Valid;
/*
* TODO TL;DR: Use HandlerMethodArgumentResolver
*
* This class currently has many confusing methods that do a variety of things,
* but mostly work around polymorphic restrictions of Spring.
* Ideally, spring would allow a form to be polymorphic
* based on its attributes, if they are unique,
* or based on another parameter.
* From what I could find,
* this is not possible without implementing a custom HandlerMethodArgumentResolver.
* If there is time, the controller should be changed to use this,
* which should vastly simplify it.
*
* Adding will always require a type parameter,
* as the type is not known at first.
* However, currently there are two (or even three) POST handlers,
* as there are two different form types it must support
* (and an infinite amount of invalid types that it should ignore).
*
* Editing should not require passing any additional type parameter around
* as the type can be inferred from the product.
* Changing the type of a product should not be possible.
*/
@Controller
class InventoryController {
private final UniqueInventory<UniqueInventoryItem> inventory;
@ -64,17 +80,10 @@ class InventoryController {
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/inventory/edit/{pid}")
String edit(Model model, @PathVariable Product pid) {
CatalogDummy product = (CatalogDummy) pid;
UniqueInventoryItem item = inventory.findByProduct(pid).get();
return edit(model,
new InventoryMutateForm(product.getType(),
product.getName(),
item.getQuantity(),
product.getWholesalePrice().getNumber().doubleValueExact(),
product.getPrice().getNumber().doubleValueExact(),
Optional.ofNullable(product.getPromotionPrice())
.map(MonetaryAmount::getNumber)
.map(NumberValue::doubleValueExact)));
final InventoryMutateForm form = InventoryMutateForm.of(pid, item);
return edit(model, form);
}
String edit(Model model, InventoryMutateForm form) {
@ -85,23 +94,28 @@ class InventoryController {
}
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/inventory/edit/{pid}")
String edit(@Valid @ModelAttribute("form") InventoryMutateForm form, Errors result, @PathVariable Product pid,
Model model) {
@PostMapping(path = "/inventory/edit/{pid}", params = "type=Consumable")
String editConsumable(@Valid @ModelAttribute("form") ConsumableMutateForm form, Errors result,
@PathVariable Product pid, Model model) {
return edit(form, result, pid, model);
}
@PreAuthorize("hasRole('ADMIN')")
@PostMapping(path = "/inventory/edit/{pid}", params = "type=Rentable")
String editRentable(@Valid @ModelAttribute("form") RentableMutateForm form, Errors result,
@PathVariable Product pid, Model model) {
return edit(form, result, pid, model);
}
String edit(InventoryMutateForm form, Errors result, Product product, Model model) {
if (result.hasErrors()) {
return edit(model, form);
}
CatalogDummy product = (CatalogDummy) pid;
UniqueInventoryItem item = inventory.findByProduct(pid).get();
product.setName(form.getName());
product.setType(form.getType());
product.setPrice(Money.of(form.getRetailPrice(), EURO));
product.setWholesalePrice(Money.of(form.getWholesalePrice(), EURO));
product.setPromotionPrice(form.getPromotionPrice().map(price -> Money.of(price, EURO)).orElse(null));
form.modifyProduct(product);
product = cateringCatalog.save(product);
UniqueInventoryItem item = inventory.findByProduct(product).get();
// no setQuantity in enterprise java
// (though returing a modified object is actually nice)
inventory.save(item.increaseQuantity(form.getQuantity().subtract(item.getQuantity())));
@ -109,9 +123,17 @@ class InventoryController {
}
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/inventory/add")
String add(Model model) {
return add(model, InventoryMutateForm.empty());
@GetMapping(path = "/inventory/add")
String add(Model model, @RequestParam String type) {
switch (type) {
case "Consumable":
return add(model, new ConsumableMutateForm());
case "Rentable":
return add(model, new RentableMutateForm());
default:
// TODO better error handling
return "redirect:/inventory";
}
}
String add(Model model, InventoryMutateForm form) {
@ -121,17 +143,22 @@ class InventoryController {
}
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/inventory/add")
String add(@Valid @ModelAttribute("form") InventoryMutateForm form, Errors result, Model model) {
@PostMapping(path = "/inventory/add", params = "type=Consumable")
String addConsumable(@Valid @ModelAttribute("form") ConsumableMutateForm form, Errors result, Model model) {
return add(form, result, model);
}
@PreAuthorize("hasRole('ADMIN')")
@PostMapping(path = "/inventory/add", params = "type=Rentable")
String addRentable(@Valid @ModelAttribute("form") ConsumableMutateForm form, Errors result, Model model) {
return add(form, result, model);
}
String add(@Valid InventoryMutateForm form, Errors result, Model model) {
if (result.hasErrors()) {
return add(model, form);
}
inventory.save(new UniqueInventoryItem(
cateringCatalog
.save(new CatalogDummy(form.getName(), form.getType(), Money.of(form.getRetailPrice(), EURO),
Money.of(form.getWholesalePrice(), EURO),
form.getPromotionPrice().map(price -> Money.of(price, EURO)).orElse(null))),
form.getQuantity()));
inventory.save(new UniqueInventoryItem(cateringCatalog.save(form.toProduct()), form.getQuantity()));
return "redirect:/inventory";
}
@ -140,7 +167,7 @@ class InventoryController {
String delete(@PathVariable Product pid) {
UniqueInventoryItem item = inventory.findByProduct(pid).get();
inventory.delete(item);
cateringCatalog.delete((CatalogDummy) pid);
cateringCatalog.delete(pid);
return "redirect:/inventory";
}
}

View file

@ -16,39 +16,33 @@
*/
package catering.inventory;
import java.util.Optional;
import static org.salespointframework.core.Currencies.EURO;
import org.javamoney.moneta.Money;
import org.salespointframework.catalog.Product;
import org.salespointframework.inventory.UniqueInventoryItem;
import org.salespointframework.quantity.Quantity;
import catering.catalog.CatalogDummyType;
import catering.catalog.Consumable;
import catering.catalog.Rentable;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero; // NonNegative in enterprise java
class InventoryMutateForm {
private final @NotNull CatalogDummyType type;
private final @NotEmpty String name;
private final @NotNull Quantity quantity;
private final @NotNull double wholesalePrice, retailPrice;
private final @NotNull Optional<Double> promotionPrice;
/**
* Abstract class for handling inventory mutations.
*
* It has children for every product type.
*
* The current implementation requires {@link #forProductType(Class)} and {@link #of(Product, UniqueInventoryItem)}
* to also be updated when a new child class is added.
*/
abstract class InventoryMutateForm {
private @NotEmpty String name;
private @NotNull Quantity quantity;
private @NotNull @PositiveOrZero Double retailPrice;
public InventoryMutateForm(@NotNull CatalogDummyType type, @NotEmpty String name,
@NotNull Quantity quantity, @PositiveOrZero double wholesalePrice, @PositiveOrZero double retailPrice,
@PositiveOrZero Optional<Double> promotionPrice) {
this.type = type;
this.name = name;
this.quantity = quantity;
this.wholesalePrice = wholesalePrice;
this.retailPrice = retailPrice;
this.promotionPrice = promotionPrice;
}
public static InventoryMutateForm empty() {
return new InventoryMutateForm(null, "", Quantity.of(0), 0, 0, Optional.empty());
}
public CatalogDummyType getType() {
return type;
public InventoryMutateForm() {
}
public String getName() {
@ -59,15 +53,80 @@ class InventoryMutateForm {
return quantity;
}
public double getWholesalePrice() {
return wholesalePrice;
}
public double getRetailPrice() {
public Double getRetailPrice() {
return retailPrice;
}
public Optional<Double> getPromotionPrice() {
return promotionPrice;
public void setName(String name) {
this.name = name;
}
public void setQuantity(Quantity quantity) {
this.quantity = quantity;
}
public void setRetailPrice(Double retailPrice) {
this.retailPrice = retailPrice;
}
/**
* Creates an empty {@link InventoryMutateForm} for {@link Product}s of the given {@link Class}.
*
* @param <T> a child class of {@link Product}
* @param type the concrete {@link Product} the form should mutate
* @return an object of an {@link InventoryMutateForm} subclass
* @throws IllegalArgumentException if the {@literal type} is not supported
*/
public static <T extends Product> InventoryMutateForm forProductType(Class<T> type) {
// Java cant switch over Class in JDK17 (without preview features)
// See https://openjdk.org/jeps/406 for improvement in higher versions.
if (type.equals(Consumable.class)) {
return new ConsumableMutateForm();
} else if (type.equals(Rentable.class)) {
return new RentableMutateForm();
} else {
throw new IllegalArgumentException(
"InventoryMutateForm::forProductType not supported for given types");
}
}
/**
* Creates a populated {@link InventoryMutateForm} from a given {@link Product} and {@link UniqueInventoryItem}.
*
* @param product an instance of a {@link Product} subclass
* @param item an {@link UniqueInventoryItem} holding a {@link Quantity}
* @return an object of an {@link InventoryMutateForm} subclass
*/
public static InventoryMutateForm of(Product product, UniqueInventoryItem item) {
if (product instanceof Consumable consumable) {
return ConsumableMutateForm.of(consumable, item);
} else if (product instanceof Rentable rentable) {
return RentableMutateForm.of(rentable, item);
} else {
throw new IllegalArgumentException("InventoryMutateForm::ofProductAndItem not supported for given types");
}
}
/**
* Creates a new {@link Product} from a populated {@link InventoryMutateForm}.
*
* @return an instance of a {@link Product} subclass
*/
public abstract Product toProduct();
/**
* Modifies a given {@link Product} to match the values from the {@link InventoryMutateForm}.
*
* As the {@link Quantity} is stored inside of the {@link UniqueInventoryItem},
* it has to be updated manually.
*
* @param product the {@link Product} to be updated
*/
public void modifyProduct(Product product) {
product.setName(getName());
product.setPrice(Money.of(getRetailPrice(), EURO));
modifyProductPrimitive(product);
}
protected abstract void modifyProductPrimitive(Product product);
}

View file

@ -0,0 +1,70 @@
/*
* Copyright (C) 2023 Simon Bruder
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package catering.inventory;
import static org.salespointframework.core.Currencies.EURO;
import java.util.Set;
import org.javamoney.moneta.Money;
import org.salespointframework.catalog.Product;
import org.salespointframework.inventory.UniqueInventoryItem;
import catering.catalog.Rentable;
import catering.order.OrderType;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
class RentableMutateForm extends InventoryMutateForm {
private @NotNull @PositiveOrZero Double wholesalePrice;
public Double getWholesalePrice() {
return wholesalePrice;
}
public void setWholesalePrice(Double wholesalePrice) {
this.wholesalePrice = wholesalePrice;
}
public static RentableMutateForm of(Rentable product, UniqueInventoryItem item) {
RentableMutateForm form = new RentableMutateForm();
form.setName(product.getName());
form.setQuantity(item.getQuantity());
form.setWholesalePrice(product.getWholesalePrice().getNumber().doubleValueExact());
form.setRetailPrice(product.getRetailPrice().getNumber().doubleValueExact());
return form;
}
@Override
public Product toProduct() {
return new Rentable(
getName(),
Money.of(getRetailPrice(), EURO),
Money.of(getWholesalePrice(), EURO),
// FIXME: categories for creation of rentables are hardcoded
Set.of(OrderType.EVENT_CATERING, OrderType.MOBILE_BREAKFAST));
}
@Override
protected void modifyProductPrimitive(Product product) {
if (product instanceof Rentable rentable) {
rentable.setWholesalePrice(Money.of(getWholesalePrice(), EURO));
} else {
throw new IllegalArgumentException("RentableMutateForm can only modify instances of Rentable");
}
}
}

View file

@ -7,12 +7,9 @@
<div layout:fragment="content">
<h2 th:text="${'Produkt ' + (actionIsAdd ? 'anlegen' : 'bearbeiten')}"></h2>
<form method="post" th:object="${form}">
<div class="mb-3">
<label class="form-label" for="type">Typ</label>
<div class="form-check form-check-inline" th:each="type : ${T(catering.catalog.CatalogDummyType).values()}">
<input class="form-check-input" type="radio" th:field="*{type}" th:value="${type}" th:text="${type}" th:errorclass="is-invalid" required/>
<div th:if="${#fields.hasErrors('type')}" class="invalid-feedback">Ungültiger Typ.</div>
</div>
<div class="mb-3" th:if="${actionIsAdd}">
<a th:href="@{/inventory/add?type=Consumable}" class="btn" th:classappend="${form.getClass().getSimpleName() == 'ConsumableMutateForm' ? 'btn-primary' : 'btn-secondary'}">Verbrauchsmaterial</a>
<a th:href="@{/inventory/add?type=Rentable}" class="btn" th:classappend="${form.getClass().getSimpleName() == 'RentableMutateForm' ? 'btn-primary' : 'btn-secondary'}">Leihmaterial</a>
</div>
<div class="mb-3">
<label class="form-label" for="name">Produktname</label>
@ -35,8 +32,7 @@
<input class="form-control" type="number" name="retailPrice" th:value="${#numbers.formatDecimal(form.retailPrice, 1, 2)}" th:errorclass="is-invalid" step="0.01" min="0" required/>
<div th:if="${#fields.hasErrors('retailPrice')}" class="invalid-feedback">Ungültiger Verkaufspreis.</div>
</div>
<div class="mb-3">
<!-- FIXME darf nur bei angeboten als teil von partyservice angezeigt werden -->
<div class="mb-3" th:if="${form.getClass().getSimpleName() == 'ConsumableMutateForm'}">
<label class="form-label" for="promotionPrice">Aktionspreis</label>
<input class="form-control" type="number" name="promotionPrice" th:value="${#numbers.formatDecimal(form.promotionPrice.orElse(null), 1, 2)}" th:errorclass="is-invalid" step="0.01" min="0"/>
<div th:if="${#fields.hasErrors('promotionPrice')}" class="invalid-feedback">Ungültiger Aktionspreis.</div>

View file

@ -24,17 +24,18 @@
<td th:text="${item.product.name}"></td>
<td th:text="${item.quantity}"></td>
<td th:text="${item.product.wholesalePrice}"></td>
<td th:if="${item.product.promotionPrice != null}"><del th:text="${item.product.price}"></del> <span th:text="${item.product.promotionPrice}"></span></td>
<td th:if="${item.product.promotionPrice == null}" th:text="${item.product.price}"></td>
<td th:if="${item.product.getClass().getSimpleName() == 'Consumable' && item.product.promotionPrice.isPresent()}"><del th:text="${item.product.retailPrice}"></del> <span th:text="${item.product.promotionPrice.get()}"></span></td>
<td th:if="${item.product.getClass().getSimpleName() != 'Consumable' || item.product.promotionPrice.isEmpty()}" th:text="${item.product.price}"></td>
<td th:text="${item.product.wholesalePrice.multiply(item.quantity.getAmount())}"></td>
<td>
<a th:href="@{/inventory/edit/{id}(id=${item.product.id})}"><button class="btn btn-warning">Bearbeiten</button></a>
<a th:href="@{/inventory/edit/{id}(id=${item.product.id},type=${item.product.getClass().getSimpleName()})}"><button class="btn btn-warning">Bearbeiten</button></a>
<a th:href="@{/inventory/delete/{id}(id=${item.product.id})}"><button class="btn btn-danger">Entfernen</button></a>
</td>
</tr>
</tbody>
</table>
<a href="/inventory/add"><button class="btn btn-primary">Artikel hinzufügen</button></a>
<a href="/inventory/add?type=Consumable"><button class="btn btn-primary">Verbrauchsmaterial hinzufügen</button></a>
<a href="/inventory/add?type=Rentable"><button class="btn btn-primary">Leihmaterial hinzufügen</button></a>
</div>
</body>
</html>

View file

@ -19,8 +19,8 @@ package catering.inventory;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.CoreMatchers.endsWith;
import static org.hamcrest.CoreMatchers.not;
import static org.salespointframework.core.Currencies.EURO;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@ -29,9 +29,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Optional;
import org.javamoney.moneta.Money;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.salespointframework.catalog.Product;
import org.salespointframework.catalog.Product.ProductIdentifier;
import org.salespointframework.inventory.UniqueInventory;
import org.salespointframework.inventory.UniqueInventoryItem;
@ -44,8 +47,7 @@ import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import catering.catalog.CatalogDummy;
import catering.catalog.CatalogDummyType;
import catering.catalog.Consumable;
@AutoConfigureMockMvc
@SpringBootTest
@ -57,13 +59,13 @@ class InventoryControllerIntegrationTests {
UniqueInventory<UniqueInventoryItem> inventory;
UniqueInventoryItem anyInventoryItem;
CatalogDummy anyProduct;
Product anyProduct;
ProductIdentifier anyPid;
@BeforeEach
void populateAnyInventoryItem() {
anyInventoryItem = inventory.findAll().stream().findAny().get();
anyProduct = (CatalogDummy) anyInventoryItem.getProduct();
anyProduct = anyInventoryItem.getProduct();
anyPid = anyProduct.getId();
}
@ -77,15 +79,15 @@ class InventoryControllerIntegrationTests {
@Test
@WithMockUser(username = "admin", roles = "ADMIN")
void adminCanAdd() throws Exception {
mvc.perform(get("/inventory/add"))
void adminCanAddConsumable() throws Exception {
mvc.perform(get("/inventory/add?type=Consumable"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("Produkt anlegen")));
long itemCountBefore = inventory.findAll().stream().count();
mvc.perform(post("/inventory/add")
.param("type", "CONSUMABLE")
mvc.perform(post("/inventory/add?type=Consumable")
.queryParam("type", Consumable.class.getSimpleName())
.param("name", "MOCK Schnitzel Wiener Art (vegan)")
.param("quantity", "100")
.param("wholesalePrice", "3.00")
@ -97,12 +99,11 @@ class InventoryControllerIntegrationTests {
assertThat(itemCountAfter).isEqualTo(itemCountBefore + 1);
// TODO: this must be changed once the catalog split is done
assertThat(inventory.findAll().stream())
.extracting("product.type", "product.name", "quantity", "product.wholesalePrice", "product.price",
assertThat(inventory.findAll().filter(ie -> ie.getProduct() instanceof Consumable).stream())
.extracting("product.name", "quantity", "product.wholesalePrice", "product.retailPrice",
"product.promotionPrice")
.contains(tuple(CatalogDummyType.CONSUMABLE, "MOCK Schnitzel Wiener Art (vegan)", Quantity.of(100),
Money.of(3, EURO), Money.of(7.5, EURO), Money.of(6.66, EURO)));
.contains(tuple("MOCK Schnitzel Wiener Art (vegan)", Quantity.of(100),
Money.of(3, EURO), Money.of(7.5, EURO), Optional.of(Money.of(6.66, EURO))));
}
@Test
@ -117,54 +118,52 @@ class InventoryControllerIntegrationTests {
assertThat(itemCountAfter).isEqualTo(itemCountBefore - 1);
// TODO: this must be changed once the catalog split is done
assertThat(inventory.findAll().stream())
.extracting("product.type", "product.name", "quantity")
.doesNotContain(tuple(anyProduct.getType(), anyProduct.getName(), anyInventoryItem.getQuantity()));
.extracting("product.name", "quantity")
.doesNotContain(tuple(anyProduct.getName(), anyInventoryItem.getQuantity()));
}
@Test
@WithMockUser(username = "admin", roles = "ADMIN")
void adminCanEditConsumable() throws Exception {
// TODO: this must be changed once the catalog split is done
CatalogDummy anyConsumable = inventory.findAll().stream()
Consumable anyConsumable = inventory.findAll().stream()
.map(UniqueInventoryItem::getProduct)
.map(p -> (CatalogDummy) p)
.filter(cd -> cd.getType().equals(CatalogDummyType.CONSUMABLE))
.filter(Consumable.class::isInstance)
.map(Consumable.class::cast)
.findAny()
.get();
mvc.perform(get("/inventory/edit/" + anyConsumable.getId()))
mvc.perform(
get("/inventory/edit/" + anyConsumable.getId()))
.andExpect(status().isOk())
.andExpect(content().string(containsString("Produkt bearbeiten")));
boolean hasPromotionPrice = anyConsumable.getPromotionPrice() != null;
mvc.perform(post("/inventory/edit/" + anyConsumable.getId())
.queryParam("type", Consumable.class.getSimpleName())
.param("type", "CONSUMABLE")
.param("name", "MOCK edited")
.param("quantity", "4711")
.param("wholesalePrice", "0.01")
.param("retailPrice", "0.01")
.param("promotionPrice", hasPromotionPrice ? "" : "5"))
.param("retailPrice", "0.03")
.param("promotionPrice", "0.02"))
.andExpect(redirectedUrl("/inventory"));
UniqueInventoryItem editedInventoryItem = inventory.findByProductIdentifier(anyConsumable.getId()).stream()
.findAny().get();
CatalogDummy editedProduct = (CatalogDummy) editedInventoryItem.getProduct();
Consumable editedProduct = (Consumable) editedInventoryItem.getProduct();
assertThat(editedInventoryItem.getQuantity()).isEqualTo(Quantity.of(4711));
assertThat(editedProduct)
.extracting("type", "name", "wholesalePrice", "price", "promotionPrice")
.containsExactly(CatalogDummyType.CONSUMABLE, "MOCK edited", Money.of(0.01, EURO),
Money.of(0.01, EURO), hasPromotionPrice ? null : Money.of(0.01, EURO));
.extracting("name", "wholesalePrice", "retailPrice", "promotionPrice")
.containsExactly("MOCK edited", Money.of(0.01, EURO),
Money.of(0.03, EURO), Optional.of(Money.of(0.02, EURO)));
}
@Test
@WithMockUser(username = "admin", roles = "ADMIN")
void invalidAddReturnsNiceError() throws Exception {
mvc.perform(post("/inventory/add")
.param("type", "CONSUMABLE")
.queryParam("type", Consumable.class.getSimpleName())
.param("name", "")
.param("quantity", "10")
.param("wholesalePrice", "1.00")
@ -176,7 +175,7 @@ class InventoryControllerIntegrationTests {
@WithMockUser(username = "admin", roles = "ADMIN")
void missingRetailPriceIsNoError() throws Exception {
mvc.perform(post("/inventory/add")
.param("type", "CONSUMABLE")
.queryParam("type", Consumable.class.getSimpleName())
.param("name", "MOCK")
.param("quantity", "10")
.param("wholesalePrice", "1.00")
@ -188,7 +187,7 @@ class InventoryControllerIntegrationTests {
@WithMockUser(username = "admin", roles = "ADMIN")
void invalidEditReturnsNiceError() throws Exception {
mvc.perform(post("/inventory/edit/" + anyPid)
.param("type", "CONSUMABLE")
.queryParam("type", Consumable.class.getSimpleName())
.param("name", "")
.param("quantity", "10")
.param("wholesalePrice", "1.00")
@ -220,22 +219,25 @@ class InventoryControllerIntegrationTests {
@Test
void disallowUnauthorizedEditPage() throws Exception {
assertRedirectsToLogin(mvc.perform(get("/inventory/edit/" + anyPid)));
assertRedirectsToLogin(
mvc.perform(get("/inventory/edit/" + anyPid).queryParam("type", Consumable.class.getSimpleName())));
}
@Test
void disallowUnauthorizedEdit() throws Exception {
assertRedirectsToLogin(mvc.perform(post("/inventory/edit/" + anyPid)));
assertRedirectsToLogin(
mvc.perform(post("/inventory/edit/" + anyPid).queryParam("type", Consumable.class.getSimpleName())));
}
@Test
void disallowUnauthorizedAddPage() throws Exception {
assertRedirectsToLogin(mvc.perform(get("/inventory/add")));
assertRedirectsToLogin(mvc.perform(get("/inventory/add").queryParam("type", Consumable.class.getSimpleName())));
}
@Test
void disallowUnauthorizedAdd() throws Exception {
assertRedirectsToLogin(mvc.perform(post("/inventory/add")));
assertRedirectsToLogin(
mvc.perform(post("/inventory/add").queryParam("type", Consumable.class.getSimpleName())));
}
@Test