diff --git a/src/Apps/W1/Subcontracting/App/src/Manufacturing/SubcReqWkshMakeOrd.Codeunit.al b/src/Apps/W1/Subcontracting/App/src/Manufacturing/SubcReqWkshMakeOrd.Codeunit.al index 4778b502c49..fd61afe1170 100644 --- a/src/Apps/W1/Subcontracting/App/src/Manufacturing/SubcReqWkshMakeOrd.Codeunit.al +++ b/src/Apps/W1/Subcontracting/App/src/Manufacturing/SubcReqWkshMakeOrd.Codeunit.al @@ -33,6 +33,7 @@ codeunit 20516 "Subc. Req. Wksh. Make Ord." local procedure OnInsertPurchOrderLineOnAfterTransferFromReqLineToPurchLine(var PurchOrderLine: Record "Purchase Line"; RequisitionLine: Record "Requisition Line") var SubcPriceManagement: Codeunit "Subc. Price Management"; + AutomaticReqLineCost: Decimal; begin #if not CLEAN29 #pragma warning disable AL0432 @@ -43,6 +44,10 @@ codeunit 20516 "Subc. Req. Wksh. Make Ord." if (RequisitionLine."Prod. Order No." = '') or (RequisitionLine."Operation No." = '') then exit; + AutomaticReqLineCost := SubcPriceManagement.GetAutomaticSubcCostForReqLine(RequisitionLine); + if RequisitionLine."Direct Unit Cost" <> AutomaticReqLineCost then + exit; + SubcPriceManagement.GetSubcPriceForPurchLine(PurchOrderLine); end; diff --git a/src/Apps/W1/Subcontracting/App/src/Purchase/SubcPriceManagement.Codeunit.al b/src/Apps/W1/Subcontracting/App/src/Purchase/SubcPriceManagement.Codeunit.al index d240a0167bb..3849ddde1e4 100644 --- a/src/Apps/W1/Subcontracting/App/src/Purchase/SubcPriceManagement.Codeunit.al +++ b/src/Apps/W1/Subcontracting/App/src/Purchase/SubcPriceManagement.Codeunit.al @@ -365,16 +365,18 @@ codeunit 20508 "Subc. Price Management" PriceListQty := QtyBase / PriceListQtyPerUOM; end; - local procedure GetPriceByUOM(var SubcontractorPrice: Record "Subcontractor Price"; PriceListQty: Decimal; var PriceListCost: Decimal) + local procedure GetPriceByUOM(var SubcontractorPrice: Record "Subcontractor Price"; PriceListQty: Decimal; var PriceListCost: Decimal): Boolean begin SubcontractorPrice.SetRange("Minimum Quantity", 0, PriceListQty); SubcontractorPrice.SetRange("Unit of Measure Code", SubcontractorPrice."Unit of Measure Code"); - if SubcontractorPrice.FindLast() then begin - PriceListCost := SubcontractorPrice."Direct Unit Cost"; - if PriceListCost <> 0 then - if (PriceListCost * PriceListQty) < SubcontractorPrice."Minimum Amount" then - PriceListCost := SubcontractorPrice."Minimum Amount" / PriceListQty; - end; + if not SubcontractorPrice.FindLast() then + exit(false); + + PriceListCost := SubcontractorPrice."Direct Unit Cost"; + if PriceListCost <> 0 then + if (PriceListCost * PriceListQty) < SubcontractorPrice."Minimum Amount" then + PriceListCost := SubcontractorPrice."Minimum Amount" / PriceListQty; + exit(true); end; procedure ConvertPriceToUOM(ProdUOM: Code[10]; ProdQtyPerUoM: Decimal; PriceListUOM: Code[10]; PriceListQtyPerUOM: Decimal; PriceListCost: Decimal; var DirectCost: Decimal) @@ -483,7 +485,8 @@ codeunit 20508 "Subc. Price Management" end else GetUOMPrice(RequisitionLine."No.", RequisitionLine.GetQuantityBase(), SubcontractorPrice, PriceListUOM, PriceListQtyPerUOM, PriceListQty); - GetPriceByUOM(SubcontractorPrice, PriceListQty, PriceListCost); + if not GetPriceByUOM(SubcontractorPrice, PriceListQty, PriceListCost) then + exit; if PriceListCost <> 0 then begin ConvertPriceToUOM(RequisitionLine."Unit of Measure Code", RequisitionLine.GetQuantityForUOM(), PriceListUOM, PriceListQtyPerUOM, PriceListCost, DirectCost); ConvertPriceToCurrency(RequisitionLine."Currency Code", SubcontractorPrice."Currency Code", PriceListCost, DirectCost); @@ -503,13 +506,24 @@ codeunit 20508 "Subc. Price Management" end; end; + internal procedure GetAutomaticSubcCostForReqLine(RequisitionLine: Record "Requisition Line"): Decimal + var + ProdOrderRoutingLine: Record "Prod. Order Routing Line"; + begin + GetProdOrderRtngLine( + RequisitionLine."Prod. Order No.", RequisitionLine."Routing Reference No.", + RequisitionLine."Routing No.", RequisitionLine."Operation No.", ProdOrderRoutingLine); + ProdOrderRoutingLine.TestField(Type, "Capacity Type"::"Work Center"); + RequisitionLine."Direct Unit Cost" := GetNonPriceListDirectCost(ProdOrderRoutingLine); + GetSubcPriceForReqLine(RequisitionLine, ''); + exit(RequisitionLine."Direct Unit Cost"); + end; + procedure GetSubcPriceForPurchLine(var PurchaseLine: Record "Purchase Line") var ProdOrderRoutingLine: Record "Prod. Order Routing Line"; - SubcontractorPrice: Record "Subcontractor Price"; - PriceListUOM: Code[10]; OrderDate: Date; - DirectCost, PriceListCost, PriceListQty, PriceListQtyPerUOM : Decimal; + DirectCost: Decimal; begin #if not CLEAN29 #pragma warning disable AL0432 @@ -521,36 +535,84 @@ codeunit 20508 "Subc. Price Management" if OrderDate = 0D then OrderDate := WorkDate(); + if not TryGetSubcPriceListCostForPurchLine(PurchaseLine, OrderDate, DirectCost, ProdOrderRoutingLine) then begin + ProdOrderRoutingLine.TestField(Type, "Capacity Type"::"Work Center"); + DirectCost := GetNonPriceListDirectCost(ProdOrderRoutingLine); + end; + + PurchaseLine."Direct Unit Cost" := DirectCost; + PurchaseLine.Validate("Line Discount %"); + end; + + local procedure TryGetSubcPriceListCostForPurchLine(PurchaseLine: Record "Purchase Line"; OrderDate: Date; var DirectCost: Decimal; var ProdOrderRoutingLine: Record "Prod. Order Routing Line"): Boolean + var + SubcontractorPrice: Record "Subcontractor Price"; + PriceListUOM: Code[10]; + PriceListCost, PriceListQty, PriceListQtyPerUOM : Decimal; + begin + DirectCost := 0; +#if not CLEAN29 +#pragma warning disable AL0432 + if not SubcFeatureFlagHandler.IsSubcontractingEnabled() then +#pragma warning restore AL0432 + exit(false); +#endif + if OrderDate = 0D then + OrderDate := WorkDate(); + + ProdOrderRoutingLine.SetLoadFields("Standard Task Code"); + GetProdOrderRtngLine( + PurchaseLine."Prod. Order No.", PurchaseLine."Routing Reference No.", + PurchaseLine."Routing No.", PurchaseLine."Operation No.", ProdOrderRoutingLine); + SubcontractorPrice.SetRange("Vendor No.", PurchaseLine."Buy-from Vendor No."); SubcontractorPrice.SetRange("Work Center No.", PurchaseLine."Work Center No."); SubcontractorPrice.SetRange("Item No.", PurchaseLine."No."); SubcontractorPrice.SetFilter("Variant Code", '%1|%2', PurchaseLine."Variant Code", ''); SubcontractorPrice.SetFilter("Unit of Measure Code", '%1|%2', PurchaseLine."Unit of Measure Code", ''); - - GetProdOrderRtngLine(PurchaseLine."Prod. Order No.", PurchaseLine."Routing Reference No.", PurchaseLine."Routing No.", PurchaseLine."Operation No.", ProdOrderRoutingLine); - SubcontractorPrice.SetFilter("Standard Task Code", '%1|%2', ProdOrderRoutingLine."Standard Task Code", ''); SubcontractorPrice.SetFilter("Currency Code", '%1|%2', PurchaseLine."Currency Code", ''); SubcontractorPrice.SetRange("Starting Date", 0D, OrderDate); SubcontractorPrice.SetFilter("Ending Date", '>=%1|%2', OrderDate, 0D); - if SubcontractorPrice.FindLast() then begin - if SubcontractorPrice."Unit of Measure Code" = PurchaseLine."Unit of Measure Code" then - PriceListUOM := SubcontractorPrice."Unit of Measure Code"; - GetUOMPrice(PurchaseLine."No.", GetQuantityBase(PurchaseLine), SubcontractorPrice, PriceListUOM, PriceListQtyPerUOM, PriceListQty); - GetPriceByUOM(SubcontractorPrice, PriceListQty, PriceListCost); - if PriceListCost <> 0 then begin - ConvertPriceToUOM(PurchaseLine."Unit of Measure Code", PurchaseLine.GetQuantityPerUOM(), PriceListUOM, PriceListQtyPerUOM, PriceListCost, DirectCost); - ConvertPriceToCurrency(PurchaseLine."Currency Code", SubcontractorPrice."Currency Code", PriceListCost, DirectCost) - end; - end else begin - GetUOMPrice(PurchaseLine."No.", PurchaseLine.GetQuantityBase(), SubcontractorPrice, PriceListUOM, PriceListQtyPerUOM, PriceListQty); - ProdOrderRoutingLine.TestField(Type, "Capacity Type"::"Work Center"); - DirectCost := ProdOrderRoutingLine."Direct Unit Cost"; - end; + if not SubcontractorPrice.FindLast() then + exit(false); - PurchaseLine."Direct Unit Cost" := DirectCost; - PurchaseLine.Validate("Line Discount %"); + if SubcontractorPrice."Unit of Measure Code" = PurchaseLine."Unit of Measure Code" then + PriceListUOM := SubcontractorPrice."Unit of Measure Code"; + GetUOMPrice(PurchaseLine."No.", GetQuantityBase(PurchaseLine), SubcontractorPrice, PriceListUOM, PriceListQtyPerUOM, PriceListQty); + if not GetPriceByUOM(SubcontractorPrice, PriceListQty, PriceListCost) then + exit(false); + if PriceListCost = 0 then + exit(true); + + ConvertPriceToUOM(PurchaseLine."Unit of Measure Code", PurchaseLine.GetQuantityPerUOM(), PriceListUOM, PriceListQtyPerUOM, PriceListCost, DirectCost); + ConvertPriceToCurrency(PurchaseLine."Currency Code", SubcontractorPrice."Currency Code", PriceListCost, DirectCost); + exit(true); + end; + + local procedure GetNonPriceListDirectCost(ProdOrderRoutingLine: Record "Prod. Order Routing Line"): Decimal + var + GeneralLedgerSetup: Record "General Ledger Setup"; + ProdOrderLine: Record "Prod. Order Line"; + begin + GetLine(ProdOrderLine, ProdOrderRoutingLine); + GeneralLedgerSetup.Get(); + if ProdOrderRoutingLine."Unit Cost Calculation" = ProdOrderRoutingLine."Unit Cost Calculation"::Units then + exit( + Round( + ProdOrderRoutingLine."Direct Unit Cost" * ProdOrderLine."Qty. per Unit of Measure", + GeneralLedgerSetup."Unit-Amount Rounding Precision")); + + ProdOrderLine.CalcFields("Total Exp. Oper. Output (Qty.)"); + if ProdOrderLine."Total Exp. Oper. Output (Qty.)" = 0 then + exit(0); + + exit( + Round( + (ProdOrderRoutingLine."Expected Operation Cost Amt." - ProdOrderRoutingLine."Expected Capacity Ovhd. Cost") / + ProdOrderLine."Total Exp. Oper. Output (Qty.)", + GeneralLedgerSetup."Unit-Amount Rounding Precision")); end; local procedure GetProdOrderRtngLine(ProdOrderNo: Code[20]; RtngRefNo: Integer; RoutingNo: Code[20]; OperationNo: Code[10]; var ProdOrderRoutingLine: Record "Prod. Order Routing Line") diff --git a/src/Apps/W1/Subcontracting/App/src/Purchase/SubcPurchaseLineExt.Codeunit.al b/src/Apps/W1/Subcontracting/App/src/Purchase/SubcPurchaseLineExt.Codeunit.al index 8662e2be2af..976b55f9a52 100644 --- a/src/Apps/W1/Subcontracting/App/src/Purchase/SubcPurchaseLineExt.Codeunit.al +++ b/src/Apps/W1/Subcontracting/App/src/Purchase/SubcPurchaseLineExt.Codeunit.al @@ -100,10 +100,14 @@ codeunit 20534 "Subc. Purchase Line Ext" if GetExecutionContext() = ExecutionContext::Upgrade then exit; - if Rec."Planned Receipt Date" = xRec."Planned Receipt Date" then + // Gate on the resulting Order Date rather than Planned Receipt Date so lead-time-only + // reschedules (for example changing Lead Time Calculation on an open line with nonblank + // Requested and Planned Receipt Dates) still trigger date-effective repricing when the + // planned-date validation reassigns Order Date without changing Planned Receipt Date. + if Rec."Order Date" = xRec."Order Date" then exit; - GetSubcontractingPrice(Rec); + RepriceSubcPurchLineOnScheduleChange(Rec); end; [EventSubscriber(ObjectType::Table, Database::"Purchase Line", OnAfterValidateEvent, "Order Date", false, false)] @@ -124,7 +128,7 @@ codeunit 20534 "Subc. Purchase Line Ext" if Rec."Order Date" = xRec."Order Date" then exit; - GetSubcontractingPrice(Rec); + RepriceSubcPurchLineOnScheduleChange(Rec); end; [EventSubscriber(ObjectType::Table, Database::"Purchase Line", OnAfterValidateEvent, Quantity, false, false)] @@ -354,6 +358,32 @@ codeunit 20534 "Subc. Purchase Line Ext" SubcPriceManagement.GetSubcPriceForPurchLine(PurchaseLine); end; + local procedure RepriceSubcPurchLineOnScheduleChange(var PurchaseLine: Record "Purchase Line") + var + PurchaseHeader: Record "Purchase Header"; + begin + if (PurchaseLine.Type <> PurchaseLine.Type::Item) or (PurchaseLine."No." = '') or + (PurchaseLine."Prod. Order No." = '') or (PurchaseLine."Operation No." = '') + then + exit; + + // Preserve released-order scheduling: repricing a subcontracting line after release + // would call Validate("Line Discount %") through GetSubcPriceForPurchLine, which in + // turn calls TestStatusOpen on the released header and fails the date edit. The base + // test suite explicitly permits Planned Receipt Date and Order Date edits on released + // purchase order lines (see ERMSalesPurchStatusError CanChangeOrderDateOnReleasedPurchOrderLine + // and CanChangePlannedReceiptDateOnReleasedPurchOrderLine). Bypass repricing entirely + // once the header is no longer Open so scheduling still works without silently + // changing released financial terms. + PurchaseHeader.SetLoadFields(Status); + if not PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No.") then + exit; + if PurchaseHeader.Status <> PurchaseHeader.Status::Open then + exit; + + GetSubcontractingPrice(PurchaseLine); + end; + [EventSubscriber(ObjectType::Table, Database::"Purchase Line", OnBeforeOpenItemTrackingLines, '', false, false)] local procedure OpenProdOrderLineItemTrackingOnBeforeOpenItemTrackingLines(PurchaseLine: Record "Purchase Line"; var IsHandled: Boolean) begin diff --git a/src/Apps/W1/Subcontracting/Test/Tests/SubcPricingTest.Codeunit.al b/src/Apps/W1/Subcontracting/Test/Tests/SubcPricingTest.Codeunit.al index 5d3037e20c9..64e2b522533 100644 --- a/src/Apps/W1/Subcontracting/Test/Tests/SubcPricingTest.Codeunit.al +++ b/src/Apps/W1/Subcontracting/Test/Tests/SubcPricingTest.Codeunit.al @@ -223,6 +223,425 @@ codeunit 139982 "Subc. Pricing Test" Assert.AreEqual(LaterPrice, PurchaseLine."Direct Unit Cost", 'The purchase line must use the subcontractor price valid on its changed order date.'); end; + [Test] + procedure NoMatchPriceListPreservesCalculatedWorksheetCostForTimeBasedRouting() + var + Item: Record Item; + ProductionOrder: Record "Production Order"; + ProdOrderRoutingLine: Record "Prod. Order Routing Line"; + PurchaseLine: Record "Purchase Line"; + ReqWkshTemplate: Record "Req. Wksh. Template"; + RequisitionLine: Record "Requisition Line"; + RequisitionWkshName: Record "Requisition Wksh. Name"; + RoutingHeader: Record "Routing Header"; + RoutingLine: Record "Routing Line"; + SubcontractorPrice: Record "Subcontractor Price"; + Vendor: Record Vendor; + WorkCenter: Record "Work Center"; + CalculatedWorksheetCost: Decimal; + RoutingRate: Decimal; + begin + // [SCENARIO 648535] Carry-out preserves the calculated worksheet Direct Unit Cost when + // no subcontractor price covers the purchase-line date and the routing has a nontrivial + // per-output-unit multiplier, instead of overwriting with the raw routing rate. + Initialize(); + + // [GIVEN] A subcontracting item whose routing operation has a multi-minute Run Time + CreateSubcontractingItemWithSingleOperationRouting(Item, Vendor, WorkCenter, ''); + WorkCenter.Validate("Direct Unit Cost", LibraryRandom.RandDecInRange(10, 100, 2)); + WorkCenter.Modify(true); + RoutingHeader.Get(Item."Routing No."); + RoutingHeader.Validate(Status, RoutingHeader.Status::New); + RoutingHeader.Modify(true); + RoutingLine.SetRange("Routing No.", Item."Routing No."); + RoutingLine.FindFirst(); + RoutingLine.Validate("Run Time", 5); + RoutingLine.Modify(true); + RoutingHeader.Validate(Status, RoutingHeader.Status::Certified); + RoutingHeader.Modify(true); + + // [GIVEN] No subcontractor price entries exist for the operation + SubcontractorPrice.SetRange("Vendor No.", Vendor."No."); + SubcontractorPrice.SetRange("Work Center No.", WorkCenter."No."); + SubcontractorPrice.SetRange("Item No.", Item."No."); + Assert.IsTrue(SubcontractorPrice.IsEmpty(), 'Test setup expects no subcontractor prices for the operation.'); + + // [GIVEN] A released production order whose worksheet line reflects the time-based cost + LibraryManufacturing.CreateProductionOrder( + ProductionOrder, "Production Order Status"::Released, Item, '', '', 1, WorkDate()); + ProdOrderRoutingLine.SetRange(Status, ProductionOrder.Status); + ProdOrderRoutingLine.SetRange("Prod. Order No.", ProductionOrder."No."); + ProdOrderRoutingLine.SetRange("Work Center No.", WorkCenter."No."); + ProdOrderRoutingLine.FindFirst(); + RoutingRate := ProdOrderRoutingLine."Direct Unit Cost"; + + SubcontractingMgmtLibrary.CreateReqWkshTemplateAndName(ReqWkshTemplate, RequisitionWkshName); + SubcontractingMgmtLibrary.CalculateSubcontractsAndFindReqLine(RequisitionWkshName, ProductionOrder."No.", RequisitionLine); + CalculatedWorksheetCost := RequisitionLine."Direct Unit Cost"; + Assert.AreNotEqual(RoutingRate, CalculatedWorksheetCost, 'Test setup expects the calculated worksheet cost to include the routing multiplier.'); + + // [WHEN] The worksheet action is carried out + SubcontractingMgmtLibrary.CarryOutSubcontractingAction(RequisitionLine); + + // [THEN] The purchase line preserves the calculated worksheet cost instead of falling + // back to the raw routing rate + SubcontractingMgmtLibrary.FindSubcPurchLineForProdOrder(PurchaseLine, Item."No.", ProductionOrder."No."); + Assert.AreEqual(CalculatedWorksheetCost, PurchaseLine."Direct Unit Cost", + 'Carry-out must preserve the worksheet Direct Unit Cost when no subcontractor price matches the purchase-line date.'); + Assert.AreNotEqual(RoutingRate, PurchaseLine."Direct Unit Cost", + 'The purchase line must not fall back to the raw routing Direct Unit Cost when a proper worksheet cost was calculated.'); + end; + + [Test] + procedure NoMatchPriceListFallbackUsesCalculatedCostForUnitsAndTime() + var + ProdOrderLine: Record "Prod. Order Line"; + PurchaseLine: Record "Purchase Line"; + SubcPriceManagement: Codeunit "Subc. Price Management"; + TimeDirectUnitCost: Decimal; + UnitsDirectUnitCost: Decimal; + begin + // [SCENARIO 648535] Direct purchase-line repricing without a matching price uses the + // standard subcontracting calculation for both unit- and time-based operations. + Initialize(); + + CreateNoPriceSubcontractingPurchaseLine( + PurchaseLine, ProdOrderLine, Enum::"Unit Cost Calculation Type"::Units); + UnitsDirectUnitCost := PurchaseLine."Direct Unit Cost"; + Assert.AreNotEqual(0, UnitsDirectUnitCost, 'Test setup expects a nonzero Units Direct Unit Cost.'); + PurchaseLine."Direct Unit Cost" := 0; + + SubcPriceManagement.GetSubcPriceForPurchLine(PurchaseLine); + + Assert.AreEqual( + UnitsDirectUnitCost, PurchaseLine."Direct Unit Cost", + 'The Units fallback must match the Direct Unit Cost calculated when the purchase line was created.'); + + CreateNoPriceSubcontractingPurchaseLine( + PurchaseLine, ProdOrderLine, Enum::"Unit Cost Calculation Type"::Time); + TimeDirectUnitCost := PurchaseLine."Direct Unit Cost"; + Assert.AreNotEqual(0, TimeDirectUnitCost, 'Test setup expects a nonzero Time Direct Unit Cost.'); + Assert.AreNotEqual(UnitsDirectUnitCost, TimeDirectUnitCost, 'Test setup expects Units and Time calculations to produce different costs.'); + PurchaseLine."Direct Unit Cost" := 0; + + SubcPriceManagement.GetSubcPriceForPurchLine(PurchaseLine); + + Assert.AreEqual( + TimeDirectUnitCost, PurchaseLine."Direct Unit Cost", + 'The Time fallback must match the Direct Unit Cost calculated when the purchase line was created.'); + end; + + [Test] + procedure FinalPurchaseLineDateAppliesPriceWhenReqLineDateHasNoPrice() + var + Item: Record Item; + ProductionOrder: Record "Production Order"; + PurchaseLine: Record "Purchase Line"; + ReqWkshTemplate: Record "Req. Wksh. Template"; + RequisitionLine: Record "Requisition Line"; + RequisitionWkshName: Record "Requisition Wksh. Name"; + SubcontractorPrice: Record "Subcontractor Price"; + Vendor: Record Vendor; + WorkCenter: Record "Work Center"; + FinalDatePrice: Decimal; + begin + // [SCENARIO 648535] An automatically calculated worksheet fallback is repriced when + // the final purchase-line date has an applicable subcontractor price. + Initialize(); + + CreateSubcontractingItemWithSingleOperationRouting(Item, Vendor, WorkCenter, ''); + WorkCenter.Validate("Direct Unit Cost", LibraryRandom.RandDecInRange(10, 100, 2)); + WorkCenter.Modify(true); + Evaluate(Item."Lead Time Calculation", '<5D>'); + Item.Modify(true); + LibraryManufacturing.CreateProductionOrder( + ProductionOrder, "Production Order Status"::Released, Item, '', '', 1, WorkDate()); + SubcontractingMgmtLibrary.CreateReqWkshTemplateAndName(ReqWkshTemplate, RequisitionWkshName); + SubcontractingMgmtLibrary.CalculateSubcontractsAndFindReqLine( + RequisitionWkshName, ProductionOrder."No.", RequisitionLine); + RequisitionLine.Validate("Order Date", WorkDate()); + RequisitionLine.Modify(true); + FinalDatePrice := LibraryRandom.RandDecInRange(100, 200, 2); + SubcontractingMgmtLibrary.CreateSubContractingPrice( + SubcontractorPrice, WorkCenter."No.", Vendor."No.", Item."No.", '', '', + CalcDate('<-1M>', WorkDate()), Item."Base Unit of Measure", 0, ''); + SubcontractorPrice.Validate("Ending Date", CalcDate('<-1D>', WorkDate())); + SubcontractorPrice.Validate("Direct Unit Cost", FinalDatePrice); + SubcontractorPrice.Modify(true); + + SubcontractingMgmtLibrary.CarryOutSubcontractingAction(RequisitionLine); + + SubcontractingMgmtLibrary.FindSubcPurchLineForProdOrder( + PurchaseLine, Item."No.", ProductionOrder."No."); + Assert.IsTrue( + PurchaseLine."Order Date" < WorkDate(), + 'The final purchase-line Order Date must be in the price validity period.'); + Assert.AreEqual( + FinalDatePrice, PurchaseLine."Direct Unit Cost", + 'The price valid on the final purchase-line date must replace the automatic worksheet fallback.'); + end; + + [Test] + procedure ReqLinePriceAboveMinimumQuantityPreservesCalculatedFallback() + var + Item: Record Item; + ProductionOrder: Record "Production Order"; + ReqWkshTemplate: Record "Req. Wksh. Template"; + RequisitionLine: Record "Requisition Line"; + RequisitionWkshName: Record "Requisition Wksh. Name"; + SubcontractorPrice: Record "Subcontractor Price"; + Vendor: Record Vendor; + WorkCenter: Record "Work Center"; + SubcPriceManagement: Codeunit "Subc. Price Management"; + CalculatedFallbackCost: Decimal; + begin + // [SCENARIO 648535] A requisition-line price whose minimum quantity is too high does + // not replace the standard calculated subcontracting cost with zero. + Initialize(); + + CreateSubcontractingItemWithSingleOperationRouting(Item, Vendor, WorkCenter, ''); + WorkCenter.Validate("Direct Unit Cost", LibraryRandom.RandDecInRange(10, 100, 2)); + WorkCenter.Modify(true); + LibraryManufacturing.CreateProductionOrder( + ProductionOrder, "Production Order Status"::Released, Item, '', '', 1, WorkDate()); + SubcontractingMgmtLibrary.CreateReqWkshTemplateAndName(ReqWkshTemplate, RequisitionWkshName); + SubcontractingMgmtLibrary.CalculateSubcontractsAndFindReqLine( + RequisitionWkshName, ProductionOrder."No.", RequisitionLine); + CalculatedFallbackCost := RequisitionLine."Direct Unit Cost"; + Assert.AreNotEqual(0, CalculatedFallbackCost, 'Test setup expects a nonzero calculated fallback cost.'); + SubcontractingMgmtLibrary.CreateSubContractingPrice( + SubcontractorPrice, WorkCenter."No.", Vendor."No.", Item."No.", '', '', + RequisitionLine."Order Date", RequisitionLine."Unit of Measure Code", + RequisitionLine.Quantity + 1, RequisitionLine."Currency Code"); + SubcontractorPrice.Validate("Direct Unit Cost", CalculatedFallbackCost * 2); + SubcontractorPrice.Modify(true); + + SubcPriceManagement.GetSubcPriceForReqLine(RequisitionLine, ''); + + Assert.AreEqual( + CalculatedFallbackCost, RequisitionLine."Direct Unit Cost", + 'A requisition-line price above the quantity threshold must preserve the calculated fallback.'); + end; + + [Test] + procedure NoApplicableMinimumQuantityPriceUsesCalculatedFallback() + var + ProdOrderLine: Record "Prod. Order Line"; + PurchaseLine: Record "Purchase Line"; + SubcontractorPrice: Record "Subcontractor Price"; + SubcPriceManagement: Codeunit "Subc. Price Management"; + CalculatedFallbackCost: Decimal; + begin + // [SCENARIO 648535] A date-compatible price with a minimum quantity above the purchase + // quantity is not an applicable price and must not replace the calculated fallback with zero. + Initialize(); + + CreateNoPriceSubcontractingPurchaseLine( + PurchaseLine, ProdOrderLine, Enum::"Unit Cost Calculation Type"::Time); + CalculatedFallbackCost := PurchaseLine."Direct Unit Cost"; + Assert.AreNotEqual(0, CalculatedFallbackCost, 'Test setup expects a nonzero calculated fallback cost.'); + SubcontractingMgmtLibrary.CreateSubContractingPrice( + SubcontractorPrice, PurchaseLine."Work Center No.", PurchaseLine."Buy-from Vendor No.", + PurchaseLine."No.", '', PurchaseLine."Variant Code", PurchaseLine."Order Date", + PurchaseLine."Unit of Measure Code", PurchaseLine.Quantity + 1, PurchaseLine."Currency Code"); + SubcontractorPrice.Validate("Direct Unit Cost", CalculatedFallbackCost * 2); + SubcontractorPrice.Modify(true); + PurchaseLine."Direct Unit Cost" := 0; + + SubcPriceManagement.GetSubcPriceForPurchLine(PurchaseLine); + + Assert.AreEqual( + CalculatedFallbackCost, PurchaseLine."Direct Unit Cost", + 'A price above the purchase quantity threshold must not suppress the calculated fallback.'); + end; + + [Test] + procedure ApplicableZeroPriceIsNotReplacedByCalculatedFallback() + var + ProdOrderLine: Record "Prod. Order Line"; + PurchaseLine: Record "Purchase Line"; + SubcontractorPrice: Record "Subcontractor Price"; + SubcPriceManagement: Codeunit "Subc. Price Management"; + begin + // [SCENARIO 648535] A matched price tier can intentionally have a zero direct unit cost. + Initialize(); + + CreateNoPriceSubcontractingPurchaseLine( + PurchaseLine, ProdOrderLine, Enum::"Unit Cost Calculation Type"::Time); + Assert.AreNotEqual(0, PurchaseLine."Direct Unit Cost", 'Test setup expects a nonzero calculated fallback cost.'); + SubcontractingMgmtLibrary.CreateSubContractingPrice( + SubcontractorPrice, PurchaseLine."Work Center No.", PurchaseLine."Buy-from Vendor No.", + PurchaseLine."No.", '', PurchaseLine."Variant Code", PurchaseLine."Order Date", + PurchaseLine."Unit of Measure Code", 0, PurchaseLine."Currency Code"); + + SubcPriceManagement.GetSubcPriceForPurchLine(PurchaseLine); + + Assert.AreEqual( + 0, PurchaseLine."Direct Unit Cost", + 'An applicable zero price must not be replaced by the calculated fallback.'); + end; + + [Test] + procedure NoMatchPriceListFallbackHandlesZeroExpectedOutputQuantity() + var + ProdOrderLine: Record "Prod. Order Line"; + PurchaseLine: Record "Purchase Line"; + SubcPriceManagement: Codeunit "Subc. Price Management"; + begin + // [SCENARIO 648535] Time-based fallback pricing returns zero instead of dividing by + // zero when the production order has no expected operation output quantity. + Initialize(); + + CreateNoPriceSubcontractingPurchaseLine( + PurchaseLine, ProdOrderLine, Enum::"Unit Cost Calculation Type"::Time); + ProdOrderLine.Validate(Quantity, 0); + ProdOrderLine.Modify(true); + + SubcPriceManagement.GetSubcPriceForPurchLine(PurchaseLine); + + Assert.AreEqual( + 0, PurchaseLine."Direct Unit Cost", + 'The no-price fallback must be zero when expected operation output quantity is zero.'); + end; + + [Test] + procedure ManualWorksheetDirectUnitCostOverridePreservedOnCarryOut() + var + Item: Record Item; + ProductionOrder: Record "Production Order"; + ProdOrderRoutingLine: Record "Prod. Order Routing Line"; + PurchaseLine: Record "Purchase Line"; + ReqWkshTemplate: Record "Req. Wksh. Template"; + RequisitionLine: Record "Requisition Line"; + RequisitionWkshName: Record "Requisition Wksh. Name"; + EarlierPrice: Decimal; + LaterPrice: Decimal; + ManualCost: Decimal; + begin + // [SCENARIO 648535] Carry-out preserves a manually overridden worksheet Direct Unit + // Cost even when a subcontractor price valid on the purchase-line date exists, so + // deliberate worksheet values are not silently replaced by the automatic lookup. + Initialize(); + + // [GIVEN] A subcontracting operation with adjacent price-list entries and a worksheet + // line whose Direct Unit Cost has been manually overridden to a distinct value + CreateDateEffectiveSubcontractingScenario(Item, ProductionOrder, ProdOrderRoutingLine, EarlierPrice, LaterPrice); + SubcontractingMgmtLibrary.CreateReqWkshTemplateAndName(ReqWkshTemplate, RequisitionWkshName); + SubcontractingMgmtLibrary.CalculateSubcontractsAndFindReqLine(RequisitionWkshName, ProductionOrder."No.", RequisitionLine); + ManualCost := EarlierPrice + LaterPrice; + Assert.AreNotEqual(RequisitionLine."Direct Unit Cost", ManualCost, 'Test setup expects the manual override to differ from the automatic price.'); + RequisitionLine.Validate("Direct Unit Cost", ManualCost); + RequisitionLine.Modify(true); + + // [WHEN] The worksheet action is carried out + SubcontractingMgmtLibrary.CarryOutSubcontractingAction(RequisitionLine); + + // [THEN] The purchase line preserves the manually overridden cost + SubcontractingMgmtLibrary.FindSubcPurchLineForProdOrder(PurchaseLine, Item."No.", ProductionOrder."No."); + Assert.AreEqual(ManualCost, PurchaseLine."Direct Unit Cost", + 'Carry-out must preserve a manually overridden worksheet Direct Unit Cost.'); + end; + + [Test] + procedure DateEditsOnReleasedSubcPurchOrderLinePreserveFinancialTerms() + var + Item: Record Item; + ProductionOrder: Record "Production Order"; + ProdOrderRoutingLine: Record "Prod. Order Routing Line"; + PurchaseHeader: Record "Purchase Header"; + PurchaseLine: Record "Purchase Line"; + EarlierPrice: Decimal; + LaterPrice: Decimal; + NewPlannedReceiptDate: Date; + OriginalDirectUnitCost: Decimal; + OriginalLineDiscount: Decimal; + begin + // [SCENARIO 648535] Editing Planned Receipt Date and Order Date on a released + // subcontracting purchase order line must succeed without a status-open error and must + // not silently change financial terms, matching base test coverage in + // ERMSalesPurchStatusError CanChangePlannedReceiptDateOnReleasedPurchOrderLine and + // CanChangeOrderDateOnReleasedPurchOrderLine. + Initialize(); + + // [GIVEN] A backward-scheduled subcontracting purchase line then released to the vendor + CreateDateEffectiveSubcontractingScenario(Item, ProductionOrder, ProdOrderRoutingLine, EarlierPrice, LaterPrice); + CreateSubcontractingPurchaseLine(PurchaseLine, ProdOrderRoutingLine, Item."No.", ProductionOrder."No."); + OriginalDirectUnitCost := PurchaseLine."Direct Unit Cost"; + OriginalLineDiscount := PurchaseLine."Line Discount %"; + PurchaseHeader.Get(PurchaseLine."Document Type", PurchaseLine."Document No."); + LibraryPurchase.ReleasePurchaseDocument(PurchaseHeader); + PurchaseLine.Get(PurchaseLine."Document Type", PurchaseLine."Document No.", PurchaseLine."Line No."); + + // [WHEN] Planned Receipt Date is edited on the released line to move Order Date into + // the later price period + NewPlannedReceiptDate := CalcDate('<20D>', WorkDate()); + PurchaseLine.Validate("Planned Receipt Date", NewPlannedReceiptDate); + + // [THEN] The edit is accepted and the released financial terms are preserved + PurchaseLine.TestField("Planned Receipt Date", NewPlannedReceiptDate); + Assert.AreEqual(OriginalDirectUnitCost, PurchaseLine."Direct Unit Cost", + 'Direct Unit Cost must not change when Planned Receipt Date is edited on a released subcontracting line.'); + Assert.AreEqual(OriginalLineDiscount, PurchaseLine."Line Discount %", + 'Line Discount % must not change when Planned Receipt Date is edited on a released subcontracting line.'); + + // [WHEN] Order Date is edited directly on the released line + PurchaseLine.Validate("Order Date", WorkDate()); + + // [THEN] The edit is accepted and financial terms remain preserved + PurchaseLine.TestField("Order Date", WorkDate()); + Assert.AreEqual(OriginalDirectUnitCost, PurchaseLine."Direct Unit Cost", + 'Direct Unit Cost must not change when Order Date is edited on a released subcontracting line.'); + Assert.AreEqual(OriginalLineDiscount, PurchaseLine."Line Discount %", + 'Line Discount % must not change when Order Date is edited on a released subcontracting line.'); + end; + + [Test] + procedure LeadTimeCalculationOnlyOrderDateShiftRepricesSubcPurchLine() + var + Item: Record Item; + ProductionOrder: Record "Production Order"; + ProdOrderRoutingLine: Record "Prod. Order Routing Line"; + PurchaseLine: Record "Purchase Line"; + NewLeadTime: DateFormula; + EarlierPrice: Decimal; + LaterPrice: Decimal; + AlignedDate: Date; + OriginalPlannedReceiptDate: Date; + begin + // [SCENARIO 648535] A rescheduling that keeps Planned Receipt Date unchanged but + // reassigns Order Date across a price boundary — for example changing Lead Time + // Calculation on an open line with nonblank Requested and Planned Receipt Dates and + // blank Promised Receipt Date — must reprice the subcontracting purchase line. + Initialize(); + + // [GIVEN] A backward-scheduled subcontracting purchase line initially using the + // earlier price + CreateDateEffectiveSubcontractingScenario(Item, ProductionOrder, ProdOrderRoutingLine, EarlierPrice, LaterPrice); + CreateSubcontractingPurchaseLine(PurchaseLine, ProdOrderRoutingLine, Item."No.", ProductionOrder."No."); + Assert.AreEqual(EarlierPrice, PurchaseLine."Direct Unit Cost", 'Initial purchase-line Direct Unit Cost must match the earlier subcontractor price.'); + + // [GIVEN] Requested Receipt Date is populated and Promised Receipt Date blank so that + // the Lead Time Calculation change validates Planned Receipt Date without changing it + // and reassigns Order Date directly, and Planned Receipt Date sits in the later price + // period so a zero-day lead time places Order Date there. + AlignedDate := CalcDate('<20D>', WorkDate()); + PurchaseLine.Validate("Requested Receipt Date", AlignedDate); + PurchaseLine.Validate("Promised Receipt Date", 0D); + OriginalPlannedReceiptDate := PurchaseLine."Planned Receipt Date"; + Assert.IsTrue(OriginalPlannedReceiptDate >= WorkDate(), 'Planned Receipt Date must reach the later price period for this test scenario.'); + + // [WHEN] Lead Time Calculation is set to zero so Order Date shifts to Planned Receipt + // Date without changing that field + Evaluate(NewLeadTime, '<0D>'); + PurchaseLine.Validate("Lead Time Calculation", NewLeadTime); + + // [THEN] Planned Receipt Date is unchanged, Order Date crossed the boundary and the + // line reprices from the resulting Order Date + Assert.AreEqual(OriginalPlannedReceiptDate, PurchaseLine."Planned Receipt Date", 'Planned Receipt Date must remain unchanged for a lead-time-only reschedule.'); + Assert.IsTrue(PurchaseLine."Order Date" >= WorkDate(), 'Order Date must reach the later price period after the lead-time-only reschedule.'); + Assert.AreEqual(LaterPrice, PurchaseLine."Direct Unit Cost", 'The purchase line must reprice to the subcontractor price valid on the resulting Order Date after a lead-time-only reschedule.'); + end; + [Test] [Scope('OnPrem')] procedure DeleteWorkCenterWithPricesDeletesRelatedPrices() @@ -973,6 +1392,53 @@ codeunit 139982 "Subc. Pricing Test" SubcontractingMgmtLibrary.FindSubcPurchLineForProdOrder(PurchaseLine, ItemNo, ProdOrderNo); end; + local procedure CreateNoPriceSubcontractingPurchaseLine(var PurchaseLine: Record "Purchase Line"; var ProdOrderLine: Record "Prod. Order Line"; UnitCostCalculation: Enum "Unit Cost Calculation Type") + var + Item: Record Item; + ProductionOrder: Record "Production Order"; + ProdOrderRoutingLine: Record "Prod. Order Routing Line"; + RoutingHeader: Record "Routing Header"; + RoutingLine: Record "Routing Line"; + SubcontractorPrice: Record "Subcontractor Price"; + Vendor: Record Vendor; + WorkCenter: Record "Work Center"; + begin + CreateSubcontractingItemWithSingleOperationRouting(Item, Vendor, WorkCenter, ''); + WorkCenter.Validate("Direct Unit Cost", 10); + WorkCenter.Validate("Unit Cost Calculation", UnitCostCalculation); + WorkCenter.Modify(true); + + RoutingHeader.Get(Item."Routing No."); + RoutingHeader.Validate(Status, RoutingHeader.Status::New); + RoutingHeader.Modify(true); + RoutingLine.SetRange("Routing No.", Item."Routing No."); + RoutingLine.FindFirst(); + RoutingLine.Validate("Run Time", 5); + RoutingLine.Modify(true); + RoutingHeader.Validate(Status, RoutingHeader.Status::Certified); + RoutingHeader.Modify(true); + + SubcontractorPrice.SetRange("Vendor No.", Vendor."No."); + SubcontractorPrice.SetRange("Work Center No.", WorkCenter."No."); + SubcontractorPrice.SetRange("Item No.", Item."No."); + Assert.IsTrue(SubcontractorPrice.IsEmpty(), 'Test setup expects no subcontractor prices for the operation.'); + + LibraryManufacturing.CreateProductionOrder( + ProductionOrder, "Production Order Status"::Released, Item, '', '', 1, WorkDate()); + ProdOrderRoutingLine.SetRange(Status, ProductionOrder.Status); + ProdOrderRoutingLine.SetRange("Prod. Order No.", ProductionOrder."No."); + ProdOrderRoutingLine.SetRange("Work Center No.", WorkCenter."No."); + ProdOrderRoutingLine.FindFirst(); + + CreateSubcontractingPurchaseLine(PurchaseLine, ProdOrderRoutingLine, Item."No.", ProductionOrder."No."); + + ProdOrderLine.SetRange(Status, ProductionOrder.Status); + ProdOrderLine.SetRange("Prod. Order No.", ProductionOrder."No."); + ProdOrderLine.SetRange("Routing No.", ProdOrderRoutingLine."Routing No."); + ProdOrderLine.SetRange("Routing Reference No.", ProdOrderRoutingLine."Routing Reference No."); + ProdOrderLine.FindFirst(); + end; + local procedure CreateItemVendorAndSubcontractingWorkCenter(var Item: Record Item; var Vendor: Record Vendor; var WorkCenter: Record "Work Center") begin LibraryInventory.CreateItem(Item);