produzione 1 #9
@@ -18,10 +18,12 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -200,9 +202,8 @@ public class QuoteSessionController {
|
||||
|
||||
// Store breakdown
|
||||
Map<String, Object> breakdown = new HashMap<>();
|
||||
breakdown.put("machine_cost", result.getTotalPrice() - result.getSetupCost()); // Approximation?
|
||||
// Better: QuoteResult could expose detailed breakdown. For now just storing what we have.
|
||||
breakdown.put("setup_fee", result.getSetupCost());
|
||||
breakdown.put("machine_cost", result.getTotalPrice()); // Excludes setup fee which is at session level
|
||||
breakdown.put("setup_fee", 0);
|
||||
item.setPricingBreakdown(breakdown);
|
||||
|
||||
// Dimensions
|
||||
@@ -210,9 +211,9 @@ public class QuoteSessionController {
|
||||
// If GCodeParser doesn't return size, we might defaults or 0.
|
||||
// Stats has filament used.
|
||||
// Let's set dummy for now or upgrade parser later.
|
||||
item.setBoundingBoxXMm(BigDecimal.ZERO);
|
||||
item.setBoundingBoxYMm(BigDecimal.ZERO);
|
||||
item.setBoundingBoxZMm(BigDecimal.ZERO);
|
||||
item.setBoundingBoxXMm(settings.getBoundingBoxX() != null ? BigDecimal.valueOf(settings.getBoundingBoxX()) : BigDecimal.ZERO);
|
||||
item.setBoundingBoxYMm(settings.getBoundingBoxY() != null ? BigDecimal.valueOf(settings.getBoundingBoxY()) : BigDecimal.ZERO);
|
||||
item.setBoundingBoxZMm(settings.getBoundingBoxZ() != null ? BigDecimal.valueOf(settings.getBoundingBoxZ()) : BigDecimal.ZERO);
|
||||
|
||||
item.setCreatedAt(OffsetDateTime.now());
|
||||
item.setUpdatedAt(OffsetDateTime.now());
|
||||
@@ -245,7 +246,7 @@ public class QuoteSessionController {
|
||||
case "standard":
|
||||
default:
|
||||
settings.setLayerHeight(0.20);
|
||||
settings.setInfillDensity(20.0);
|
||||
settings.setInfillDensity(15.0);
|
||||
settings.setInfillPattern("grid");
|
||||
break;
|
||||
}
|
||||
@@ -308,20 +309,78 @@ public class QuoteSessionController {
|
||||
|
||||
List<QuoteLineItem> items = lineItemRepo.findByQuoteSessionId(id);
|
||||
|
||||
// Calculate Totals
|
||||
// Calculate Totals and global session hours
|
||||
BigDecimal itemsTotal = BigDecimal.ZERO;
|
||||
BigDecimal totalSeconds = BigDecimal.ZERO;
|
||||
|
||||
for (QuoteLineItem item : items) {
|
||||
BigDecimal lineTotal = item.getUnitPriceChf().multiply(BigDecimal.valueOf(item.getQuantity()));
|
||||
itemsTotal = itemsTotal.add(lineTotal);
|
||||
|
||||
if (item.getPrintTimeSeconds() != null) {
|
||||
totalSeconds = totalSeconds.add(BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(item.getQuantity())));
|
||||
}
|
||||
}
|
||||
|
||||
BigDecimal totalHours = totalSeconds.divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
|
||||
com.printcalculator.entity.PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
|
||||
BigDecimal globalMachineCost = quoteCalculator.calculateSessionMachineCost(policy, totalHours);
|
||||
|
||||
itemsTotal = itemsTotal.add(globalMachineCost);
|
||||
|
||||
// Map items to DTO to embed distributed machine cost
|
||||
List<Map<String, Object>> itemsDto = new ArrayList<>();
|
||||
for (QuoteLineItem item : items) {
|
||||
Map<String, Object> dto = new HashMap<>();
|
||||
dto.put("id", item.getId());
|
||||
dto.put("originalFilename", item.getOriginalFilename());
|
||||
dto.put("quantity", item.getQuantity());
|
||||
dto.put("printTimeSeconds", item.getPrintTimeSeconds());
|
||||
dto.put("materialGrams", item.getMaterialGrams());
|
||||
dto.put("colorCode", item.getColorCode());
|
||||
dto.put("status", item.getStatus());
|
||||
|
||||
BigDecimal unitPrice = item.getUnitPriceChf();
|
||||
if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) {
|
||||
BigDecimal itemSeconds = BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(item.getQuantity()));
|
||||
BigDecimal share = itemSeconds.divide(totalSeconds, 8, RoundingMode.HALF_UP);
|
||||
BigDecimal itemMachineCost = globalMachineCost.multiply(share);
|
||||
BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(item.getQuantity()), 2, RoundingMode.HALF_UP);
|
||||
unitPrice = unitPrice.add(unitMachineCost);
|
||||
}
|
||||
dto.put("unitPriceChf", unitPrice);
|
||||
itemsDto.add(dto);
|
||||
}
|
||||
|
||||
BigDecimal setupFee = session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO;
|
||||
BigDecimal grandTotal = itemsTotal.add(setupFee);
|
||||
|
||||
// Calculate shipping cost based on dimensions
|
||||
boolean exceedsBaseSize = false;
|
||||
for (QuoteLineItem item : items) {
|
||||
BigDecimal x = item.getBoundingBoxXMm() != null ? item.getBoundingBoxXMm() : BigDecimal.ZERO;
|
||||
BigDecimal y = item.getBoundingBoxYMm() != null ? item.getBoundingBoxYMm() : BigDecimal.ZERO;
|
||||
BigDecimal z = item.getBoundingBoxZMm() != null ? item.getBoundingBoxZMm() : BigDecimal.ZERO;
|
||||
|
||||
BigDecimal[] dims = {x, y, z};
|
||||
java.util.Arrays.sort(dims);
|
||||
|
||||
if (dims[2].compareTo(BigDecimal.valueOf(250.0)) > 0 ||
|
||||
dims[1].compareTo(BigDecimal.valueOf(176.0)) > 0 ||
|
||||
dims[0].compareTo(BigDecimal.valueOf(20.0)) > 0) {
|
||||
exceedsBaseSize = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
BigDecimal shippingCostChf = exceedsBaseSize ? BigDecimal.valueOf(4.00) : BigDecimal.valueOf(2.00);
|
||||
|
||||
BigDecimal grandTotal = itemsTotal.add(setupFee).add(shippingCostChf);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("session", session);
|
||||
response.put("items", items);
|
||||
response.put("itemsTotalChf", itemsTotal);
|
||||
response.put("items", itemsDto);
|
||||
response.put("itemsTotalChf", itemsTotal); // Includes the base cost of all items + the global tiered machine cost
|
||||
response.put("shippingCostChf", shippingCostChf);
|
||||
response.put("globalMachineCostChf", globalMachineCost); // Provide it so frontend knows how much it was (optional now)
|
||||
response.put("grandTotalChf", grandTotal);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
@@ -21,4 +21,9 @@ public class PrintSettingsDto {
|
||||
private String infillPattern;
|
||||
private Boolean supportsEnabled;
|
||||
private String notes;
|
||||
|
||||
// Dimensions
|
||||
private Double boundingBoxX;
|
||||
private Double boundingBoxY;
|
||||
private Double boundingBoxZ;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,15 @@ public class OrderItem {
|
||||
@Column(name = "material_grams", precision = 12, scale = 2)
|
||||
private BigDecimal materialGrams;
|
||||
|
||||
@Column(name = "bounding_box_x_mm", precision = 10, scale = 3)
|
||||
private BigDecimal boundingBoxXMm;
|
||||
|
||||
@Column(name = "bounding_box_y_mm", precision = 10, scale = 3)
|
||||
private BigDecimal boundingBoxYMm;
|
||||
|
||||
@Column(name = "bounding_box_z_mm", precision = 10, scale = 3)
|
||||
private BigDecimal boundingBoxZMm;
|
||||
|
||||
@Column(name = "unit_price_chf", nullable = false, precision = 12, scale = 2)
|
||||
private BigDecimal unitPriceChf;
|
||||
|
||||
@@ -181,6 +190,30 @@ public class OrderItem {
|
||||
this.materialGrams = materialGrams;
|
||||
}
|
||||
|
||||
public BigDecimal getBoundingBoxXMm() {
|
||||
return boundingBoxXMm;
|
||||
}
|
||||
|
||||
public void setBoundingBoxXMm(BigDecimal boundingBoxXMm) {
|
||||
this.boundingBoxXMm = boundingBoxXMm;
|
||||
}
|
||||
|
||||
public BigDecimal getBoundingBoxYMm() {
|
||||
return boundingBoxYMm;
|
||||
}
|
||||
|
||||
public void setBoundingBoxYMm(BigDecimal boundingBoxYMm) {
|
||||
this.boundingBoxYMm = boundingBoxYMm;
|
||||
}
|
||||
|
||||
public BigDecimal getBoundingBoxZMm() {
|
||||
return boundingBoxZMm;
|
||||
}
|
||||
|
||||
public void setBoundingBoxZMm(BigDecimal boundingBoxZMm) {
|
||||
this.boundingBoxZMm = boundingBoxZMm;
|
||||
}
|
||||
|
||||
public BigDecimal getUnitPriceChf() {
|
||||
return unitPriceChf;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,10 @@ public class QuoteResult {
|
||||
private double totalPrice;
|
||||
private String currency;
|
||||
private PrintStats stats;
|
||||
private double setupCost;
|
||||
|
||||
public QuoteResult(double totalPrice, String currency, PrintStats stats, double setupCost) {
|
||||
public QuoteResult(double totalPrice, String currency, PrintStats stats) {
|
||||
this.totalPrice = totalPrice;
|
||||
this.currency = currency;
|
||||
this.stats = stats;
|
||||
this.setupCost = setupCost;
|
||||
}
|
||||
|
||||
public double getTotalPrice() {
|
||||
@@ -24,8 +21,4 @@ public class QuoteResult {
|
||||
public PrintStats getStats() {
|
||||
return stats;
|
||||
}
|
||||
|
||||
public double getSetupCost() {
|
||||
return setupCost;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import com.printcalculator.repository.OrderItemRepository;
|
||||
import com.printcalculator.repository.OrderRepository;
|
||||
import com.printcalculator.repository.QuoteLineItemRepository;
|
||||
import com.printcalculator.repository.QuoteSessionRepository;
|
||||
import com.printcalculator.repository.PricingPolicyRepository;
|
||||
import com.printcalculator.service.QuoteCalculator;
|
||||
import com.printcalculator.event.OrderCreatedEvent;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -15,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -37,6 +40,8 @@ public class OrderService {
|
||||
private final QrBillService qrBillService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final PaymentService paymentService;
|
||||
private final QuoteCalculator quoteCalculator;
|
||||
private final PricingPolicyRepository pricingRepo;
|
||||
|
||||
public OrderService(OrderRepository orderRepo,
|
||||
OrderItemRepository orderItemRepo,
|
||||
@@ -47,7 +52,9 @@ public class OrderService {
|
||||
InvoicePdfRenderingService invoiceService,
|
||||
QrBillService qrBillService,
|
||||
ApplicationEventPublisher eventPublisher,
|
||||
PaymentService paymentService) {
|
||||
PaymentService paymentService,
|
||||
QuoteCalculator quoteCalculator,
|
||||
PricingPolicyRepository pricingRepo) {
|
||||
this.orderRepo = orderRepo;
|
||||
this.orderItemRepo = orderItemRepo;
|
||||
this.quoteSessionRepo = quoteSessionRepo;
|
||||
@@ -58,6 +65,8 @@ public class OrderService {
|
||||
this.qrBillService = qrBillService;
|
||||
this.eventPublisher = eventPublisher;
|
||||
this.paymentService = paymentService;
|
||||
this.quoteCalculator = quoteCalculator;
|
||||
this.pricingRepo = pricingRepo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -145,12 +154,41 @@ public class OrderService {
|
||||
order.setTotalChf(BigDecimal.ZERO);
|
||||
order.setDiscountChf(BigDecimal.ZERO);
|
||||
order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO);
|
||||
order.setShippingCostChf(BigDecimal.valueOf(9.00));
|
||||
|
||||
// Calculate shipping cost based on dimensions before initial save
|
||||
boolean exceedsBaseSize = false;
|
||||
for (QuoteLineItem item : quoteItems) {
|
||||
BigDecimal x = item.getBoundingBoxXMm() != null ? item.getBoundingBoxXMm() : BigDecimal.ZERO;
|
||||
BigDecimal y = item.getBoundingBoxYMm() != null ? item.getBoundingBoxYMm() : BigDecimal.ZERO;
|
||||
BigDecimal z = item.getBoundingBoxZMm() != null ? item.getBoundingBoxZMm() : BigDecimal.ZERO;
|
||||
|
||||
BigDecimal[] dims = {x, y, z};
|
||||
java.util.Arrays.sort(dims);
|
||||
|
||||
if (dims[2].compareTo(BigDecimal.valueOf(250.0)) > 0 ||
|
||||
dims[1].compareTo(BigDecimal.valueOf(176.0)) > 0 ||
|
||||
dims[0].compareTo(BigDecimal.valueOf(20.0)) > 0) {
|
||||
exceedsBaseSize = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
order.setShippingCostChf(exceedsBaseSize ? BigDecimal.valueOf(4.00) : BigDecimal.valueOf(2.00));
|
||||
|
||||
order = orderRepo.save(order);
|
||||
|
||||
List<OrderItem> savedItems = new ArrayList<>();
|
||||
|
||||
// Calculate global machine cost upfront
|
||||
BigDecimal totalSeconds = BigDecimal.ZERO;
|
||||
for (QuoteLineItem qItem : quoteItems) {
|
||||
if (qItem.getPrintTimeSeconds() != null) {
|
||||
totalSeconds = totalSeconds.add(BigDecimal.valueOf(qItem.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(qItem.getQuantity())));
|
||||
}
|
||||
}
|
||||
BigDecimal totalHours = totalSeconds.divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
|
||||
PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
|
||||
BigDecimal globalMachineCost = quoteCalculator.calculateSessionMachineCost(policy, totalHours);
|
||||
|
||||
for (QuoteLineItem qItem : quoteItems) {
|
||||
OrderItem oItem = new OrderItem();
|
||||
oItem.setOrder(order);
|
||||
@@ -159,10 +197,22 @@ public class OrderService {
|
||||
oItem.setColorCode(qItem.getColorCode());
|
||||
oItem.setMaterialCode(session.getMaterialCode());
|
||||
|
||||
oItem.setUnitPriceChf(qItem.getUnitPriceChf());
|
||||
oItem.setLineTotalChf(qItem.getUnitPriceChf().multiply(BigDecimal.valueOf(qItem.getQuantity())));
|
||||
BigDecimal distributedUnitPrice = qItem.getUnitPriceChf();
|
||||
if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && qItem.getPrintTimeSeconds() != null) {
|
||||
BigDecimal itemSeconds = BigDecimal.valueOf(qItem.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(qItem.getQuantity()));
|
||||
BigDecimal share = itemSeconds.divide(totalSeconds, 8, RoundingMode.HALF_UP);
|
||||
BigDecimal itemMachineCost = globalMachineCost.multiply(share);
|
||||
BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(qItem.getQuantity()), 2, RoundingMode.HALF_UP);
|
||||
distributedUnitPrice = distributedUnitPrice.add(unitMachineCost);
|
||||
}
|
||||
|
||||
oItem.setUnitPriceChf(distributedUnitPrice);
|
||||
oItem.setLineTotalChf(distributedUnitPrice.multiply(BigDecimal.valueOf(qItem.getQuantity())));
|
||||
oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds());
|
||||
oItem.setMaterialGrams(qItem.getMaterialGrams());
|
||||
oItem.setBoundingBoxXMm(qItem.getBoundingBoxXMm());
|
||||
oItem.setBoundingBoxYMm(qItem.getBoundingBoxYMm());
|
||||
oItem.setBoundingBoxZMm(qItem.getBoundingBoxZMm());
|
||||
|
||||
UUID fileUuid = UUID.randomUUID();
|
||||
String ext = getExtension(qItem.getOriginalFilename());
|
||||
@@ -196,9 +246,6 @@ public class OrderService {
|
||||
}
|
||||
|
||||
order.setSubtotalChf(subtotal);
|
||||
if (order.getShippingCostChf() == null) {
|
||||
order.setShippingCostChf(BigDecimal.valueOf(9.00));
|
||||
}
|
||||
|
||||
BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO);
|
||||
order.setTotalChf(total);
|
||||
|
||||
@@ -79,25 +79,27 @@ public class QuoteCalculator {
|
||||
BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
|
||||
BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg());
|
||||
|
||||
// Machine Cost: Tiered
|
||||
// We do NOT add tiered machine cost here anymore - it is calculated globally per session
|
||||
BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
|
||||
BigDecimal machineCost = calculateMachineCost(policy, totalHours);
|
||||
|
||||
// Energy Cost: (watts / 1000) * hours * costPerKwh
|
||||
BigDecimal kw = BigDecimal.valueOf(machine.getPowerWatts()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
|
||||
BigDecimal kwh = kw.multiply(totalHours);
|
||||
BigDecimal energyCost = kwh.multiply(policy.getElectricityCostChfPerKwh());
|
||||
|
||||
// Subtotal (Costs + Fixed Fees)
|
||||
BigDecimal fixedFee = policy.getFixedJobFeeChf();
|
||||
BigDecimal subtotal = materialCost.add(machineCost).add(energyCost).add(fixedFee);
|
||||
// Subtotal (Costs without Fixed Fees and without Machine Tiers)
|
||||
BigDecimal subtotal = materialCost.add(energyCost);
|
||||
|
||||
// Markup
|
||||
// Markup is percentage (e.g. 20.0)
|
||||
BigDecimal markupFactor = BigDecimal.ONE.add(policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP));
|
||||
BigDecimal totalPrice = subtotal.multiply(markupFactor).setScale(2, RoundingMode.HALF_UP);
|
||||
|
||||
return new QuoteResult(totalPrice.doubleValue(), "CHF", stats, fixedFee.doubleValue());
|
||||
subtotal = subtotal.multiply(markupFactor);
|
||||
return new QuoteResult(subtotal.doubleValue(), "CHF", stats);
|
||||
}
|
||||
public BigDecimal calculateSessionMachineCost(PricingPolicy policy, BigDecimal hours) {
|
||||
BigDecimal rawCost = calculateMachineCost(policy, hours);
|
||||
BigDecimal markupFactor = BigDecimal.ONE.add(policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP));
|
||||
return rawCost.multiply(markupFactor).setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private BigDecimal calculateMachineCost(PricingPolicy policy, BigDecimal hours) {
|
||||
|
||||
5
db.sql
5
db.sql
@@ -535,6 +535,11 @@ CREATE TABLE IF NOT EXISTS order_items
|
||||
-- Snapshot output
|
||||
print_time_seconds integer CHECK (print_time_seconds >= 0),
|
||||
material_grams numeric(12, 2) CHECK (material_grams >= 0),
|
||||
|
||||
bounding_box_x_mm numeric(10, 3),
|
||||
bounding_box_y_mm numeric(10, 3),
|
||||
bounding_box_z_mm numeric(10, 3),
|
||||
|
||||
unit_price_chf numeric(12, 2) NOT NULL CHECK (unit_price_chf >= 0),
|
||||
line_total_chf numeric(12, 2) NOT NULL CHECK (line_total_chf >= 0),
|
||||
|
||||
|
||||
@@ -208,9 +208,31 @@ export class CalculatorPageComponent implements OnInit {
|
||||
|
||||
// 2. Update backend session if ID exists
|
||||
if (event.id) {
|
||||
const currentSessionId = this.result()?.sessionId;
|
||||
if (!currentSessionId) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.estimator.updateLineItem(event.id, { quantity: event.quantity }).subscribe({
|
||||
next: (res) => console.log('Line item updated', res),
|
||||
error: (err) => console.error('Failed to update line item', err)
|
||||
next: () => {
|
||||
// 3. Fetch the updated session totals from the backend
|
||||
this.estimator.getQuoteSession(currentSessionId).subscribe({
|
||||
next: (sessionData) => {
|
||||
const newResult = this.estimator.mapSessionToQuoteResult(sessionData);
|
||||
// Preserve notes
|
||||
newResult.notes = this.result()?.notes;
|
||||
this.result.set(newResult);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to refresh session totals', err);
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to update line item', err);
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
</div>
|
||||
|
||||
<div class="setup-note">
|
||||
<small>{{ 'CALC.SETUP_NOTE' | translate:{cost: (result().setupCost | currency:result().currency)} }}</small>
|
||||
<small>{{ 'CALC.SETUP_NOTE' | translate:{cost: (result().setupCost | currency:result().currency)} }}</small><br>
|
||||
<small class="shipping-note" style="color: #666;">{{ 'CALC.SHIPPING_NOTE' | translate }}</small>
|
||||
</div>
|
||||
|
||||
@if (result().notes) {
|
||||
|
||||
@@ -10,11 +10,15 @@ import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl
|
||||
import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component';
|
||||
import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, MaterialOption, VariantOption } from '../../services/quote-estimator.service';
|
||||
import { getColorHex } from '../../../../core/constants/colors.const';
|
||||
import * as THREE from 'three';
|
||||
// @ts-ignore
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
|
||||
|
||||
interface FormItem {
|
||||
file: File;
|
||||
quantity: number;
|
||||
color: string;
|
||||
dimensions?: {x: number, y: number, z: number};
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -69,6 +73,35 @@ export class UploadFormComponent implements OnInit {
|
||||
return name.endsWith('.stl');
|
||||
}
|
||||
|
||||
private async getStlDimensions(file: File): Promise<{x: number, y: number, z: number} | null> {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const loader = new STLLoader();
|
||||
const geometry = loader.parse(e.target?.result as ArrayBuffer);
|
||||
geometry.computeBoundingBox();
|
||||
if (geometry.boundingBox) {
|
||||
const size = new THREE.Vector3();
|
||||
geometry.boundingBox.getSize(size);
|
||||
resolve({
|
||||
x: Math.round(size.x * 10) / 10,
|
||||
y: Math.round(size.y * 10) / 10,
|
||||
z: Math.round(size.z * 10) / 10
|
||||
});
|
||||
return;
|
||||
}
|
||||
resolve(null);
|
||||
} catch (err) {
|
||||
console.error("Error parsing STL for dimensions:", err);
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.form = this.fb.group({
|
||||
itemsTouched: [false], // Hack to track touched state for custom items list
|
||||
@@ -77,7 +110,7 @@ export class UploadFormComponent implements OnInit {
|
||||
items: [[]], // Track items in form for validation if needed
|
||||
notes: [''],
|
||||
// Advanced fields
|
||||
infillDensity: [20, [Validators.min(0), Validators.max(100)]],
|
||||
infillDensity: [15, [Validators.min(0), Validators.max(100)]],
|
||||
layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]],
|
||||
nozzleDiameter: [0.4, Validators.required],
|
||||
infillPattern: ['grid'],
|
||||
@@ -136,7 +169,7 @@ export class UploadFormComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
onFilesDropped(newFiles: File[]) {
|
||||
async onFilesDropped(newFiles: File[]) {
|
||||
const MAX_SIZE = 200 * 1024 * 1024; // 200MB
|
||||
const validItems: FormItem[] = [];
|
||||
let hasError = false;
|
||||
@@ -145,8 +178,13 @@ export class UploadFormComponent implements OnInit {
|
||||
if (file.size > MAX_SIZE) {
|
||||
hasError = true;
|
||||
} else {
|
||||
let dimensions = undefined;
|
||||
if (file.name.toLowerCase().endsWith('.stl')) {
|
||||
const dims = await this.getStlDimensions(file);
|
||||
if (dims) dimensions = dims;
|
||||
}
|
||||
// Default color is Black
|
||||
validItems.push({ file, quantity: 1, color: 'Black' });
|
||||
validItems.push({ file, quantity: 1, color: 'Black', dimensions });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,11 +276,16 @@ export class UploadFormComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
setFiles(files: File[]) {
|
||||
async setFiles(files: File[]) {
|
||||
const validItems: FormItem[] = [];
|
||||
for (const file of files) {
|
||||
let dimensions = undefined;
|
||||
if (file.name.toLowerCase().endsWith('.stl')) {
|
||||
const dims = await this.getStlDimensions(file);
|
||||
if (dims) dimensions = dims;
|
||||
}
|
||||
// Default color is Black or derive from somewhere if possible, but here we just init
|
||||
validItems.push({ file, quantity: 1, color: 'Black' });
|
||||
validItems.push({ file, quantity: 1, color: 'Black', dimensions });
|
||||
}
|
||||
|
||||
if (validItems.length > 0) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { map, catchError, tap } from 'rxjs/operators';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
|
||||
export interface QuoteRequest {
|
||||
items: { file: File, quantity: number, color?: string }[];
|
||||
items: { file: File, quantity: number, color?: string, dimensions?: {x: number, y: number, z: number} }[];
|
||||
material: string;
|
||||
quality: string;
|
||||
notes?: string;
|
||||
@@ -32,6 +32,7 @@ export interface QuoteResult {
|
||||
sessionId?: string;
|
||||
items: QuoteItem[];
|
||||
setupCost: number;
|
||||
globalMachineCost: number;
|
||||
currency: string;
|
||||
totalPrice: number;
|
||||
totalTimeHours: number;
|
||||
@@ -219,6 +220,9 @@ export class QuoteEstimatorService {
|
||||
quality: request.quality,
|
||||
supportsEnabled: request.supportEnabled,
|
||||
color: item.color || '#FFFFFF',
|
||||
boundingBoxX: item.dimensions?.x,
|
||||
boundingBoxY: item.dimensions?.y,
|
||||
boundingBoxZ: item.dimensions?.z,
|
||||
layerHeight: request.mode === 'advanced' ? request.layerHeight : null,
|
||||
infillDensity: request.mode === 'advanced' ? request.infillDensity : null,
|
||||
infillPattern: request.mode === 'advanced' ? request.infillPattern : null,
|
||||
@@ -260,59 +264,19 @@ export class QuoteEstimatorService {
|
||||
});
|
||||
|
||||
const finalize = (responses: any[], setupCost: number, sessionId: string) => {
|
||||
observer.next(100);
|
||||
const items: QuoteItem[] = [];
|
||||
let grandTotal = 0;
|
||||
let totalTime = 0;
|
||||
let totalWeight = 0;
|
||||
let validCount = 0;
|
||||
|
||||
responses.forEach((res, idx) => {
|
||||
if (!res || !res.success) return;
|
||||
validCount++;
|
||||
|
||||
const unitPrice = res.unitPriceChf || 0;
|
||||
const quantity = res.originalQty || 1;
|
||||
|
||||
items.push({
|
||||
id: res.id,
|
||||
fileName: res.fileName,
|
||||
unitPrice: unitPrice,
|
||||
unitTime: res.printTimeSeconds || 0,
|
||||
unitWeight: res.materialGrams || 0,
|
||||
quantity: quantity,
|
||||
material: request.material,
|
||||
color: res.originalItem.color || 'Default'
|
||||
// Store ID if needed for updates? QuoteItem interface might need update
|
||||
// or we map it in component
|
||||
});
|
||||
|
||||
grandTotal += unitPrice * quantity;
|
||||
totalTime += (res.printTimeSeconds || 0) * quantity;
|
||||
totalWeight += (res.materialGrams || 0) * quantity;
|
||||
this.http.get<any>(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { headers }).subscribe({
|
||||
next: (sessionData) => {
|
||||
observer.next(100);
|
||||
const result = this.mapSessionToQuoteResult(sessionData);
|
||||
result.notes = request.notes;
|
||||
observer.next(result);
|
||||
observer.complete();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to fetch final session calculation', err);
|
||||
observer.error('Failed to calculate final quote');
|
||||
}
|
||||
});
|
||||
|
||||
if (validCount === 0) {
|
||||
observer.error('All calculations failed.');
|
||||
return;
|
||||
}
|
||||
|
||||
grandTotal += setupCost;
|
||||
|
||||
const result: QuoteResult = {
|
||||
sessionId: sessionId,
|
||||
items,
|
||||
setupCost: setupCost,
|
||||
currency: 'CHF',
|
||||
totalPrice: Math.round(grandTotal * 100) / 100,
|
||||
totalTimeHours: Math.floor(totalTime / 3600),
|
||||
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
|
||||
totalWeight: Math.ceil(totalWeight),
|
||||
notes: request.notes
|
||||
};
|
||||
|
||||
observer.next(result);
|
||||
observer.complete();
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -361,10 +325,11 @@ export class QuoteEstimatorService {
|
||||
// But line items might have different colors.
|
||||
color: item.colorCode
|
||||
})),
|
||||
setupCost: session.setupCostChf,
|
||||
currency: 'CHF', // Fixed for now
|
||||
totalPrice: sessionData.grandTotalChf,
|
||||
totalTimeHours: Math.floor(totalTime / 3600),
|
||||
setupCost: session.setupCostChf || 0,
|
||||
globalMachineCost: sessionData.globalMachineCostChf || 0,
|
||||
currency: 'CHF', // Fixed for now
|
||||
totalPrice: (sessionData.itemsTotalChf || 0) + (session.setupCostChf || 0) + (sessionData.shippingCostChf || 0),
|
||||
totalTimeHours: Math.floor(totalTime / 3600),
|
||||
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
|
||||
totalWeight: Math.ceil(totalWeight),
|
||||
notes: session.notes
|
||||
|
||||
@@ -143,11 +143,11 @@
|
||||
</div>
|
||||
<div class="total-row">
|
||||
<span>{{ 'CHECKOUT.SHIPPING' | translate }}</span>
|
||||
<span>{{ 9.00 | currency:'CHF' }}</span>
|
||||
<span>{{ session.shippingCostChf | currency:'CHF' }}</span>
|
||||
</div>
|
||||
<div class="grand-total">
|
||||
<span>{{ 'CHECKOUT.TOTAL' | translate }}</span>
|
||||
<span>{{ (session.grandTotalChf + 9.00) | currency:'CHF' }}</span>
|
||||
<span>{{ session.grandTotalChf | currency:'CHF' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"CALCULATE": "Calculate Quote",
|
||||
"RESULT": "Estimated Quote",
|
||||
"TIME": "Print Time",
|
||||
"MACHINE_COST": "Machine Cost",
|
||||
"COST": "Total Cost",
|
||||
"ORDER": "Order Now",
|
||||
"CONSULT": "Request Consultation",
|
||||
@@ -178,6 +179,8 @@
|
||||
"PROCESSING": "Processing...",
|
||||
"SUMMARY_TITLE": "Order Summary",
|
||||
"SUBTOTAL": "Subtotal",
|
||||
"ITEMS_BASE_SUBTOTAL": "Items Subtotal",
|
||||
"MACHINE_COST": "Machine Cost",
|
||||
"SETUP_FEE": "Setup Fee",
|
||||
"TOTAL": "Total",
|
||||
"QTY": "Qty",
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"CALCULATE": "Calcola Preventivo",
|
||||
"RESULT": "Preventivo Stimato",
|
||||
"TIME": "Tempo Stampa",
|
||||
"MACHINE_COST": "Costo Macchina",
|
||||
"COST": "Costo Totale",
|
||||
"ORDER": "Ordina Ora",
|
||||
"CONSULT": "Richiedi Consulenza",
|
||||
@@ -102,6 +103,7 @@
|
||||
"PROCESSING": "Elaborazione...",
|
||||
"NOTES_PLACEHOLDER": "Istruzioni specifiche...",
|
||||
"SETUP_NOTE": "* Include {{cost}} Costo di Setup",
|
||||
"SHIPPING_NOTE": "** costi di spedizione esclusi calcolati al prossimo passaggio",
|
||||
"STEP_WARNING": "La visualizzazione 3D non è compatibile con i file step e 3mf"
|
||||
},
|
||||
"QUOTE": {
|
||||
@@ -167,7 +169,7 @@
|
||||
},
|
||||
"LOCATIONS": {
|
||||
"TITLE": "Le Nostre Sedi",
|
||||
"SUBTITLE": "Siamo presenti in due sedi per coprire meglio il territorio. Seleziona la sede per vedere i dettagli.",
|
||||
"SUBTITLE": "Siamo presenti in due sedi. Seleziona la sede per vedere i dettagli.",
|
||||
"TICINO": "Ticino",
|
||||
"BIENNE": "Bienne",
|
||||
"ADDRESS_TICINO": "Via G. Pioda 29a, 6710 Biasca (TI)",
|
||||
@@ -244,19 +246,23 @@
|
||||
"PROCESSING": "Elaborazione...",
|
||||
"SUMMARY_TITLE": "Riepilogo Ordine",
|
||||
"SUBTOTAL": "Subtotale",
|
||||
"ITEMS_BASE_SUBTOTAL": "Costo Base Articoli",
|
||||
"MACHINE_COST": "Costo Macchina",
|
||||
"SETUP_FEE": "Costo di Avvio",
|
||||
"TOTAL": "Totale",
|
||||
"QTY": "Qtà",
|
||||
"SHIPPING": "Spedizione",
|
||||
"SHIPPING": "Spedizione (CH)",
|
||||
"INVALID_EMAIL": "Email non valida",
|
||||
"COMPANY_OPTIONAL": "Nome Azienda (Opzionale)",
|
||||
"REF_PERSON_OPTIONAL": "Persona di Riferimento (Opzionale)"
|
||||
"REF_PERSON_OPTIONAL": "Persona di Riferimento (Opzionale)",
|
||||
"SHIPPING_CALCULATED_NEXT_STEP": "il costo di spedizione viene calcolato al prossimo passaggio",
|
||||
"EXCLUDES_SHIPPING": "Escluso costo di spedizione"
|
||||
},
|
||||
"PAYMENT": {
|
||||
"TITLE": "Pagamento",
|
||||
"METHOD": "Metodo di Pagamento",
|
||||
"TWINT_TITLE": "Paga con TWINT",
|
||||
"TWINT_DESC": "Inquadra il codice con l'app TWINT",
|
||||
"TWINT_DESC": "Inquadra il codice con l'app TWINT, da mobile clicca il bottone.",
|
||||
"TWINT_OPEN": "Apri direttamente in TWINT",
|
||||
"TWINT_LINK": "Apri link di pagamento",
|
||||
"BANK_TITLE": "Bonifico Bancario",
|
||||
|
||||
Reference in New Issue
Block a user