Java tutorial
/* * Version: 1.0 * * The contents of this file are subject to the OpenVPMS License Version * 1.0 (the 'License'); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * http://www.openvpms.org/license/ * * Software distributed under the License is distributed on an 'AS IS' basis, * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License * for the specific language governing rights and limitations under the * License. * * Copyright 2014 (C) OpenVPMS Ltd. All Rights Reserved. */ package org.openvpms.archetype.rules.product; import org.apache.commons.collections.ComparatorUtils; import org.apache.commons.collections.Predicate; import org.apache.commons.collections.functors.AndPredicate; import org.apache.commons.lang.ObjectUtils; import org.openvpms.archetype.rules.finance.tax.TaxRules; import org.openvpms.archetype.rules.math.Currency; import org.openvpms.archetype.rules.math.MathRules; import org.openvpms.archetype.rules.util.DateRules; import org.openvpms.component.business.domain.im.common.Entity; import org.openvpms.component.business.domain.im.common.EntityLink; import org.openvpms.component.business.domain.im.common.IMObjectReference; import org.openvpms.component.business.domain.im.lookup.Lookup; import org.openvpms.component.business.domain.im.party.Party; import org.openvpms.component.business.domain.im.product.Product; import org.openvpms.component.business.domain.im.product.ProductPrice; import org.openvpms.component.business.service.archetype.ArchetypeServiceException; import org.openvpms.component.business.service.archetype.IArchetypeService; import org.openvpms.component.business.service.archetype.functor.IsActiveRelationship; import org.openvpms.component.business.service.archetype.helper.EntityBean; import org.openvpms.component.business.service.archetype.helper.IMObjectBean; import org.openvpms.component.business.service.archetype.helper.TypeHelper; import org.openvpms.component.business.service.lookup.ILookupService; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.List; import static java.math.BigDecimal.ONE; import static java.math.BigDecimal.ZERO; import static org.openvpms.archetype.rules.math.MathRules.ONE_HUNDRED; import static org.openvpms.archetype.rules.product.ProductArchetypes.FIXED_PRICE; import static org.openvpms.component.business.service.archetype.functor.IsActiveRelationship.isActive; import static org.openvpms.component.business.service.archetype.functor.IsActiveRelationship.isActiveNow; import static org.openvpms.component.business.service.archetype.functor.RefEquals.getTargetEquals; /** * Product price rules. * * @author Tim Anderson */ public class ProductPriceRules { /** * Default maximum discount. */ public static final BigDecimal DEFAULT_MAX_DISCOUNT = MathRules.ONE_HUNDRED; /** * The archetype service. */ private final IArchetypeService service; /** * The lookup service. */ private final ILookupService lookups; private static final int PARTIAL_MATCH = 1; private static final int EXACT_MATCH = 2; /** * Constructs a {@link ProductPriceRules}. * * @param service the archetype service * @param lookups the lookup service */ public ProductPriceRules(IArchetypeService service, ILookupService lookups) { this.service = service; this.lookups = lookups; } /** * Returns the first price with the specified short name and pricing group, active at the specified date. * <p/> * If {@code group} is: * <ul> * <li>non-null - it matches prices that have that pricing group, or no pricing group</li> * <li>null - it matches prices that have no pricing group</li> * </ul> * * @param product the product * @param shortName the price short name * @param date the date * @param group the pricing group. May be {@code null} * @return the first matching price, or {@code null} if none is found */ public ProductPrice getProductPrice(Product product, String shortName, Date date, Lookup group) { ProductPricePredicate predicate = new ShortNameDatePredicate(shortName, date, new PricingGroup(group)); return getProductPrice(shortName, product, predicate, date); } /** * Returns the first product price with the specified price, short name, and group, active at the specified date. * <p/> * If {@code group} is: * <ul> * <li>non-null - it matches prices that have that pricing group, or no pricing group</li> * <li>null - it matches prices that have no pricing group</li> * </ul> * * @param product the product * @param price the price * @param shortName the price short name * @param date the date * @param group the pricing group. May be {@code null} * @return the first matching price, or {@code null} if none is found */ public ProductPrice getProductPrice(Product product, BigDecimal price, String shortName, Date date, Lookup group) { PricePredicate predicate = new PricePredicate(price, shortName, date, new PricingGroup(group)); return getProductPrice(shortName, product, predicate, date); } /** * Returns all prices matching the specified short name. * <p/> * This will examine linked products if {@code shortName} is <em>productPrice.fixedPrice</em>. * * @param product the product * @param shortName the price short name * @param group the pricing group. May be {@code null} * @return the matching prices, sorted on descending time */ public List<ProductPrice> getProductPrices(Product product, String shortName, PricingGroup group) { return getProductPrices(product, shortName, true, group); } /** * Returns all prices matching the specified short name. * * @param product the product * @param shortName the price short name * @param includeLinked if {@code true} and the requested prices are <em>productPrice.fixedPrice</em>, linked * products will be searched * @param group the pricing group * @return the matching prices, sorted on descending time */ public List<ProductPrice> getProductPrices(Product product, String shortName, boolean includeLinked, PricingGroup group) { List<ProductPrice> result = new ArrayList<ProductPrice>(); ProductPricePredicate predicate = new ProductPricePredicate(shortName, group); List<ProductPrice> prices = findPrices(product, predicate); result.addAll(prices); if (includeLinked && FIXED_PRICE.equals(shortName)) { // see if there is a fixed price in linked products result.addAll(findLinkedPrices(product, predicate, IsActiveRelationship.isActiveNow())); } return sort(result); } /** * Returns all prices matching the specified short name, active at the specified date. * <p/> * This will examine linked products if {@code shortName} is <em>productPrice.fixedPrice</em>. * <p/> * If {@code group} is: * <ul> * <li>non-null - it matches prices that have that pricing group, or no pricing group</li> * <li>null - it matches prices that have no pricing group</li> * </ul> * * @param product the product * @param shortName the price short name * @param date the date * @param group the pricing group * @return all prices matching the criteria */ public List<ProductPrice> getProductPrices(Product product, String shortName, Date date, PricingGroup group) { return getProductPrices(product, shortName, date, true, group); } /** * Returns all prices matching the specified short name, active at the specified date. * <p/> * If {@code group} is: * <ul> * <li>non-null - it matches prices that have that pricing group, or no pricing group</li> * <li>null - it matches prices that have no pricing group</li> * </ul> * * @param product the product * @param shortName the price short name * @param date the date * @param includeLinked if {@code true} and the requested prices are <em>productPrice.fixedPrice</em>, linked * products will be searched * @param group the pricing group * @return all prices matching the criteria */ public List<ProductPrice> getProductPrices(Product product, String shortName, Date date, boolean includeLinked, PricingGroup group) { List<ProductPrice> result = new ArrayList<ProductPrice>(); ShortNameDatePredicate predicate = new ShortNameDatePredicate(shortName, date, group); List<ProductPrice> prices = findPrices(product, predicate); result.addAll(prices); if (includeLinked && FIXED_PRICE.equals(shortName)) { // see if there is a fixed price in linked products result.addAll(findLinkedPrices(product, predicate, isActive(date))); } return sort(result); } /** * Returns all prices matching the specified short name, active between a date range. * <p/> * This will examine linked products if {@code shortName} is <em>productPrice.fixedPrice</em>. * <p/> * If {@code group} is: * <ul> * <li>non-null - it matches prices that have that pricing group, or no pricing group</li> * <li>null - it matches prices that have no pricing group</li> * </ul> * * @param product the product * @param shortName the price short name * @param from the start of the date range. May be {@code null} * @param to the end of the date range. May be {@code null} * @param group the pricing group * @return the matching prices, sorted on descending time */ public List<ProductPrice> getProductPrices(Product product, String shortName, Date from, Date to, PricingGroup group) { return getProductPrices(product, shortName, from, to, true, group); } /** * Returns all prices matching the specified short name and pricing group active between a date range. * <p/> * If {@code group} is: * <ul> * <li>non-null - it matches prices that have that pricing group, or no pricing group</li> * <li>null - it matches prices that have no pricing group</li> * </ul> * * @param product the product * @param shortName the price short name * @param from the start of the date range. May be {@code null} * @param to the end of the date range. May be {@code null} * @param includeLinked if {@code true} and the requested prices are <em>productPrice.fixedPrice</em>, linked * products will be searched * @param group the pricing group * @return the matching prices, sorted on descending time */ public List<ProductPrice> getProductPrices(Product product, String shortName, Date from, Date to, boolean includeLinked, PricingGroup group) { List<ProductPrice> result = new ArrayList<ProductPrice>(); ShortNameDateRangePredicate predicate = new ShortNameDateRangePredicate(shortName, from, to, group); List<ProductPrice> prices = findPrices(product, predicate); result.addAll(prices); if (includeLinked && FIXED_PRICE.equals(shortName)) { // see if there is a fixed price in linked products result.addAll(findLinkedPrices(product, predicate, isActive(from, to))); } return sort(result); } /** * Calculates a product price using the following formula: * <p/> * {@code price = (cost * (1 + markup/100) ) * (1 + tax/100)} * * @param product the product * @param cost the product cost * @param markup the markup percentage * @param practice the <em>party.organisationPractice</em> used to determine product taxes * @param currency the currency, for rounding conventions * @return the price * @throws ArchetypeServiceException for any archetype service error */ public BigDecimal getPrice(Product product, BigDecimal cost, BigDecimal markup, Party practice, Currency currency) { BigDecimal price = ZERO; if (cost.compareTo(ZERO) != 0) { BigDecimal markupDec = getRate(markup); BigDecimal taxRate = getTaxRate(product, practice); price = cost.multiply(ONE.add(markupDec)).multiply(ONE.add(taxRate)); price = currency.round(price); } return price; } /** * Calculates a product markup using the following formula: * <p/> * {@code markup = ((price / (cost * ( 1 + tax/100))) - 1) * 100} * * @param product the product * @param cost the product cost * @param price the price * @param practice the <em>party.organisationPractice</em> used to determine product taxes * @return the markup * @throws ArchetypeServiceException for any archetype service error */ public BigDecimal getMarkup(Product product, BigDecimal cost, BigDecimal price, Party practice) { BigDecimal markup = ZERO; if (cost.compareTo(ZERO) != 0) { BigDecimal taxRate = ZERO; if (product != null) { taxRate = getTaxRate(product, practice); } markup = price.divide(cost.multiply(ONE.add(taxRate)), 3, RoundingMode.HALF_UP).subtract(ONE) .multiply(ONE_HUNDRED); if (markup.compareTo(ZERO) < 0) { markup = ZERO; } } return markup; } /** * Calculates the maximum discount that can be applied for a given markup. * <p/> * Uses the equation: * <code>(markup / (100 + markup)) * 100</code> * * @param markup the markup expressed as a percentage * @return the discount as a percentage rounded down */ public BigDecimal calcMaxDiscount(BigDecimal markup) { BigDecimal discount = DEFAULT_MAX_DISCOUNT; if (markup.compareTo(BigDecimal.ZERO) > 0) { discount = markup.divide(ONE_HUNDRED.add(markup), 3, RoundingMode.HALF_DOWN).multiply(ONE_HUNDRED); } return discount; } /** * Updates the cost node of any <em>productPrice.unitPrice</em> * associated with a product active at the current time, and recalculates its price. * <p/> * Returns a list of unit prices whose cost and price have changed. * * @param product the product * @param cost the new cost * @param practice the <em>party.organisationPractice</em> used to determine product taxes * @param currency the currency, for rounding conventions * @return the list of any updated prices */ public List<ProductPrice> updateUnitPrices(Product product, BigDecimal cost, Party practice, Currency currency) { List<ProductPrice> result = null; IMObjectBean bean = new IMObjectBean(product, service); final Date now = new Date(); Predicate predicate = new Predicate() { @Override public boolean evaluate(Object object) { ProductPrice price = (ProductPrice) object; return price.isActive() && DateRules.between(now, price.getFromDate(), price.getToDate()); } }; List<ProductPrice> prices = bean.getValues("prices", predicate, ProductPrice.class); if (!prices.isEmpty()) { cost = currency.round(cost); for (ProductPrice price : prices) { if (TypeHelper.isA(price, ProductArchetypes.UNIT_PRICE)) { if (updateUnitPrice(price, product, cost, practice, currency)) { if (result == null) { result = new ArrayList<ProductPrice>(); } result.add(price); } } } } if (result == null) { result = Collections.emptyList(); } return result; } /** * Returns the maximum discount for a product price, expressed as a percentage. * * @param price the price * @return the maximum discount for the product price, or {@code 100} if there is no maximum discount associated * with the price. */ public BigDecimal getMaxDiscount(ProductPrice price) { IMObjectBean bean = new IMObjectBean(price, service); BigDecimal result = bean.getBigDecimal("maxDiscount"); return (result == null) ? DEFAULT_MAX_DISCOUNT : result; } /** * Returns the pricing groups for a price. * * @param price the price * @return the pricing groups */ public List<Lookup> getPricingGroups(ProductPrice price) { IMObjectBean bean = new IMObjectBean(price, service); return bean.getValues("pricingGroups", Lookup.class); } /** * Returns the cost price for a product price. * * @param price the cost price * @return the cost price */ public BigDecimal getCostPrice(ProductPrice price) { IMObjectBean bean = new IMObjectBean(price, service); return bean.getBigDecimal("cost", BigDecimal.ZERO); } /** * Returns the service ratio for a product. * <p/> * This is a factor that is applied to a product's prices when the product is charged at a particular practice * location. It is determined by the <em>entityLink.locationProductType</em> relationship between the location and * the product's type. * * @param product the product * @param location the practice location * @return the service ratio. If there is no relationship, returns {@code 1.0} */ public BigDecimal getServiceRatio(Product product, Party location) { BigDecimal ratio = BigDecimal.ONE; IMObjectBean bean = new IMObjectBean(product, service); IMObjectReference productType = bean.getNodeSourceObjectRef("type"); if (productType != null) { IMObjectBean locationBean = new IMObjectBean(location, service); Predicate predicate = AndPredicate.getInstance(isActiveNow(), getTargetEquals(productType)); EntityLink link = (EntityLink) locationBean.getValue("serviceRatios", predicate); if (link != null) { IMObjectBean linkBean = new IMObjectBean(link, service); ratio = linkBean.getBigDecimal("ratio", BigDecimal.ONE); } } return ratio; } /** * Sorts prices on descending time. * <p/> * NOTE: this modifies the input list. * * @param prices the prices to sort * @return the prices */ @SuppressWarnings("unchecked") public List<ProductPrice> sort(List<ProductPrice> prices) { Collections.sort(prices, ComparatorUtils.reversedComparator(ProductPriceComparator.INSTANCE)); return prices; } /** * Updates an <em>productPrice.unitPrice</em> if required. * * @param price the price * @param product the associated product * @param cost the cost price * @param practice the <em>party.organisationPractice</em> used to determine product taxes * @param currency the currency, for rounding conventions * @return {@code true} if the price was updated */ private boolean updateUnitPrice(ProductPrice price, Product product, BigDecimal cost, Party practice, Currency currency) { IMObjectBean priceBean = new IMObjectBean(price, service); BigDecimal old = priceBean.getBigDecimal("cost", ZERO); if (!MathRules.equals(old, cost)) { priceBean.setValue("cost", cost); BigDecimal markup = priceBean.getBigDecimal("markup", ZERO); BigDecimal newPrice = getPrice(product, cost, markup, practice, currency); price.setPrice(newPrice); return true; } return false; } /** * Returns the tax rate of a product. * * @param product the product * @param practice the <em>party.organisationPractice</em> used to determine product taxes * @return the product tax rate * @throws ArchetypeServiceException for any archetype service error */ private BigDecimal getTaxRate(Product product, Party practice) { TaxRules rules = new TaxRules(practice, service, lookups); return getRate(rules.getTaxRate(product)); } /** * Returns a percentage / 100. * * @param percent the percent * @return {@code percent / 100 } */ private BigDecimal getRate(BigDecimal percent) { if (percent.compareTo(ZERO) != 0) { return MathRules.divide(percent, ONE_HUNDRED, 3); } return ZERO; } /** * Returns a product price matching the specified predicate. * * @param shortName the price short name * @param product the product * @param predicate the predicate * @param date the date * @return a price matching the predicate, or {@code null} if none is found */ private ProductPrice getProductPrice(String shortName, Product product, ProductPricePredicate predicate, Date date) { boolean useDefault = FIXED_PRICE.equals(shortName); ProductPrice result = findPrice(product, predicate, useDefault); if (useDefault && (result == null || !isDefault(result))) { // see if there is a fixed price in linked products EntityBean bean = new EntityBean(product, service); if (bean.hasNode("linked")) { List<Entity> products = bean.getNodeTargetEntities("linked", date); for (Entity linked : products) { ProductPrice price = findPrice((Product) linked, predicate, useDefault); if (price != null) { if (isDefault(price)) { result = price; break; } else if (result == null) { result = price; } } } } } return result; } /** * Finds a product price matching the predicate. * * @param product the product * @param predicate the predicate to evaluate * @param useDefault if {@code true}, select prices that have a {@code true} default node * @return the price matching the criteria, or {@code null} if none is found */ private ProductPrice findPrice(Product product, ProductPricePredicate predicate, boolean useDefault) { ProductPrice result = null; ProductPrice fallback = null; int fallbackMatch = 0; for (ProductPrice price : product.getProductPrices()) { int match = predicate.matches(price); if (match > 0) { if (useDefault) { if (isDefault(price) && match == EXACT_MATCH) { result = price; break; } } else if (match == EXACT_MATCH) { result = price; break; } if (match > fallbackMatch) { fallback = price; fallbackMatch = match; } } } return (result != null) ? result : fallback; } /** * Finds product prices matching the specified predicate. * * @param product the product * @param predicate the predicate * @return the prices matching the predicate */ private List<ProductPrice> findPrices(Product product, ProductPricePredicate predicate) { List<ProductPrice> result = null; for (ProductPrice price : product.getProductPrices()) { if (predicate.evaluate(price)) { if (result == null) { result = new ArrayList<ProductPrice>(); } result.add(price); } } if (result == null) { result = Collections.emptyList(); } return result; } /** * Adds any prices from linked products active at time determined by the {@code active} predicate. * * @param product the product * @param price the predicate to select prices * @param active the predicate to select active linked products */ private List<ProductPrice> findLinkedPrices(Product product, ProductPricePredicate price, Predicate active) { List<ProductPrice> result = null; List<ProductPrice> prices; EntityBean bean = new EntityBean(product, service); if (bean.hasNode("linked")) { for (Entity linked : bean.getNodeTargetEntities("linked", active)) { prices = findPrices((Product) linked, price); if (result == null) { result = new ArrayList<ProductPrice>(); } result.addAll(prices); } } return (result != null) ? result : Collections.<ProductPrice>emptyList(); } /** * Determines if a fixed price is the default. * * @param price the price * @return {@code true} if it is the default, otherwise {@code false} */ private boolean isDefault(ProductPrice price) { IMObjectBean bean = new IMObjectBean(price, service); return bean.getBoolean("default"); } private class ProductPricePredicate implements Predicate { /** * The price short name. */ private final String shortName; /** * The pricing group. */ private final PricingGroup group; public ProductPricePredicate(String shortName, PricingGroup group) { this.shortName = shortName; this.group = group; } public boolean evaluate(Object object) { ProductPrice price = (ProductPrice) object; return matches(price) > 0; } /** * Determines if the predicate matches the price * * @param price the price * @return {@code 0} if it doesn't match, {@link #PARTIAL_MATCH} if it is a partial match on group, * {@link #EXACT_MATCH} if it is an exact match on group */ public int matches(ProductPrice price) { int result = 0; if (TypeHelper.isA(price, shortName) && price.isActive()) { if (group.isAll()) { result = EXACT_MATCH; } else { IMObjectBean bean = new IMObjectBean(price, service); List<Lookup> groups = bean.getValues("pricingGroups", Lookup.class); Lookup lookup = group.getGroup(); if ((lookup == null && groups.isEmpty()) || (lookup != null && groups.contains(lookup))) { result = EXACT_MATCH; } else if (lookup != null && groups.isEmpty() && group.useFallback()) { result = PARTIAL_MATCH; } } } return result; } } /** * Predicate to determine if a price matches a short name and date. */ private class ShortNameDatePredicate extends ProductPricePredicate { /** * The date. */ private final Date date; public ShortNameDatePredicate(String shortName, Date date, PricingGroup group) { super(shortName, group); this.date = date; } @Override public int matches(ProductPrice price) { int result = super.matches(price); if (result > 0) { Date from = price.getFromDate(); Date to = price.getToDate(); if (!DateRules.betweenDates(date, from, to)) { result = 0; } } return result; } } /** * Predicate to determine if a price matches a short name and date range. */ private class ShortNameDateRangePredicate extends ProductPricePredicate { /** * The from date. */ private final Date from; /** * The to date. */ private final Date to; public ShortNameDateRangePredicate(String shortName, Date from, Date to, PricingGroup group) { super(shortName, group); this.from = from; this.to = to; } @Override public int matches(ProductPrice price) { int result = super.matches(price); if (result > 0) { if (!DateRules.intersects(from, to, price.getFromDate(), price.getToDate())) { result = 0; } } return result; } } /** * Predicate to determine if a product price matches a price, short name * and date. */ private class PricePredicate extends ShortNameDatePredicate { /** * The price. */ private final BigDecimal price; public PricePredicate(BigDecimal price, String shortName, Date date, PricingGroup group) { super(shortName, date, group); this.price = price; } @Override public int matches(ProductPrice other) { if (other.getPrice().compareTo(price) == 0) { return super.matches(other); } return 0; } } private static class ProductPriceComparator implements Comparator<ProductPrice> { /** * The singleton instance. */ public static Comparator<ProductPrice> INSTANCE = new ProductPriceComparator(); @Override public int compare(ProductPrice o1, ProductPrice o2) { int result; if (ObjectUtils.equals(o1.getToDate(), o2.getToDate())) { result = 0; } else if (o1.getToDate() == null) { result = 1; } else if (o2.getToDate() == null) { result = -1; } else { result = DateRules.compareTo(o1.getToDate(), o2.getToDate()); } if (result == 0) { if (!ObjectUtils.equals(o1.getFromDate(), o2.getFromDate())) { if (o1.getFromDate() == null) { result = 1; } else if (o2.getFromDate() == null) { result = -1; } else { result = DateRules.compareDates(o1.getFromDate(), o2.getFromDate()); } } } return result; } } }