This is an automated email from the ASF dual-hosted git repository.

mridulpathak pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 03aeb0dbd4 Fixed: Reject shipments mixing ship groups with different 
destinations (OFBIZ-5346) (#1508)
03aeb0dbd4 is described below

commit 03aeb0dbd42879afb61ca9dac339673da809b21a
Author: Mridul Pathak <[email protected]>
AuthorDate: Fri Jul 31 12:27:01 2026 +0530

    Fixed: Reject shipments mixing ship groups with different destinations 
(OFBIZ-5346) (#1508)
    
    Fixed: Reject shipments mixing ship groups with different destinations
    (OFBIZ-5346)
    - A `Shipment` could receive items from `OrderItemShipGroup`s with
    different
    destinations, causing `InvoiceServices.createInvoicesFromShipments` to
    tax
    shipping charges against the wrong state (it uses only the primary ship
      group's address).
    - `issueOrderItemShipGrpInvResToShipment` now rejects issuing an item
    whose
    ship group resolves to a different destination (country/state/postal
    code)
      than the shipment's primary ship group, comparing the full (orderId,
    shipGroupSeqId) pair to also catch cross-order collisions. Two ship
    groups
      that happen to use different address records for the same physical
      address are not treated as a mismatch.
    - `PackingSession.checkShipGroupDestinations()` adds the same check
    earlier
    in the manual Pack Order flow, before any `Shipment`/`ItemIssuance` rows
      are created.
    - Fixes a 2020 regression (`7fcde49d82` / OFBIZ-11462) that mixed ship
    groups' items in `quickShipEntireOrder` when they shared a facility,
    plus
    a related early-return that could abort shipment creation for remaining
      ship groups.
    - Adds `ShipmentTests.groovy` coverage for all of the above.
    
    Thanks to Christian Carlow for reporting this issue.
---
 applications/product/config/ProductUiLabels.xml    |   3 +
 .../shipment/issuance/IssuanceServices.xml         |  64 ++++-
 .../ofbiz/product/shipment/ShipmentServices.groovy |  15 +-
 .../ofbiz/shipment/packing/PackingSession.java     |  54 ++++
 .../product/product/test/ShipmentTests.groovy      | 312 +++++++++++++++++++++
 5 files changed, 439 insertions(+), 9 deletions(-)

diff --git a/applications/product/config/ProductUiLabels.xml 
b/applications/product/config/ProductUiLabels.xml
index d4e1694d21..261bf5ae30 100644
--- a/applications/product/config/ProductUiLabels.xml
+++ b/applications/product/config/ProductUiLabels.xml
@@ -20229,6 +20229,9 @@
         <value xml:lang="zh">没有发放订单明细送货组库存预留到送货 
${parameters.shipmentId},因为要发放的数量 ${parameters.quantity} 大于剩余的退货的数量 
${orderItemShipGrpInvRes.quantity},订单 ${orderItemShipGrpInvRes.orderId}、订单明细 
${orderItemShipGrpInvRes.orderItemSeqId}、库存明细 
${orderItemShipGrpInvRes.inventoryItemId}</value>
         <value xml:lang="zh-TW">沒有發放訂單細項出貨群組庫存預留到出貨 
${parameters.shipmentId},因為要發放的數量 ${parameters.quantity} 大於剩餘的退貨的數量 
${orderItemShipGrpInvRes.quantity},訂單 ${orderItemShipGrpInvRes.orderId}、訂單細項 
${orderItemShipGrpInvRes.orderItemSeqId}、庫存細項 
${orderItemShipGrpInvRes.inventoryItemId}</value>
     </property>
+    <property key="ProductNotIssueOrderToShipmentShipGroupDestinationMismatch">
+        <value xml:lang="en">Not issuing Order Item Ship Group Inventory 
Reservation to shipment ${parameters.shipmentId} because ship group 
${orderItemShipGrpInvRes.shipGroupSeqId} for order 
${orderItemShipGrpInvRes.orderId} has a different destination than ship group 
${shipment.primaryShipGroupSeqId} already associated with this shipment</value>
+    </property>
     <property key="ProductNotIssueToFixedAssetMaintQuantityLess">
         <value xml:lang="de">Füge Bestandsposition nicht zu Anlagegütern 
${fixedAssetMaint.fixedAssetId:fixedAssetMaint.maintHistSeqId} hinzu da die 
Menge ${parameters.quantity} kleiner oder gleich 0 ist für Bestandsposition 
${inventoryItem.inventoryItemId}</value>
         <value xml:lang="en">Not issuing InventoryItem to FixedAssetMaint 
${fixedAssetMaint.fixedAssetId:fixedAssetMaint.maintHistSeqId} because the 
quantity to issue ${parameters.quantity} is less than or equal to 0 for 
inventoryItem ${inventoryItem.inventoryItemId}</value>
diff --git 
a/applications/product/minilang/shipment/issuance/IssuanceServices.xml 
b/applications/product/minilang/shipment/issuance/IssuanceServices.xml
index 53f2322e1d..80d4f806cd 100644
--- a/applications/product/minilang/shipment/issuance/IssuanceServices.xml
+++ b/applications/product/minilang/shipment/issuance/IssuanceServices.xml
@@ -129,14 +129,74 @@ under the License.
             </add-error>
         </if-compare-field>
 
+        <!-- get shipment -->
+        <entity-one entity-name="Shipment" value-field="shipment">
+            <field-map field-name="shipmentId" 
from-field="parameters.shipmentId"/>
+        </entity-one>
+
+        <!-- reject if this ship group's destination is in a different taxing 
jurisdiction than the
+             shipment's primary ship group; compare the full (orderId, 
shipGroupSeqId) pair, since
+             shipGroupSeqId restarts at 00001 per order -->
+        <if-not-empty field="orderItemShipGrpInvRes">
+        <if-not-empty field="shipment.primaryShipGroupSeqId">
+            <if>
+                <condition>
+                    <or>
+                        <if-compare-field 
field="orderItemShipGrpInvRes.orderId" operator="not-equals"
+                                to-field="shipment.primaryOrderId"/>
+                        <if-compare-field 
field="orderItemShipGrpInvRes.shipGroupSeqId" operator="not-equals"
+                                to-field="shipment.primaryShipGroupSeqId"/>
+                    </or>
+                </condition>
+                <then>
+                    <entity-one entity-name="OrderItemShipGroup" 
value-field="primaryShipGroupDestCheck">
+                        <field-map field-name="orderId" 
from-field="shipment.primaryOrderId"/>
+                        <field-map field-name="shipGroupSeqId" 
from-field="shipment.primaryShipGroupSeqId"/>
+                    </entity-one>
+                    <get-related-one value-field="primaryShipGroupDestCheck" 
relation-name="PostalAddress"
+                            to-value-field="primaryShipGroupDestAddress"/>
+
+                    <entity-one entity-name="OrderItemShipGroup" 
value-field="incomingShipGroupDestCheck">
+                        <field-map field-name="orderId" 
from-field="orderItemShipGrpInvRes.orderId"/>
+                        <field-map field-name="shipGroupSeqId" 
from-field="orderItemShipGrpInvRes.shipGroupSeqId"/>
+                    </entity-one>
+                    <get-related-one value-field="incomingShipGroupDestCheck" 
relation-name="PostalAddress"
+                            to-value-field="incomingShipGroupDestAddress"/>
+
+                    <!-- only reject when both sides resolve to a known 
address in a different
+                         country/state/postal code -->
+                    <if-not-empty field="primaryShipGroupDestAddress">
+                        <if-not-empty field="incomingShipGroupDestAddress">
+                            <if>
+                                <condition>
+                                    <or>
+                                        <if-compare-field 
field="primaryShipGroupDestAddress.countryGeoId" operator="not-equals"
+                                                
to-field="incomingShipGroupDestAddress.countryGeoId"/>
+                                        <if-compare-field 
field="primaryShipGroupDestAddress.stateProvinceGeoId" operator="not-equals"
+                                                
to-field="incomingShipGroupDestAddress.stateProvinceGeoId"/>
+                                        <if-compare-field 
field="primaryShipGroupDestAddress.postalCode" operator="not-equals"
+                                                
to-field="incomingShipGroupDestAddress.postalCode"/>
+                                    </or>
+                                </condition>
+                                <then>
+                                    <add-error>
+                                        <fail-property 
resource="ProductUiLabels" 
property="ProductNotIssueOrderToShipmentShipGroupDestinationMismatch"/>
+                                    </add-error>
+                                </then>
+                            </if>
+                        </if-not-empty>
+                    </if-not-empty>
+                </then>
+            </if>
+        </if-not-empty>
+        </if-not-empty>
+
         <check-errors/>
 
         <!-- get orderItem -->
         <entity-one entity-name="OrderItem" value-field="orderItem"/>
         <!-- get inventoryItem -->
         <entity-one entity-name="InventoryItem" value-field="inventoryItem"/>
-        <!-- get shipment -->
-        <entity-one entity-name="Shipment" value-field="shipment"/>
         <!-- get orderShipment -->
         <make-value entity-name="OrderShipment" 
value-field="orderShipmentLookupPk"/>
         <set-pk-fields value-field="orderShipmentLookupPk" map="parameters"/>
diff --git 
a/applications/product/src/main/groovy/org/apache/ofbiz/product/shipment/ShipmentServices.groovy
 
b/applications/product/src/main/groovy/org/apache/ofbiz/product/shipment/ShipmentServices.groovy
index d263435dee..e46d2c3565 100644
--- 
a/applications/product/src/main/groovy/org/apache/ofbiz/product/shipment/ShipmentServices.groovy
+++ 
b/applications/product/src/main/groovy/org/apache/ofbiz/product/shipment/ShipmentServices.groovy
@@ -912,9 +912,9 @@ Map getOrderItemShipGroupLists(GenericValue orderHeader) {
     List orderItemShipGroupList = orderHeader.getRelated('OrderItemShipGroup', 
null, null, false)
     // group orderItems (actually OrderItemAndShipGroupAssocs) by 
shipGroupSeqId in a Map with List values
     // This Map is actually used only for sales orders' shipments right now.
-    List orderItemListByShGrpMap = []
+    Map orderItemListByShGrpMap = [:]
     for (GenericValue orderItemAndShipGroupAssoc : 
orderItemAndShipGroupAssocList) {
-        orderItemListByShGrpMap << orderItemAndShipGroupAssoc
+        
orderItemListByShGrpMap.computeIfAbsent(orderItemAndShipGroupAssoc.shipGroupSeqId)
 { [] } << orderItemAndShipGroupAssoc
     }
     Map result = success()
     result.orderItemListByShGrpMap = orderItemListByShGrpMap
@@ -926,7 +926,7 @@ Map getOrderItemShipGroupLists(GenericValue orderHeader) {
 /**
  * Sub-method used by quickShip methods to create a shipment
  */
-Map createShipmentForFacilityAndShipGroup(GenericValue orderHeader, List 
orderItemListByShGrpMap,
+Map createShipmentForFacilityAndShipGroup(GenericValue orderHeader, Map 
orderItemListByShGrpMap,
                                           List orderItemShipGroupList, List 
orderItemAndShipGroupAssocList,
                                           String 
orderItemShipGrpInvResFacilityId,
                                           Timestamp eventDate, Boolean 
setPackedOnly) {
@@ -938,12 +938,13 @@ Map createShipmentForFacilityAndShipGroup(GenericValue 
orderHeader, List orderIt
     // for OrderItemShipGroup need to split all OISGIRs into their ship groups 
and create a shipment for each
     GenericValue facility = from('Facility').where(facilityId: 
orderItemShipGrpInvResFacilityId).cache().queryOne()
     for (GenericValue orderItemShipGroup : orderItemShipGroupList) {
-        List perShipGroupItemList = orderItemListByShGrpMap
-        // make sure we have something to ship
+        List perShipGroupItemList = 
orderItemListByShGrpMap[orderItemShipGroup.shipGroupSeqId] ?: []
+        // skip to the next ship group instead of aborting the remaining ones
         if (!perShipGroupItemList) {
             List argListNames = [orderItemShipGroup.shipGroupSeqId]
-            return success(UtilProperties.getMessage('ProductUiLabels',
-                    'FacilityShipmentNoItemsAvailableToShip', argListNames, 
locale))
+            successMessageList << UtilProperties.getMessage('ProductUiLabels',
+                    'FacilityShipmentNoItemsAvailableToShip', argListNames, 
locale)
+            continue
         }
         // create the shipment for this facility and ship group combination
         Map shipmentContext = [primaryOrderId: orderHeader.orderId,
diff --git 
a/applications/product/src/main/java/org/apache/ofbiz/shipment/packing/PackingSession.java
 
b/applications/product/src/main/java/org/apache/ofbiz/shipment/packing/PackingSession.java
index 6348745cb8..aa20d59223 100644
--- 
a/applications/product/src/main/java/org/apache/ofbiz/shipment/packing/PackingSession.java
+++ 
b/applications/product/src/main/java/org/apache/ofbiz/shipment/packing/PackingSession.java
@@ -963,6 +963,8 @@ public class PackingSession implements java.io.Serializable 
{
 
         // check for errors
         this.checkReservations(force);
+        // reject packing ship groups with different destination addresses 
onto one shipment
+        this.checkShipGroupDestinations();
         // set the status to 0
         this.status = 0;
         // create the shipment
@@ -1014,6 +1016,58 @@ public class PackingSession implements 
java.io.Serializable {
         }
     }
 
+    /**
+     * Check that all packed lines' ship groups share the same destination 
address.
+     * @throws GeneralException the general exception
+     */
+    protected void checkShipGroupDestinations() throws GeneralException {
+        Delegator delegator = this.getDelegator();
+        Map<String, String> jurisdictionByShipGroupKey = new HashMap<>();
+        for (PackingSessionLine line : this.getLines()) {
+            String shipGroupKey = line.getOrderId() + "/" + 
line.getShipGroupSeqId();
+            if (jurisdictionByShipGroupKey.containsKey(shipGroupKey)) {
+                continue;
+            }
+            GenericValue destinationAddress;
+            try {
+                GenericValue orderItemShipGroup = 
EntityQuery.use(delegator).from("OrderItemShipGroup")
+                        .where("orderId", line.getOrderId(), "shipGroupSeqId", 
line.getShipGroupSeqId())
+                        .queryOne();
+                destinationAddress = orderItemShipGroup != null
+                        ? orderItemShipGroup.getRelatedOne("PostalAddress", 
false) : null;
+            } catch (GenericEntityException e) {
+                throw new GeneralException(e.getMessage());
+            }
+            jurisdictionByShipGroupKey.put(shipGroupKey, 
destinationJurisdictionKey(destinationAddress));
+        }
+
+        Set<String> distinctJurisdictions = new 
HashSet<>(jurisdictionByShipGroupKey.values());
+        distinctJurisdictions.remove(null);
+        if (distinctJurisdictions.size() > 1) {
+            throw new GeneralException("Cannot pack items from ship groups 
with different destinations onto"
+                    + " the same shipment: " + 
jurisdictionByShipGroupKey.keySet() + " [104]");
+        }
+    }
+
+    /**
+     * Builds a key identifying the country/state/postal code of a destination 
address, or {@code null}
+     * if the address (or all three of those fields) is unknown.
+     * @param destinationAddress the destination address
+     * @return the jurisdiction key
+     */
+    private static String destinationJurisdictionKey(GenericValue 
destinationAddress) {
+        if (destinationAddress == null) {
+            return null;
+        }
+        String countryGeoId = destinationAddress.getString("countryGeoId");
+        String stateProvinceGeoId = 
destinationAddress.getString("stateProvinceGeoId");
+        String postalCode = destinationAddress.getString("postalCode");
+        if (countryGeoId == null && stateProvinceGeoId == null && postalCode 
== null) {
+            return null;
+        }
+        return countryGeoId + "|" + stateProvinceGeoId + "|" + postalCode;
+    }
+
     /**
      * Check empty lines.
      * @throws GeneralException the general exception
diff --git 
a/applications/product/src/test/groovy/org/apache/ofbiz/product/product/test/ShipmentTests.groovy
 
b/applications/product/src/test/groovy/org/apache/ofbiz/product/product/test/ShipmentTests.groovy
index 7bc600978e..7519fbcaaa 100644
--- 
a/applications/product/src/test/groovy/org/apache/ofbiz/product/product/test/ShipmentTests.groovy
+++ 
b/applications/product/src/test/groovy/org/apache/ofbiz/product/product/test/ShipmentTests.groovy
@@ -177,6 +177,186 @@ class ShipmentTests extends OFBizTestCase {
         assert shipmentReceipt.productId == serviceCtx.productId
     }
 
+    void testIssueOrderItemShipGrpInvResToShipmentRejectsMixedDestinations() {
+        Map orderResult = dispatcher.runSync('createTestSalesOrderSingle',
+                [userLogin: userLogin, productId: 'GZ-2644'])
+        assert ServiceUtil.isSuccess(orderResult)
+        String orderId = orderResult.orderId
+        assert orderId
+
+        GenericValue orderItem = from('OrderItem').where('orderId', 
orderId).queryFirst()
+        assert orderItem
+        String orderItemSeqId = orderItem.orderItemSeqId
+
+        GenericValue shipGroup1 = from('OrderItemShipGroup')
+                .where('orderId', orderId, 'shipGroupSeqId', '00001')
+                .queryOne()
+        assert shipGroup1
+        assert shipGroup1.contactMechId
+
+        Map createShipmentCtx = [
+                shipmentTypeId: 'SALES_SHIPMENT',
+                statusId: 'SHIPMENT_INPUT',
+                primaryOrderId: orderId,
+                primaryShipGroupSeqId: '00001',
+                originFacilityId: 'WebStoreWarehouse',
+                userLogin: userLogin
+        ]
+        Map createShipmentResult = dispatcher.runSync('createShipment', 
createShipmentCtx)
+        assert ServiceUtil.isSuccess(createShipmentResult)
+        String shipmentId = createShipmentResult.shipmentId
+        assert shipmentId
+
+        GenericValue shipment = from('Shipment').where('shipmentId', 
shipmentId).queryOne()
+        assert shipment.destinationContactMechId == shipGroup1.contactMechId
+
+        String differentContactMechId = 
resolveDifferentDestinationAddress('789 Different St')
+        assert differentContactMechId != shipGroup1.contactMechId
+        addShipGroup(orderId, '00002', differentContactMechId)
+        GenericValue inventoryItem = findGz2644InventoryItem()
+        associateItemWithShipGroup(orderId, orderItemSeqId, '00002', new 
BigDecimal('1'))
+        reserveInventoryToShipGroup(orderId, orderItemSeqId, '00002', 
inventoryItem.inventoryItemId, new BigDecimal('1'))
+
+        Map issueCtx = [
+                shipmentId: shipmentId,
+                orderId: orderId,
+                shipGroupSeqId: '00002',
+                orderItemSeqId: orderItemSeqId,
+                inventoryItemId: inventoryItem.inventoryItemId,
+                quantity: new BigDecimal('1'),
+                userLogin: userLogin
+        ]
+        Map issueResult = 
dispatcher.runSync('issueOrderItemShipGrpInvResToShipment', issueCtx)
+        assert ServiceUtil.isError(issueResult)
+        assert ServiceUtil.getErrorMessage(issueResult).contains('different 
destination')
+    }
+
+    void 
testIssueOrderItemShipGrpInvResToShipmentRejectsMixedDestinationsAcrossOrders() 
{
+        Map orderResult1 = dispatcher.runSync('createTestSalesOrderSingle',
+                [userLogin: userLogin, productId: 'GZ-2644'])
+        assert ServiceUtil.isSuccess(orderResult1)
+        String orderId1 = orderResult1.orderId
+        assert orderId1
+
+        GenericValue shipGroup1 = from('OrderItemShipGroup')
+                .where('orderId', orderId1, 'shipGroupSeqId', '00001')
+                .queryOne()
+        assert shipGroup1
+        assert shipGroup1.contactMechId
+
+        Map createShipmentCtx = [
+                shipmentTypeId: 'SALES_SHIPMENT',
+                statusId: 'SHIPMENT_INPUT',
+                primaryOrderId: orderId1,
+                primaryShipGroupSeqId: '00001',
+                originFacilityId: 'WebStoreWarehouse',
+                userLogin: userLogin
+        ]
+        Map createShipmentResult = dispatcher.runSync('createShipment', 
createShipmentCtx)
+        assert ServiceUtil.isSuccess(createShipmentResult)
+        String shipmentId = createShipmentResult.shipmentId
+        assert shipmentId
+
+        // second, independent order; also uses shipGroupSeqId '00001' but a 
different destination
+        Map orderResult2 = dispatcher.runSync('createTestSalesOrderSingle',
+                [userLogin: userLogin, productId: 'GZ-2644'])
+        assert ServiceUtil.isSuccess(orderResult2)
+        String orderId2 = orderResult2.orderId
+        assert orderId2
+        assert orderId2 != orderId1
+
+        GenericValue orderItem2 = from('OrderItem').where('orderId', 
orderId2).queryFirst()
+        assert orderItem2
+        String orderItemSeqId2 = orderItem2.orderItemSeqId
+
+        String differentContactMechId = 
resolveDifferentDestinationAddress('999 Cross Order Ave')
+        assert differentContactMechId != shipGroup1.contactMechId
+
+        Map updateShipGroupCtx = [
+                orderId: orderId2,
+                shipGroupSeqId: '00001',
+                contactMechId: differentContactMechId,
+                contactMechPurposeTypeId: 'SHIPPING_LOCATION',
+                userLogin: userLogin
+        ]
+        Map updateShipGroupResult = 
dispatcher.runSync('updateOrderItemShipGroup', updateShipGroupCtx)
+        assert ServiceUtil.isSuccess(updateShipGroupResult)
+
+        // order2's item is already associated with ship group 00001 from 
checkout; only reserve
+        GenericValue inventoryItem = findGz2644InventoryItem()
+        reserveInventoryToShipGroup(orderId2, orderItemSeqId2, '00001', 
inventoryItem.inventoryItemId, new BigDecimal('1'))
+
+        Map issueCtx = [
+                shipmentId: shipmentId,
+                orderId: orderId2,
+                shipGroupSeqId: '00001',
+                orderItemSeqId: orderItemSeqId2,
+                inventoryItemId: inventoryItem.inventoryItemId,
+                quantity: new BigDecimal('1'),
+                userLogin: userLogin
+        ]
+        Map issueResult = 
dispatcher.runSync('issueOrderItemShipGrpInvResToShipment', issueCtx)
+        assert ServiceUtil.isError(issueResult)
+        assert ServiceUtil.getErrorMessage(issueResult).contains('different 
destination')
+    }
+
+    void testPackingSessionRejectsMixedShipGroupDestinations() {
+        Map orderResult = dispatcher.runSync('createTestSalesOrderSingle',
+                [userLogin: userLogin, productId: 'GZ-2644'])
+        assert ServiceUtil.isSuccess(orderResult)
+        String orderId = orderResult.orderId
+        assert orderId
+
+        GenericValue orderItem = from('OrderItem').where('orderId', 
orderId).queryFirst()
+        assert orderItem
+        String orderItemSeqId = orderItem.orderItemSeqId
+
+        String differentContactMechId = 
resolveDifferentDestinationAddress('456 Other Ave')
+        addShipGroup(orderId, '00002', differentContactMechId)
+        GenericValue inventoryItem = findGz2644InventoryItem()
+        associateItemWithShipGroup(orderId, orderItemSeqId, '00002', new 
BigDecimal('1'))
+        reserveInventoryToShipGroup(orderId, orderItemSeqId, '00002', 
inventoryItem.inventoryItemId, new BigDecimal('1'))
+
+        PackingSession packingSession = new PackingSession(dispatcher, 
userLogin)
+
+        Map packLine1Ctx = [
+                productId: 'GZ-2644',
+                orderId: orderId,
+                shipGroupSeqId: '00001',
+                quantity: new BigDecimal('1'),
+                packageSeq: 1,
+                pickerPartyId: 'DemoCustomer',
+                packingSession: packingSession,
+                userLogin: userLogin
+        ]
+        Map packLine1Result = dispatcher.runSync('packSingleItem', 
packLine1Ctx)
+        assert ServiceUtil.isSuccess(packLine1Result)
+
+        Map packLine2Ctx = [
+                productId: 'GZ-2644',
+                orderId: orderId,
+                shipGroupSeqId: '00002',
+                quantity: new BigDecimal('1'),
+                packageSeq: 1,
+                pickerPartyId: 'DemoCustomer',
+                packingSession: packingSession,
+                userLogin: userLogin
+        ]
+        Map packLine2Result = dispatcher.runSync('packSingleItem', 
packLine2Ctx)
+        assert ServiceUtil.isSuccess(packLine2Result)
+
+        Map completeCtx = [
+                orderId: orderId,
+                pickerPartyId: 'DemoCustomer',
+                packingSession: packingSession,
+                forceComplete: true,
+                userLogin: userLogin
+        ]
+        Map completeResult = dispatcher.runSync('completePack', completeCtx)
+        assert ServiceUtil.isError(completeResult)
+        assert ServiceUtil.getErrorMessage(completeResult).contains('[104]')
+    }
+
     void testCreateShipmentRouteSegment() {
         GenericValue shipment = from('Shipment')
                 .where('shipmentId', '9998')
@@ -201,4 +381,136 @@ class ShipmentTests extends OFBizTestCase {
         assert shipmentRouteSegment.shipmentRouteSegmentId == 
shipmentRouteSegmentId
     }
 
+    void testQuickShipEntireOrderDoesNotMixShipGroupDestinations() {
+        Map orderResult = dispatcher.runSync('createTestSalesOrderSingle',
+                [userLogin: userLogin, productId: 'GZ-2644'])
+        assert ServiceUtil.isSuccess(orderResult)
+        String orderId = orderResult.orderId
+        assert orderId
+
+        GenericValue orderItem = from('OrderItem').where('orderId', 
orderId).queryFirst()
+        assert orderItem
+        String orderItemSeqId = orderItem.orderItemSeqId
+
+        String differentContactMechId = 
resolveDifferentDestinationAddress('321 Another Rd')
+        addShipGroup(orderId, '00002', differentContactMechId)
+        GenericValue inventoryItem = findGz2644InventoryItem()
+        associateItemWithShipGroup(orderId, orderItemSeqId, '00002', new 
BigDecimal('1'))
+        reserveInventoryToShipGroup(orderId, orderItemSeqId, '00002', 
inventoryItem.inventoryItemId, new BigDecimal('1'))
+
+        Map quickShipResult = dispatcher.runSync('quickShipEntireOrder', 
[orderId: orderId, userLogin: userLogin])
+        assert ServiceUtil.isSuccess(quickShipResult)
+
+        List<GenericValue> shipments = 
from('Shipment').where('primaryOrderId', orderId).queryList()
+        assert shipments.size() == 2
+
+        for (GenericValue shipment : shipments) {
+            List<GenericValue> itemIssuances = 
from('ItemIssuance').where('shipmentId', shipment.shipmentId).queryList()
+            Set<String> shipGroupsOnThisShipment = 
itemIssuances*.shipGroupSeqId as Set
+            assert shipGroupsOnThisShipment.size() == 1
+            assert 
shipGroupsOnThisShipment.contains(shipment.primaryShipGroupSeqId)
+        }
+    }
+
+    void testQuickShipEntireOrderSkipsShipGroupWithNothingToShip() {
+        Map orderResult = dispatcher.runSync('createTestSalesOrderSingle',
+                [userLogin: userLogin, productId: 'GZ-2644'])
+        assert ServiceUtil.isSuccess(orderResult)
+        String orderId = orderResult.orderId
+        assert orderId
+
+        GenericValue orderItem = from('OrderItem').where('orderId', 
orderId).queryFirst()
+        assert orderItem
+
+        // Ship group 00002: created but given NO order items and NO inventory 
reservation —
+        // this is the "nothing to ship for this group" case.
+        String secondContactMechId = resolveDifferentDestinationAddress('555 
Empty Group Rd')
+        addShipGroup(orderId, '00002', secondContactMechId)
+
+        // orderItemShipGroupList (from getRelated('OrderItemShipGroup')) has 
no guaranteed order,
+        // so this test only proves correctness if ship group 00001 (which HAS 
items) still gets
+        // shipped regardless of iteration order relative to the empty 00002.
+        Map quickShipResult = dispatcher.runSync('quickShipEntireOrder', 
[orderId: orderId, userLogin: userLogin])
+        assert ServiceUtil.isSuccess(quickShipResult)
+
+        List<GenericValue> shipments = 
from('Shipment').where('primaryOrderId', orderId).queryList()
+        assert shipments.size() == 1
+        assert shipments[0].primaryShipGroupSeqId == '00001'
+
+        List<GenericValue> itemIssuances = 
from('ItemIssuance').where('shipmentId', shipments[0].shipmentId).queryList()
+        assert itemIssuances
+        assert itemIssuances*.shipGroupSeqId as Set == ['00001'] as Set
+    }
+
+    /**
+     * Resolves a postal address in a different country/state/postal code than 
the demo customer's
+     * default shipping address, returning its contactMechId.
+     */
+    private String resolveDifferentDestinationAddress(String addressStreet) {
+        Map createAddressCtx = [
+                address1: addressStreet,
+                city: 'New York',
+                stateProvinceGeoId: 'NY',
+                postalCode: '10001',
+                countryGeoId: 'USA',
+                userLogin: userLogin
+        ]
+        Map createAddressResult = dispatcher.runSync('createPostalAddress', 
createAddressCtx)
+        assert ServiceUtil.isSuccess(createAddressResult)
+        String contactMechId = createAddressResult.contactMechId
+        assert contactMechId
+        return contactMechId
+    }
+
+    /** Adds a splittable OrderItemShipGroup with the given destination. */
+    private void addShipGroup(String orderId, String shipGroupSeqId, String 
contactMechId) {
+        Map createShipGroupCtx = [
+                orderId: orderId,
+                shipGroupSeqId: shipGroupSeqId,
+                contactMechId: contactMechId,
+                facilityId: 'WebStoreWarehouse',
+                maySplit: 'Y',
+                userLogin: userLogin
+        ]
+        Map createShipGroupResult = 
dispatcher.runSync('createOrderItemShipGroup', createShipGroupCtx)
+        assert ServiceUtil.isSuccess(createShipGroupResult)
+    }
+
+    /** Associates an order item with a ship group for the given quantity. */
+    private void associateItemWithShipGroup(String orderId, String 
orderItemSeqId, String shipGroupSeqId, BigDecimal quantity) {
+        Map createAssocCtx = [
+                orderId: orderId,
+                orderItemSeqId: orderItemSeqId,
+                shipGroupSeqId: shipGroupSeqId,
+                quantity: quantity,
+                userLogin: userLogin
+        ]
+        Map createAssocResult = 
dispatcher.runSync('addOrderItemShipGroupAssoc', createAssocCtx)
+        assert ServiceUtil.isSuccess(createAssocResult)
+    }
+
+    /** Reserves inventory for an order item under a ship group. */
+    private void reserveInventoryToShipGroup(String orderId, String 
orderItemSeqId, String shipGroupSeqId,
+            String inventoryItemId, BigDecimal quantity) {
+        Map createInvResCtx = [
+                orderId: orderId,
+                shipGroupSeqId: shipGroupSeqId,
+                orderItemSeqId: orderItemSeqId,
+                inventoryItemId: inventoryItemId,
+                quantity: quantity,
+                userLogin: userLogin
+        ]
+        Map createInvResResult = 
dispatcher.runSync('createOrderItemShipGrpInvRes', createInvResCtx)
+        assert ServiceUtil.isSuccess(createInvResResult)
+    }
+
+    /** Finds an available GZ-2644 inventory item at WebStoreWarehouse. */
+    private GenericValue findGz2644InventoryItem() {
+        GenericValue inventoryItem = from('InventoryItem')
+                .where('productId', 'GZ-2644', 'facilityId', 
'WebStoreWarehouse')
+                .queryFirst()
+        assert inventoryItem
+        return inventoryItem
+    }
+
 }

Reply via email to