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

Binary file not shown.

View file

@ -18,12 +18,17 @@ package catering.catalog;
import static org.salespointframework.core.Currencies.EURO; import static org.salespointframework.core.Currencies.EURO;
import java.util.Set;
import java.util.Optional;
import org.javamoney.moneta.Money; import org.javamoney.moneta.Money;
import org.salespointframework.core.DataInitializer; import org.salespointframework.core.DataInitializer;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import catering.order.OrderType;
@Component @Component
@Order(20) @Order(20)
class CatalogDataInitializer implements DataInitializer { class CatalogDataInitializer implements DataInitializer {
@ -41,9 +46,21 @@ class CatalogDataInitializer implements DataInitializer {
return; return;
} }
cateringCatalog.save(new CatalogDummy("Brötchen Vollkorn", CatalogDummyType.CONSUMABLE, Money.of(1, EURO), Money.of(0.5, EURO), cateringCatalog.save(new Consumable(
Money.of(0.75, EURO))); "Brötchen Vollkorn",
cateringCatalog.save(new CatalogDummy("Kerze Rot", CatalogDummyType.CONSUMABLE, Money.of(2, EURO), Money.of(1.5, EURO))); Money.of(1, EURO),
cateringCatalog.save(new CatalogDummy("Brotschneidemaschine Power X 3000", CatalogDummyType.RENTABLE, Money.of(25, EURO), Money.of(10000, 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; 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.catalog.Product;
import org.salespointframework.inventory.UniqueInventory; import org.salespointframework.inventory.UniqueInventory;
import org.salespointframework.inventory.UniqueInventoryItem; 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.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import catering.catalog.CatalogDummy;
import catering.catalog.CateringCatalog; import catering.catalog.CateringCatalog;
import jakarta.validation.Valid; 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 @Controller
class InventoryController { class InventoryController {
private final UniqueInventory<UniqueInventoryItem> inventory; private final UniqueInventory<UniqueInventoryItem> inventory;
@ -64,17 +80,10 @@ class InventoryController {
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
@GetMapping("/inventory/edit/{pid}") @GetMapping("/inventory/edit/{pid}")
String edit(Model model, @PathVariable Product pid) { String edit(Model model, @PathVariable Product pid) {
CatalogDummy product = (CatalogDummy) pid;
UniqueInventoryItem item = inventory.findByProduct(pid).get(); UniqueInventoryItem item = inventory.findByProduct(pid).get();
return edit(model, final InventoryMutateForm form = InventoryMutateForm.of(pid, item);
new InventoryMutateForm(product.getType(),
product.getName(), return edit(model, form);
item.getQuantity(),
product.getWholesalePrice().getNumber().doubleValueExact(),
product.getPrice().getNumber().doubleValueExact(),
Optional.ofNullable(product.getPromotionPrice())
.map(MonetaryAmount::getNumber)
.map(NumberValue::doubleValueExact)));
} }
String edit(Model model, InventoryMutateForm form) { String edit(Model model, InventoryMutateForm form) {
@ -85,23 +94,28 @@ class InventoryController {
} }
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
@PostMapping("/inventory/edit/{pid}") @PostMapping(path = "/inventory/edit/{pid}", params = "type=Consumable")
String edit(@Valid @ModelAttribute("form") InventoryMutateForm form, Errors result, @PathVariable Product pid, String editConsumable(@Valid @ModelAttribute("form") ConsumableMutateForm form, Errors result,
Model model) { @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()) { if (result.hasErrors()) {
return edit(model, form); 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); product = cateringCatalog.save(product);
UniqueInventoryItem item = inventory.findByProduct(product).get();
// no setQuantity in enterprise java // no setQuantity in enterprise java
// (though returing a modified object is actually nice) // (though returing a modified object is actually nice)
inventory.save(item.increaseQuantity(form.getQuantity().subtract(item.getQuantity()))); inventory.save(item.increaseQuantity(form.getQuantity().subtract(item.getQuantity())));
@ -109,9 +123,17 @@ class InventoryController {
} }
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
@GetMapping("/inventory/add") @GetMapping(path = "/inventory/add")
String add(Model model) { String add(Model model, @RequestParam String type) {
return add(model, InventoryMutateForm.empty()); 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) { String add(Model model, InventoryMutateForm form) {
@ -121,17 +143,22 @@ class InventoryController {
} }
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
@PostMapping("/inventory/add") @PostMapping(path = "/inventory/add", params = "type=Consumable")
String add(@Valid @ModelAttribute("form") InventoryMutateForm form, Errors result, Model model) { 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()) { if (result.hasErrors()) {
return add(model, form); return add(model, form);
} }
inventory.save(new UniqueInventoryItem( inventory.save(new UniqueInventoryItem(cateringCatalog.save(form.toProduct()), form.getQuantity()));
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()));
return "redirect:/inventory"; return "redirect:/inventory";
} }
@ -140,7 +167,7 @@ class InventoryController {
String delete(@PathVariable Product pid) { String delete(@PathVariable Product pid) {
UniqueInventoryItem item = inventory.findByProduct(pid).get(); UniqueInventoryItem item = inventory.findByProduct(pid).get();
inventory.delete(item); inventory.delete(item);
cateringCatalog.delete((CatalogDummy) pid); cateringCatalog.delete(pid);
return "redirect:/inventory"; return "redirect:/inventory";
} }
} }

View file

@ -16,39 +16,33 @@
*/ */
package catering.inventory; 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 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.NotEmpty;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero; // NonNegative in enterprise java import jakarta.validation.constraints.PositiveOrZero; // NonNegative in enterprise java
class InventoryMutateForm { /**
private final @NotNull CatalogDummyType type; * Abstract class for handling inventory mutations.
private final @NotEmpty String name; *
private final @NotNull Quantity quantity; * It has children for every product type.
private final @NotNull double wholesalePrice, retailPrice; *
private final @NotNull Optional<Double> promotionPrice; * 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, public InventoryMutateForm() {
@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 String getName() { public String getName() {
@ -59,15 +53,80 @@ class InventoryMutateForm {
return quantity; return quantity;
} }
public double getWholesalePrice() { public Double getRetailPrice() {
return wholesalePrice;
}
public double getRetailPrice() {
return retailPrice; return retailPrice;
} }
public Optional<Double> getPromotionPrice() { public void setName(String name) {
return promotionPrice; 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"> <div layout:fragment="content">
<h2 th:text="${'Produkt ' + (actionIsAdd ? 'anlegen' : 'bearbeiten')}"></h2> <h2 th:text="${'Produkt ' + (actionIsAdd ? 'anlegen' : 'bearbeiten')}"></h2>
<form method="post" th:object="${form}"> <form method="post" th:object="${form}">
<div class="mb-3"> <div class="mb-3" th:if="${actionIsAdd}">
<label class="form-label" for="type">Typ</label> <a th:href="@{/inventory/add?type=Consumable}" class="btn" th:classappend="${form.getClass().getSimpleName() == 'ConsumableMutateForm' ? 'btn-primary' : 'btn-secondary'}">Verbrauchsmaterial</a>
<div class="form-check form-check-inline" th:each="type : ${T(catering.catalog.CatalogDummyType).values()}"> <a th:href="@{/inventory/add?type=Rentable}" class="btn" th:classappend="${form.getClass().getSimpleName() == 'RentableMutateForm' ? 'btn-primary' : 'btn-secondary'}">Leihmaterial</a>
<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> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label" for="name">Produktname</label> <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/> <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 th:if="${#fields.hasErrors('retailPrice')}" class="invalid-feedback">Ungültiger Verkaufspreis.</div>
</div> </div>
<div class="mb-3"> <div class="mb-3" th:if="${form.getClass().getSimpleName() == 'ConsumableMutateForm'}">
<!-- FIXME darf nur bei angeboten als teil von partyservice angezeigt werden -->
<label class="form-label" for="promotionPrice">Aktionspreis</label> <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"/> <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> <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.product.name}"></td>
<td th:text="${item.quantity}"></td> <td th:text="${item.quantity}"></td>
<td th:text="${item.product.wholesalePrice}"></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.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.promotionPrice == null}" th:text="${item.product.price}"></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 th:text="${item.product.wholesalePrice.multiply(item.quantity.getAmount())}"></td>
<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> <a th:href="@{/inventory/delete/{id}(id=${item.product.id})}"><button class="btn btn-danger">Entfernen</button></a>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </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> </div>
</body> </body>
</html> </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.assertThat;
import static org.assertj.core.api.Assertions.tuple; import static org.assertj.core.api.Assertions.tuple;
import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.CoreMatchers.endsWith; import static org.hamcrest.CoreMatchers.endsWith;
import static org.hamcrest.CoreMatchers.not;
import static org.salespointframework.core.Currencies.EURO; 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.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 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.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Optional;
import org.javamoney.moneta.Money; import org.javamoney.moneta.Money;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.salespointframework.catalog.Product;
import org.salespointframework.catalog.Product.ProductIdentifier; import org.salespointframework.catalog.Product.ProductIdentifier;
import org.salespointframework.inventory.UniqueInventory; import org.salespointframework.inventory.UniqueInventory;
import org.salespointframework.inventory.UniqueInventoryItem; 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.MockMvc;
import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultActions;
import catering.catalog.CatalogDummy; import catering.catalog.Consumable;
import catering.catalog.CatalogDummyType;
@AutoConfigureMockMvc @AutoConfigureMockMvc
@SpringBootTest @SpringBootTest
@ -57,13 +59,13 @@ class InventoryControllerIntegrationTests {
UniqueInventory<UniqueInventoryItem> inventory; UniqueInventory<UniqueInventoryItem> inventory;
UniqueInventoryItem anyInventoryItem; UniqueInventoryItem anyInventoryItem;
CatalogDummy anyProduct; Product anyProduct;
ProductIdentifier anyPid; ProductIdentifier anyPid;
@BeforeEach @BeforeEach
void populateAnyInventoryItem() { void populateAnyInventoryItem() {
anyInventoryItem = inventory.findAll().stream().findAny().get(); anyInventoryItem = inventory.findAll().stream().findAny().get();
anyProduct = (CatalogDummy) anyInventoryItem.getProduct(); anyProduct = anyInventoryItem.getProduct();
anyPid = anyProduct.getId(); anyPid = anyProduct.getId();
} }
@ -77,15 +79,15 @@ class InventoryControllerIntegrationTests {
@Test @Test
@WithMockUser(username = "admin", roles = "ADMIN") @WithMockUser(username = "admin", roles = "ADMIN")
void adminCanAdd() throws Exception { void adminCanAddConsumable() throws Exception {
mvc.perform(get("/inventory/add")) mvc.perform(get("/inventory/add?type=Consumable"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(content().string(containsString("Produkt anlegen"))); .andExpect(content().string(containsString("Produkt anlegen")));
long itemCountBefore = inventory.findAll().stream().count(); long itemCountBefore = inventory.findAll().stream().count();
mvc.perform(post("/inventory/add") mvc.perform(post("/inventory/add?type=Consumable")
.param("type", "CONSUMABLE") .queryParam("type", Consumable.class.getSimpleName())
.param("name", "MOCK Schnitzel Wiener Art (vegan)") .param("name", "MOCK Schnitzel Wiener Art (vegan)")
.param("quantity", "100") .param("quantity", "100")
.param("wholesalePrice", "3.00") .param("wholesalePrice", "3.00")
@ -97,12 +99,11 @@ class InventoryControllerIntegrationTests {
assertThat(itemCountAfter).isEqualTo(itemCountBefore + 1); assertThat(itemCountAfter).isEqualTo(itemCountBefore + 1);
// TODO: this must be changed once the catalog split is done assertThat(inventory.findAll().filter(ie -> ie.getProduct() instanceof Consumable).stream())
assertThat(inventory.findAll().stream()) .extracting("product.name", "quantity", "product.wholesalePrice", "product.retailPrice",
.extracting("product.type", "product.name", "quantity", "product.wholesalePrice", "product.price",
"product.promotionPrice") "product.promotionPrice")
.contains(tuple(CatalogDummyType.CONSUMABLE, "MOCK Schnitzel Wiener Art (vegan)", Quantity.of(100), .contains(tuple("MOCK Schnitzel Wiener Art (vegan)", Quantity.of(100),
Money.of(3, EURO), Money.of(7.5, EURO), Money.of(6.66, EURO))); Money.of(3, EURO), Money.of(7.5, EURO), Optional.of(Money.of(6.66, EURO))));
} }
@Test @Test
@ -117,54 +118,52 @@ class InventoryControllerIntegrationTests {
assertThat(itemCountAfter).isEqualTo(itemCountBefore - 1); assertThat(itemCountAfter).isEqualTo(itemCountBefore - 1);
// TODO: this must be changed once the catalog split is done
assertThat(inventory.findAll().stream()) assertThat(inventory.findAll().stream())
.extracting("product.type", "product.name", "quantity") .extracting("product.name", "quantity")
.doesNotContain(tuple(anyProduct.getType(), anyProduct.getName(), anyInventoryItem.getQuantity())); .doesNotContain(tuple(anyProduct.getName(), anyInventoryItem.getQuantity()));
} }
@Test @Test
@WithMockUser(username = "admin", roles = "ADMIN") @WithMockUser(username = "admin", roles = "ADMIN")
void adminCanEditConsumable() throws Exception { void adminCanEditConsumable() throws Exception {
// TODO: this must be changed once the catalog split is done Consumable anyConsumable = inventory.findAll().stream()
CatalogDummy anyConsumable = inventory.findAll().stream()
.map(UniqueInventoryItem::getProduct) .map(UniqueInventoryItem::getProduct)
.map(p -> (CatalogDummy) p) .filter(Consumable.class::isInstance)
.filter(cd -> cd.getType().equals(CatalogDummyType.CONSUMABLE)) .map(Consumable.class::cast)
.findAny() .findAny()
.get(); .get();
mvc.perform(get("/inventory/edit/" + anyConsumable.getId())) mvc.perform(
get("/inventory/edit/" + anyConsumable.getId()))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(content().string(containsString("Produkt bearbeiten"))); .andExpect(content().string(containsString("Produkt bearbeiten")));
boolean hasPromotionPrice = anyConsumable.getPromotionPrice() != null;
mvc.perform(post("/inventory/edit/" + anyConsumable.getId()) mvc.perform(post("/inventory/edit/" + anyConsumable.getId())
.queryParam("type", Consumable.class.getSimpleName())
.param("type", "CONSUMABLE") .param("type", "CONSUMABLE")
.param("name", "MOCK edited") .param("name", "MOCK edited")
.param("quantity", "4711") .param("quantity", "4711")
.param("wholesalePrice", "0.01") .param("wholesalePrice", "0.01")
.param("retailPrice", "0.01") .param("retailPrice", "0.03")
.param("promotionPrice", hasPromotionPrice ? "" : "5")) .param("promotionPrice", "0.02"))
.andExpect(redirectedUrl("/inventory")); .andExpect(redirectedUrl("/inventory"));
UniqueInventoryItem editedInventoryItem = inventory.findByProductIdentifier(anyConsumable.getId()).stream() UniqueInventoryItem editedInventoryItem = inventory.findByProductIdentifier(anyConsumable.getId()).stream()
.findAny().get(); .findAny().get();
CatalogDummy editedProduct = (CatalogDummy) editedInventoryItem.getProduct(); Consumable editedProduct = (Consumable) editedInventoryItem.getProduct();
assertThat(editedInventoryItem.getQuantity()).isEqualTo(Quantity.of(4711)); assertThat(editedInventoryItem.getQuantity()).isEqualTo(Quantity.of(4711));
assertThat(editedProduct) assertThat(editedProduct)
.extracting("type", "name", "wholesalePrice", "price", "promotionPrice") .extracting("name", "wholesalePrice", "retailPrice", "promotionPrice")
.containsExactly(CatalogDummyType.CONSUMABLE, "MOCK edited", Money.of(0.01, EURO), .containsExactly("MOCK edited", Money.of(0.01, EURO),
Money.of(0.01, EURO), hasPromotionPrice ? null : Money.of(0.01, EURO)); Money.of(0.03, EURO), Optional.of(Money.of(0.02, EURO)));
} }
@Test @Test
@WithMockUser(username = "admin", roles = "ADMIN") @WithMockUser(username = "admin", roles = "ADMIN")
void invalidAddReturnsNiceError() throws Exception { void invalidAddReturnsNiceError() throws Exception {
mvc.perform(post("/inventory/add") mvc.perform(post("/inventory/add")
.param("type", "CONSUMABLE") .queryParam("type", Consumable.class.getSimpleName())
.param("name", "") .param("name", "")
.param("quantity", "10") .param("quantity", "10")
.param("wholesalePrice", "1.00") .param("wholesalePrice", "1.00")
@ -176,7 +175,7 @@ class InventoryControllerIntegrationTests {
@WithMockUser(username = "admin", roles = "ADMIN") @WithMockUser(username = "admin", roles = "ADMIN")
void missingRetailPriceIsNoError() throws Exception { void missingRetailPriceIsNoError() throws Exception {
mvc.perform(post("/inventory/add") mvc.perform(post("/inventory/add")
.param("type", "CONSUMABLE") .queryParam("type", Consumable.class.getSimpleName())
.param("name", "MOCK") .param("name", "MOCK")
.param("quantity", "10") .param("quantity", "10")
.param("wholesalePrice", "1.00") .param("wholesalePrice", "1.00")
@ -188,7 +187,7 @@ class InventoryControllerIntegrationTests {
@WithMockUser(username = "admin", roles = "ADMIN") @WithMockUser(username = "admin", roles = "ADMIN")
void invalidEditReturnsNiceError() throws Exception { void invalidEditReturnsNiceError() throws Exception {
mvc.perform(post("/inventory/edit/" + anyPid) mvc.perform(post("/inventory/edit/" + anyPid)
.param("type", "CONSUMABLE") .queryParam("type", Consumable.class.getSimpleName())
.param("name", "") .param("name", "")
.param("quantity", "10") .param("quantity", "10")
.param("wholesalePrice", "1.00") .param("wholesalePrice", "1.00")
@ -220,22 +219,25 @@ class InventoryControllerIntegrationTests {
@Test @Test
void disallowUnauthorizedEditPage() throws Exception { void disallowUnauthorizedEditPage() throws Exception {
assertRedirectsToLogin(mvc.perform(get("/inventory/edit/" + anyPid))); assertRedirectsToLogin(
mvc.perform(get("/inventory/edit/" + anyPid).queryParam("type", Consumable.class.getSimpleName())));
} }
@Test @Test
void disallowUnauthorizedEdit() throws Exception { void disallowUnauthorizedEdit() throws Exception {
assertRedirectsToLogin(mvc.perform(post("/inventory/edit/" + anyPid))); assertRedirectsToLogin(
mvc.perform(post("/inventory/edit/" + anyPid).queryParam("type", Consumable.class.getSimpleName())));
} }
@Test @Test
void disallowUnauthorizedAddPage() throws Exception { void disallowUnauthorizedAddPage() throws Exception {
assertRedirectsToLogin(mvc.perform(get("/inventory/add"))); assertRedirectsToLogin(mvc.perform(get("/inventory/add").queryParam("type", Consumable.class.getSimpleName())));
} }
@Test @Test
void disallowUnauthorizedAdd() throws Exception { void disallowUnauthorizedAdd() throws Exception {
assertRedirectsToLogin(mvc.perform(post("/inventory/add"))); assertRedirectsToLogin(
mvc.perform(post("/inventory/add").queryParam("type", Consumable.class.getSimpleName())));
} }
@Test @Test