init
This commit is contained in:
@@ -52,6 +52,7 @@ async def advisor(request: AdvisorRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
materials_data=materials_data,
|
materials_data=materials_data,
|
||||||
budget_preference=request.budget_preference,
|
budget_preference=request.budget_preference,
|
||||||
file_info=request.file_info,
|
file_info=request.file_info,
|
||||||
|
db=db,
|
||||||
)
|
)
|
||||||
logger.info("AI advisor returned recommendation: material_id=%s, name=%s",
|
logger.info("AI advisor returned recommendation: material_id=%s, name=%s",
|
||||||
rec.get("recommended_material_id"), rec.get("recommended_material_name"))
|
rec.get("recommended_material_id"), rec.get("recommended_material_name"))
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from app.services.file_parser import FileInfo
|
from app.services.file_parser import FileInfo
|
||||||
|
|
||||||
logger = logging.getLogger("app.services.price_engine")
|
logger = logging.getLogger("app.services.price_engine")
|
||||||
|
|
||||||
TIME_RATE_PER_HOUR = 200.0 # руб/час
|
TIME_RATE_PER_HOUR = 200.0 # руб/час
|
||||||
SETUP_TIME_MIN = 15.0 # минуты
|
SETUP_TIME_MIN = 10.0 # подготовка стола, прогрев
|
||||||
TRAVEL_TIME_PER_LAYER_MIN = 0.3
|
|
||||||
|
|
||||||
POST_PROCESSING_COSTS = {
|
POST_PROCESSING_COSTS = {
|
||||||
"sanding": 300.0,
|
"sanding": 300.0,
|
||||||
@@ -15,7 +15,7 @@ POST_PROCESSING_COSTS = {
|
|||||||
"acetone_smoothing": 400.0,
|
"acetone_smoothing": 400.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
MULTICOLOR_SURCHARGE_PERCENT = 30 # наценка за многоцветную печать
|
MULTICOLOR_SURCHARGE_PERCENT = 30
|
||||||
|
|
||||||
QUANTITY_DISCOUNTS = [
|
QUANTITY_DISCOUNTS = [
|
||||||
(1, 0),
|
(1, 0),
|
||||||
@@ -25,6 +25,24 @@ QUANTITY_DISCOUNTS = [
|
|||||||
(101, 20),
|
(101, 20),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# ─── Slicer-like defaults (Bambu Lab / OrcaSlicer profile) ───
|
||||||
|
WALL_COUNT = 3 # количество стенок (периметров)
|
||||||
|
LINE_WIDTH_MM = 0.4 # ширина линии
|
||||||
|
TOP_SOLID_LAYERS = 4 # верхние сплошные слои
|
||||||
|
BOTTOM_SOLID_LAYERS = 4 # нижние сплошные слои
|
||||||
|
|
||||||
|
# Скорости печати (мм/с) — Bambu Lab X1C типичный профиль
|
||||||
|
SPEED_OUTER_WALL = 80
|
||||||
|
SPEED_INNER_WALL = 120
|
||||||
|
SPEED_INFILL = 200
|
||||||
|
SPEED_TOP_BOTTOM = 80
|
||||||
|
SPEED_TRAVEL = 300
|
||||||
|
SPEED_FIRST_LAYER = 50
|
||||||
|
|
||||||
|
# Коэффициент учёта retraction, acceleration, non-print moves
|
||||||
|
# Bambu быстрый, но всё равно ~25-35% времени — непечатные перемещения
|
||||||
|
NON_PRINT_OVERHEAD = 1.30
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PriceResult:
|
class PriceResult:
|
||||||
@@ -45,23 +63,122 @@ def get_quantity_discount(quantity: int) -> int:
|
|||||||
for min_qty, disc in QUANTITY_DISCOUNTS:
|
for min_qty, disc in QUANTITY_DISCOUNTS:
|
||||||
if quantity >= min_qty:
|
if quantity >= min_qty:
|
||||||
discount = disc
|
discount = disc
|
||||||
logger.debug("Quantity discount for %d pcs: %d%%", quantity, discount)
|
|
||||||
return discount
|
return discount
|
||||||
|
|
||||||
|
|
||||||
def estimate_print_time(file_info: FileInfo, layer_height_mm: float, flow_rate_mm3_s: float) -> float:
|
def _estimate_weight(file_info: FileInfo, density_g_cm3: float,
|
||||||
"""Estimate print time in hours."""
|
infill_percent: int, layer_height_mm: float) -> float:
|
||||||
z_height = file_info.bounding_box_mm.get("z", 10.0)
|
"""
|
||||||
layers = max(z_height / layer_height_mm, 1)
|
Estimate weight using a slicer-like model:
|
||||||
|
- Walls: perimeter_length * wall_count * line_width * z_height * density
|
||||||
|
- Top/bottom: footprint_area * solid_layers * layer_height * density
|
||||||
|
- Infill: remaining interior volume * infill% * density
|
||||||
|
"""
|
||||||
|
bbox = file_info.bounding_box_mm
|
||||||
|
x = bbox.get("x", 10.0)
|
||||||
|
y = bbox.get("y", 10.0)
|
||||||
|
z = bbox.get("z", 10.0)
|
||||||
|
|
||||||
volume_mm3 = file_info.volume_cm3 * 1000.0
|
volume_mm3 = file_info.volume_cm3 * 1000.0
|
||||||
volume_per_layer = volume_mm3 / layers
|
surface_mm2 = file_info.surface_area_cm2 * 100.0
|
||||||
time_per_layer_min = volume_per_layer / flow_rate_mm3_s / 60.0
|
|
||||||
total_min = layers * (time_per_layer_min + TRAVEL_TIME_PER_LAYER_MIN) + SETUP_TIME_MIN
|
# Approximate the perimeter length per layer from surface area:
|
||||||
hours = round(total_min / 60.0, 1)
|
# surface_area ≈ perimeter_per_layer * z + 2 * footprint
|
||||||
logger.debug("Print time estimate: z=%.1fmm, layers=%.0f, vol_per_layer=%.1fmm3, "
|
# So perimeter_per_layer ≈ (surface_area - 2 * footprint) / z
|
||||||
"time_per_layer=%.2fmin, total=%.1fmin (%.1fh)",
|
footprint_mm2 = x * y * 0.65 # ~65% fill of bounding box for typical parts
|
||||||
z_height, layers, volume_per_layer, time_per_layer_min, total_min, hours)
|
# Clamp: footprint can't be more than half of surface
|
||||||
return hours
|
footprint_mm2 = min(footprint_mm2, surface_mm2 * 0.4)
|
||||||
|
|
||||||
|
perimeter_per_layer_mm = max((surface_mm2 - 2 * footprint_mm2) / max(z, 1.0), 10.0)
|
||||||
|
num_layers = max(z / layer_height_mm, 1)
|
||||||
|
|
||||||
|
# Wall volume
|
||||||
|
wall_thickness_total = WALL_COUNT * LINE_WIDTH_MM
|
||||||
|
wall_volume_mm3 = perimeter_per_layer_mm * wall_thickness_total * layer_height_mm * num_layers
|
||||||
|
logger.debug("Walls: perimeter=%.1f mm/layer, thickness=%.1f mm, volume=%.1f mm3",
|
||||||
|
perimeter_per_layer_mm, wall_thickness_total, wall_volume_mm3)
|
||||||
|
|
||||||
|
# Top + bottom solid layers volume
|
||||||
|
solid_layers = TOP_SOLID_LAYERS + BOTTOM_SOLID_LAYERS
|
||||||
|
solid_volume_mm3 = footprint_mm2 * layer_height_mm * solid_layers
|
||||||
|
logger.debug("Solid top/bottom: footprint=%.1f mm2, layers=%d, volume=%.1f mm3",
|
||||||
|
footprint_mm2, solid_layers, solid_volume_mm3)
|
||||||
|
|
||||||
|
# Interior volume for infill (total volume minus walls minus top/bottom)
|
||||||
|
interior_volume_mm3 = max(volume_mm3 - wall_volume_mm3 - solid_volume_mm3, 0)
|
||||||
|
infill_volume_mm3 = interior_volume_mm3 * (infill_percent / 100.0)
|
||||||
|
logger.debug("Infill: interior=%.1f mm3, infill%%=%d, infill_volume=%.1f mm3",
|
||||||
|
interior_volume_mm3, infill_percent, infill_volume_mm3)
|
||||||
|
|
||||||
|
total_volume_mm3 = wall_volume_mm3 + solid_volume_mm3 + infill_volume_mm3
|
||||||
|
|
||||||
|
# Sanity check: total filament volume shouldn't exceed full solid object
|
||||||
|
total_volume_mm3 = min(total_volume_mm3, volume_mm3 * 1.05)
|
||||||
|
|
||||||
|
weight_g = total_volume_mm3 / 1000.0 * density_g_cm3
|
||||||
|
logger.debug("Total filament volume: %.1f mm3, weight: %.1f g", total_volume_mm3, weight_g)
|
||||||
|
return round(weight_g, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_print_time(file_info: FileInfo, layer_height_mm: float,
|
||||||
|
infill_percent: int, weight_grams: float,
|
||||||
|
density_g_cm3: float) -> float:
|
||||||
|
"""
|
||||||
|
Estimate print time using toolpath-like model:
|
||||||
|
- Wall time: perimeter_length * wall_count * layers / wall_speed
|
||||||
|
- Infill time: infill_volume / (line_width * layer_height * infill_speed)
|
||||||
|
- Top/bottom time: solid_area * solid_layers / solid_speed
|
||||||
|
- First layer penalty
|
||||||
|
- Non-print overhead (travel, retraction, acceleration)
|
||||||
|
"""
|
||||||
|
bbox = file_info.bounding_box_mm
|
||||||
|
x = bbox.get("x", 10.0)
|
||||||
|
y = bbox.get("y", 10.0)
|
||||||
|
z = bbox.get("z", 10.0)
|
||||||
|
|
||||||
|
surface_mm2 = file_info.surface_area_cm2 * 100.0
|
||||||
|
volume_mm3 = file_info.volume_cm3 * 1000.0
|
||||||
|
|
||||||
|
footprint_mm2 = x * y * 0.65
|
||||||
|
footprint_mm2 = min(footprint_mm2, surface_mm2 * 0.4)
|
||||||
|
perimeter_per_layer_mm = max((surface_mm2 - 2 * footprint_mm2) / max(z, 1.0), 10.0)
|
||||||
|
num_layers = max(z / layer_height_mm, 1)
|
||||||
|
|
||||||
|
# Wall time: outer wall slower, inner walls faster
|
||||||
|
outer_wall_time_s = (perimeter_per_layer_mm * num_layers) / SPEED_OUTER_WALL
|
||||||
|
inner_wall_time_s = (perimeter_per_layer_mm * (WALL_COUNT - 1) * num_layers) / SPEED_INNER_WALL
|
||||||
|
wall_time_s = outer_wall_time_s + inner_wall_time_s
|
||||||
|
logger.debug("Wall time: outer=%.0fs, inner=%.0fs, total=%.0fs",
|
||||||
|
outer_wall_time_s, inner_wall_time_s, wall_time_s)
|
||||||
|
|
||||||
|
# Infill time: total infill length / speed
|
||||||
|
# infill_length = infill_volume / (line_width * layer_height)
|
||||||
|
total_filament_mm3 = weight_grams / density_g_cm3 * 1000.0
|
||||||
|
wall_volume_mm3 = perimeter_per_layer_mm * WALL_COUNT * LINE_WIDTH_MM * layer_height_mm * num_layers
|
||||||
|
solid_volume_mm3 = footprint_mm2 * layer_height_mm * (TOP_SOLID_LAYERS + BOTTOM_SOLID_LAYERS)
|
||||||
|
infill_volume_mm3 = max(total_filament_mm3 - wall_volume_mm3 - solid_volume_mm3, 0)
|
||||||
|
infill_length_mm = infill_volume_mm3 / (LINE_WIDTH_MM * layer_height_mm)
|
||||||
|
infill_time_s = infill_length_mm / SPEED_INFILL
|
||||||
|
logger.debug("Infill time: length=%.0f mm, time=%.0fs", infill_length_mm, infill_time_s)
|
||||||
|
|
||||||
|
# Top/bottom solid time
|
||||||
|
solid_layers = TOP_SOLID_LAYERS + BOTTOM_SOLID_LAYERS
|
||||||
|
solid_length_mm = (footprint_mm2 / LINE_WIDTH_MM) * solid_layers
|
||||||
|
solid_time_s = solid_length_mm / SPEED_TOP_BOTTOM
|
||||||
|
logger.debug("Solid top/bottom time: length=%.0f mm, time=%.0fs", solid_length_mm, solid_time_s)
|
||||||
|
|
||||||
|
# First layer is slower
|
||||||
|
first_layer_penalty_s = perimeter_per_layer_mm * WALL_COUNT / SPEED_FIRST_LAYER
|
||||||
|
logger.debug("First layer penalty: %.0fs", first_layer_penalty_s)
|
||||||
|
|
||||||
|
# Sum and apply overhead
|
||||||
|
raw_time_s = wall_time_s + infill_time_s + solid_time_s + first_layer_penalty_s
|
||||||
|
total_time_s = raw_time_s * NON_PRINT_OVERHEAD + SETUP_TIME_MIN * 60
|
||||||
|
hours = round(total_time_s / 3600.0, 1)
|
||||||
|
|
||||||
|
logger.debug("Print time: raw=%.0fs, with overhead=%.0fs (%.1fh)",
|
||||||
|
raw_time_s, total_time_s, hours)
|
||||||
|
return max(hours, 0.1)
|
||||||
|
|
||||||
|
|
||||||
def calculate_price(
|
def calculate_price(
|
||||||
@@ -78,47 +195,39 @@ def calculate_price(
|
|||||||
post_processing = post_processing or []
|
post_processing = post_processing or []
|
||||||
|
|
||||||
logger.info("=== Price calculation start ===")
|
logger.info("=== Price calculation start ===")
|
||||||
logger.info("Input: volume=%.2f cm3, density=%.2f g/cm3, price_per_gram=%.1f RUB",
|
logger.info("Input: volume=%.2f cm3, area=%.2f cm2, density=%.2f g/cm3, price=%.1f RUB/g",
|
||||||
file_info.volume_cm3, density_g_cm3, price_per_gram)
|
file_info.volume_cm3, file_info.surface_area_cm2, density_g_cm3, price_per_gram)
|
||||||
logger.info("Params: infill=%d%%, layer=%.2fmm, qty=%d, multicolor=%s, post_processing=%s",
|
logger.info("Params: infill=%d%%, layer=%.2fmm, qty=%d, multicolor=%s",
|
||||||
infill_percent, layer_height_mm, quantity, multicolor, post_processing)
|
infill_percent, layer_height_mm, quantity, multicolor)
|
||||||
|
|
||||||
effective_volume = file_info.volume_cm3 * (infill_percent / 100.0) * 0.7 + file_info.volume_cm3 * 0.3
|
# Weight (slicer-like)
|
||||||
logger.debug("Effective volume: %.2f cm3 (infill-scaled: %.2f + walls: %.2f)",
|
weight_g = _estimate_weight(file_info, density_g_cm3, infill_percent, layer_height_mm)
|
||||||
effective_volume,
|
|
||||||
file_info.volume_cm3 * (infill_percent / 100.0) * 0.7,
|
|
||||||
file_info.volume_cm3 * 0.3)
|
|
||||||
|
|
||||||
weight_g = round(effective_volume * density_g_cm3, 1)
|
|
||||||
material_cost = round(weight_g * price_per_gram, 2)
|
material_cost = round(weight_g * price_per_gram, 2)
|
||||||
logger.debug("Weight: %.1f g, material cost: %.2f RUB", weight_g, material_cost)
|
logger.info("Weight: %.1f g, material cost: %.2f RUB", weight_g, material_cost)
|
||||||
|
|
||||||
print_time_h = estimate_print_time(file_info, layer_height_mm, flow_rate_mm3_s)
|
# Time (toolpath-like)
|
||||||
|
print_time_h = _estimate_print_time(file_info, layer_height_mm, infill_percent,
|
||||||
|
weight_g, density_g_cm3)
|
||||||
time_cost = round(print_time_h * TIME_RATE_PER_HOUR, 2)
|
time_cost = round(print_time_h * TIME_RATE_PER_HOUR, 2)
|
||||||
logger.debug("Print time: %.1f h, time cost: %.2f RUB (rate: %.0f RUB/h)", print_time_h, time_cost, TIME_RATE_PER_HOUR)
|
logger.info("Print time: %.1f h, time cost: %.2f RUB", print_time_h, time_cost)
|
||||||
|
|
||||||
|
# Post-processing
|
||||||
pp_cost = 0.0
|
pp_cost = 0.0
|
||||||
for pp in post_processing:
|
for pp in post_processing:
|
||||||
cost = POST_PROCESSING_COSTS.get(pp, 0)
|
cost = POST_PROCESSING_COSTS.get(pp, 0)
|
||||||
logger.debug("Post-processing '%s': %.0f RUB", pp, cost)
|
|
||||||
pp_cost += cost
|
pp_cost += cost
|
||||||
pp_cost = round(pp_cost, 2)
|
pp_cost = round(pp_cost, 2)
|
||||||
logger.debug("Total post-processing cost: %.2f RUB", pp_cost)
|
|
||||||
|
|
||||||
subtotal = round(material_cost + time_cost + pp_cost, 2)
|
subtotal = round(material_cost + time_cost + pp_cost, 2)
|
||||||
logger.debug("Subtotal before multicolor (1 pc): %.2f RUB = material(%.2f) + time(%.2f) + pp(%.2f)",
|
|
||||||
subtotal, material_cost, time_cost, pp_cost)
|
|
||||||
|
|
||||||
if multicolor:
|
if multicolor:
|
||||||
multicolor_surcharge = round(subtotal * MULTICOLOR_SURCHARGE_PERCENT / 100.0, 2)
|
multicolor_surcharge = round(subtotal * MULTICOLOR_SURCHARGE_PERCENT / 100.0, 2)
|
||||||
subtotal = round(subtotal + multicolor_surcharge, 2)
|
subtotal = round(subtotal + multicolor_surcharge, 2)
|
||||||
logger.debug("Multicolor surcharge: +%.2f RUB (%d%%), new subtotal: %.2f",
|
logger.debug("Multicolor surcharge: +%.2f RUB", multicolor_surcharge)
|
||||||
multicolor_surcharge, MULTICOLOR_SURCHARGE_PERCENT, subtotal)
|
|
||||||
|
|
||||||
discount_pct = get_quantity_discount(quantity)
|
discount_pct = get_quantity_discount(quantity)
|
||||||
total = round(subtotal * quantity * (1 - discount_pct / 100.0), 2)
|
total = round(subtotal * quantity * (1 - discount_pct / 100.0), 2)
|
||||||
logger.info("Total: %.2f RUB (qty=%d, discount=%d%%, subtotal_per_unit=%.2f)",
|
logger.info("Total: %.2f RUB (qty=%d, discount=%d%%)", total, quantity, discount_pct)
|
||||||
total, quantity, discount_pct, subtotal)
|
|
||||||
|
|
||||||
if print_time_h <= 2:
|
if print_time_h <= 2:
|
||||||
estimated_days = 2
|
estimated_days = 2
|
||||||
@@ -132,7 +241,6 @@ def calculate_price(
|
|||||||
if quantity > 50:
|
if quantity > 50:
|
||||||
estimated_days += 3
|
estimated_days += 3
|
||||||
|
|
||||||
logger.info("Estimated days: %d", estimated_days)
|
|
||||||
logger.info("=== Price calculation complete ===")
|
logger.info("=== Price calculation complete ===")
|
||||||
|
|
||||||
return PriceResult(
|
return PriceResult(
|
||||||
|
|||||||
@@ -55,6 +55,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Disclaimer -->
|
||||||
|
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 mb-4">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<svg class="h-5 w-5 flex-shrink-0 text-amber-500 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-amber-800">Предварительный расчёт</p>
|
||||||
|
<p class="text-xs text-amber-700 mt-0.5">Стоимость является ориентировочной и может быть скорректирована после проверки модели специалистом. Окончательная цена будет подтверждена при обработке заказа.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<router-link :to="`/order/${result.calculation_id}`" class="btn-primary w-full text-center block">
|
<router-link :to="`/order/${result.calculation_id}`" class="btn-primary w-full text-center block">
|
||||||
Оформить заказ
|
Оформить заказ
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|||||||
Reference in New Issue
Block a user