Files
votianng/backend/src/main/java/de/stadtbote/votian/OrderRecalculation.java
T
Sven 363bc27c26 Erweitere VoTiAn NG um Auftrags-, Stamm- und Verwaltungsfunktionen
Ergänzt Auftragserfassung und -bearbeitung, Unternehmerbelege, Stornos, Lagerkorrekturen, Stammdaten, Abrechnung, CRM, Formulare, Berichte und Preisverwaltung auf Basis der aktuellen PHP-Anwendung.

Enthält Integrations- und Oberflächentests sowie dokumentierte Abgleiche und Prüfstände.
2026-10-13 20:31:31 +02:00

147 lines
16 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package de.stadtbote.votian;
import de.stadtbote.votian.database.DatabaseService;
import jakarta.validation.Valid;
import jakarta.validation.Validator;
import jakarta.validation.constraints.*;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.*;
import static de.stadtbote.votian.AdminSupport.*;
import static de.stadtbote.votian.BusinessSupport.*;
import static de.stadtbote.votian.LegacyMapping.*;
import static de.stadtbote.votian.LegacyOrderPricing.*;
import static de.stadtbote.votian.OrderEntryModels.*;
import static de.stadtbote.votian.OrderEntryService.fields;
/** Reprices the saved route without replaying creation, stock movements or driver state. */
@RestController
@RequestMapping("/api/orders/{id}/calculation")
public class OrderRecalculation {
final DatabaseService db;
final BusinessSupport support;
final OrderOperations orders;
final OrderEntryCatalog catalog;
final LegacyOrderPricing pricing;
final OrderRoutingService routing;
final Validator validator;
public OrderRecalculation(DatabaseService db,BusinessSupport support,OrderOperations orders,OrderEntryCatalog catalog,LegacyOrderPricing pricing,OrderRoutingService routing,Validator validator) {
this.db=db;this.support=support;this.orders=orders;this.catalog=catalog;this.pricing=pricing;this.routing=routing;this.validator=validator;
}
public record Input(@NotNull @Valid Pricing pricing,@Min(0) int vehicleTypeId,
@Size(max=100) List<@Valid ServiceItem> services,boolean costSplit,boolean confirmCourierLimit,
@Size(max=20) List<@NotNull @Valid CourierShare> courierShares,String childrenRevision,
@Size(max=20) List<@NotNull @Min(0) Long> pairIds) {
public Input { services=services==null?List.of():List.copyOf(services);courierShares=courierShares==null?List.of():List.copyOf(courierShares); }
public Input(Pricing pricing,int vehicleTypeId,List<ServiceItem> services,boolean costSplit,boolean confirmCourierLimit){this(pricing,vehicleTypeId,services,costSplit,confirmCourierLimit,List.of(),null,null);}
public Input(Pricing pricing,int vehicleTypeId,List<ServiceItem> services,boolean costSplit,boolean confirmCourierLimit,List<CourierShare> shares,String revision){this(pricing,vehicleTypeId,services,costSplit,confirmCourierLimit,shares,revision,null);}
}
public record Command(String requestId,String revision,@NotNull @Valid Input input,String fingerprint) {}
record Context(OrderOperations.Snapshot snapshot,Request request,Map<String,Object> payer,List<OrderChildRecalculation.Pair> children) {}
@GetMapping public Object get(Authentication auth,@PathVariable long id) {return options(ApiController.user(auth),id);}
@PostMapping public Object preview(Authentication auth,@PathVariable long id,@Valid @RequestBody Command command) {return preview(ApiController.user(auth),id,command);}
@PutMapping public Object save(Authentication auth,@PathVariable long id,@Valid @RequestBody Command command) {return save(ApiController.user(auth),id,command);}
Map<String,Object> options(LegacyUser user,long id) {
user.require(4);user.require(7);
return db.readTransaction(()->{
var s=orders.snapshot(user,id,false);
var options=catalog.options(user);
long tax=options.taxes().stream().filter(t->t.label().equals(text(s.job(),"jb_sales_tax_rate_sign"))).mapToLong(Tax::id).findFirst().orElse(0);
PriceMode mode=s.prices().stream().anyMatch(p->text(p,"trs_srvt_name").equals("z"))?PriceMode.ZONE:positive(decimal(s.job(),"jb_fixprice"))?PriceMode.FIXED:PriceMode.TARIFF;
var previous=db.queryForList("SELECT gdc_content,gdc_context FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_calculation_input'",id);
var services=new OrderEntryTemplates(db,catalog).services(user,payerId(s),text(s.job(),"jb_freetext_2"));
if(!services.isEmpty())mode=PriceMode.SERVICES;
Input input=new Input(new Pricing(mode,decimal(s.job(),"jb_fixprice"),decimal(s.job(),"jb_serviceprice"),detail(s,2),detail(s,3),detail(s,8),detail(s,9),s.prices().stream().anyMatch(p->positive(decimal(p,"trs_discount"))),tax),(int)id(s.job(),"vht_id"),services,id(s.job(),"jb_payment")==1,false);
if(previous.size()==1&&text(previous.getFirst(),"gdc_context").equals(Long.toString(id(s.job(),"jb_version"))))try {input=support.json.readValue(text(previous.getFirst(),"gdc_content"),Input.class);}catch(Exception e){throw bad("Der gespeicherte Kalkulationsstand ist nicht lesbar.");}
if(!user.hasRight(38)){var p=input.pricing();input=new Input(new Pricing(p.mode(),p.fixedPrice(),p.servicePrice(),null,null,p.markup(),null,p.applyFixedDiscount(),p.taxId()),input.vehicleTypeId(),input.services(),input.costSplit(),false);}
var children=new OrderChildRecalculation(db,orders,support);var pairs=children.load(user,s,false);
var currentPricing=input.pricing();input=new Input(pairs.isEmpty()?currentPricing:new Pricing(currentPricing.mode(),currentPricing.fixedPrice(),currentPricing.servicePrice(),null,null,currentPricing.markup(),null,currentPricing.applyFixedDiscount(),currentPricing.taxId()),input.vehicleTypeId(),input.services(),input.costSplit(),false,children.shares(user,pairs),revision(pairs),pairs.stream().map(pair->id(pair.positive().job(),"jb_id")).toList());
return fields("children",pairs.stream().map(p->fields("positive",id(p.positive().job(),"jb_id"),"negative",id(p.negative().job(),"jb_id"),"sid",text(p.positive().job(),"cr_sid"))).toList(),"revision",revision(s),"input",input,"options",options,"services",catalog.services(user,payerId(s)),"paymentTerm",OrderPaymentTerms.read(db,id));
});
}
static BigDecimal detail(OrderOperations.Snapshot s,int kind) {var rows=s.priceDetails().stream().filter(p->id(p,"mt_sort")==kind).toList();return rows.size()==1?decimal(rows.getFirst(),"jbprc_price"):null;}
static long payerId(OrderOperations.Snapshot s) {return id(s.job(),"csc_id_payer_cash")>0?id(s.job(),"csc_id_payer_cash"):id(s.job(),"csc_id_payer");}
Context prepare(LegacyUser user,long id,Command command,boolean lock) {
user.require(4);user.require(7);
if(command==null||command.input()==null||!validator.validate(command).isEmpty())throw bad("Bitte die Kalkulationswerte prüfen.");
var s=orders.snapshot(user,id,lock);unchanged(s,command.revision());orders.writable(s);
var children=new OrderChildRecalculation(db,orders,support);var pairs=children.load(user,s,lock);children.validate(user,s,pairs,command.input());
if(!s.payments().isEmpty())throw bad("Vor der Neukalkulation müssen vorhandene Einnahmenbuchungen ausgeglichen werden.");
if(command.input().pricing().mode()!=PriceMode.SERVICES&&!command.input().services().isEmpty())throw bad("Für Leistungspositionen bitte Leistungskalkulation wählen.");
if(command.input().pricing().courierPrice()!=null||command.input().pricing().courierServicePrice()!=null||command.input().pricing().courierMarkup()!=null)user.require(38);
var payer=catalog.costCenter(user,payerId(s),id(s.job(),"jb_payment")!=2);
var job=s.job();var stops=new ArrayList<Station>();
for(var t:s.stops())stops.add(new Station(nullableId(t,"csc_id"),id(t,"tr_ware_from_to")==3?Models.StopType.BOTH:id(t,"tr_ware_from_to")==1?Models.StopType.DELIVERY:Models.StopType.PICKUP,text(t,"tr_comp"),text(t,"ad_street"),text(t,"tr_hsno"),text(t,"ad_zipcode"),text(t,"ad_city"),text(t,"ad_country").equals("D")?"DE":text(t,"ad_country"),text(t,"tr_person"),text(t,"tr_phone"),text(t,"tr_remark"),text(t,"tr_commission_no"),null,nullableId(t,"tr_mediationarea_id"),List.of()));
if(stops.isEmpty())throw bad("Der Auftrag besitzt keine Stationen.");
var vehicles=db.queryForList("SELECT crvh_id FROM couriervehicle WHERE cr_id=? AND crvh_sid=?",id(job,"cr_id"),text(job,"cr_sid"));
var toll=db.queryForList("SELECT gdc_content FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='jb_toll'",id);
if(toll.size()>1)throw bad("Mehrdeutige Mautposition.");
var data=fields("mode",id(job,"jb_status")==2?"RETROSPECTIVE":"IMMEDIATE","pickupAt",dateTime(job,"jb_ordertime"),"payerCostCenterId",payerId(s),"payment",id(job,"jb_payment")==2?"CASH":"INVOICE","cashStop",id(job,"jb_payment")==2?1:null,
"vehicleTypeId",command.input().vehicleTypeId(),"courierVehicleId",vehicles.size()==1?id(vehicles.getFirst(),"crvh_id"):null,"stops",stops,"orderer",text(job,"jb_orderer"),"reference",text(job,"jb_commission_no"),"weight",decimal(job,"jb_weight"),"longhaul",id(job,"jb_longhaul")==1?"AUTO":"ASAP",
"pricing",command.input().pricing(),"requestId",UUID.randomUUID().toString(),"services",command.input().services(),"copies",1,"courierShares",command.input().courierShares(),"costSplit",command.input().costSplit(),"toll",toll.isEmpty()?BigDecimal.ZERO:new BigDecimal(text(toll.getFirst(),"gdc_content")));
return new Context(s,support.json.convertValue(data,Request.class),payer,pairs);
}
Quote calculate(LegacyUser user,Context context,OrderRoutingService.Result route) {
var q=pricing.calculate(user,context.request(),context.payer(),dateTime(context.snapshot().job(),"jb_ordertime"),route,List.of());
return new OrderEntryAccounting(db,catalog).quote(user,context.request(),q,List.of());
}
Map<String,Object> preview(LegacyUser user,long id,Command command) {
var context=db.readTransaction(()->prepare(user,id,command,false));
var route=routing.resolve(user,context.request(),id(context.snapshot().job(),"hq_id_dispo"));
return db.readTransaction(()->{var current=prepare(user,id,command,false);var q=calculate(user,current,route);return fields("quote",q,"fingerprint",fingerprint(command,q),"pairChanges",new OrderChildRecalculation(db,orders,support).changes(current.children(),command.input()),"previousNet",decimal(current.snapshot().job(),"jb_totalprice"),"previousCourierNet",decimal(current.snapshot().job(),"jb_cr_price"));});
}
static String fingerprint(Command c,Quote q) {return revision(List.of(c.revision(),c.input(),q));}
Map<String,Object> save(LegacyUser user,long id,Command command) {
user.require(4);user.require(7);if(command==null)throw bad("Kalkulationsdaten fehlen.");token(command.requestId());
if(db.readTransaction(()->support.receipt(user,command.requestId(),List.of(id,command)))!=null)return orders.view(user,id);
var context=db.readTransaction(()->prepare(user,id,command,false));
var route=routing.resolve(user,context.request(),id(context.snapshot().job(),"hq_id_dispo"));
orders.transactional();
db.writeTransaction(()->{
lockHeadquarters(db,user);if(support.receipt(user,command.requestId(),List.of(id,command))!=null)return null;
var current=prepare(user,id,command,true);var q=calculate(user,current,route);
if(!fingerprint(command,q).equals(command.fingerprint()))throw bad("Die Preisvorschau hat sich geändert. Bitte neu berechnen.");
if(q.courierLimitExceeded()){user.require(39);if(!command.input().confirmCourierLimit())throw bad("Die Überschreitung der Frachtvergütungsgrenze muss bestätigt werden.");}
write(user,id,current,q);
new OrderChildRecalculation(db,orders,support).write(user,current.snapshot(),current.children(),command.input(),q,command.requestId());
metadata(id,"ng_calculation_input",support.encode(command.input()));
db.update("UPDATE genericdatacontainer SET gdc_context=? WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_calculation_input'",Long.toString(id(current.snapshot().job(),"jb_version")+1),id);
metadata(id,"ng_calculation_result",support.encode(q));
if(q.route()!=null)metadata(id,"ng_route_calculation",support.encode(q.route()));
support.audit(user,id,id(current.payer(),"cs_id"),id(current.snapshot().job(),"cr_id"),2,"Preis neu berechnet · "+command.input().pricing().mode()+" · Netto "+q.net()+" · Frachtvergütung "+q.courierNet(),command.requestId());
support.receipt(user,command.requestId(),List.of(id,command),id);return null;
});return orders.view(user,id);
}
void metadata(long id,String key,String value) {
db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname=?",id,key);
db.insert("genericdatacontainer",fields("gdc_obj_type","jb","gdc_obj_id",id,"gdc_gen_fieldname",key,"gdc_content",value,"gdc_context","RECALCULATE"));
}
void write(LegacyUser user,long id,Context context,Quote q) {
Request r=context.request();var job=context.snapshot().job();long payer=id(job,"csc_id_payer");
db.update("DELETE FROM tourservice WHERE jb_id=?",id);
int skip=r.pricing().mode()==PriceMode.SERVICES?r.services().size():0;
if(!q.batch()&&skip>0)component(id,payer,0,"Fixpreis","",q.components().stream().limit(skip).map(Component::net).reduce(BigDecimal.ZERO,BigDecimal::add),BigDecimal.ZERO);
if(!q.batch())for(var c:q.components().stream().skip(skip).toList())component(id,payer,c.sequence(),c.name(),c.type(),c.price(),c.discount());
if(r.pricing().mode()==PriceMode.ZONE&&!q.batch()){int n=0;for(var c:q.calculation())component(id,payer,++n,c.quantity().toPlainString()+" × "+c.name()+" à "+c.rate(),"z",BigDecimal.ZERO,BigDecimal.ZERO);}
db.update("DELETE FROM jobprice WHERE jb_id=? AND (mt_sort<11 OR mt_sort=15)",id);
price(id,r.pricing().courierMarkup()==null?1:9,q.courierMarkup());price(id,2,q.courierBase());price(id,3,q.courierService());price(id,5,q.courierDeduction());price(id,10,q.courierSubtotal());
if(r.pricing().markup()!=null)price(id,8,q.markup());if(positive(q.courierAgio()))price(id,7,q.courierAgio());
db.update("DELETE FROM genericdatacontainer WHERE gdc_obj_type='jb' AND gdc_obj_id=? AND gdc_gen_fieldname='ng_cost_split'",id);
new OrderEntryAccounting(db,catalog).split(id,q);
BigDecimal fixed=q.batch()?BigDecimal.ZERO:q.calculatedBase()!=null?q.calculatedBase():r.pricing().mode()==PriceMode.FIXED?r.pricing().fixedPrice():skip>0?q.components().stream().limit(skip).map(Component::subtotal).reduce(BigDecimal.ZERO,BigDecimal::add):BigDecimal.ZERO;
var update=fields("vht_id",r.vehicleTypeId(),"jb_payment",r.payment()==Payment.CASH?2:r.costSplit()?1:0,"jb_fixprice",fixed,"jb_serviceprice",q.components().stream().filter(c->c.name().equals("Servicepreis")).map(Component::price).reduce(BigDecimal.ZERO,BigDecimal::add),"jb_totalprice",q.net(),"jb_subtotalprice",q.subtotal(),"jb_cr_price",q.courierNet(),"jb_cr_subprice",q.courierBase(),"jb_markup",q.markup(),"jb_invmode",q.invoiceMode(),"jb_sales_tax_rate",q.taxRate(),"jb_sales_tax_rate_sign",q.taxSign(),"jb_version",id(job,"jb_version")+1,"jb_freetext_2",skip>0?new OrderEntryExtras(db,catalog).calculatorText(user,r,q):"");
// Repricing does not silently confirm unrelated missing references, emissions or customer checks.
if(q.batch())update.put("jb_incomplete",1);
BusinessSupport.update(db,"job","jb_id",id,update);
metadata(id,"jb_invtext_dirty","1");
if(id(job,"jb_status")==1)orders.command(user,id(job,"cr_id"),"5",Long.toString(id));
}
void component(long id,long payer,int sequence,String name,String type,BigDecimal price,BigDecimal discount) {
db.update("INSERT INTO tourservice (jb_id,csc_id,tr_sort,srv_id,trs_srv_name,srvt_id,trs_srvt_name,trs_price,trs_discount) VALUES (?,?,?,0,?,0,?,?,?)",id,payer,sequence,name.substring(0,Math.min(50,name.length())),type.substring(0,Math.min(50,type.length())),price,discount);
}
void price(long id,int type,BigDecimal value) {db.update("INSERT INTO jobprice (jb_id,mt_sort,jbprc_price,jbprc_remark) VALUES (?,?,?,'')",id,type,value);}
}