|
@@ -397,11 +397,46 @@ watch(selectedMaterial, (newVal) => {
|
|
|
selectedColor.value = "";
|
|
selectedColor.value = "";
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
|
|
+// --- PRICING CONSTANTS (Must match backend/services/pricing.py) ---
|
|
|
|
|
+const PRICING_CONFIG = {
|
|
|
|
|
+ BASE_FEE: 0.0,
|
|
|
|
|
+ VOLUME_PROXY_COEFF: 8.0, // cm3 per 1MB of STL
|
|
|
|
|
+ MAX_DISCOUNT: 0.20,
|
|
|
|
|
+ DISCOUNT_PER_ITEM: 0.05
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
const estimatedPrice = computed(() => {
|
|
const estimatedPrice = computed(() => {
|
|
|
if (files.value.length === 0) return null;
|
|
if (files.value.length === 0) return null;
|
|
|
- const total = files.value.reduce((acc, f) => acc + ((f.basePrice || 0) * f.quantity), 0);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ const mat = materials.value.find(m => String(m.id) === selectedMaterial.value);
|
|
|
|
|
+ if (!mat) return null;
|
|
|
|
|
+
|
|
|
|
|
+ const totalQty = files.value.reduce((acc, f) => acc + f.quantity, 0);
|
|
|
|
|
+ const discountMultiplier = totalQty > 1 ? 1 - Math.min((totalQty - 1) * PRICING_CONFIG.DISCOUNT_PER_ITEM, PRICING_CONFIG.MAX_DISCOUNT) : 1.0;
|
|
|
|
|
+
|
|
|
|
|
+ let total = 0;
|
|
|
|
|
+ files.value.forEach(f => {
|
|
|
|
|
+ const sizeMb = f.size / (1024 * 1024);
|
|
|
|
|
+ const volumeCm3 = sizeMb * PRICING_CONFIG.VOLUME_PROXY_COEFF;
|
|
|
|
|
+ const hours = ((volumeCm3 * 100.0) / 3600.0) * (mat.speed_coeff || 1.0);
|
|
|
|
|
+
|
|
|
|
|
+ const materialCost = volumeCm3 * (mat.price_per_cm3 || 0);
|
|
|
|
|
+ const operationCost = hours * (mat.effective_price_per_hour || 0);
|
|
|
|
|
+
|
|
|
|
|
+ const unitPrice = (PRICING_CONFIG.BASE_FEE + materialCost + operationCost) * discountMultiplier;
|
|
|
|
|
+ f.basePrice = unitPrice; // Store for per-file display
|
|
|
|
|
+ total += unitPrice * f.quantity;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Update metadata for display
|
|
|
|
|
+ estimateMetadata.value = {
|
|
|
|
|
+ speed_coeff: mat.speed_coeff,
|
|
|
|
|
+ discount_percent: Math.round((1 - discountMultiplier) * 100)
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
return parseFloat(total.toFixed(2));
|
|
return parseFloat(total.toFixed(2));
|
|
|
});
|
|
});
|
|
|
|
|
+
|
|
|
const notes = ref("");
|
|
const notes = ref("");
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
onMounted(async () => {
|
|
@@ -427,29 +462,11 @@ onMounted(async () => {
|
|
|
} catch (e) { console.error("Failed to load initial data:", e); }
|
|
} catch (e) { console.error("Failed to load initial data:", e); }
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
-import { useDebounceFn } from "@vueuse/core";
|
|
|
|
|
-
|
|
|
|
|
-const updatePriceEstimate = useDebounceFn(async () => {
|
|
|
|
|
- if ((files.value.length > 0) && selectedMaterial.value) {
|
|
|
|
|
- try {
|
|
|
|
|
- const res = await getPriceEstimate({
|
|
|
|
|
- material_id: parseInt(selectedMaterial.value),
|
|
|
|
|
- file_sizes: files.value.map(f => f.size),
|
|
|
|
|
- file_quantities: files.value.map(f => f.quantity),
|
|
|
|
|
- });
|
|
|
|
|
- if (res.file_prices && Array.isArray(res.file_prices)) {
|
|
|
|
|
- files.value.forEach((f, i) => {
|
|
|
|
|
- f.basePrice = res.file_prices[i];
|
|
|
|
|
- });
|
|
|
|
|
- estimateMetadata.value = res.metadata;
|
|
|
|
|
- }
|
|
|
|
|
- } catch { /* silent */ }
|
|
|
|
|
- }
|
|
|
|
|
-}, 500);
|
|
|
|
|
-
|
|
|
|
|
-watch([() => files.value.map(f => f.quantity), selectedMaterial, modelLink], () => {
|
|
|
|
|
- updatePriceEstimate();
|
|
|
|
|
-}, { deep: true });
|
|
|
|
|
|
|
+// We still keep the watcher for files.length to ensure everything is initialized,
|
|
|
|
|
+// but we don't need to poll the server for quantity/material changes anymore.
|
|
|
|
|
+watch(() => files.value.length, () => {
|
|
|
|
|
+ // Logic is now in computed estimatedPrice
|
|
|
|
|
+}, { immediate: true });
|
|
|
|
|
|
|
|
const isFormValid = computed(() =>
|
|
const isFormValid = computed(() =>
|
|
|
firstName.value.trim() !== "" && lastName.value.trim() !== "" &&
|
|
firstName.value.trim() !== "" && lastName.value.trim() !== "" &&
|