diff --git a/.gitignore b/.gitignore index 7f0b1dd..16b5027 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,7 @@ dist/ log/ # Skip VisualStudio Code folders -.vscode/ \ No newline at end of file +.vscode/ + +.tomcat/ +.integration/ diff --git a/cm-all/pom.xml b/cm-all/pom.xml index 63884e8..1268ea3 100644 --- a/cm-all/pom.xml +++ b/cm-all/pom.xml @@ -15,7 +15,7 @@ cm-all 1.0.0 - Catalog Manager :: Web API and Frontent together + Catalog Manager :: Web API and Frontend together 1.8 1.0.0 @@ -79,7 +79,13 @@ + org.apache.maven.plugins maven-resources-plugin + 2.6 + false + + UTF-8 + position-react-build @@ -101,7 +107,9 @@ + org.apache.maven.plugins maven-clean-plugin + 3.0.0 true @@ -110,6 +118,7 @@ org.springframework.boot spring-boot-maven-plugin + 2.4.0 dk.erst.cm.CatalogApiApplication diff --git a/cm-api/pom.xml b/cm-api/pom.xml index 66b7376..45a0cf1 100644 --- a/cm-api/pom.xml +++ b/cm-api/pom.xml @@ -12,7 +12,7 @@ 1.8 1.0.0 - 2.4.0 + 2.6.7 UTF-8 ${java.version} ${java.version} @@ -24,6 +24,13 @@ cm-ubl ${cm.version} + + + com.helger.ubl + ph-ubl21 + 6.6.3 + + org.springframework.boot spring-boot-starter-data-mongodb @@ -33,7 +40,7 @@ com.opencsv opencsv - 4.4 + 5.6 @@ -84,4 +91,4 @@ - \ No newline at end of file + diff --git a/cm-api/src/main/java/dk/erst/cm/api/dao/mongo/BasketRepository.java b/cm-api/src/main/java/dk/erst/cm/api/dao/mongo/BasketRepository.java new file mode 100644 index 0000000..4822521 --- /dev/null +++ b/cm-api/src/main/java/dk/erst/cm/api/dao/mongo/BasketRepository.java @@ -0,0 +1,8 @@ +package dk.erst.cm.api.dao.mongo; + +import dk.erst.cm.api.data.Basket; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface BasketRepository extends MongoRepository { + +} diff --git a/cm-api/src/main/java/dk/erst/cm/api/dao/mongo/OrderRepository.java b/cm-api/src/main/java/dk/erst/cm/api/dao/mongo/OrderRepository.java new file mode 100644 index 0000000..1eb722e --- /dev/null +++ b/cm-api/src/main/java/dk/erst/cm/api/dao/mongo/OrderRepository.java @@ -0,0 +1,13 @@ +package dk.erst.cm.api.dao.mongo; + +import java.util.List; + +import org.springframework.data.mongodb.repository.MongoRepository; + +import dk.erst.cm.api.data.Order; + +public interface OrderRepository extends MongoRepository { + + List findByBasketId(String basketId); + +} diff --git a/cm-api/src/main/java/dk/erst/cm/api/dao/mongo/ProductCatalogUpdateRepository.java b/cm-api/src/main/java/dk/erst/cm/api/dao/mongo/ProductCatalogUpdateRepository.java index 9d836f5..b1d9ba0 100644 --- a/cm-api/src/main/java/dk/erst/cm/api/dao/mongo/ProductCatalogUpdateRepository.java +++ b/cm-api/src/main/java/dk/erst/cm/api/dao/mongo/ProductCatalogUpdateRepository.java @@ -6,4 +6,6 @@ public interface ProductCatalogUpdateRepository extends MongoRepository { + ProductCatalogUpdate findTop1ByProductCatalogIdOrderByCreateTimeDesc(String productCatalogId); + } diff --git a/cm-api/src/main/java/dk/erst/cm/api/data/Basket.java b/cm-api/src/main/java/dk/erst/cm/api/data/Basket.java new file mode 100644 index 0000000..a712a58 --- /dev/null +++ b/cm-api/src/main/java/dk/erst/cm/api/data/Basket.java @@ -0,0 +1,23 @@ +package dk.erst.cm.api.data; + +import java.time.Instant; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Document +@Data +@NoArgsConstructor +public class Basket { + + @Id + private String id; + private Instant createTime; + private int version; + private int orderCount; + private int lineCount; + +} diff --git a/cm-api/src/main/java/dk/erst/cm/api/data/Order.java b/cm-api/src/main/java/dk/erst/cm/api/data/Order.java new file mode 100644 index 0000000..8bc688c --- /dev/null +++ b/cm-api/src/main/java/dk/erst/cm/api/data/Order.java @@ -0,0 +1,41 @@ +package dk.erst.cm.api.data; + +import java.time.Instant; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Document +@Data +@NoArgsConstructor +public class Order { + + @Id + private String id; + private Instant createTime; + private int version; + + @Indexed + private String basketId; + + private OrderStatus status; + private int orderIndex; + + private String supplierName; + + @Indexed + private String orderNumber; + private int lineCount; + + private Object document; + + private String resultFileName; + + private Instant downloadDate; + private Instant deliveredDate; + +} diff --git a/cm-api/src/main/java/dk/erst/cm/api/data/OrderStatus.java b/cm-api/src/main/java/dk/erst/cm/api/data/OrderStatus.java new file mode 100644 index 0000000..3efe19b --- /dev/null +++ b/cm-api/src/main/java/dk/erst/cm/api/data/OrderStatus.java @@ -0,0 +1,7 @@ +package dk.erst.cm.api.data; + +public enum OrderStatus { + + GENERATED, DELIVERED, DELIVERY_FAILED, ORDER_CONFIRMED, ORDER_REJECTED + +} diff --git a/cm-api/src/main/java/dk/erst/cm/api/item/CatalogService.java b/cm-api/src/main/java/dk/erst/cm/api/item/CatalogService.java index 74c2bdc..861a73c 100644 --- a/cm-api/src/main/java/dk/erst/cm/api/item/CatalogService.java +++ b/cm-api/src/main/java/dk/erst/cm/api/item/CatalogService.java @@ -12,6 +12,7 @@ import dk.erst.cm.api.data.ProductCatalog; import dk.erst.cm.api.data.ProductCatalogUpdate; import dk.erst.cm.xml.ubl21.model.Catalogue; +import dk.erst.cm.xml.ubl21.model.NestedParty; import dk.erst.cm.xml.ubl21.model.Party; import dk.erst.cm.xml.ubl21.model.SchemeID; import lombok.extern.slf4j.Slf4j; @@ -72,6 +73,43 @@ public ProductCatalogUpdate saveCatalogue(Catalogue catalogue) { return c; } + public Party loadLastSellerParty(String productCatalogId) { + + log.debug("Requested to load last seller party for productCatalog " + productCatalogId); + + long start = System.currentTimeMillis(); + ProductCatalogUpdate catalogUpdate = productCatalogUpdateRepository.findTop1ByProductCatalogIdOrderByCreateTimeDesc(productCatalogId); + long duration = System.currentTimeMillis() - start; + if (duration > 50) { + log.warn("LastSellerParty Mongo lookup by " + productCatalogId + " took more than 50ms: " + 50); + } + + if (catalogUpdate != null && catalogUpdate.getDocument() != null) { + if (catalogUpdate.getDocument() instanceof Catalogue) { + Catalogue document = (Catalogue) catalogUpdate.getDocument(); + + if (document.getSellerSupplierParty() != null) { + NestedParty sellerSupplierParty = document.getSellerSupplierParty(); + if (sellerSupplierParty != null && sellerSupplierParty.getParty() != null) { + log.debug("LastSellerParty found by document.sellerSupplierParty.party for productCatalog " + productCatalogId); + return sellerSupplierParty.getParty(); + } + } + if (document.getProviderParty() != null) { + log.debug("LastSellerParty found by document.providerParty for productCatalog " + productCatalogId); + return document.getProviderParty(); + } + log.warn("Neither sellerSupplierParty, nor providerParty are defined by last catalogUpdate by id " + productCatalogId + ": " + document); + } else { + log.warn("Found catalogUpdate by lastCatalog with id" + productCatalogId + " has unexpected type: " + catalogUpdate.getDocument().getClass()); + } + } else { + log.warn("No catalogUpdate found by lastCatalog with id" + productCatalogId); + } + + return null; + } + private String buildSellerLocalId(Catalogue catalogue) { String sellerLogicalId = null; if (catalogue.getSellerSupplierParty() != null) { diff --git a/cm-api/src/main/java/dk/erst/cm/api/item/ProductService.java b/cm-api/src/main/java/dk/erst/cm/api/item/ProductService.java index 2563011..1262e33 100644 --- a/cm-api/src/main/java/dk/erst/cm/api/item/ProductService.java +++ b/cm-api/src/main/java/dk/erst/cm/api/item/ProductService.java @@ -1,9 +1,10 @@ package dk.erst.cm.api.item; -import java.time.Instant; -import java.util.List; -import java.util.Optional; - +import dk.erst.cm.api.dao.mongo.ProductRepository; +import dk.erst.cm.api.data.Product; +import dk.erst.cm.api.data.ProductCatalogUpdate; +import dk.erst.cm.xml.ubl21.model.CatalogueLine; +import dk.erst.cm.xml.ubl21.model.NestedSchemeID; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; @@ -11,91 +12,88 @@ import org.springframework.data.mongodb.core.query.TextCriteria; import org.springframework.stereotype.Service; -import dk.erst.cm.api.dao.mongo.ProductRepository; -import dk.erst.cm.api.data.Product; -import dk.erst.cm.api.data.ProductCatalogUpdate; -import dk.erst.cm.xml.ubl21.model.CatalogueLine; -import dk.erst.cm.xml.ubl21.model.NestedSchemeID; +import java.time.Instant; +import java.util.List; +import java.util.Optional; @Service public class ProductService { - private ProductRepository productRepository; - - @Autowired - public ProductService(ProductRepository itemRepository) { - this.productRepository = itemRepository; - } - - public Product saveCatalogUpdateItem(ProductCatalogUpdate catalog, CatalogueLine line) { - String lineLogicalId = line.getLogicalId(); - String productCatalogId = catalog.getProductCatalogId(); - - String itemLogicalId = productCatalogId + "_" + lineLogicalId; - - boolean deleteAction = line.getActionCode() != null && "Delete".equals(line.getActionCode().getId()); - - Product product; - Optional optional = productRepository.findById(itemLogicalId); - if (optional.isPresent()) { - product = optional.get(); - product.setUpdateTime(Instant.now()); - product.setVersion(product.getVersion() + 1); - } else { - product = new Product(); - product.setId(itemLogicalId); - product.setCreateTime(Instant.now()); - product.setUpdateTime(null); - product.setVersion(1); - } - product.setDocumentVersion(ProductDocumentVersion.PEPPOL_CATALOGUE_3_1); - product.setProductCatalogId(productCatalogId); - product.setStandardNumber(getLineStandardNumber(line)); - product.setDocument(line); - - if (deleteAction) { - if (optional.isPresent()) { - productRepository.delete(product); - } - return null; - } - productRepository.save(product); - - return product; - } - - private String getLineStandardNumber(CatalogueLine line) { - if (line != null && line.getItem() != null) { - if (line.getItem().getStandardItemIdentification() != null) { - NestedSchemeID sn = line.getItem().getStandardItemIdentification(); - if (sn.getId() != null && sn.getId().getId() != null) { - return sn.getId().getId().toUpperCase(); - } - } - } - return null; - } - - public long countItems() { - return productRepository.count(); - } - - public Page findAll(String searchParam, Pageable pageable) { - Page productList; - if (!StringUtils.isEmpty(searchParam)) { - TextCriteria textCriteria = TextCriteria.forDefaultLanguage().matching(searchParam); - productList = productRepository.findAllBy(textCriteria, pageable); - } else { - productList = productRepository.findAll(pageable); - } - return productList; - } - - public Optional findById(String id) { - return productRepository.findById(id); - } - - public List findByStandardNumber(String standardNumber) { - return productRepository.findByStandardNumber(standardNumber); - } + private final ProductRepository productRepository; + + @Autowired + public ProductService(ProductRepository itemRepository) { + this.productRepository = itemRepository; + } + + public Product saveCatalogUpdateItem(ProductCatalogUpdate catalog, CatalogueLine line) { + String lineLogicalId = line.getLogicalId(); + String productCatalogId = catalog.getProductCatalogId(); + String itemLogicalId = productCatalogId + "_" + lineLogicalId; + boolean deleteAction = line.getActionCode() != null && "Delete".equals(line.getActionCode().getId()); + Product product; + Optional optional = productRepository.findById(itemLogicalId); + if (optional.isPresent()) { + product = optional.get(); + product.setUpdateTime(Instant.now()); + product.setVersion(product.getVersion() + 1); + } else { + product = new Product(); + product.setId(itemLogicalId); + product.setCreateTime(Instant.now()); + product.setUpdateTime(null); + product.setVersion(1); + } + product.setDocumentVersion(ProductDocumentVersion.PEPPOL_CATALOGUE_3_1); + product.setProductCatalogId(productCatalogId); + product.setStandardNumber(getLineStandardNumber(line)); + product.setDocument(line); + if (deleteAction) { + if (optional.isPresent()) { + productRepository.delete(product); + } + return null; + } + productRepository.save(product); + return product; + } + + private String getLineStandardNumber(CatalogueLine line) { + if (line != null && line.getItem() != null) { + if (line.getItem().getStandardItemIdentification() != null) { + NestedSchemeID sn = line.getItem().getStandardItemIdentification(); + if (sn.getId() != null && sn.getId().getId() != null) { + return sn.getId().getId().toUpperCase(); + } + } + } + return null; + } + + public long countItems() { + return productRepository.count(); + } + + public Page findAll(String searchParam, Pageable pageable) { + Page productList; + if (!StringUtils.isEmpty(searchParam)) { + TextCriteria textCriteria = TextCriteria.forDefaultLanguage().matching(searchParam); + productList = productRepository.findAllBy(textCriteria, pageable); + } else { + productList = productRepository.findAll(pageable); + } + return productList; + } + + public Optional findById(String id) { + return productRepository.findById(id); + } + + public Iterable findAllByIds(Iterable ids) { + return productRepository.findAllById(ids); + } + + public List findByStandardNumber(String standardNumber) { + return productRepository.findByStandardNumber(standardNumber); + } } diff --git a/cm-api/src/main/java/dk/erst/cm/api/load/FolderLoadService.java b/cm-api/src/main/java/dk/erst/cm/api/load/FolderLoadService.java new file mode 100644 index 0000000..9d3aae4 --- /dev/null +++ b/cm-api/src/main/java/dk/erst/cm/api/load/FolderLoadService.java @@ -0,0 +1,60 @@ +package dk.erst.cm.api.load; + +import dk.erst.cm.api.item.LoadCatalogService; +import dk.erst.cm.api.load.handler.FileUploadConsumer; +import dk.erst.cm.api.util.StatData; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Locale; +import java.util.Optional; + +@Service +@Slf4j +public class FolderLoadService { + + private static final File[] EMPTY_FILE_LIST = new File[0]; + @Autowired + private LoadCatalogService loadCatalogService; + @Autowired + private PeppolLoadService loadService; + + public StatData loadCatalogues(File folder) { + StatData statData = new StatData(); + log.debug("Start load from folder {}", folder.getAbsolutePath()); + Optional optionalFiles = Optional.ofNullable(folder.listFiles((dir, fileName) -> fileName.toLowerCase().endsWith(".xml"))); + Arrays.stream(optionalFiles.orElse(EMPTY_FILE_LIST)).forEach(file -> { + FileUploadConsumer fileUploadConsumer = new FileUploadConsumer(loadCatalogService); + try { + log.info("Start reading file {}", file.getName()); + File tempFile = new File(file.getAbsolutePath() + ".tmp"); + if (!file.renameTo(tempFile)){ + log.debug("Could not rename file {}, skip it", file.getName()); + return; + } + try (InputStream inputStream = Files.newInputStream(tempFile.toPath())) { + loadService.loadXml(inputStream, file.getName(), fileUploadConsumer); + } + log.info("Loaded file " + file.getName() + " with " + fileUploadConsumer.getLineCount() + " lines"); + statData.increment("loaded-files"); + statData.increase("loaded-lines", fileUploadConsumer.getLineCount()); + if (tempFile.delete()) { + tempFile.deleteOnExit(); + } + } catch (Exception e) { + log.error("Error loading file " + file.getName(), e); + statData.increment("error-"+e.getClass().getName()); + } + }); + return statData; + } + public StatData loadOrderResponses(File folder) { + log.debug("Start load from folder {}", folder.getAbsolutePath()); + return StatData.error("Not implemented"); + } +} diff --git a/cm-webapi/src/main/java/dk/erst/cm/webapi/FileUploadConsumer.java b/cm-api/src/main/java/dk/erst/cm/api/load/handler/FileUploadConsumer.java similarity index 78% rename from cm-webapi/src/main/java/dk/erst/cm/webapi/FileUploadConsumer.java rename to cm-api/src/main/java/dk/erst/cm/api/load/handler/FileUploadConsumer.java index dd3fca5..c67dedb 100644 --- a/cm-webapi/src/main/java/dk/erst/cm/webapi/FileUploadConsumer.java +++ b/cm-api/src/main/java/dk/erst/cm/api/load/handler/FileUploadConsumer.java @@ -1,57 +1,56 @@ -package dk.erst.cm.webapi; - -import java.util.Arrays; -import java.util.Map; -import java.util.TreeMap; - -import dk.erst.cm.api.data.Product; -import dk.erst.cm.api.data.ProductCatalogUpdate; -import dk.erst.cm.api.item.LoadCatalogService; -import dk.erst.cm.api.load.handler.CatalogConsumer; -import dk.erst.cm.xml.ubl21.model.Catalogue; -import dk.erst.cm.xml.ubl21.model.CatalogueLine; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -public class FileUploadConsumer implements CatalogConsumer { - - private LoadCatalogService loadCatalogService; - - @Getter - private ProductCatalogUpdate productCatalogUpdate; - - @Getter - private int lineCount; - - @Getter - private Map lineActionStat; - - public static enum LineAction { - ADD, UPDATE, DELETE - } - - public FileUploadConsumer(LoadCatalogService loadCatalogService) { - this.loadCatalogService = loadCatalogService; - this.lineActionStat = new TreeMap(); - Arrays.stream(LineAction.values()).forEach(la -> this.lineActionStat.put(la, 0)); - } - - @Override - public void consumeHead(Catalogue catalog) { - this.productCatalogUpdate = loadCatalogService.saveCatalogue(catalog); - } - - @Override - public void consumeLine(CatalogueLine line) { - Product product = this.loadCatalogService.saveCatalogUpdateItem(productCatalogUpdate, line); - LineAction action = product == null ? LineAction.DELETE : product.getVersion() == 1 ? LineAction.ADD : LineAction.UPDATE; - - this.lineCount++; - this.lineActionStat.put(action, this.lineActionStat.get(action) + 1); - - if (this.lineCount % 100 == 0) { - log.info(String.format("Loaded %d lines, stat: %s", this.lineCount, this.lineActionStat.toString())); - } - } -} +package dk.erst.cm.api.load.handler; + +import java.util.Arrays; +import java.util.Map; +import java.util.TreeMap; + +import dk.erst.cm.api.data.Product; +import dk.erst.cm.api.data.ProductCatalogUpdate; +import dk.erst.cm.api.item.LoadCatalogService; +import dk.erst.cm.xml.ubl21.model.Catalogue; +import dk.erst.cm.xml.ubl21.model.CatalogueLine; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class FileUploadConsumer implements CatalogConsumer { + + private final LoadCatalogService loadCatalogService; + + @Getter + private ProductCatalogUpdate productCatalogUpdate; + + @Getter + private int lineCount; + + @Getter + private final Map lineActionStat; + + public enum LineAction { + ADD, UPDATE, DELETE + } + + public FileUploadConsumer(LoadCatalogService loadCatalogService) { + this.loadCatalogService = loadCatalogService; + this.lineActionStat = new TreeMap<>(); + Arrays.stream(LineAction.values()).forEach(la -> this.lineActionStat.put(la, 0)); + } + + @Override + public void consumeHead(Catalogue catalog) { + this.productCatalogUpdate = loadCatalogService.saveCatalogue(catalog); + } + + @Override + public void consumeLine(CatalogueLine line) { + Product product = this.loadCatalogService.saveCatalogUpdateItem(productCatalogUpdate, line); + LineAction action = product == null ? LineAction.DELETE : product.getVersion() == 1 ? LineAction.ADD : LineAction.UPDATE; + + this.lineCount++; + this.lineActionStat.put(action, this.lineActionStat.get(action) + 1); + + if (this.lineCount % 100 == 0) { + log.info(String.format("Loaded %d lines, stat: %s", this.lineCount, this.lineActionStat)); + } + } +} diff --git a/cm-api/src/main/java/dk/erst/cm/api/order/BasketService.java b/cm-api/src/main/java/dk/erst/cm/api/order/BasketService.java new file mode 100644 index 0000000..2c2d627 --- /dev/null +++ b/cm-api/src/main/java/dk/erst/cm/api/order/BasketService.java @@ -0,0 +1,343 @@ +package dk.erst.cm.api.order; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.apache.commons.io.FileUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import dk.erst.cm.api.data.Basket; +import dk.erst.cm.api.data.Order; +import dk.erst.cm.api.data.OrderStatus; +import dk.erst.cm.api.data.Product; +import dk.erst.cm.api.item.CatalogService; +import dk.erst.cm.api.item.ProductService; +import dk.erst.cm.api.order.OrderProducerService.OrderDefaultConfig; +import dk.erst.cm.api.order.OrderProducerService.PartyInfo; +import dk.erst.cm.api.order.data.CustomerOrderData; +import dk.erst.cm.xml.ubl21.model.Party; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import oasis.names.specification.ubl.schema.xsd.order_21.OrderType; + +@Service +@Slf4j +public class BasketService { + + private final ProductService productService; + private final OrderService orderService; + private final OrderProducerService orderProducerService; + private final CatalogService catalogService; + + @Autowired + public BasketService(ProductService productService, OrderService orderService, CatalogService catalogService, OrderProducerService orderProducerService) { + this.productService = productService; + this.orderService = orderService; + this.catalogService = catalogService; + this.orderProducerService = orderProducerService; + } + + + @Data + public static class SentBasketData { + private Basket basket; + private List orderList; + } + + @Data + public static class BasketData { + private Map orderLines; + } + + @Data + public static class SendBasketData { + private BasketData basketData; + private CustomerOrderData orderData; + } + + @Data + public static class SendBasketResponse { + private boolean success; + private String basketId; + private String errorMessage; + private List errorProductIdList; + + public static SendBasketResponse error(String message) { + SendBasketResponse r = new SendBasketResponse(); + r.setSuccess(false); + r.setErrorMessage(message); + return r; + } + + public SendBasketResponse withErrorProductIdList(List errorProductIdList) { + this.setErrorProductIdList(errorProductIdList); + return this; + } + + public static SendBasketResponse success(String basketId) { + SendBasketResponse r = new SendBasketResponse(); + r.setSuccess(true); + r.setBasketId(basketId); + return r; + } + } + + public SendBasketResponse basketSend(SendBasketData query, String outboxFolder, OrderDefaultConfig orderConfig) { + Set queryProductIdSet = query.basketData.orderLines.keySet(); + Iterable products = productService.findAllByIds(queryProductIdSet); + + Set resolvedProductIdSet = new HashSet<>(); + + log.debug("1. Load necessary data for XML generation"); + + Map> byCatalogMap = new HashMap<>(); + for (Product p : products) { + String productCatalogId = p.getProductCatalogId(); + List productList = byCatalogMap.computeIfAbsent(productCatalogId, k -> new ArrayList<>()); + productList.add(p); + resolvedProductIdSet.add(p.getId()); + } + + if (resolvedProductIdSet.size() < queryProductIdSet.size()) { + int countNotFoundProducts = queryProductIdSet.size() - resolvedProductIdSet.size(); + String errorMessage = countNotFoundProducts + " product" + (countNotFoundProducts > 1 ? "s are" : "is") + " not found, please delete highlighted products to send the basket."; + Set notResolvedProductIdSet = new HashSet<>(queryProductIdSet); + notResolvedProductIdSet.removeAll(resolvedProductIdSet); + return SendBasketResponse.error(errorMessage).withErrorProductIdList(new ArrayList<>(notResolvedProductIdSet)); + } + + Set noSellerCatalogSet = new HashSet<>(); + Map sellerPartyByCatalog = new HashMap<>(); + for (String catalogId : byCatalogMap.keySet()) { + Party sellerParty = catalogService.loadLastSellerParty(catalogId); + if (sellerParty != null) { + sellerPartyByCatalog.put(catalogId, sellerParty); + } else { + noSellerCatalogSet.add(catalogId); + } + } + + if (!noSellerCatalogSet.isEmpty()) { + int countProductIncompleteSeller = 0; + + List errorProductIdList = new ArrayList<>(); + for (String catalogId : noSellerCatalogSet) { + List list = byCatalogMap.get(catalogId); + errorProductIdList.addAll(list.stream().map(Product::getId).collect(Collectors.toList())); + countProductIncompleteSeller += list.size(); + } + String errorMessage = buildErrorMessageNoSellerInfo(byCatalogMap, noSellerCatalogSet, countProductIncompleteSeller); + return SendBasketResponse.error(errorMessage).withErrorProductIdList(errorProductIdList); + } + + log.debug("2. Generate internal models for XML"); + + Basket basket = new Basket(); + basket.setCreateTime(Instant.now()); + basket.setId(generateId()); + basket.setLineCount(resolvedProductIdSet.size()); + basket.setOrderCount(byCatalogMap.size()); + basket.setVersion(1); + + List orderList = new ArrayList<>(); + int orderIndex = 0; + for (String catalogId : byCatalogMap.keySet()) { + List productList = byCatalogMap.get(catalogId); + + Order order = new Order(); + order.setBasketId(basket.getId()); + order.setOrderIndex(orderIndex); + order.setCreateTime(Instant.now()); + order.setId(generateId()); + order.setLineCount(productList.size()); + order.setStatus(OrderStatus.GENERATED); + order.setOrderNumber(generateOrderNumber(order)); + order.setSupplierName(extractSupplierName(sellerPartyByCatalog.get(catalogId))); + order.setVersion(1); + + CustomerOrderData customerOrderData = query.getOrderData(); + PartyInfo buyer = new PartyInfo("5798009882806", "0088", customerOrderData.getBuyerCompany().getRegistrationName()); + PartyInfo seller = new PartyInfo("5798009882783", "0088", "Danish Company"); + + OrderType sendOrder = orderProducerService.generateOrder(order, orderConfig, buyer, seller, productList, query.basketData.orderLines); + order.setDocument(sendOrder); + + orderList.add(order); + + orderIndex++; + } + + File tempDirectory = createTempDirectory("delis-cm-" + basket.getId()); + + log.debug("3. Save XML files into temporary folder " + tempDirectory); + + Map fileNameToOrderMap = new HashMap<>(); + for (Order order : orderList) { + try { + File orderFile = orderService.saveOrderXML(tempDirectory, (OrderType) order.getDocument()); + log.debug(" - Saved order XML to " + orderFile.getAbsolutePath()); + order.setResultFileName(orderFile.getName()); + fileNameToOrderMap.put(orderFile.getName(), order); + } catch (IOException e) { + log.error(" - Failed to generate xml by order " + order, e); + return SendBasketResponse.error("Failed to generate XML for order to " + order.getSupplierName() + ": " + e.getMessage()); + } + } + + log.debug("4. Save basket and orders to database"); + + orderService.saveBasket(basket); + for (Order order : orderList) { + orderService.saveOrder(order); + } + + log.debug("5. Move XML files from temporary folder to destination folder at " + outboxFolder); + + File[] tempFiles = tempDirectory.listFiles(); + Set notMovedFiles = new HashSet<>(); + Set movedFiles = new HashSet<>(); + assert tempFiles != null; + for (File file : tempFiles) { + File outFile = new File(outboxFolder, file.getName()); + try { + Order order = fileNameToOrderMap.get(file.getName()); + String supplierName = order.getSupplierName(); + FileUtils.moveFile(file, outFile); + movedFiles.add(file); + log.debug(" - Order " + order.getId() + " to " + supplierName + " successfully moved to " + outFile); + } catch (IOException e) { + log.error(" - Failed to move file " + file + " to " + outFile, e); + notMovedFiles.add(file); + } + } + + if (!notMovedFiles.isEmpty()) { + if (movedFiles.isEmpty()) { + return SendBasketResponse.error("Failed to move XML files to destination folder, please contact system administrator."); + } else { + String notSentOrdersToSuppliers = getSupplierNamesByFileSet(notMovedFiles, fileNameToOrderMap); + String sentOrdersToSuppliers = getSupplierNamesByFileSet(movedFiles, fileNameToOrderMap); + String errorMessage = notMovedFiles.size() + " file(s) to supplier(s) " + notSentOrdersToSuppliers + " failed to move to destination folder, please contact system administrator. " + movedFiles.size() + " orders to " + sentOrdersToSuppliers + " were sent."; + return SendBasketResponse.error(errorMessage); + } + } + + log.debug("6. Cleanup temporary folder " + tempDirectory); + + // Delete temporary folder anyway + if (!FileUtils.deleteQuietly(tempDirectory)) { + log.error("Failed to delete temporary directory, check that streams are closed: " + tempDirectory); + } + + log.debug("7. Basket #" + basket.getId() + " is sent successfully"); + + return SendBasketResponse.success(basket.getId()); + } + + private String generateId() { + return UUID.randomUUID().toString(); + } + + private String getSupplierNamesByFileSet(Set files, Map fileNameToOrderMap) { + StringBuilder sb = new StringBuilder(); + for (File file : files) { + Order order = fileNameToOrderMap.get(file.getName()); + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(order.getSupplierName()); + } + return sb.toString(); + } + + private String buildErrorMessageNoSellerInfo(Map> byCatalog, Set noSellerCatalog, int countProductWithCatalogWithoutSeller) { + StringBuilder sb = new StringBuilder(); + sb.append("Cannot send basket, as "); + sb.append(countProductWithCatalogWithoutSeller); + sb.append(" product"); + if (countProductWithCatalogWithoutSeller > 1) { + sb.append("s"); + } + int countNoSellerCatalog = noSellerCatalog.size(); + sb.append(" relate to "); + if (countNoSellerCatalog == 1) { + sb.append("a"); + } else { + sb.append(countNoSellerCatalog); + } + sb.append(" catalog"); + if (countNoSellerCatalog > 1) { + sb.append("s"); + } + sb.append(" for which there is not enough information about seller to send an order."); + if (byCatalog.size() > countNoSellerCatalog) { + sb.append(" Remove highlighted products from the basket to send the rest."); + } + return sb.toString(); + } + + private File createTempDirectory(String dirName) { + File tempDirectory = new File(FileUtils.getTempDirectory(), dirName); + //noinspection ResultOfMethodCallIgnored + tempDirectory.mkdirs(); + return tempDirectory; + } + + private String extractSupplierName(Party party) { + if (party.getPartyName() != null) { + return party.getPartyName().getName(); + } + if (party.getPartyLegalEntity() != null) { + if (party.getPartyLegalEntity().getRegistrationName() != null) { + return party.getPartyLegalEntity().getRegistrationName(); + } + } + return null; + } + + private final static DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").withZone(ZoneOffset.UTC); + + private String generateOrderNumber(Order order) { + String creationTimeFormatted = DATE_TIME_FORMATTER.format(order.getCreateTime()); + return creationTimeFormatted + "-" + (order.getOrderIndex() + 1); + } + + public Optional loadSentBasketData(String basketId) { + Optional optional = this.orderService.findBasketById(basketId); + if (optional.isPresent()) { + SentBasketData sbd = new SentBasketData(); + sbd.setBasket(optional.get()); + sbd.setOrderList(this.orderService.findOrdersByBasketId(sbd.getBasket().getId())); + return Optional.of(sbd); + } + return Optional.empty(); + } + + public Optional loadSentOrderAsJSON(String orderId) { + return this.orderService.findOrderByIdAsJSON(orderId); + } + + public Optional loadSentOrderAsXML(String id) { + Optional orderById = this.orderService.findOrderById(id); + if (orderById.isPresent() && orderById.get().getDocument() != null) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + this.orderService.saveOrderXMLToStream((OrderType) orderById.get().getDocument(), out); + return Optional.of(out.toByteArray()); + } + return Optional.empty(); + } +} diff --git a/cm-api/src/main/java/dk/erst/cm/api/order/OrderProducerService.java b/cm-api/src/main/java/dk/erst/cm/api/order/OrderProducerService.java new file mode 100644 index 0000000..7892bac --- /dev/null +++ b/cm-api/src/main/java/dk/erst/cm/api/order/OrderProducerService.java @@ -0,0 +1,166 @@ +package dk.erst.cm.api.order; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; + +import dk.erst.cm.api.data.Order; +import dk.erst.cm.api.data.Product; +import dk.erst.cm.xml.ubl21.model.CatalogueLine; +import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_21.AddressType; +import oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_21.CountryType; +import oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_21.CustomerPartyType; +import oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_21.ItemIdentificationType; +import oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_21.ItemType; +import oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_21.LineItemType; +import oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_21.OrderLineType; +import oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_21.PartyIdentificationType; +import oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_21.PartyLegalEntityType; +import oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_21.PartyNameType; +import oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_21.PartyType; +import oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_21.PeriodType; +import oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_21.SupplierPartyType; +import oasis.names.specification.ubl.schema.xsd.commonbasiccomponents_21.NoteType; +import oasis.names.specification.ubl.schema.xsd.order_21.OrderType; + +@Service +public class OrderProducerService { + + @Getter + @Setter + @ToString + public static class OrderDefaultConfig { + private String endpointGLN; + private String note; + } + + @Data + public static class PartyInfo { + + private String endpointID; + private String endpointIdSchemeID; + private String partyIdentificationID; + private String partyIdentificationIDSchemeID; + private String partyName; + private String legalEntityRegistrationName; + private String legalEntityCompanyID; + private String legalEntityCompanyIDSchemeID; + + public PartyInfo(String defaultId, String defaultSchemeID, String defaultName) { + this.endpointID = defaultId; + this.endpointIdSchemeID = defaultSchemeID; + this.partyIdentificationID = defaultId; + this.partyIdentificationIDSchemeID = defaultSchemeID; + this.legalEntityCompanyID = defaultId; + this.legalEntityCompanyIDSchemeID = defaultSchemeID; + this.partyName = defaultName; + this.legalEntityRegistrationName = defaultName; + } + } + + public OrderType generateOrder(Order dataOrder, OrderDefaultConfig defaultConfig, PartyInfo buyer, PartyInfo seller, List productList, Map productQuantityMap) { + OrderType order = new OrderType(); + + order.setCustomizationID("urn:fdc:peppol.eu:poacc:trns:order:3"); + order.setProfileID("urn:fdc:peppol.eu:poacc:bis:order_only:3"); + /* + * PEPPOL-T01-B00110 Document MUST NOT contain elements not part of the data model. /Order[1] /UUID[1] + */ +// order.setUUID(dataOrder.getId()); + order.setID(dataOrder.getOrderNumber()); + order.setIssueDate(dataOrder.getCreateTime().atZone(ZoneOffset.UTC).toLocalDate()); + order.setIssueTime(dataOrder.getCreateTime().atZone(ZoneOffset.UTC).toLocalTime()); + order.setDocumentCurrencyCode("DKK"); + if (defaultConfig.getNote() != null) { + order.getNote().add(new NoteType(defaultConfig.getNote())); + } + + CustomerPartyType buyerCustomerParty = new CustomerPartyType(); + buyerCustomerParty.setParty(buildParty(buyer)); + order.setBuyerCustomerParty(buyerCustomerParty); + + SupplierPartyType supplierPartyType = new SupplierPartyType(); + supplierPartyType.setParty(buildParty(seller)); + order.setSellerSupplierParty(supplierPartyType); + + AddressType supplierAddress = new AddressType(); + supplierAddress.setCityName("Stockholm"); + supplierAddress.setPostalZone("2100"); + CountryType countryType = new CountryType(); + countryType.setIdentificationCode("SE"); + supplierAddress.setCountry(countryType); + supplierPartyType.getParty().setPostalAddress(supplierAddress); + + ArrayList validityPeriodList = new ArrayList<>(); + PeriodType periodType = new PeriodType(); + periodType.setEndDate(LocalDate.now().plusDays(1)); + validityPeriodList.add(periodType); + order.setValidityPeriod(validityPeriodList); + + ArrayList orderLineList = new ArrayList<>(); + order.setOrderLine(orderLineList); + + for (int i = 0; i < productList.size(); i++) { + Product product = productList.get(i); + + CatalogueLine catalogueLine = (CatalogueLine) product.getDocument(); + + OrderLineType line = new OrderLineType(); + LineItemType lineItem = new LineItemType(); + lineItem.setID(product.getId()); + long quantity = 1; + if (productQuantityMap != null && productQuantityMap.containsKey(product.getId())) { + quantity = productQuantityMap.get(product.getId()); + } + lineItem.setQuantity(BigDecimal.valueOf(quantity)); + lineItem.getQuantity().setUnitCode("EA"); + ItemType item = new ItemType(); + item.setName(catalogueLine.getItem().getName()); + + ItemIdentificationType itemIdentificationType = new ItemIdentificationType(); + itemIdentificationType.setID(catalogueLine.getItem().getSellersItemIdentification().getId()); + item.setSellersItemIdentification(itemIdentificationType); + + lineItem.setItem(item); + line.setLineItem(lineItem); + orderLineList.add(line); + } + + return order; + } + + public PartyType buildParty(PartyInfo partyInfo) { + PartyType buyerParty = new PartyType(); + buyerParty.setEndpointID(partyInfo.getEndpointID()); + buyerParty.getEndpointID().setSchemeID(partyInfo.getEndpointIdSchemeID()); + List list = new ArrayList<>(); + PartyIdentificationType partyIdentificationType = new PartyIdentificationType(); + partyIdentificationType.setID(partyInfo.getPartyIdentificationID()); + partyIdentificationType.getID().setSchemeID(partyInfo.getPartyIdentificationIDSchemeID()); + list.add(partyIdentificationType); + buyerParty.setPartyIdentification(list); + List partyNameList = new ArrayList<>(); + PartyNameType e = new PartyNameType(); + e.setName(partyInfo.getPartyName()); + partyNameList.add(e); + buyerParty.setPartyName(partyNameList); + List legalEntityList = new ArrayList<>(); + PartyLegalEntityType legalEntity = new PartyLegalEntityType(); + legalEntity.setRegistrationName(partyInfo.getLegalEntityRegistrationName()); + legalEntity.setCompanyID(partyInfo.getLegalEntityCompanyID()); + legalEntity.getCompanyID().setSchemeID(partyInfo.getLegalEntityCompanyIDSchemeID()); + legalEntityList.add(legalEntity); + buyerParty.setPartyLegalEntity(legalEntityList); + return buyerParty; + } + +} diff --git a/cm-api/src/main/java/dk/erst/cm/api/order/OrderService.java b/cm-api/src/main/java/dk/erst/cm/api/order/OrderService.java new file mode 100644 index 0000000..2abeb04 --- /dev/null +++ b/cm-api/src/main/java/dk/erst/cm/api/order/OrderService.java @@ -0,0 +1,105 @@ +package dk.erst.cm.api.order; + +import com.helger.ubl21.UBL21Writer; +import dk.erst.cm.api.dao.mongo.BasketRepository; +import dk.erst.cm.api.dao.mongo.OrderRepository; +import dk.erst.cm.api.data.Basket; +import dk.erst.cm.api.data.Order; +import dk.erst.cm.api.data.OrderStatus; +import oasis.names.specification.ubl.schema.xsd.order_21.OrderType; +import org.bson.Document; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Service; + +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +@Service +public class OrderService { + + private final BasketRepository basketRepository; + private final OrderRepository orderRepository; + private final MongoTemplate mongoTemplate; + + @Autowired + public OrderService(MongoTemplate mongoTemplate, BasketRepository basketRepository, OrderRepository orderRepository) { + this.mongoTemplate = mongoTemplate; + this.basketRepository = basketRepository; + this.orderRepository = orderRepository; + } + + public Optional findBasketById(String basketId) { + return basketRepository.findById(basketId); + } + + public List findOrdersByBasketId(String basketId) { + return orderRepository.findByBasketId(basketId); + } + + @SuppressWarnings("unused") + public Optional findOrderById(String orderId) { + return orderRepository.findById(orderId); + } + + /* + * If we return to controller an object of OrderType, it is serialized to JSON with Jackson. + * + * As a result, 2 different ways to serialize objects are used - MongoConverterConfig to save into MongoDB and Jackson to render on GUI. + * + * Moreover, it is quite difficult to configure them to serialize so complex structures as OrderType in the same way, + * so let's avoid double conversion and use the same approach for both. + * + * Finally, it gives us the opportunity to see exact values of MongoDB document fields, useful for querying. + */ + public Optional findOrderByIdAsJSON(String id) { + String result = mongoTemplate.execute("order", collection -> { + Document d = collection.find(new Document("_id", id)).first(); + if (d != null) { + return d.toJson(); + } + return null; + }); + return Optional.ofNullable(result); + } + + public void saveBasket(Basket basket) { + this.basketRepository.save(basket); + } + + public void saveOrder(Order order) { + this.orderRepository.save(order); + } + + @SuppressWarnings("unused") + public void updateOrderStatus(String orderId, OrderStatus status) { + Optional optionalOrder = this.orderRepository.findById(orderId); + if (optionalOrder.isPresent()) { + Order order = optionalOrder.get(); + if (status == OrderStatus.DELIVERED) { + order.setDeliveredDate(Instant.now()); + } + order.setStatus(status); + this.orderRepository.save(order); + } + } + + public File saveOrderXML(File directory, OrderType sendOrder) throws IOException { + File tempFile = new File(directory, "delis-cm-" + sendOrder.getIDValue() + ".xml"); + try (OutputStream out = new BufferedOutputStream(Files.newOutputStream(tempFile.toPath()))) { + saveOrderXMLToStream(sendOrder, out); + } + return tempFile; + } + + public void saveOrderXMLToStream(OrderType sendOrder, OutputStream out) { + UBL21Writer.order().write(sendOrder, out); + } + +} diff --git a/cm-api/src/main/java/dk/erst/cm/api/order/data/CustomerOrderData.java b/cm-api/src/main/java/dk/erst/cm/api/order/data/CustomerOrderData.java new file mode 100644 index 0000000..4494545 --- /dev/null +++ b/cm-api/src/main/java/dk/erst/cm/api/order/data/CustomerOrderData.java @@ -0,0 +1,25 @@ +package dk.erst.cm.api.order.data; + +import lombok.Data; + +@Data +public class CustomerOrderData { + + private Company buyerCompany; + private Contact buyerContact; + + @Data + public static class Contact { + private String personName; + private String email; + private String telephone; + } + + @Data + public static class Company { + private String registrationName; + private String legalIdentifier; + private String partyIdentifier; + } + +} diff --git a/cm-api/src/main/java/dk/erst/cm/api/util/StatData.java b/cm-api/src/main/java/dk/erst/cm/api/util/StatData.java new file mode 100644 index 0000000..839309b --- /dev/null +++ b/cm-api/src/main/java/dk/erst/cm/api/util/StatData.java @@ -0,0 +1,97 @@ +package dk.erst.cm.api.util; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class StatData { + + private final Map statMap; + + private final long startMs; + + private Object result; + + public StatData() { + this.startMs = System.currentTimeMillis(); + this.statMap = new HashMap<>(); + } + + public int getCount(String key) { + int[] c = this.statMap.get(key); + if (c != null) { + return c[0]; + } + return -1; + } + + public void incrementObject(Object key) { + increment(String.valueOf(key)); + } + + public void increment(String key) { + this.increase(key, 1); + } + + public void increase(String key, int count) { + String code = key == null ? "UNDEFINED" : key; + int[] c = statMap.get(code); + if (c == null) { + statMap.put(code, new int[]{count}); + } else { + c[0] += count; + } + } + + public static StatData error(String message) { + StatData sd = new StatData(); + sd.increment(message); + return sd; + } + + @Override + public String toString() { + return toStatString(); + } + + public String toStatString() { + if (isEmpty()) { + return "Nothing"; + } + StringBuilder sb = new StringBuilder(); + List keyList = new ArrayList<>(statMap.keySet()); + Collections.sort(keyList); + for (String key : keyList) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(key); + sb.append(": "); + sb.append(statMap.get(key)[0]); + } + return sb.toString(); + } + + public boolean isEmpty() { + return statMap.isEmpty(); + } + + public long getStartMs() { + return startMs; + } + + public String toDurationString() { + return (System.currentTimeMillis() - startMs) + " ms"; + } + + public Object getResult() { + return result; + } + + public void setResult(Object result) { + this.result = result; + } +} + diff --git a/cm-api/src/test/java/dk/erst/cm/api/order/OrderProducerServiceTest.java b/cm-api/src/test/java/dk/erst/cm/api/order/OrderProducerServiceTest.java new file mode 100644 index 0000000..050ca9d --- /dev/null +++ b/cm-api/src/test/java/dk/erst/cm/api/order/OrderProducerServiceTest.java @@ -0,0 +1,104 @@ +package dk.erst.cm.api.order; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.helger.commons.error.IError; +import com.helger.commons.error.list.IErrorList; +import com.helger.ubl21.UBL21Reader; +import com.helger.ubl21.UBL21Validator; +import com.helger.ubl21.UBL21Writer; + +import dk.erst.cm.api.data.Order; +import dk.erst.cm.api.data.Product; +import dk.erst.cm.api.order.OrderProducerService.OrderDefaultConfig; +import dk.erst.cm.api.order.OrderProducerService.PartyInfo; +import dk.erst.cm.api.order.data.CustomerOrderData; +import dk.erst.cm.api.order.data.CustomerOrderData.Company; +import dk.erst.cm.xml.ubl21.model.CatalogueLine; +import dk.erst.cm.xml.ubl21.model.Item; +import dk.erst.cm.xml.ubl21.model.NestedID; +import lombok.extern.slf4j.Slf4j; +import oasis.names.specification.ubl.schema.xsd.commonaggregatecomponents_21.OrderLineType; +import oasis.names.specification.ubl.schema.xsd.order_21.OrderType; + +@Slf4j +class OrderProducerServiceTest { + + @Test + void read() throws IOException { + try (InputStream is = new FileInputStream("../cm-resources/examples/order/OrderOnly.xml")) { + OrderType res = UBL21Reader.order().read(is); + assertEquals("1005", res.getIDValue()); + assertEquals("Contract0101", res.getContract().get(0).getIDValue()); + List orderLine = res.getOrderLine(); + for (int i = 0; i < orderLine.size(); i++) { + OrderLineType orderLineType = orderLine.get(i); + assertEquals(String.valueOf(i + 1), orderLineType.getLineItem().getIDValue()); + } + } + } + + @Test + void produce() { + OrderProducerService service = new OrderProducerService(); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + List productList = new ArrayList(); + Product product = new Product(); + product.setId(UUID.randomUUID().toString()); + CatalogueLine catalogueLine = new CatalogueLine(); + Item item = new Item(); + item.setName("Test line"); + item.setSellersItemIdentification(new NestedID()); + item.getSellersItemIdentification().setId("TESTID"); + catalogueLine.setItem(item); + product.setDocument(catalogueLine); + + productList.add(product); + CustomerOrderData customerOrderData = new CustomerOrderData(); + Company buyerCompany = new Company(); + buyerCompany.setRegistrationName("Danish Customer Company"); + customerOrderData.setBuyerCompany(buyerCompany); + Order dataOrder = new Order(); + dataOrder.setCreateTime(Instant.now()); + + PartyInfo buyer = new PartyInfo("5798009882806", "0088", "Buyer Company"); + PartyInfo seller = new PartyInfo("5798009882783", "0088", "Seller Company"); + + OrderDefaultConfig defaultConfig = new OrderDefaultConfig(); + defaultConfig.setNote("TEST NOTE"); + Map productQuantityMap = new HashMap(); + productList.forEach(p -> productQuantityMap.put(p.getId(), 2)); + OrderType order = service.generateOrder(dataOrder, defaultConfig, buyer, seller, productList, productQuantityMap); + IErrorList errorList = UBL21Validator.order().validate(order); + if (errorList.isNotEmpty()) { + log.error("Found " + errorList.size() + " errors:"); + for (int i = 0; i < errorList.size(); i++) { + IError error = errorList.get(i); + log.error((i + 1) + "\t" + error.toString()); + } + } + assertTrue(errorList.isEmpty()); + UBL21Writer.order().write(order, out); + String xml = new String(out.toByteArray(), StandardCharsets.UTF_8); + log.info(xml); + assertTrue(xml.indexOf(order.getSellerSupplierParty().getParty().getPostalAddress().getCountry().getIdentificationCodeValue()) > 0); + assertTrue(xml.indexOf(defaultConfig.getNote()) > 0); + } + +} diff --git a/cm-api/src/test/resources/logback-test.xml b/cm-api/src/test/resources/logback-test.xml new file mode 100644 index 0000000..b2d8dca --- /dev/null +++ b/cm-api/src/test/resources/logback-test.xml @@ -0,0 +1,11 @@ + + + + + %d{dd.MM.yyyy HH:mm:ss.SSS} [%thread] %-5level %logger{20} - %msg%n + + + + + + \ No newline at end of file diff --git a/cm-frontend/src/components/AddToBasket.js b/cm-frontend/src/components/AddToBasket.js new file mode 100644 index 0000000..8496840 --- /dev/null +++ b/cm-frontend/src/components/AddToBasket.js @@ -0,0 +1,38 @@ +import React from "react"; +import {Button} from "@material-ui/core"; +import {ProductBasketStatus} from "./BasketData"; + +export const getProductBasketButtonTitle = (basketState) => { + switch (basketState) { + case ProductBasketStatus.Added: + return 'Remove from basket'; + default: + return 'Add to basket'; + } +} + +export const handleProductBasketIconClick = (basketData, product, changeBasket) => { + const state = basketData.getProductBasketStatus(product.id); + if (state === ProductBasketStatus.Empty) { + changeBasket(product.id, 1); + } else if (state === ProductBasketStatus.Added) { + changeBasket(product.id, 0); + } +} + +export default function AddToBasket(props) { + + const {changeBasket, basketData, product} = props; + + const handleClick = () => { + handleProductBasketIconClick(basketData, product, changeBasket); + } + + return ( + <> + + + ) +} \ No newline at end of file diff --git a/cm-frontend/src/components/Banner.js b/cm-frontend/src/components/Banner.js index 62a93dd..ee35914 100644 --- a/cm-frontend/src/components/Banner.js +++ b/cm-frontend/src/components/Banner.js @@ -54,6 +54,10 @@ export default function Banner(props) {
  • related items
  • + + Products can be added to basket and sent as BIS3 Orders for demo purposes, generated orders can be downloaded as XML, delivery status and potential OrderResponse + can be checked by direct link. + + )} + + + + + + + + + + + + + + + + + + Order + Status + Supplier + Order number + Lines + Actions + + + + {orderList?.map((row, index) => ( showRowDetails(row.id)}> + {(index + 1)} + {row.status} + {row.supplierName} + {row.orderNumber} + {row.lineCount} + + + + + ))} + +
    +
    +
    + + setShowSnackBar(false)}/> + + + + ) +} diff --git a/cm-frontend/src/components/OrderData.js b/cm-frontend/src/components/OrderData.js new file mode 100644 index 0000000..76e473c --- /dev/null +++ b/cm-frontend/src/components/OrderData.js @@ -0,0 +1,54 @@ +// Global state for order data - because I want not finished input of sending form to be kept between page navigations, +// so changes should be applied without any submit. But when useState on the root component was used, any changes +// led to re-render of many components. So instead local useState per input is used, current global value is passed as +// parameter and is used as local state initial value in each input. +const _orderData = createOrderData(); + +export const getOrderData = () => _orderData; + +function createOrderData() { + + class Company { + constructor() { + this.registrationName = null; + this.legalIdentifier = null; + this.partyIdentifier = null; + } + + setDefault() { + this.registrationName = "My Company ApS"; + this.legalIdentifier = "DK11223344"; + this.partyIdentifier = "7300010000001"; + } + } + + class Contact { + constructor() { + this.personName = null; + this.email = null; + this.telephone = null; + } + + setDefault() { + this.personName = "John Doe"; + this.email = "some@email.com"; + this.telephone = "+45 11223344"; + } + } + + class OrderData { + constructor() { + this.buyerCompany = new Company(); + this.buyerCompany.setDefault(); + this.buyerContact = new Contact(); + this.buyerContact.setDefault(); + } + + isEmpty() { + // TODO: Implement empty validation + return false; + } + } + + return new OrderData(); +} \ No newline at end of file diff --git a/cm-frontend/src/components/OrderHeader.js b/cm-frontend/src/components/OrderHeader.js new file mode 100644 index 0000000..02a7578 --- /dev/null +++ b/cm-frontend/src/components/OrderHeader.js @@ -0,0 +1,104 @@ +import React from "react"; +import {Grid, Paper, TextField, Typography} from "@material-ui/core"; +import {makeStyles} from "@material-ui/core/styles"; + +const buildDefaultLabelByName = (str) => { + return str.split(/(?=[A-Z])/).map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(" ") +} + +const DataInput = (props) => { + const {name, label = buildDefaultLabelByName(name), lockControls, target, ...rest} = props; + + const [value, setValue] = React.useState(target[name]); + + const onChange = (e) => { + const newValue = e.target.value; + target[e.target.name] = newValue; + return setValue(newValue); + }; + + const useStyles = makeStyles((theme) => ({ + input: { + paddingInline: theme.spacing(0.5), + } + })); + + const classes = useStyles(); + + return +} + +const DataBlock = (props) => { + + const {name, target, lockControls} = props; + + const useStyles = makeStyles((theme) => ({ + formHeader: { + paddingTop: theme.spacing(1), + paddingLeft: theme.spacing(2), + textAlign: "left", + fontSize: '1em', + }, + form: { + padding: theme.spacing(2), + display: "flex", + flex: "1", + flexDirection: "row", + justifyContent: "space-between", + }, + input: { + paddingInline: theme.spacing(0.5), + }, + + })); + + const classes = useStyles(); + + return + +
    {name}
    +
    + {/* + // Below trick is needed to avoid writing same target and lockControls attribute in each child + // - parent expands children with these equal parameters by cloning them. + // If it affects performance - just do copy/paste... + */} + {props.children.map((child) => ( +
    + {React.cloneElement(child, {target: target, lockControls: lockControls})} +
    + ))} +
    +
    +
    + +} + +export default function OrderHeader(props) { + + const {orderData, lockControls} = props; + + const useStyles = makeStyles((theme) => ({ + paper: { + padding: theme.spacing(2), + marginBottom: theme.spacing(2), + }, + })); + + const classes = useStyles(); + + return + + + + + + + + + + + + + +} \ No newline at end of file diff --git a/cm-frontend/src/components/OrderLineList.js b/cm-frontend/src/components/OrderLineList.js new file mode 100644 index 0000000..5d928e6 --- /dev/null +++ b/cm-frontend/src/components/OrderLineList.js @@ -0,0 +1,124 @@ +import {makeStyles, withStyles} from "@material-ui/core/styles"; +import TableContainer from "@material-ui/core/TableContainer"; +import Table from "@material-ui/core/Table"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import TableBody from "@material-ui/core/TableBody"; +import TableCell from "@material-ui/core/TableCell"; +import ItemDetailsService from "../services/ItemDetailsService"; +import React from "react"; +import {Fab, Paper} from "@material-ui/core"; +import RemoveIcon from "@material-ui/icons/Remove"; +import AddIcon from "@material-ui/icons/Add"; + +const StyledTableCell = withStyles(() => ({ + head: { + fontWeight: 'bold', + }, +}))(TableCell); + +const StyledTableRow = withStyles((theme) => ({ + root: { + '&:nth-of-type(odd)': { + backgroundColor: theme.palette.action.hover, + }, + '&:hover': { + backgroundColor: 'rgba(0,0,0,.08)', + }, + cursor: 'pointer' + }, +}))(TableRow); + +function QuantityControl(props) { + const {productId, quantity, changeBasket, lockControls} = props; + + const changeQuantity = (e, quantityChange) => { + e.stopPropagation(); + if (quantity + quantityChange === 0) { + changeBasket(productId, 0); + } else { + changeBasket(productId, quantityChange); + } + return false; + } + + return ( +
    { + e.stopPropagation(); + return false; + }}> + + changeQuantity(e, -1)}/> + + {quantity} + + changeQuantity(e, 1)}/> + +
    + ) +} + +export default function OrderLineList(props) { + + const {basketData, showRowDetails, changeBasket, productList, lockControls, errorProductIdSet} = props; + + const useStyles = makeStyles((theme) => ({ + paper: { + padding: theme.spacing(2), + paddingBottom: theme.spacing(5), + marginBottom: theme.spacing(3), + }, + table: { + minWidth: 600, + }, + errorLine: { + textDecoration: "line-through", + color: theme.palette.error.main, + } + })); + + const classes = useStyles(); + + const productItem = (productId) => { + if (productId in productList) { + return productList[productId].document.item; + } + return null; + } + + const isErrorLine = (productId) => { + return productId && errorProductIdSet && errorProductIdSet.has(productId); + } + + return + + + + + # + Quantity + Name + Standard number + Seller number + + + + {!basketData.isEmpty() ? basketData.getOrderLineList().map((orderLine, index) => ( + showRowDetails(orderLine.productId)} className={isErrorLine(orderLine.productId) ? classes.errorLine : null}> + {(index + 1)} + + {ItemDetailsService.itemName(productItem(orderLine.productId))} + {ItemDetailsService.itemStandardNumber(productItem(orderLine.productId))} + {ItemDetailsService.itemSellerNumber(productItem(orderLine.productId))} + + )) : ( + + Basket is empty + + )} + +
    +
    +
    + ; +} diff --git a/cm-frontend/src/components/OrderSendResult.js b/cm-frontend/src/components/OrderSendResult.js new file mode 100644 index 0000000..aaf146f --- /dev/null +++ b/cm-frontend/src/components/OrderSendResult.js @@ -0,0 +1,103 @@ +import {makeStyles} from "@material-ui/core/styles"; +import Paper from "@material-ui/core/Paper"; +import {Button} from "@material-ui/core"; +import React from "react"; +import TableContainer from "@material-ui/core/TableContainer"; +import Table from "@material-ui/core/Table"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import TableBody from "@material-ui/core/TableBody"; +import TableCell from "@material-ui/core/TableCell"; +import {StyledTableCell, StyledTableRow} from "../pages/ProductListPage"; +import {DataRow, DataView} from "./ProductDetail"; +import {useHistory} from "react-router"; +import {ViewToggle} from "../pages/ProductDetailPage"; +import SmallSnackbar from "./SmallSnackbar"; +import {copyCurrentUrlToClipboard} from "../services/ClipboardService"; +import DataService from "../services/DataService"; + + +export default function OrderSendResult(props) { + + const {sentOrderData} = props; + + const [viewMode, setViewMode] = React.useState("table"); + const [showSnackBar, setShowSnackBar] = React.useState(false); + + const handleViewChange = (event) => { + setViewMode(event.target.value); + } + const useStyles = makeStyles((theme) => ({ + paper: { + padding: theme.spacing(2), + marginBottom: theme.spacing(2), + }, + })); + + + const classes = useStyles(); + + const {push} = useHistory(); + + const showBasketDetails = (basketId) => { + push('/basket/' + basketId); + } + const showProductDetails = (productId) => { + push('/product/view/' + productId); + } + + return ( + <> + + + + + + + + + + + + + + + + + + + {viewMode === "json" ? ( +
    {JSON.stringify(sentOrderData, null, 2)}
    + ) : ( + + + + + + Line + Quantity + Name + Seller number + + + + {sentOrderData.document?.orderLine.map((row, index) => ( + showProductDetails(row.lineItem._id.value)}> + {(index + 1)} + {row.lineItem.quantity.value} {row.lineItem.quantity.unitCode} + {row.lineItem.item.name.value} + {row.lineItem.item.sellersItemIdentification._id.value} + + ))} + +
    +
    + )} + +
    + + setShowSnackBar(false)}/> + + + ) +} diff --git a/cm-frontend/src/components/ProductListHeader.js b/cm-frontend/src/components/PageHeader.js similarity index 84% rename from cm-frontend/src/components/ProductListHeader.js rename to cm-frontend/src/components/PageHeader.js index 6e965d1..2621d44 100644 --- a/cm-frontend/src/components/ProductListHeader.js +++ b/cm-frontend/src/components/PageHeader.js @@ -1,50 +1,53 @@ -import { Card, CardContent, Fab, makeStyles, Typography } from "@material-ui/core"; -import RefreshIcon from '@material-ui/icons/Refresh'; - -const useStyles = makeStyles(theme => ({ - cardContent: { - '&:last-child': { - paddingBottom: theme.spacing(2), - }, - }, - row: { - display: "flex", - }, - header: { - flex: '1', - }, - buttons: { - flex: '1', - display: "flex", - placeContent: 'stretch flex-end', - alignItems: 'stretch', - - '& button': { - marginLeft: theme.spacing(4), - } - } -})); - -export default function ProductListHeader(prop) { - const { name, refreshAction } = prop; - - const classes = useStyles(); - - return ( - - -
    -
    - {name} -
    - -
    - - refreshAction()}/> - -
    -
    -
    -
    - ) +import { Card, CardContent, Fab, makeStyles, Typography } from "@material-ui/core"; +import RefreshIcon from '@material-ui/icons/Refresh'; + +const useStyles = makeStyles(theme => ({ + cardContent: { + '&:last-child': { + paddingBottom: theme.spacing(2), + }, + }, + row: { + display: "flex", + }, + header: { + flex: '1', + }, + buttons: { + flex: '1', + display: "flex", + placeContent: 'stretch flex-end', + alignItems: 'stretch', + + '& button': { + marginLeft: theme.spacing(4), + } + } +})); + +export default function PageHeader(prop) { + const { name, refreshAction, children } = prop; + + const classes = useStyles(); + + return ( + + +
    +
    + {name} +
    + +
    + {refreshAction && ( + + refreshAction()}/> + + )} + {children} +
    +
    +
    +
    + ) } \ No newline at end of file diff --git a/cm-frontend/src/components/ProductDetail.js b/cm-frontend/src/components/ProductDetail.js index 0ff9dee..d570958 100644 --- a/cm-frontend/src/components/ProductDetail.js +++ b/cm-frontend/src/components/ProductDetail.js @@ -1,8 +1,11 @@ +// noinspection JSUnresolvedVariable + import { makeStyles } from "@material-ui/core"; import { Fragment } from "react"; import ItemDetailsService from '../services/ItemDetailsService'; import CatalogBadge from "./CatalogBadge"; import ProductPictureList from './ProductPictureList'; +import AddToBasket from "./AddToBasket"; const useStyles = makeStyles(theme => ({ row: { @@ -23,26 +26,36 @@ const useStyles = makeStyles(theme => ({ }, })); -function DataView(props) { +export function DataRow(props) { + const { name, children } = props; + + const classes = useStyles(); + + return ( +
    +
    {name}
    +
    {children}
    +
    + ) +} + +export function DataView(props) { const _isValueDefined = (value) => value ? true : false; + // noinspection JSUnusedLocalSymbols const _renderValue = (v, i) => { return v }; const { name, value, isValueDefined = _isValueDefined, renderValue = _renderValue } = props; - const classes = useStyles(); return ( <> {isValueDefined(value) ? ( -
    -
    {name}
    -
    {renderValue(value)}
    -
    + {renderValue(value)} ) : (<>)} ) } -const isListFilled = (list) => list && list.length > 0 ? true : false; +const isListFilled = (list) => list && list.length > 0; function DataListView(props) { const _isValueDefined = isListFilled; @@ -79,7 +92,7 @@ const renderCatalogs = (source) => { {source.length}{source.length > 1 ? ' catalogs: ':' catalog'} {source.map((s,i) => { return ( - + ) })} @@ -88,7 +101,7 @@ const renderCatalogs = (source) => { const renderSourcedValue = (v, extractValue = (e)=> {return e.value}) => { return ( -
    +
    {v._source ? (<>{' '}{extractValue(v)}) : <>{v}}
    ) @@ -98,35 +111,44 @@ export default function ProductView(props) { const showTech = false; + const showOrdering = true; + const { product } = props; return ( <> + { showOrdering && ( + <> + + + + + )} { showTech && ( <> - - - - - - - - + + + + + + + + )} - - - - {return e.id}}> - {return e?.partyName?.name}}> - - - - + + + + {return e.id}}/> + {return e?.partyName?.name}}/> + + + + - - + + ) diff --git a/cm-frontend/src/components/ProductDetailHeader.js b/cm-frontend/src/components/ProductDetailHeader.js index 085484a..70e8c5e 100644 --- a/cm-frontend/src/components/ProductDetailHeader.js +++ b/cm-frontend/src/components/ProductDetailHeader.js @@ -1,7 +1,11 @@ -import { Card, CardContent, Fab, makeStyles, Typography } from "@material-ui/core"; +import {Card, CardContent, Fab, makeStyles, Typography} from "@material-ui/core"; import ArrowIcon from '@material-ui/icons/KeyboardBackspaceOutlined'; import RefreshIcon from '@material-ui/icons/Refresh'; -import { useHistory } from "react-router"; +import ShoppingBasketIcon from '@material-ui/icons/ShoppingBasketOutlined'; +import {useHistory} from "react-router"; +import React from "react"; +import {getProductBasketButtonTitle, handleProductBasketIconClick} from "./AddToBasket"; +import {ProductBasketStatus} from "./BasketData"; const useStyles = makeStyles(theme => ({ @@ -31,16 +35,25 @@ const useStyles = makeStyles(theme => ({ } })); +// noinspection JSUnusedLocalSymbols const _emptyNavigator = { - hasNext: (id) => {return false}, - hasPrevious: (id) => {return false}, - getNext: (id) => { return null}, - getPrevious: (id) => { return null}, + hasNext: (id) => { + return false + }, + hasPrevious: (id) => { + return false + }, + getNext: (id) => { + return null + }, + getPrevious: (id) => { + return null + }, } export default function ProductDetailHeader(prop) { - const { name, navigator = _emptyNavigator, id, refreshAction } = prop; + const {name, navigator = _emptyNavigator, id, refreshAction, basketData, product, changeBasket} = prop; const classes = useStyles(); @@ -54,25 +67,36 @@ export default function ProductDetailHeader(prop) { history.push(path); } + const handleIconClick = () => { + handleProductBasketIconClick(basketData, product, changeBasket); + } + + const getShoppingBasketColor = (product) => { + return product && basketData.getProductBasketStatus(product.id) === ProductBasketStatus.Empty ? "default" : "primary"; + } + return ( - + -
    +
    {name}
    - navigateTo(navigator.getPrevious(id)) } > - + handleIconClick()}> + + + navigateTo(navigator.getPrevious(id))}> + - navigateTo(navigator.getNext(id)) } > - + navigateTo(navigator.getNext(id))}> + - refreshAction(id)}> + refreshAction(id)}> - - + +
    diff --git a/cm-frontend/src/components/ProductListContainer.js b/cm-frontend/src/components/ProductListContainer.js index b773174..26cf3a1 100644 --- a/cm-frontend/src/components/ProductListContainer.js +++ b/cm-frontend/src/components/ProductListContainer.js @@ -1,5 +1,5 @@ import React from "react"; -import { Route, useHistory } from "react-router-dom"; +import {Route, useHistory} from "react-router-dom"; import ProductListPage from "../pages/ProductListPage"; import Banner from "./Banner"; import UploadPage from "../pages/UploadPage"; @@ -7,89 +7,116 @@ import ProductDetailPage from "../pages/ProductDetailPage"; import TopNav from "./TopNav"; import DataService from "../services/DataService"; import useStickyState from '../utils/useStickyState'; +import {createBasketData} from "./BasketData"; +import {getOrderData} from "./OrderData"; +import SendPage from "../pages/SendPage"; +import BasketPage from "../pages/BasketPage"; +import OrderPage from "../pages/OrderPage"; const currentPosition = (list, id) => { if (list._cachedPos) { - if (list._cachedPos[id]) { - return list._cachedPos[id] - 1; // Cache position with + 1 - so 0 is not considered as absent - } + if (list._cachedPos[id]) { + return list._cachedPos[id] - 1; // Cache position with + 1 - so 0 is not considered as absent + } } else { - list._cachedPos = {}; + list._cachedPos = {}; } if (!list) { - return 0; + return 0; + } + for (let i = 0; i < list.length; i++) if (list[i].id === id) { + list._cachedPos[id] = (i + 1); + return i; } - for (var i = 0; i < list.length; i++) if (list[i].id === id) { - list._cachedPos[id] = (i + 1); - return i; - }; return 0; - }; - - export const listNavigator = (list) => { +}; + +export const listNavigator = (list) => { return { - hasNext: (id) => { return currentPosition(list, id) < list.length - 1}, - hasPrevious: (id) => { return currentPosition(list, id) > 0}, - getNext: (id) => { return '/product/view/'+ list[currentPosition(list, id)+1].id}, - getPrevious: (id) => { return '/product/view/'+ list[currentPosition(list, id)-1].id}, + hasNext: (id) => { + return currentPosition(list, id) < list.length - 1 + }, + hasPrevious: (id) => { + return currentPosition(list, id) > 0 + }, + getNext: (id) => { + return '/product/view/' + list[currentPosition(list, id) + 1].id + }, + getPrevious: (id) => { + return '/product/view/' + list[currentPosition(list, id) - 1].id + }, } - } +} -export function ProductListContainer(props) { +export function ProductListContainer() { - const [showBanner, setShowBanner] = useStickyState(true, 'dcm-banner'); - const [productList, setProductList] = React.useState([]); - const [productListPage, setProductListPage] = React.useState(0); - const [productListPageSize, setProductListPageSize] = React.useState(20); - const [productListTotal, setProductListTotal] = React.useState(0); - const [productListLoading, setProductListLoading] = React.useState(false); + const [showBanner, setShowBanner] = useStickyState(true, 'dcm-banner'); + const [productList, setProductList] = React.useState([]); + const [productListPage, setProductListPage] = React.useState(0); + const [productListPageSize, setProductListPageSize] = React.useState(20); + const [productListTotal, setProductListTotal] = React.useState(0); + const [productListLoading, setProductListLoading] = React.useState(false); - const setBannerClosed = () => { - setShowBanner(false); - }; - const setBannerOpened = () => { - setShowBanner(true); - }; + const [basketData, setBasketData] = React.useState(createBasketData()); + const changeBasket = (productId, quantity) => { + setBasketData(basketData.changeBasket(productId, quantity)); + } - const history = useHistory(); - const searchAction = (...params) => { - history.push('/'); - loadProducts(...params); - }; + const setBannerClosed = () => { + setShowBanner(false); + }; + const setBannerOpened = () => { + setShowBanner(true); + }; + + const history = useHistory(); + const searchAction = (...params) => { + history.push('/'); + loadProducts(...params); + }; - React.useEffect(() => { - loadProducts(); - }, []); + React.useEffect(() => { + loadProducts(); + }, []); - async function loadProducts(search = null, page = 0, size = 20) { - console.log("Load products: "+search+" "+page+" "+size); - setProductListLoading(true); - await DataService.fetchProducts(search, page, size).then(response => { - let responseData = response.data; - console.log(responseData); - setProductList(responseData.content); - setProductListPage(responseData.number); - setProductListPageSize(responseData.size); - setProductListTotal(responseData.totalElements); - setProductListLoading(false); + async function loadProducts(search = null, page = 0, size = 20) { + console.log("Load products: " + search + " " + page + " " + size); + setProductListLoading(true); + await DataService.fetchProducts(search, page, size).then(response => { + let responseData = response.data; + console.log(responseData); + setProductList(responseData.content); + setProductListPage(responseData.number); + setProductListPageSize(responseData.size); + setProductListTotal(responseData.totalElements); + setProductListLoading(false); + } + ).catch(error => { + console.log('Error occurred: ' + error.message); + setProductListLoading(false); + }); } - ).catch(error => { - console.log('Error occured: ' + error.message); - setProductListLoading(false); - }); - } - return ( - <> - - - - - - - - - - - ); + return ( + <> + + + + + + + + + + + + + + + + + + + + ); } diff --git a/cm-frontend/src/components/ProductPictureList.js b/cm-frontend/src/components/ProductPictureList.js index f283944..a600f33 100644 --- a/cm-frontend/src/components/ProductPictureList.js +++ b/cm-frontend/src/components/ProductPictureList.js @@ -23,7 +23,7 @@ export default function RenderPictureList(props) { const classes = useStyles(); return ( - + {props.specList.map((spec) => ( Product diff --git a/cm-frontend/src/components/SmallSnackbar.js b/cm-frontend/src/components/SmallSnackbar.js new file mode 100644 index 0000000..56c631e --- /dev/null +++ b/cm-frontend/src/components/SmallSnackbar.js @@ -0,0 +1,21 @@ +import {Alert} from "@material-ui/lab"; +import React from "react"; +import {Snackbar} from "@material-ui/core"; + +export default function SmallSnackbar(props) { + + const {message = "Copied", opened, hide} = props; + + const handleClose = (event, reason) => { + if (reason === 'clickaway') { + return; + } + hide(); + }; + + return ( + + {message} + + ) +} \ No newline at end of file diff --git a/cm-frontend/src/components/TopNav.js b/cm-frontend/src/components/TopNav.js index 26a8fa5..9287588 100644 --- a/cm-frontend/src/components/TopNav.js +++ b/cm-frontend/src/components/TopNav.js @@ -1,70 +1,85 @@ import React from 'react'; -import { makeStyles } from '@material-ui/core/styles'; +import {makeStyles} from '@material-ui/core/styles'; import AppBar from '@material-ui/core/AppBar'; import Toolbar from '@material-ui/core/Toolbar'; import Typography from '@material-ui/core/Typography'; import Button from '@material-ui/core/Button'; -import { Link } from 'react-router-dom'; +import {Link} from 'react-router-dom'; import SearchBar from './SearchBar'; import './TopNav.css'; +// import BasketBar from "./BasketBar"; const useStyles = makeStyles((theme) => ({ - root: { - flexGrow: 1, - }, -title: { - flexGrow: 1, - marginRight: theme.spacing(2), - }, -fullName: { - [theme.breakpoints.down('sm')]: { - display: 'none', - }, -}, -link: { - color: "inherit", - textDecoration: "none", - "&:hover": { - color: "#d3d3d3" + root: { + flexGrow: 1, + }, + title: { + flexGrow: 1, + marginRight: theme.spacing(2), + }, + fullName: { + [theme.breakpoints.down('sm')]: { + display: 'none', + }, + }, + link: { + color: "inherit", + textDecoration: "none", + "&:hover": { + color: "#d3d3d3" + } + }, + basketBar: { + [theme.breakpoints.up('xs')]: { + margin: theme.spacing(2), + }, + }, + searchBar: { + [theme.breakpoints.down('xs')]: { + margin: theme.spacing(2), + } + }, + flexBreak: { + display: 'none', + [theme.breakpoints.down('xs')]: { + display: 'flex', + flexBasis: '100%', + height: '0', + } } - }, - searchBar: { - [theme.breakpoints.down('xs')]: { - margin: theme.spacing(2), - } - }, - flexBreak: { - display: 'none', - [theme.breakpoints.down('xs')]: { - display: 'flex', - flexBasis: '100%', - height: '0', - } - } })); export default function TopNav(props) { - const classes = useStyles(); + const classes = useStyles(); + + const {aboutAction, searchAction, showBasketBar} = props; - const { aboutAction, searchAction } = props; + return ( +
    + + + + DELIS{' '}Catalogue + + + + + + {showBasketBar && ( - return ( -
    - - - - DELIS{' '}Catalogue - - - - - -
    -
    - -
    - - -
    - ); + // + // + // + + + + )} +
    +
    + +
    + + +
    + ); } diff --git a/cm-frontend/src/pages/BasketPage.js b/cm-frontend/src/pages/BasketPage.js new file mode 100644 index 0000000..161061c --- /dev/null +++ b/cm-frontend/src/pages/BasketPage.js @@ -0,0 +1,82 @@ +import React from "react"; +import {makeStyles} from "@material-ui/core/styles"; +import Paper from "@material-ui/core/Paper"; +import CircularProgress from "@material-ui/core/CircularProgress"; +import PageHeader from '../components/PageHeader'; +import BasketSendResult from "../components/BasketSendResult"; +import DataService from "../services/DataService"; +import {useParams} from "react-router"; +import {useLocation} from "react-router-dom"; +import {Alert, AlertTitle} from "@material-ui/lab"; + +const useStyles = makeStyles(theme => ({ + table: { + minWidth: 600, + }, + header: { + marginBottom: '1em' + }, + paper: { + padding: theme.spacing(2), + } +})); + + +export default function BasketPage() { + + const [isLoading, setLoading] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(null); + const [sentBasketData, setSentBasketData] = React.useState(null); + const [reloadCount, setReloadCount] = React.useState(0); + const classes = useStyles(); + + const search = useLocation().search; + const ok = new URLSearchParams(search).get('ok'); + const {id} = useParams(); + + React.useEffect(() => { + loadBasket(id).finally(() => setLoading(false)); + }, [id, reloadCount]); + + async function loadBasket(id) { + setErrorMessage(null); + setLoading(true); + await DataService.fetchSentBasketData(id).then(response => { + let responseData = response.data; + console.log(responseData); + setSentBasketData(responseData); + }).catch((error) => { + console.log('Error occurred: ' + error.message); + setErrorMessage(error.message); + }); + } + + const refreshAction = () => { + setReloadCount(reloadCount + 1); + } + + return ( + <> + + + {(isLoading) ? ( + + + + ) : ( + <> + {errorMessage && ( + + Error +
    {errorMessage}
    +
    + )} + {sentBasketData && ( + + )} + + )} + + ); + +} \ No newline at end of file diff --git a/cm-frontend/src/pages/OrderPage.js b/cm-frontend/src/pages/OrderPage.js new file mode 100644 index 0000000..2eedb18 --- /dev/null +++ b/cm-frontend/src/pages/OrderPage.js @@ -0,0 +1,79 @@ +import React from "react"; +import {makeStyles} from "@material-ui/core/styles"; +import Paper from "@material-ui/core/Paper"; +import CircularProgress from "@material-ui/core/CircularProgress"; +import PageHeader from '../components/PageHeader'; +import DataService from "../services/DataService"; +import {useParams} from "react-router"; +import {Alert, AlertTitle} from "@material-ui/lab"; +import OrderSendResult from "../components/OrderSendResult"; + +const useStyles = makeStyles(theme => ({ + table: { + minWidth: 600, + }, + header: { + marginBottom: '1em' + }, + paper: { + padding: theme.spacing(2), + } +})); + + +export default function OrderPage() { + + const [isLoading, setLoading] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(null); + const [sentOrderData, setSentOrderData] = React.useState(null); + const [reloadCount, setReloadCount] = React.useState(0); + const classes = useStyles(); + + const {id} = useParams(); + + React.useEffect(() => { + loadOrder(id).finally(() => setLoading(false)); + }, [id, reloadCount]); + + async function loadOrder(id) { + setErrorMessage(null); + setLoading(true); + await DataService.fetchSentOrderData(id).then(response => { + let responseData = response.data; + console.log(responseData); + setSentOrderData(responseData); + }).catch((error) => { + console.log('Error occurred: ' + error.message); + setErrorMessage(error.message); + }); + } + + const refreshAction = () => { + setReloadCount(reloadCount + 1); + } + + return ( + <> + + + {(isLoading) ? ( + + + + ) : ( + <> + {errorMessage && ( + + Error +
    {errorMessage}
    +
    + )} + {sentOrderData && ( + + )} + + )} + + ); + +} \ No newline at end of file diff --git a/cm-frontend/src/pages/ProductDetailPage.js b/cm-frontend/src/pages/ProductDetailPage.js index 8e22953..0265b85 100644 --- a/cm-frontend/src/pages/ProductDetailPage.js +++ b/cm-frontend/src/pages/ProductDetailPage.js @@ -1,89 +1,99 @@ import React from "react"; -import { makeStyles } from "@material-ui/core/styles"; -import { useParams } from "react-router"; +import {makeStyles} from "@material-ui/core/styles"; +import {useParams} from "react-router"; import ProductDetail from "../components/ProductDetail"; import ProductDetailHeader from "../components/ProductDetailHeader"; import CircularProgress from "@material-ui/core/CircularProgress"; -import { Box, FormControl, FormControlLabel, Paper, Radio, RadioGroup } from "@material-ui/core"; +import {Box, FormControl, FormControlLabel, Paper, Radio, RadioGroup} from "@material-ui/core"; import DataService from "../services/DataService"; import MergeService from "../services/MergeService"; const useStyles = makeStyles(theme => ({ - paper: { - display: "flex", - flexDirection: "column", - justifyContent: "left", - alignItems: "left", - height: "100%", - padding: theme.spacing(2), - marginBottom: theme.spacing(3), - } + paper: { + display: "flex", + flexDirection: "column", + justifyContent: "left", + alignItems: "left", + height: "100%", + padding: theme.spacing(2), + marginBottom: theme.spacing(3), + } })); -function ViewToggle(props) { - return ( - - - - } label="Table" /> - } label="JSON" /> - - - - ) +export function ViewToggle(props) { + return ( + + + + } label="Table"/> + } label="JSON"/> + + + + ) } export default function ProductDetailPage(props) { - - const { navigator } = props; - let { id } = useParams(); + const {navigator} = props; + + let {id} = useParams(); - const classes = useStyles(); + const classes = useStyles(); - const [data, setData] = React.useState(null); - const [dataLoading, setDataLoading] = React.useState(true); - const [viewMode, setViewMode] = React.useState("table"); + const [data, setData] = React.useState(null); + const [dataLoading, setDataLoading] = React.useState(true); + const [viewMode, setViewMode] = React.useState("table"); - React.useEffect(() => { - loadProduct(id); - }, [id]); + React.useEffect(() => { + loadProduct(id).finally(() => setDataLoading(false)); + }, [id]); - async function loadProduct(id) { - setDataLoading(true); - const response = await DataService.fetchProductDetails(id); - let res = await response.json(); - if (Array.isArray(res)) { - res = MergeService.mergeProducts(res); + async function loadProduct(id) { + setDataLoading(true); + await DataService.fetchProductDetails(id).then(response => { + let res = response.data; + if (Array.isArray(res)) { + res = MergeService.mergeProducts(res); + } + setData(res); + }).catch((error) => { + setData(null); + console.log('Error occurred: ' + error.message); + // setErrorMessage(error.message); + }); } - setData(res); - setDataLoading(false); - } - const handleViewChange = (event) => { - setViewMode(event.target.value); - }; + const handleViewChange = (event) => { + setViewMode(event.target.value); + }; - return ( - <> - + return ( + <> + - + - - {dataLoading ? ( - - ) : ( - <> - {viewMode === "json" ? ( -
    {JSON.stringify(data, null, 2)}
    - ) : ( - - ) - } + + {dataLoading ? ( + + ) : ( + <> + {data !== null ? ( + <> + {viewMode === "json" ? ( +
    {JSON.stringify(data, null, 2)}
    + ) : ( + + ) + } + + ) : ( +
    Product not found
    + )} + + )} +
    - )} -
    - - ); + ); } diff --git a/cm-frontend/src/pages/ProductListPage.js b/cm-frontend/src/pages/ProductListPage.js index 53f1ee4..e6c7dfe 100644 --- a/cm-frontend/src/pages/ProductListPage.js +++ b/cm-frontend/src/pages/ProductListPage.js @@ -11,7 +11,7 @@ import CircularProgress from "@material-ui/core/CircularProgress"; import TablePagination from "@material-ui/core/TablePagination"; import { useHistory } from "react-router"; import ItemDetailsService from "../services/ItemDetailsService"; -import ProductListHeader from '../components/ProductListHeader'; +import PageHeader from '../components/PageHeader'; const useStyles = makeStyles(theme => ({ table: { @@ -27,13 +27,14 @@ const useStyles = makeStyles(theme => ({ } })); -const StyledTableCell = withStyles((theme) => ({ +// noinspection JSUnusedLocalSymbols +export const StyledTableCell = withStyles((theme) => ({ head: { fontWeight: 'bold', }, }))(TableCell); -const StyledTableRow = withStyles((theme) => ({ +export const StyledTableRow = withStyles((theme) => ({ root: { '&:nth-of-type(odd)': { backgroundColor: theme.palette.action.hover, @@ -66,7 +67,7 @@ export default function ProductListPage(props) { return ( <> - + {(isLoading || false) ? ( diff --git a/cm-frontend/src/pages/SendPage.js b/cm-frontend/src/pages/SendPage.js new file mode 100644 index 0000000..88243f4 --- /dev/null +++ b/cm-frontend/src/pages/SendPage.js @@ -0,0 +1,146 @@ +import React from "react"; +import {makeStyles} from "@material-ui/core/styles"; +import Paper from "@material-ui/core/Paper"; +import CircularProgress from "@material-ui/core/CircularProgress"; +import {useHistory} from "react-router"; +import PageHeader from '../components/PageHeader'; +import {Fab} from "@material-ui/core"; +import SendIcon from "@material-ui/icons/Send"; +import DataService from "../services/DataService"; +import OrderLineList from "../components/OrderLineList"; +import OrderHeader from "../components/OrderHeader"; +import {Alert, AlertTitle} from "@material-ui/lab"; + +const useStyles = makeStyles(theme => ({ + table: { + minWidth: 600, + }, + header: { + marginBottom: '1em' + }, + paper: { + padding: theme.spacing(2), + } +})); + + +export default function SendPage(props) { + + const {basketData, changeBasket, orderData} = props; + + const [isLoading, setLoading] = React.useState(false); + const [isSending, setSending] = React.useState(false); + const [productList, setProductList] = React.useState({}); + const [reloadCount, setReloadCount] = React.useState(0); + + const [errorMessage, setErrorMessage] = React.useState(null); + const [errorProductIdSet, setErrorProductIdSet] = React.useState(null); + + const classes = useStyles(); + + const {push} = useHistory(); + + const refreshAction = () => { + setReloadCount(reloadCount + 1); + } + const sendAction = () => { + sendBasket().finally(() => setSending(false)); + } + + let imitateError = false; + + async function sendBasket() { + if (!basketData.isEmpty() && !orderData.isEmpty()) { + setErrorMessage(null); + setSending(true); + await DataService.sendBasket(basketData, orderData).then(response => { + let responseData = response.data; + console.log(responseData); + + if (imitateError) { + responseData.success = false; + responseData.errorMessage = "Some error message"; + responseData.errorProductIdList = basketData.getOrderLineList().slice(0, 1).map((ol) => ol.productId); + } + + if (responseData.success) { + changeBasket(null, 0); + const basketId = responseData.basketId; + push("/basket/"+basketId+"?ok=1"); + } else { + setErrorMessage(responseData.errorMessage); + setErrorProductIdSet(new Set(responseData.errorProductIdList)); + } + } + ).catch(error => { + console.log('Error occurred: ' + error.message); + setErrorMessage(error.message); + }); + } + } + + const callLoadProducts = () => { + loadProducts().finally(() => setLoading(false)) + }; + + async function loadProducts() { + if (!basketData.isEmpty()) { + setErrorMessage(null); + setLoading(true); + const productIdList = basketData.getOrderLineList().map((orderLine) => orderLine.productId); + await DataService.fetchProductsByIds(productIdList).then(response => { + let responseData = response.data; + console.log(responseData); + const productMapById = {} + for (const index in responseData) { + const p = responseData[index]; + productMapById[p.id] = p; + } + setProductList(productMapById); + } + ).catch(error => { + console.log('Error occurred: ' + error.message); + setErrorMessage(error.message); + }); + } + } + + React.useEffect(callLoadProducts, [reloadCount]); + + const showRowDetails = (productId) => { + push('/product/view/' + productId); + } + + return ( + <> + + + {isSending ? ( + + ) : ( + sendAction()}/> + )} + + + + {(isLoading) ? ( + + + + ) : ( + <> + <> + {errorMessage && ( + + Error +
    {errorMessage}
    +
    + )} + + + + + )} + + ); +} diff --git a/cm-frontend/src/pages/UploadPage.js b/cm-frontend/src/pages/UploadPage.js index 27277c6..21653ec 100644 --- a/cm-frontend/src/pages/UploadPage.js +++ b/cm-frontend/src/pages/UploadPage.js @@ -93,7 +93,7 @@ export default function Upload() { setLoading(false); } ) - }; + } function handleUpload() { if (loading) { @@ -180,8 +180,7 @@ export default function Upload() { Version Action ID - Success - Error message + Result Line count Add Update @@ -195,8 +194,7 @@ export default function Upload() { {u.productCatalogUpdate?.documentVersion} {u.productCatalogUpdate?.document?.actionCode} {u.productCatalogUpdate?.document?.id} - {u.success} - {u.errorMessage} + {u.success ? 'OK' : ('ERROR: ' + u.errorMessage)} {u.lineCount} {u.lineActionStat.ADD} {u.lineActionStat.UPDATE} diff --git a/cm-frontend/src/services/ClipboardService.js b/cm-frontend/src/services/ClipboardService.js new file mode 100644 index 0000000..ba02678 --- /dev/null +++ b/cm-frontend/src/services/ClipboardService.js @@ -0,0 +1,24 @@ +export const copyTextToClipboard = (value, postCopyFunction) => { + try { + navigator.clipboard.writeText(value).then(() => { + if (postCopyFunction) { + postCopyFunction(); + } + }); + } catch { + } +} + +export function copyCurrentUrlToClipboard(postCopyFunction) { + copyTextToClipboard(window.location.href, postCopyFunction); +} + +const ROOT_CONTEXT = '/dcm/'; + +export function copySubUrlToClipboard(subPath, postCopyFunction) { + const currentUrl = window.location.href; + const i = currentUrl.indexOf(ROOT_CONTEXT); + const copyPath = currentUrl.substring(0, i + ROOT_CONTEXT.length - 1) + subPath; + copyTextToClipboard(copyPath, postCopyFunction); +} + diff --git a/cm-frontend/src/services/DataService.js b/cm-frontend/src/services/DataService.js index f5f7df4..2ddde07 100644 --- a/cm-frontend/src/services/DataService.js +++ b/cm-frontend/src/services/DataService.js @@ -1,33 +1,63 @@ -import Axios from "axios"; - -//const apiUrl = "http://localhost:8080/api"; -const apiUrl = "/dcm/api"; - -const fetchProductDetails = (productId) => { - return fetch(apiUrl + "/products/" + productId); -} - -const fetchProducts = (search, page = 0, size = 20) => { - const params = { - page: page, - size: size, - } - if (search) { - params.search = search; - } - return Axios.get(apiUrl + "/products", { - params: params - }); -} - -const uploadFiles = (formData) => { - return Axios.post(apiUrl + "/upload", formData); -} - -const DataService = { - fetchProductDetails, - fetchProducts, - uploadFiles, -} - -export default DataService; \ No newline at end of file +import Axios from "axios"; +import {API_URL} from "./DataServiceConfig" + +Axios.defaults.timeout = 30000; + +const apiUrl = API_URL; + +const downloadOrderXmlLink = (id) => { return API_URL + "/order/" + id + "/xml"} +const downloadBasketZipLink = (id) => { return API_URL + "/basket/" + id + "/zip"} + +const fetchProductDetails = (productId) => { + return Axios.get(apiUrl + "/products/" + productId); +} +const fetchSentBasketData = (basketId) => { + return Axios.get(apiUrl + "/basket/" + basketId); +} +const fetchSentOrderData = (orderId) => { + return Axios.get(apiUrl + "/order/" + orderId); +} + +const fetchProducts = (search, page = 0, size = 20) => { + const params = { + page: page, + size: size, + } + if (search) { + params.search = search; + } + return Axios.get(apiUrl + "/products", { + params: params + }); +} + +const sendBasket = (basketData, orderData) => { + return Axios.post(apiUrl + "/basket/send", { + basketData: basketData, + orderData: orderData, + }); +} + +const fetchProductsByIds = (productIdList = []) => { + return Axios.post(apiUrl + "/products_by_ids", { + ids: productIdList + }); +} + +const uploadFiles = (formData) => { + return Axios.post(apiUrl + "/upload", formData); +} + +const DataService = { + fetchProductDetails, + fetchProducts, + fetchProductsByIds, + sendBasket, + fetchSentBasketData, + fetchSentOrderData, + uploadFiles, + downloadOrderXmlLink, + downloadBasketZipLink, +} + +export default DataService; diff --git a/cm-frontend/src/services/DataServiceConfig.js b/cm-frontend/src/services/DataServiceConfig.js new file mode 100644 index 0000000..9539523 --- /dev/null +++ b/cm-frontend/src/services/DataServiceConfig.js @@ -0,0 +1,2 @@ +const LOCAL_API = false; +export const API_URL = LOCAL_API ? "http://localhost:8080/dcm/api" : "/dcm/api"; diff --git a/cm-frontend/src/services/ItemDetailsService.js b/cm-frontend/src/services/ItemDetailsService.js index c855354..3e6f98b 100644 --- a/cm-frontend/src/services/ItemDetailsService.js +++ b/cm-frontend/src/services/ItemDetailsService.js @@ -1,4 +1,4 @@ -import { Box } from "@material-ui/core"; +import {Box} from "@material-ui/core"; const itemOriginCountry = (item) => { if (item && item.originCountry) { @@ -18,6 +18,12 @@ const itemUNSPSC = (item) => { } return null; } +const itemName = (item) => { + if (item) { + return item.name; + } + return null; +} const itemSellerNumber = (item) => { if (item) { if (item.sellersItemIdentification) { @@ -95,17 +101,17 @@ const renderItemCertificate = (cert) => { } const renderItemSpecification = (s) => { return ( -
    - {s.documentTypeCode && ({s.documentTypeCode})} +
    + {s.documentTypeCode && ({s.documentTypeCode})} {renderUrl(s.attachment.externalReference.uri)}
    ) } const renderItemAdditionalProperty = (s) => { return ( -
    - {s.name && ({s.name})} - {s.nameCode && ({' '}{s.nameCode.id})} +
    + {s.name && ({s.name})} + {s.nameCode && ({' '}{s.nameCode.id})} {(s.name || s.nameCode) && (":")} {s.value && ({' '}{s.value})} {s.valueQuantity && ({' '}{s.valueQuantity.quantity}{' '} {s.valueQuantity.unitCode})} @@ -113,11 +119,16 @@ const renderItemAdditionalProperty = (s) => { ) } -const renderUrlListValue = (v) => { return (renderUrl(v.attachment.externalReference.uri)) }; +const renderUrlListValue = (v) => { + return (renderUrl(v.attachment.externalReference.uri)) +}; -const renderUrl = (v) => { return ({v}) }; +const renderUrl = (v) => { + return ({v}) +}; const ItemDetailsService = { + itemName, itemOriginCountry, itemUNSPSC, itemSellerNumber, diff --git a/cm-frontend/src/utils/delay.js b/cm-frontend/src/utils/delay.js new file mode 100644 index 0000000..782424e --- /dev/null +++ b/cm-frontend/src/utils/delay.js @@ -0,0 +1,4 @@ +// Usage example: +// import delay from "../utils/delay" +// await delay(10000); +export default function delay( ms ) { return new Promise(res => setTimeout(res, ms)); } diff --git a/cm-resources/examples/order/OrderOnly.xml b/cm-resources/examples/order/OrderOnly.xml new file mode 100644 index 0000000..742fcea --- /dev/null +++ b/cm-resources/examples/order/OrderOnly.xml @@ -0,0 +1,191 @@ + + + urn:fdc:peppol.eu:poacc:trns:order:3 + urn:fdc:peppol.eu:poacc:bis:order_only:3 + 1005 + 2021-12-01 + 05:10:10 + EUR + 12345678 + + 2021-12-02 + + + Contract0101 + + + + 5798009882806 + + 5798009882806 + + + Svensk Fyrtårn + + + Svensk Fyrtårn + 5798009882806 + + Stockholm + + SE + + + + + + + + 5798009882783 + + DK31261430 + + + Grønt Fyrtårn + + + Langelinie Alle 17 + Copenhagen + 2100 + + DK + + + + Grønt Fyrtårn + 5798009882783 + + + + + + 5798009882806 + + 5798009882806 + + + Svensk Fyrtårn + + + Stockholm + 2100 + + SE + + + + Svensk Fyrtårn + 5798009882806 + + Stockholm + + SE + + + + + + + + 5798009882806 + Svensk Fyrtårn + + Godsgatan 2 + Godsmottagningen + Stockholm + 0585 + + Portkod 1234 + + + SE + + + + + 2021-12-03 + 2021-12-04 + + + + 5798009882806 + + + Svensk Fyrtårn + + + Godsgatan 2 + Intern IT + Stockholm + 0585 + + 1. sal + + + SE + + + + Ole Hansen + +453158877523 + olemad@erst.dk + + + + + 72.50 + + + 290.00 + 362.50 + + + + 1 + 1 + 200.00 + + 200.00 + + + POLY STUDIO P21 + + 637557 + + + S + 25 + + VAT + + + + + + + + 2 + 1 + 90.00 + + 90.00 + + + POLY STUDIO P15 + + 637558 + + + S + 25 + + VAT + + + + + + diff --git a/cm-resources/structure/README.md b/cm-resources/structure/README.md deleted file mode 100644 index 384c20b..0000000 --- a/cm-resources/structure/README.md +++ /dev/null @@ -1,2 +0,0 @@ -structure-1.xsd - copied from -codelist-1.xsd - generated by xml \ No newline at end of file diff --git a/cm-webapi/.gitignore b/cm-webapi/.gitignore new file mode 100644 index 0000000..9814b0c --- /dev/null +++ b/cm-webapi/.gitignore @@ -0,0 +1 @@ +.integration/ \ No newline at end of file diff --git a/cm-webapi/pom.xml b/cm-webapi/pom.xml index 047ebea..fce6934 100644 --- a/cm-webapi/pom.xml +++ b/cm-webapi/pom.xml @@ -6,7 +6,7 @@ org.springframework.boot spring-boot-starter-parent - 2.4.0 + 2.6.7 @@ -36,11 +36,6 @@ org.springframework.boot spring-boot-starter-data-rest - - com.opencsv - opencsv - 4.4 - @@ -126,4 +121,4 @@ --> - \ No newline at end of file + diff --git a/cm-webapi/src/main/java/dk/erst/cm/AppProperties.java b/cm-webapi/src/main/java/dk/erst/cm/AppProperties.java new file mode 100644 index 0000000..21c5096 --- /dev/null +++ b/cm-webapi/src/main/java/dk/erst/cm/AppProperties.java @@ -0,0 +1,30 @@ +package dk.erst.cm; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import dk.erst.cm.api.order.OrderProducerService.OrderDefaultConfig; +import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Configuration +@ConfigurationProperties(prefix = "app") +@Data +public class AppProperties { + + private IntegrationProperties integration; + + private OrderDefaultConfig order; + + @Getter + @Setter + @ToString + public static class IntegrationProperties { + private String inboxCatalogue; + private String inboxOrderResponse; + private String outboxOrder; + } + +} diff --git a/cm-webapi/src/main/java/dk/erst/cm/CatalogApiApplication.java b/cm-webapi/src/main/java/dk/erst/cm/CatalogApiApplication.java index 8661c26..f96acce 100644 --- a/cm-webapi/src/main/java/dk/erst/cm/CatalogApiApplication.java +++ b/cm-webapi/src/main/java/dk/erst/cm/CatalogApiApplication.java @@ -3,8 +3,12 @@ import org.springframework.boot.Banner.Mode; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableConfigurationProperties(AppProperties.class) +@EnableScheduling public class CatalogApiApplication { public static void main(String[] args) { diff --git a/cm-webapi/src/main/java/dk/erst/cm/MongoConverterConfig.java b/cm-webapi/src/main/java/dk/erst/cm/MongoConverterConfig.java new file mode 100644 index 0000000..359239b --- /dev/null +++ b/cm-webapi/src/main/java/dk/erst/cm/MongoConverterConfig.java @@ -0,0 +1,75 @@ +package dk.erst.cm; + +import com.helger.commons.datetime.XMLOffsetDate; +import com.helger.commons.datetime.XMLOffsetTime; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.convert.WritingConverter; +import org.springframework.data.mongodb.core.convert.MongoCustomConversions; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.Date; + +@Configuration +public class MongoConverterConfig { + + @Bean + public MongoCustomConversions mongoCustomConversions() { + return new MongoCustomConversions(Arrays.asList( + new DateToXMLOffsetDate(), + new DateToXMLOffsetTimeConverter(), + new XMLOffsetDateToDateConverter(), + new XMLOffsetTimeToTimeConverter() + )); + } + + @Slf4j + @ReadingConverter + public static class DateToXMLOffsetDate implements Converter { + @Override + public XMLOffsetDate convert(Date source) { + log.debug("DateToXMLOffsetDate {}", source); + return XMLOffsetDate.ofInstant(source.toInstant(), ZoneOffset.UTC); + } + } + + @Slf4j + @WritingConverter + public static class XMLOffsetDateToDateConverter implements Converter { + @Override + public Date convert(XMLOffsetDate source) { + log.debug("XMLOffsetDateToDateConverter {}", source); + LocalDate localDate = source.toLocalDate(); + ZonedDateTime zonedDateTime = localDate.atStartOfDay(source.getOffset() != null ? source.getOffset() : ZoneOffset.UTC); + return Date.from(Instant.from(zonedDateTime)); + } + } + + @Slf4j + @ReadingConverter + public static class DateToXMLOffsetTimeConverter implements Converter { + @Override + public XMLOffsetTime convert(Date source) { + log.debug("DateToXMLOffsetTimeConverter {}", source); + return XMLOffsetTime.ofInstant(source.toInstant(), ZoneOffset.UTC); + } + } + + @Slf4j + @WritingConverter + public static class XMLOffsetTimeToTimeConverter implements Converter { + @Override + public Date convert(XMLOffsetTime source) { + log.debug("XMLOffsetTimeToTimeConverter {}", source); + return Date.from(source.toLocalTime().atDate(LocalDate.now()).atZone(source.getOffset() != null ? source.getOffset() : ZoneOffset.UTC).withZoneSameLocal(ZoneOffset.UTC).toInstant()); + } + } + +} diff --git a/cm-webapi/src/main/java/dk/erst/cm/job/SchedulerConfig.java b/cm-webapi/src/main/java/dk/erst/cm/job/SchedulerConfig.java new file mode 100644 index 0000000..805826e --- /dev/null +++ b/cm-webapi/src/main/java/dk/erst/cm/job/SchedulerConfig.java @@ -0,0 +1,59 @@ +package dk.erst.cm.job; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.scheduling.config.FixedDelayTask; +import org.springframework.scheduling.config.IntervalTask; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; + +import java.util.ArrayList; +import java.util.List; + +@Configuration +@Slf4j +public class SchedulerConfig implements SchedulingConfigurer { + private static final int POOL_SIZE = 2; + + @Value("${job.interval.load-catalogue:-1}") + private long loadCatalogue; + @Value("${job.interval.load-order-response:-1}") + private long loadOrderResponse; + + @Override + public void configureTasks(ScheduledTaskRegistrar scheduledTaskRegistrar) { + List taskList = scheduledTaskRegistrar.getFixedDelayTaskList(); + + List newTaskList = new ArrayList<>(); + for (IntervalTask intervalTask : taskList) { + long interval = getExpectedInterval(intervalTask); + if (interval < 0) { + log.info("Skip interval task " + intervalTask); + continue; + } + FixedDelayTask newTask = new FixedDelayTask(intervalTask.getRunnable(), interval, interval); + newTaskList.add(newTask); + log.info("Set interval and delay to " + interval + " for " + newTask); + } + scheduledTaskRegistrar.setFixedDelayTasksList(newTaskList); + + ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler(); + threadPoolTaskScheduler.setPoolSize(POOL_SIZE); + threadPoolTaskScheduler.setThreadNamePrefix("tasks-"); + threadPoolTaskScheduler.initialize(); + } + + private long getExpectedInterval(IntervalTask intervalTask) { + String t = intervalTask.toString(); + if (t.endsWith("loadCatalogue")) { + return this.loadCatalogue * 1000; + } + if (t.endsWith("loadOrderResponse")) { + return this.loadOrderResponse * 1000; + } + return Long.MAX_VALUE; + } + +} diff --git a/cm-webapi/src/main/java/dk/erst/cm/job/TaskScheduler.java b/cm-webapi/src/main/java/dk/erst/cm/job/TaskScheduler.java new file mode 100644 index 0000000..3b1749a --- /dev/null +++ b/cm-webapi/src/main/java/dk/erst/cm/job/TaskScheduler.java @@ -0,0 +1,64 @@ +package dk.erst.cm.job; + +import dk.erst.cm.AppProperties; +import dk.erst.cm.api.load.FolderLoadService; +import dk.erst.cm.api.util.StatData; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.io.File; + +@Service +@Slf4j +public class TaskScheduler { + + @Autowired + private TaskSchedulerMonitor taskSchedulerMonitor; + @Autowired + private AppProperties appProperties; + @Autowired + private FolderLoadService folderLoadService; + + @Scheduled(fixedDelay = Long.MAX_VALUE) + public StatData loadCatalogue() { + String path = appProperties.getIntegration().getInboxCatalogue(); + return checkAndExecute("loadCatalogue", path, "inboxCatalogue", folder -> folderLoadService.loadCatalogues(folder)); + } + + @Scheduled(fixedDelay = Long.MAX_VALUE) + public StatData loadOrderResponse() { + String path = appProperties.getIntegration().getInboxOrderResponse(); + return checkAndExecute("loadOrderResponse", path, "inboxOrderResponse", folder -> folderLoadService.loadOrderResponses(folder)); + } + + private interface IExecuteTask { + StatData execute(File folder); + } + + protected StatData checkAndExecute(String taskName, String folder, String fieldName, IExecuteTask executeTask) { + TaskSchedulerMonitor.TaskResult task = taskSchedulerMonitor.build(taskName); + File folderFile = new File(folder); + if (!folderFile.exists() || !folderFile.isDirectory()) { + String error = String.format("Path %s = %s does not exist or is not a directory", fieldName, folderFile.getAbsolutePath()); + log.error("Task {}: {}", taskName, error); + return StatData.error(error); + } else { + try { + StatData sd = executeTask.execute(folderFile); + if (!sd.isEmpty()) { + String message = "Done loading from folder " + folderFile + " in " + sd.toDurationString() + " with next statistics of document status: " + sd.toStatString(); + log.info(message); + } + task.success(sd); + return sd; + } catch (Exception e) { + log.error("Task " + taskName + ": failed to process folder " + folder, e); + task.failure(e); + return StatData.error(e.getMessage()); + } + } + } + +} diff --git a/cm-webapi/src/main/java/dk/erst/cm/job/TaskSchedulerMonitor.java b/cm-webapi/src/main/java/dk/erst/cm/job/TaskSchedulerMonitor.java new file mode 100644 index 0000000..2c0c889 --- /dev/null +++ b/cm-webapi/src/main/java/dk/erst/cm/job/TaskSchedulerMonitor.java @@ -0,0 +1,153 @@ +package dk.erst.cm.job; + +import dk.erst.cm.api.util.StatData; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; + +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Component +public class TaskSchedulerMonitor { + + private final Map lastResultMap; + + public TaskSchedulerMonitor() { + this.lastResultMap = new HashMap<>(); + } + + public TaskResult build(String taskName) { + return new TaskResult(taskName, this); + } + + public synchronized String getLast(String taskName) { + TaskResult[] taskResults = this.lastResultMap.get(taskName); + return buildInfo(taskResults); + } + + private String buildInfo(TaskResult[] taskResults) { + if (taskResults == null) { + return "Not yet run"; + } + TaskResult prev = taskResults[1]; + TaskResult last = taskResults[0]; + StringBuilder sb = new StringBuilder(); + if (last != null) { + sb.append("Last run "); + sb.append(last); + } + if (prev != null) { + sb.append(", previous "); + sb.append(prev); + } + return sb.toString(); + } + + private synchronized void addResult(TaskResult result) { + TaskResult[] taskResults = this.lastResultMap.get(result.getTaskName()); + if (taskResults == null) { + taskResults = new TaskResult[2]; + taskResults[0] = result; + this.lastResultMap.put(result.getTaskName(), taskResults); + } else { + taskResults[1] = taskResults[0]; + taskResults[0] = result; + } + } + + public static class TaskResult { + + private final String taskName; + private final Date startTime; + private long duration; + private boolean success; + private Object result; + + private final TaskSchedulerMonitor monitor; + + public TaskResult(String taskName, TaskSchedulerMonitor monitor) { + this.taskName = taskName; + this.monitor = monitor; + this.startTime = Calendar.getInstance().getTime(); + this.duration = -1; + this.success = false; + this.result = null; + } + + public void success(Object result) { + this.success = true; + this.result = result; + this.duration = System.currentTimeMillis() - startTime.getTime(); + + this.monitor.addResult(this); + } + + public void failure(Exception e) { + this.success = false; + this.result = e.getMessage(); + this.duration = System.currentTimeMillis() - startTime.getTime(); + + this.monitor.addResult(this); + } + + public String getTaskName() { + return taskName; + } + + public Date getStartTime() { + return startTime; + } + + public long getDuration() { + return duration; + } + + public boolean isSuccess() { + return success; + } + + public Object getResult() { + return result; + } + + public String toString() { + StringBuilder sb = new StringBuilder(); + long lastRunMs = System.currentTimeMillis() - this.startTime.getTime(); + sb.append(Math.round(lastRunMs / 1000.0)); + sb.append(" sec ago"); + sb.append(" for "); + sb.append(this.duration); + sb.append(" ms"); + if (!success) { + sb.append(" FAILURE"); + } + if (result != null) { + String resultText = null; + if (result instanceof StatData) { + StatData s = (StatData)result; + if (!s.isEmpty()) { + resultText= String.valueOf(result); + } + } else if (result instanceof Exception) { + resultText = ((Exception)result).getMessage(); + } else if (result instanceof List) { + List list = (List) result; + if (!list.isEmpty()) { + resultText= list.size() + " elements"; + } + } else { + resultText = String.valueOf(result); + } + if (StringUtils.isNotEmpty(resultText)) { + sb.append(", "); + sb.append(resultText); + } + } + return sb.toString(); + } + } + +} diff --git a/cm-webapi/src/main/java/dk/erst/cm/webapi/IndexController.java b/cm-webapi/src/main/java/dk/erst/cm/webapi/IndexController.java index e6de856..8785e21 100644 --- a/cm-webapi/src/main/java/dk/erst/cm/webapi/IndexController.java +++ b/cm-webapi/src/main/java/dk/erst/cm/webapi/IndexController.java @@ -5,8 +5,10 @@ import org.springframework.web.bind.annotation.RestController; import dk.erst.cm.api.item.ProductService; +import lombok.extern.slf4j.Slf4j; @RestController +@Slf4j public class IndexController { @Autowired @@ -14,6 +16,7 @@ public class IndexController { @GetMapping("/api/status") public String index() { + log.info(null); return "OK: " + productService.countItems() + " items"; } diff --git a/cm-webapi/src/main/java/dk/erst/cm/webapi/OrderController.java b/cm-webapi/src/main/java/dk/erst/cm/webapi/OrderController.java new file mode 100644 index 0000000..af1b744 --- /dev/null +++ b/cm-webapi/src/main/java/dk/erst/cm/webapi/OrderController.java @@ -0,0 +1,111 @@ +package dk.erst.cm.webapi; + +import java.io.ByteArrayOutputStream; +import java.util.Optional; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import dk.erst.cm.AppProperties; +import dk.erst.cm.api.data.Order; +import dk.erst.cm.api.order.BasketService; +import dk.erst.cm.api.order.BasketService.SendBasketData; +import dk.erst.cm.api.order.BasketService.SendBasketResponse; +import dk.erst.cm.api.order.BasketService.SentBasketData; +import dk.erst.cm.api.order.OrderService; +import lombok.extern.slf4j.Slf4j; +import oasis.names.specification.ubl.schema.xsd.order_21.OrderType; + +@CrossOrigin(maxAge = 3600) +@RestController +@Slf4j +public class OrderController { + + private final BasketService basketService; + private final OrderService orderService; + private final AppProperties appProperties; + + @Autowired + public OrderController(BasketService basketService, AppProperties appProperties, OrderService orderService) { + this.basketService = basketService; + this.appProperties = appProperties; + this.orderService = orderService; + } + + @RequestMapping(value = "/api/basket/send") + public SendBasketResponse basketSend(@RequestBody SendBasketData query) { + log.info("START basketSend: query=" + query); + SendBasketResponse res = basketService.basketSend(query, appProperties.getIntegration().getOutboxOrder(), appProperties.getOrder()); + if (res.isSuccess()) { + log.info("END basketSend OK: " + res); + } else { + log.info("END basketSend Error: " + res); + } + return res; + } + + @RequestMapping(value = "/api/basket/{id}") + public ResponseEntity getBasketById(@PathVariable("id") String id) { + Optional findById = basketService.loadSentBasketData(id); + if (findById.isPresent()) { + return new ResponseEntity<>(findById.get(), HttpStatus.OK); + } + return ResponseEntity.notFound().build(); + } + + @RequestMapping(value = "/api/basket/{id}/zip", produces = "application/zip") + public ResponseEntity getBasketByIdXML(@PathVariable("id") String id) { + Optional findById = basketService.loadSentBasketData(id); + if (findById.isPresent()) { + ResponseEntity.BodyBuilder resp = ResponseEntity.ok(); + resp.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"basket_" + id + ".zip\""); + resp.contentType(MediaType.parseMediaType("application/zip")); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(bos)) { + for (Order order : findById.get().getOrderList()) { + ZipEntry entry = new ZipEntry(order.getId() + ".xml"); + zos.putNextEntry(entry); + ByteArrayOutputStream orderStream = new ByteArrayOutputStream(); + orderService.saveOrderXMLToStream((OrderType) order.getDocument(), orderStream); + zos.write(orderStream.toByteArray()); + zos.closeEntry(); + } + } catch (Exception e) { + log.error("Error creating zip file", e); + } + return resp.body(bos.toByteArray()); + } + return ResponseEntity.notFound().build(); + } + + @RequestMapping(value = "/api/order/{id}") + public ResponseEntity getOrderById(@PathVariable("id") String id) { + Optional findById = basketService.loadSentOrderAsJSON(id); + if (findById.isPresent()) { + return new ResponseEntity<>(findById.get(), HttpStatus.OK); + } + return ResponseEntity.notFound().build(); + } + + @RequestMapping(value = "/api/order/{id}/xml", produces = "application/xml") + public ResponseEntity getOrderByIdXML(@PathVariable("id") String id) { + Optional findById = basketService.loadSentOrderAsXML(id); + if (findById.isPresent()) { + ResponseEntity.BodyBuilder resp = ResponseEntity.ok(); + resp.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"order_" + id + ".xml\""); + resp.contentType(MediaType.parseMediaType("application/xml")); + return resp.body(findById.get()); + } + return ResponseEntity.notFound().build(); + } +} diff --git a/cm-webapi/src/main/java/dk/erst/cm/webapi/ProductController.java b/cm-webapi/src/main/java/dk/erst/cm/webapi/ProductController.java index 29a7f01..998114b 100644 --- a/cm-webapi/src/main/java/dk/erst/cm/webapi/ProductController.java +++ b/cm-webapi/src/main/java/dk/erst/cm/webapi/ProductController.java @@ -1,9 +1,11 @@ package dk.erst.cm.webapi; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Optional; +import lombok.Data; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; @@ -12,6 +14,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -25,8 +28,12 @@ @Slf4j public class ProductController { + private final ProductService productService; + @Autowired - private ProductService productService; + public ProductController(ProductService productService) { + this.productService = productService; + } @RequestMapping(value = "/api/products") public Page getProducts(@RequestParam(required = false) String search, Pageable pageable) { @@ -34,11 +41,22 @@ public Page getProducts(@RequestParam(required = false) String search, return productService.findAll(search, pageable); } + @Data + public static class IdList { + private String[] ids; + } + + @RequestMapping(value = "/api/products_by_ids") + public Iterable getProductsByIds(@RequestBody IdList query) { + log.info("Search products by ids " + query); + return productService.findAllByIds(Arrays.asList(query.ids)); + } + @RequestMapping(value = "/api/product/{id}") public ResponseEntity getProductById(@PathVariable("id") String id) { Optional findById = productService.findById(id); if (findById.isPresent()) { - return new ResponseEntity(findById.get(), HttpStatus.OK); + return new ResponseEntity<>(findById.get(), HttpStatus.OK); } return ResponseEntity.notFound().build(); } @@ -52,9 +70,9 @@ public ResponseEntity> getProductsById(@PathVariable("id") String if (!StringUtils.isEmpty(product.getStandardNumber())) { list = productService.findByStandardNumber(product.getStandardNumber()); } else { - list = Arrays.asList(product); + list = Collections.singletonList(product); } - return new ResponseEntity>(list, HttpStatus.OK); + return new ResponseEntity<>(list, HttpStatus.OK); } return ResponseEntity.notFound().build(); } diff --git a/cm-webapi/src/main/java/dk/erst/cm/webapi/UploadController.java b/cm-webapi/src/main/java/dk/erst/cm/webapi/UploadController.java index 93901c4..9833b5a 100644 --- a/cm-webapi/src/main/java/dk/erst/cm/webapi/UploadController.java +++ b/cm-webapi/src/main/java/dk/erst/cm/webapi/UploadController.java @@ -1,9 +1,13 @@ package dk.erst.cm.webapi; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - +import dk.erst.cm.api.data.ProductCatalogUpdate; +import dk.erst.cm.api.item.LoadCatalogService; +import dk.erst.cm.api.load.PeppolLoadService; +import dk.erst.cm.api.load.handler.FileUploadConsumer; +import dk.erst.cm.api.load.handler.FileUploadConsumer.LineAction; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -12,15 +16,10 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.servlet.mvc.support.RedirectAttributes; -import dk.erst.cm.api.data.ProductCatalogUpdate; -import dk.erst.cm.api.item.LoadCatalogService; -import dk.erst.cm.api.load.PeppolLoadService; -import dk.erst.cm.webapi.FileUploadConsumer.LineAction; -import lombok.Getter; -import lombok.Setter; -import lombok.extern.slf4j.Slf4j; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; @RestController @CrossOrigin(maxAge = 3600) @@ -34,8 +33,8 @@ public class UploadController { private LoadCatalogService loadCatalogService; @PostMapping(value = "/api/upload") - public ResponseEntity> upload(@RequestParam("files") MultipartFile files[], RedirectAttributes redirectAttributes) { - List uploadResultList = new ArrayList(); + public ResponseEntity> upload(@RequestParam("files") MultipartFile[] files) { + List uploadResultList = new ArrayList<>(); for (MultipartFile file : files) { UploadResult ur = new UploadResult(); ur.setFileName(file.getOriginalFilename()); diff --git a/cm-webapi/src/main/resources/application.properties b/cm-webapi/src/main/resources/application.properties index ef8b851..c36ed6f 100644 --- a/cm-webapi/src/main/resources/application.properties +++ b/cm-webapi/src/main/resources/application.properties @@ -1,15 +1,33 @@ server.servlet.context-path=/ logging.level.dk.erst.catalog = INFO +#logging.level.dk.erst.cm.MongoConverterConfig = DEBUG spring.data.mongodb.uri=mongodb://localhost:27017/dc?ssl=false spring.data.mongodb.auto-index-creation = true +app.integration.inbox-catalogue=./.integration/inbox/catalogue +app.integration.inbox-order-response=./.integration/inbox/order-response +app.integration.outbox-order=./.integration/outbox/order +app.order.endpointGLN=5798009882783 +app.order.note=This order is generated for education purposes via Delis Catalogue. + +job.interval.load-catalogue=10 +job.interval.load-order-response=-1 + # Set to DEBUG to see all Mongo queries logging.level.org.springframework.data.mongodb.core.MongoTemplate=INFO +# Set to DEBUG to see OrderController details +logging.level.dk.erst.cm.api.order=DEBUG + +#logging.level.dk.erst.cm.MongoConverterConfig=DEBUG + spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=10MB server.tomcat.accesslog.enabled=true -server.tomcat.basedir=./.tomcat \ No newline at end of file +server.tomcat.basedir=./.tomcat + +# Needed to avoid serializing all many fields of Order to JSON for showing on GUI +spring.jackson.default-property-inclusion=NON_EMPTY diff --git a/cm-webapi/src/main/resources/application.yml b/cm-webapi/src/main/resources/application.yml new file mode 100644 index 0000000..410a0a0 --- /dev/null +++ b/cm-webapi/src/main/resources/application.yml @@ -0,0 +1,13 @@ +app: + integration: + inbox-catalogue: ./.integration/inbox/catalogue + inbox-order-response: ./.integration/inbox/order-response + outbox-order: ./.integration/outbox/order + + order: + endpointGLN: '5798009882783' + note: 'This order is generated for education purposes via Delis Catalogue.' + +job.interval: + load-catalogue: 10 + load-order-response: -1 diff --git a/cm-webapi/src/test/java/dk/erst/cm/MongoConverterConfigTest.java b/cm-webapi/src/test/java/dk/erst/cm/MongoConverterConfigTest.java new file mode 100644 index 0000000..09101d4 --- /dev/null +++ b/cm-webapi/src/test/java/dk/erst/cm/MongoConverterConfigTest.java @@ -0,0 +1,66 @@ +package dk.erst.cm; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.util.Date; +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import com.helger.commons.datetime.XMLOffsetDate; +import com.helger.commons.datetime.XMLOffsetTime; + +import dk.erst.cm.MongoConverterConfig.DateToXMLOffsetDate; +import dk.erst.cm.MongoConverterConfig.DateToXMLOffsetTimeConverter; +import dk.erst.cm.MongoConverterConfig.XMLOffsetDateToDateConverter; +import dk.erst.cm.MongoConverterConfig.XMLOffsetTimeToTimeConverter; + +class MongoConverterConfigTest { + + @Test + void testDateConversions() { + DateToXMLOffsetDate dateToXml = new DateToXMLOffsetDate(); + XMLOffsetDateToDateConverter xmlToDate = new XMLOffsetDateToDateConverter(); + + ZoneOffset[] testZones = new ZoneOffset[] { ZoneOffset.UTC, null }; + + for (int i = 0; i < testZones.length; i++) { + ZoneOffset fromZone = testZones[i]; + + LocalDate fromLocalDate = LocalDate.now(); + Date convert = xmlToDate.convert(XMLOffsetDate.of(fromLocalDate, fromZone)); + + XMLOffsetDate res = dateToXml.convert(convert); + assertEquals(fromLocalDate, res.toLocalDate(), "Zone " + fromZone); + assertEquals(Optional.ofNullable(fromZone).orElse(ZoneOffset.UTC), res.getOffset(), "Zone " + fromZone); + } + } + + @Test + void testTimeConversions() { + DateToXMLOffsetTimeConverter timeToXml = new DateToXMLOffsetTimeConverter(); + XMLOffsetTimeToTimeConverter xmlToTime = new XMLOffsetTimeToTimeConverter(); + + // TODO: Implement Zones other than UTC... + ZoneOffset[] testZones = new ZoneOffset[] { null, + // ZoneOffset.ofHours(2), + // ZoneOffset.MAX, ZoneOffset.MIN, + ZoneOffset.UTC }; + + for (int i = 0; i < testZones.length; i++) { + ZoneOffset fromZone = testZones[i]; + LocalTime fromLocalTime = LocalTime.now(); + Date convert = xmlToTime.convert(XMLOffsetTime.of(fromLocalTime, fromZone)); + + System.out.println(convert); + + XMLOffsetTime res = timeToXml.convert(convert); + assertEquals(fromLocalTime, res.toLocalTime(), "Zone " + fromZone); + assertEquals(Optional.ofNullable(fromZone).orElse(ZoneOffset.UTC), res.getOffset(), "Zone " + fromZone); + } + } + +} diff --git a/cm-webapi/src/test/java/dk/erst/cm/api/item/ItemServiceTest.java b/cm-webapi/src/test/java/dk/erst/cm/api/item/ItemServiceTest.java index 5a33da8..0af6ff5 100644 --- a/cm-webapi/src/test/java/dk/erst/cm/api/item/ItemServiceTest.java +++ b/cm-webapi/src/test/java/dk/erst/cm/api/item/ItemServiceTest.java @@ -8,7 +8,7 @@ import dk.erst.cm.api.load.PeppolLoadService; import dk.erst.cm.test.TestDocument; -import dk.erst.cm.webapi.FileUploadConsumer; +import dk.erst.cm.api.load.handler.FileUploadConsumer; import lombok.extern.slf4j.Slf4j; @SpringBootTest diff --git a/cm-xml-codelist/README.md b/cm-xml-codelist/README.md index 9e9dd23..cd17943 100644 --- a/cm-xml-codelist/README.md +++ b/cm-xml-codelist/README.md @@ -6,4 +6,6 @@ It allows to have a model of Peppol document types with next information: - name - description - validation rules -- attributes, their code lists, cardinality \ No newline at end of file +- attributes, their code lists, cardinality + +codelist-xsd is generated by xml \ No newline at end of file diff --git a/cm-xml-codelist/pom.xml b/cm-xml-codelist/pom.xml index e183010..01fac10 100644 --- a/cm-xml-codelist/pom.xml +++ b/cm-xml-codelist/pom.xml @@ -22,19 +22,16 @@ org.apache.commons commons-lang3 - 3.7 org.junit.jupiter junit-jupiter - 5.7.0 test commons-io commons-io - 2.8.0 test @@ -64,9 +61,6 @@ UTF-8 dk.erst.cm.xml.syntax.codelist - - ../cm-resources/structure/codelist-1.xsd - -Xcommons-lang @@ -79,37 +73,6 @@ - - org.apache.maven.plugins - maven-compiler-plugin - - 1.8 - 1.8 - UTF-8 - - - - org.apache.maven.plugins - maven-resources-plugin - 2.6 - - UTF-8 - - - - - org.apache.maven.plugins - maven-source-plugin - 3.0.1 - - - attach-sources - - jar - - - - diff --git a/cm-xml-codelist/src/main/java/dk/erst/cm/xml/syntax/CodeListLoadService.java b/cm-xml-codelist/src/main/java/dk/erst/cm/xml/syntax/CodeListLoadService.java index 5c002b3..9b52779 100644 --- a/cm-xml-codelist/src/main/java/dk/erst/cm/xml/syntax/CodeListLoadService.java +++ b/cm-xml-codelist/src/main/java/dk/erst/cm/xml/syntax/CodeListLoadService.java @@ -22,10 +22,11 @@ public CodeList loadStructure(InputStream is, String description) throws JAXBExc public CodeList loadCodeList(CodeListStandard standard) { String pathname = "../cm-resources/structure/codelist/" + standard.getResourceName() + ".xml"; - try (InputStream is = new FileInputStream(new File(pathname))) { + File file = new File(pathname); + try (InputStream is = new FileInputStream(file)) { return this.loadStructure(is, pathname); } catch (Exception e) { - throw new IllegalStateException("Failed to load code list standard " + standard + " by path " + pathname, e); + throw new IllegalStateException("Failed to load code list standard " + standard + " by path " + pathname + " resovled to " + file.getAbsolutePath(), e); } } } diff --git a/cm-xml-codelist/src/main/java/dk/erst/cm/xml/syntax/CodeListStandard.java b/cm-xml-codelist/src/main/java/dk/erst/cm/xml/syntax/CodeListStandard.java index 9f47822..b2ad775 100644 --- a/cm-xml-codelist/src/main/java/dk/erst/cm/xml/syntax/CodeListStandard.java +++ b/cm-xml-codelist/src/main/java/dk/erst/cm/xml/syntax/CodeListStandard.java @@ -49,7 +49,7 @@ public enum CodeListStandard { UNECERec20("UNECERec20-11e"), - EAS("EAS", "eas"), + EAS("eas", "EAS"), EHF1_ActionCode_documentLevel("ehf-postaward-g2/actioncode-documentlevel", "Actioncodedocumentlevel"), diff --git a/cm-resources/structure/codelist-1.xsd b/cm-xml-codelist/src/main/xsd/codelist-1.xsd similarity index 100% rename from cm-resources/structure/codelist-1.xsd rename to cm-xml-codelist/src/main/xsd/codelist-1.xsd diff --git a/cm-xml-syntax/README.md b/cm-xml-syntax/README.md index 9e9dd23..36067f9 100644 --- a/cm-xml-syntax/README.md +++ b/cm-xml-syntax/README.md @@ -6,4 +6,6 @@ It allows to have a model of Peppol document types with next information: - name - description - validation rules -- attributes, their code lists, cardinality \ No newline at end of file +- attributes, their code lists, cardinality + +structure-1.xsd - copied from diff --git a/cm-xml-syntax/pom.xml b/cm-xml-syntax/pom.xml index 530dad7..e3a6628 100644 --- a/cm-xml-syntax/pom.xml +++ b/cm-xml-syntax/pom.xml @@ -22,29 +22,18 @@ org.apache.commons commons-lang3 - 3.7 org.junit.jupiter junit-jupiter - 5.7.0 test commons-io commons-io - 2.8.0 test - - - org.projectlombok - lombok - test - true - - @@ -64,9 +53,6 @@ UTF-8 dk.erst.cm.xml.syntax.structure - - ../cm-resources/structure/structure-1.xsd - -Xcommons-lang @@ -79,37 +65,6 @@ - - org.apache.maven.plugins - maven-compiler-plugin - - 1.8 - 1.8 - UTF-8 - - - - org.apache.maven.plugins - maven-resources-plugin - 2.6 - - UTF-8 - - - - - org.apache.maven.plugins - maven-source-plugin - 3.0.1 - - - attach-sources - - jar - - - - diff --git a/cm-resources/structure/structure-1.xsd b/cm-xml-syntax/src/main/xsd/structure-1.xsd similarity index 100% rename from cm-resources/structure/structure-1.xsd rename to cm-xml-syntax/src/main/xsd/structure-1.xsd diff --git a/cm-xml-syntax/src/test/java/dk/erst/cm/xml/syntax/StructureLoadServiceTest.java b/cm-xml-syntax/src/test/java/dk/erst/cm/xml/syntax/StructureLoadServiceTest.java index a3fd5a8..04ed8f6 100644 --- a/cm-xml-syntax/src/test/java/dk/erst/cm/xml/syntax/StructureLoadServiceTest.java +++ b/cm-xml-syntax/src/test/java/dk/erst/cm/xml/syntax/StructureLoadServiceTest.java @@ -18,8 +18,6 @@ import dk.erst.cm.xml.syntax.structure.AttributeType; import dk.erst.cm.xml.syntax.structure.ElementType; import dk.erst.cm.xml.syntax.structure.StructureType; -import lombok.Getter; -import lombok.Setter; public class StructureLoadServiceTest { @@ -58,7 +56,7 @@ public void testGenerateTxtSyntax() throws IOException { for (String[] strings : structures) { String pathname = rootPath + "/" + strings[0]; StructureType s; - try (InputStream is = new FileInputStream(new File(pathname))) { + try (InputStream is = new FileInputStream(pathname)) { s = service.loadStructure(is, pathname); } catch (Exception e) { throw new IllegalStateException("Failed to load Peppol Catalogue structure by path " + pathname, e); @@ -95,10 +93,13 @@ public void testSyntax() throws IOException { } private static class StructureDumpService { - @Getter - @Setter + private boolean removeTagNsAlias = false; + public void setRemoveTagNsAlias(boolean removeTagNsAlias) { + this.removeTagNsAlias = removeTagNsAlias; + } + public String dump(StructureType s) { StringBuilder sb = new StringBuilder(); this.dump(sb, s.getDocument(), 0); diff --git a/pom.xml b/pom.xml index 7e38376..5089901 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,11 @@ 4.12 1.22 1.2.14 + + 1.8 + ${java.version} + ${java.version} + UTF-8 @@ -55,9 +60,29 @@ ${lombok.version} provided - + + + org.junit.jupiter + junit-jupiter + 5.7.0 + test + + + + commons-io + commons-io + 2.8.0 + + + + org.apache.commons + commons-lang3 + 3.7 + + + @@ -68,23 +93,24 @@ 3 - - - org.apache.maven.wagon - wagon-http - 3.0.0 - - org.apache.maven.plugins maven-compiler-plugin 2.3.2 - - 1.8 - 1.8 - UTF-8 - + + + org.apache.maven.plugins + maven-source-plugin + 3.0.1 + + + attach-sources + + jar + + + org.apache.maven.plugins @@ -109,6 +135,14 @@ + + org.apache.maven.plugins + maven-resources-plugin + 2.6 + + UTF-8 + + org.codehaus.mojo sonar-maven-plugin @@ -141,15 +175,6 @@ - - org.apache.maven.plugins - maven-compiler-plugin - - 1.8 - 1.8 - UTF-8 - -