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.
This commit is contained in:
1 parent
42c87282ee
commit
363bc27c26
190 files changed
+14815
-1857
No files matched your search
@@ -0,0 +1,146 @@
|
||||
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);}
|
||||
}
|
||||
Reference in new issue
Block a user