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 services,boolean costSplit,boolean confirmCourierLimit){this(pricing,vehicleTypeId,services,costSplit,confirmCourierLimit,List.of(),null,null);} public Input(Pricing pricing,int vehicleTypeId,List services,boolean costSplit,boolean confirmCourierLimit,List 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 payer,List 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 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(); 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 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 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);} }