Просмотр исходного кода

feat: implement advanced pricing model with hourly rates, technology-based pricing, and material speed coefficients

unknown 2 месяцев назад
Родитель
Сommit
62f27dfc27

+ 1 - 0
backend/config.py

@@ -40,6 +40,7 @@ COMPANY_PIB = os.getenv("COMPANY_PIB", "01234567")
 COMPANY_CITY = "Podgorica"
 COMPANY_ADDRESS = "Cetinjski Put, Podgorica, Montenegro"
 PDV_RATE = 21 # In percent
+DEFAULT_PRINTER_HOUR_PRICE = 2.0 # Default price per hour of printing
 
 # EFI Fiskalizacija
 EFI_ENABLED = os.getenv("EFI_ENABLED", "False").lower() == "true"

+ 4 - 4
backend/routers/catalog.py

@@ -71,8 +71,8 @@ async def admin_get_materials(admin: dict = Depends(require_admin)):
 @router.post("/admin/materials")
 async def admin_create_material(data: schemas.MaterialCreate, admin: dict = Depends(require_admin)):
     colors_json = json.dumps(data.available_colors) if data.available_colors else None
-    query = "INSERT INTO materials (name_en, name_ru, name_ua, name_me, desc_en, desc_ru, desc_ua, desc_me, long_desc_en, long_desc_ru, long_desc_ua, long_desc_me, price_per_cm3, available_colors, is_active) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
-    params = (data.name_en, data.name_ru, data.name_ua, data.name_me, data.desc_en, data.desc_ru, data.desc_ua, data.desc_me, data.long_desc_en, data.long_desc_ru, data.long_desc_ua, data.long_desc_me, data.price_per_cm3, colors_json, data.is_active)
+    query = "INSERT INTO materials (name_en, name_ru, name_ua, name_me, desc_en, desc_ru, desc_ua, desc_me, long_desc_en, long_desc_ru, long_desc_ua, long_desc_me, service_id, price_per_cm3, price_per_hour, speed_coeff, available_colors, is_active) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
+    params = (data.name_en, data.name_ru, data.name_ua, data.name_me, data.desc_en, data.desc_ru, data.desc_ua, data.desc_me, data.long_desc_en, data.long_desc_ru, data.long_desc_ua, data.long_desc_me, data.service_id, data.price_per_cm3, data.price_per_hour, data.speed_coeff, colors_json, data.is_active)
     mat_id = db.execute_commit(query, params)
     
     await audit_service.log(
@@ -114,8 +114,8 @@ async def admin_get_services(admin: dict = Depends(require_admin)):
 
 @router.post("/admin/services")
 async def admin_create_service(data: schemas.ServiceCreate, admin: dict = Depends(require_admin)):
-    query = "INSERT INTO services (name_en, name_ru, name_ua, name_me, desc_en, desc_ru, desc_ua, desc_me, tech_type, is_active) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
-    srv_id = db.execute_commit(query, (data.name_en, data.name_ru, data.name_ua, data.name_me, data.desc_en, data.desc_ru, data.desc_ua, data.desc_me, data.tech_type, data.is_active))
+    query = "INSERT INTO services (name_en, name_ru, name_ua, name_me, desc_en, desc_ru, desc_ua, desc_me, tech_type, price_per_hour, is_active) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
+    srv_id = db.execute_commit(query, (data.name_en, data.name_ru, data.name_ua, data.name_me, data.desc_en, data.desc_ru, data.desc_ua, data.desc_me, data.tech_type, data.price_per_hour, data.is_active))
     
     await audit_service.log(
         user_id=admin.get("id"),

+ 16 - 16
backend/routers/orders.py

@@ -74,9 +74,10 @@ async def create_order(
 
     name_col = f"name_{lang}" if lang in ["en", "ru", "me"] else "name_en"
     
-    mat_info = db.execute_query(f"SELECT {name_col}, price_per_cm3 FROM materials WHERE id = %s", (material_id,))
+    mat_info = db.execute_query(f"SELECT {name_col}, price_per_cm3, price_per_hour FROM materials WHERE id = %s", (material_id,))
     mat_name = mat_info[0][name_col] if mat_info else "Unknown"
     mat_price = mat_info[0]['price_per_cm3'] if mat_info else 0.0
+    hour_price = mat_info[0]['price_per_hour'] if mat_info and mat_info[0]['price_per_hour'] is not None else config.DEFAULT_PRINTER_HOUR_PRICE
 
     file_sizes = []
     if parsed_ids:
@@ -90,6 +91,7 @@ async def create_order(
     original_params = json.dumps({
         "material_name": mat_name,
         "material_price": float(mat_price) if mat_price is not None else 0.0,
+        "printer_hour_price": float(hour_price),
         "estimated_price": float(estimated_price) if estimated_price is not None else 0.0,
         "quantity": quantity,
         "color_name": color_name,
@@ -107,10 +109,10 @@ async def create_order(
     })
 
     order_query = """
-    INSERT INTO orders (user_id, material_id, first_name, last_name, phone, email, shipping_address, model_link, status, is_company, company_name, company_pib, company_address, allow_portfolio, estimated_price, material_name, material_price, color_name, quantity, notes, original_params)
-    VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+    INSERT INTO orders (user_id, material_id, first_name, last_name, phone, email, shipping_address, model_link, status, is_company, company_name, company_pib, company_address, allow_portfolio, estimated_price, material_name, material_price, printer_hour_price, color_name, quantity, notes, original_params)
+    VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
     """
-    order_params = (user_id, material_id, first_name, last_name, phone, email, shipping_address, model_link, is_company, company_name, company_pib, company_address, allow_portfolio, estimated_price, mat_name, mat_price, color_name, quantity, notes, original_params)
+    order_params = (user_id, material_id, first_name, last_name, phone, email, shipping_address, model_link, is_company, company_name, company_pib, company_address, allow_portfolio, estimated_price, mat_name, mat_price, hour_price, color_name, quantity, notes, original_params)
     
     try:
         order_insert_id = db.execute_commit(order_query, order_params)
@@ -226,19 +228,15 @@ async def get_public_reviews():
 
 @router.post("/estimate")
 async def get_price_estimate(data: schemas.EstimateRequest):
-    material = db.execute_query("SELECT price_per_cm3 FROM materials WHERE id = %s", (data.material_id,))
-    price_per_cm3 = float(material[0]['price_per_cm3']) if material else 0.0
+    estimated_total, file_prices = pricing.calculate_estimated_price(
+        data.material_id, 
+        data.file_sizes, 
+        data.file_quantities, 
+        return_details=True
+    )
     
-    file_prices = []
-    base_fee = 5.0
-    for size in data.file_sizes:
-        total_size_mb = size / (1024 * 1024)
-        estimated_volume = total_size_mb * 8.0 
-        file_cost = base_fee + (estimated_volume * price_per_cm3)
-        file_prices.append(round(file_cost, 2))
-        
     qts = data.file_quantities if data.file_quantities else [1]*len(data.file_sizes)
-    return {"file_prices": file_prices, "total_estimate": round(sum(p * q for p, q in zip(file_prices, qts)), 2)}
+    return {"file_prices": file_prices, "total_estimate": estimated_total}
 
 # --- ADMIN ORDER ENDPOINTS ---
 
@@ -363,12 +361,14 @@ async def update_order(
         update_fields.append("material_id = %s")
         params.append(data.material_id)
         # Also update snapshot names and prices from handbook
-        mat_info = db.execute_query("SELECT name_en, price_per_cm3 FROM materials WHERE id = %s", (data.material_id,))
+        mat_info = db.execute_query("SELECT name_en, price_per_cm3, price_per_hour FROM materials WHERE id = %s", (data.material_id,))
         if mat_info:
             update_fields.append("material_name = %s")
             params.append(mat_info[0]['name_en'])
             update_fields.append("material_price = %s")
             params.append(mat_info[0]['price_per_cm3'])
+            update_fields.append("printer_hour_price = %s")
+            params.append(mat_info[0]['price_per_hour'] if mat_info[0]['price_per_hour'] is not None else config.DEFAULT_PRINTER_HOUR_PRICE)
     elif data.material_name is not None:
         update_fields.append("material_name = %s")
         params.append(data.material_name)

+ 12 - 0
backend/schemas.py

@@ -17,7 +17,10 @@ class MaterialBase(BaseModel):
     long_desc_ru: Optional[str] = None
     long_desc_ua: Optional[str] = None
     long_desc_me: Optional[str] = None
+    service_id: Optional[int] = None
     price_per_cm3: float
+    price_per_hour: Optional[float] = None
+    speed_coeff: Optional[float] = 1.0
     available_colors: Optional[List[str]] = None
     is_active: bool
 
@@ -32,6 +35,7 @@ class ServiceBase(BaseModel):
     desc_ua: Optional[str] = None
     desc_me: Optional[str] = None
     tech_type: Optional[str] = None
+    price_per_hour: Optional[float] = None
     is_active: bool
 
 # Management models
@@ -48,7 +52,10 @@ class MaterialCreate(BaseModel):
     long_desc_ru: Optional[str] = ""
     long_desc_ua: Optional[str] = ""
     long_desc_me: Optional[str] = ""
+    service_id: Optional[int] = None
     price_per_cm3: float
+    price_per_hour: Optional[float] = 0.0
+    speed_coeff: Optional[float] = 1.0
     available_colors: Optional[List[str]] = None
     is_active: bool = True
 
@@ -66,6 +73,9 @@ class MaterialUpdate(BaseModel):
     long_desc_ua: Optional[str] = None
     long_desc_me: Optional[str] = None
     price_per_cm3: Optional[float] = None
+    price_per_hour: Optional[float] = None
+    speed_coeff: Optional[float] = None
+    service_id: Optional[int] = None
     available_colors: Optional[List[str]] = None
     is_active: Optional[bool] = None
 
@@ -79,6 +89,7 @@ class ServiceCreate(BaseModel):
     desc_ua: str
     desc_me: str
     tech_type: Optional[str] = None
+    price_per_hour: Optional[float] = 0.0
     is_active: bool = True
 
 class ServiceUpdate(BaseModel):
@@ -91,6 +102,7 @@ class ServiceUpdate(BaseModel):
     desc_ua: Optional[str] = None
     desc_me: Optional[str] = None
     tech_type: Optional[str] = None
+    price_per_hour: Optional[float] = None
     is_active: Optional[bool] = None
 
 # Order creation models

+ 28 - 1
backend/services/invoice_service.py

@@ -10,6 +10,33 @@ class PDFGenerator(FPDF):
     def __init__(self, **kwargs):
         super().__init__(**kwargs)
         self.set_auto_page_break(True, margin=15)
+
+    def _safe_text(self, text):
+        """Ensures text is compatible with Latin-1 (Helvetica/Core fonts)"""
+        if text is None:
+            return ""
+        text = str(text)
+        # Mapping for Cyrillic characters to Latin equivalents
+        mapping = {
+            'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'ђ': 'dj', 'е': 'e', 'ж': 'zh',
+            'з': 'z', 'и': 'i', 'ј': 'j', 'к': 'k', 'л': 'l', 'љ': 'lj', 'м': 'm', 'н': 'n',
+            'њ': 'nj', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'ћ': 'c', 'у': 'u',
+            'ф': 'f', 'х': 'h', 'ц': 'c', 'ч': 'ch', 'џ': 'dz', 'ш': 'sh',
+            'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Ђ': 'Dj', 'Е': 'E', 'Ж': 'Zh',
+            'З': 'Z', 'И': 'I', 'Ј': 'J', 'К': 'K', 'Л': 'L', 'Љ': 'Lj', 'М': 'M', 'Н': 'N',
+            'Њ': 'Nj', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'Ћ': 'C', 'У': 'U',
+            'Ф': 'F', 'Х': 'H', 'Ц': 'C', 'Ч': 'Ch', 'Џ': 'Dz', 'Ш': 'Sh'
+        }
+        table = str.maketrans(mapping)
+        text = text.translate(table)
+        # Encode to latin-1 and replace any remaining unsupported chars with '?'
+        return text.encode('latin-1', 'replace').decode('latin-1')
+
+    def cell(self, w=None, h=None, text="", *args, **kwargs):
+        return super().cell(w, h, self._safe_text(text), *args, **kwargs)
+
+    def multi_cell(self, w, h=None, text="", *args, **kwargs):
+        return super().multi_cell(w, h, self._safe_text(text), *args, **kwargs)
         
     def header_logo(self):
         # Placeholder for a logo if needed
@@ -45,7 +72,7 @@ class InvoiceService:
     @staticmethod
     def generate_uplatnica(order_id, payer_name, payer_address, amount):
         """Generates a standard Payment Slip (Uplatnica) for individuals"""
-        pdf = FPDF(orientation='P', unit='mm', format='A4')
+        pdf = PDFGenerator(orientation='P', unit='mm', format='A4')
         pdf.set_auto_page_break(False)
         pdf.add_page()
         pdf.set_font("helvetica", size=7)

+ 23 - 4
backend/services/pricing.py

@@ -1,15 +1,26 @@
 from typing import List
 import db
+import config
 
 def calculate_estimated_price(material_id: int, file_sizes: List[int], file_quantities: List[int] = None, return_details=False) -> float:
     """
-    Internal logic to estimate price per file based on file size (proxy for volume).
+    Internal logic to estimate price per file based on file size (proxy for volume)
+    and print time (proxy for machine wear/energy).
     """
-    material = db.execute_query("SELECT price_per_cm3 FROM materials WHERE id = %s", (material_id,))
+    material = db.execute_query("""
+        SELECT m.price_per_cm3, 
+               COALESCE(m.price_per_hour, s.price_per_hour, %s) as price_per_hour,
+               m.speed_coeff
+        FROM materials m
+        LEFT JOIN services s ON m.service_id = s.id
+        WHERE m.id = %s
+    """, (config.DEFAULT_PRINTER_HOUR_PRICE, material_id))
     if not material:
         return (0.0, []) if return_details else 0.0
     
     price_per_cm3 = float(material[0]['price_per_cm3'])
+    price_per_hour = float(material[0]['price_per_hour'])
+    speed_coeff = float(material[0]['speed_coeff'] or 1.0)
     
     if file_quantities is None or len(file_quantities) != len(file_sizes):
         file_quantities = [1] * len(file_sizes)
@@ -21,8 +32,16 @@ def calculate_estimated_price(material_id: int, file_sizes: List[int], file_quan
     for size, qty in zip(file_sizes, file_quantities):
         total_size_mb = size / (1024 * 1024)
         # Empirical conversion: ~8cm3 per 1MB of STL (binary)
-        estimated_volume = total_size_mb * 8.0 
-        file_cost = round(base_fee + (estimated_volume * price_per_cm3), 2)
+        estimated_volume_cm3 = total_size_mb * 8.0 
+        
+        # Estimate printing time (same logic as in slicer_utils.py: volume_mm3 / 10.0)
+        # Apply speed_coeff to adjust time based on material difficulty
+        estimated_hours = ((estimated_volume_cm3 * 100.0) / 3600.0) * speed_coeff
+        
+        material_cost = estimated_volume_cm3 * price_per_cm3
+        operation_cost = estimated_hours * price_per_hour
+        
+        file_cost = round(base_fee + material_cost + operation_cost, 2)
         file_costs.append(file_cost)
         estimated_total += file_cost * qty
     

+ 8 - 0
src/components/admin/MaterialsSection.vue

@@ -24,6 +24,14 @@
             <p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">{{ t("admin.fields.pricePerCm3") }}</p>
             <p class="font-display font-bold text-lg text-primary">{{ m.price_per_cm3 }} EUR</p>
           </div>
+          <div class="text-right">
+            <p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">{{ t("admin.fields.pricePerHour") }}</p>
+            <p class="font-display font-bold text-lg text-primary">{{ m.price_per_hour || 0 }} EUR</p>
+          </div>
+          <div class="text-right">
+            <p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">{{ t("admin.fields.speedCoeff") }}</p>
+            <p class="font-display font-bold text-lg text-primary">x{{ m.speed_coeff || 1 }}</p>
+          </div>
           <div class="flex items-center gap-2">
             <button @click="$emit('edit', m)" class="p-2.5 hover:bg-white/5 rounded-xl text-muted-foreground hover:text-primary transition-colors">
               <Edit2 class="w-4 h-4" />

+ 6 - 2
src/components/admin/ServicesSection.vue

@@ -19,8 +19,12 @@
             </div>
             <p class="text-xs text-muted-foreground truncate max-w-md">{{ s.desc_en }}</p>
           </div>
-        </div>
-        <div class="flex items-center gap-2">
+        <div class="flex items-center gap-8">
+          <div class="text-right">
+            <p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">{{ t("admin.fields.pricePerHour") }}</p>
+            <p class="font-display font-bold text-lg text-primary">{{ s.price_per_hour || 0 }} EUR</p>
+          </div>
+          <div class="flex items-center gap-2">
           <button @click="$emit('edit', s)" class="p-2.5 hover:bg-white/5 rounded-xl text-muted-foreground hover:text-primary transition-colors">
             <Edit2 class="w-4 h-4" />
           </button>

+ 30 - 0
src/locales/translations.admin.json

@@ -435,6 +435,18 @@
         "ru": "Цена за см³",
         "ua": "Ціна за см³"
       },
+      "pricePerHour": {
+        "en": "Price / Hour",
+        "me": "Cijena / Sat",
+        "ru": "Цена за час",
+        "ua": "Ціна за годину"
+      },
+      "speedCoeff": {
+        "en": "Time Multiplier (Complexity)",
+        "me": "Koeficijent vremena (složenost)",
+        "ru": "Коэф. времени (сложность)",
+        "ua": "Коеф. часу (складність)"
+      },
       "projectNotes": {
         "en": "Project Notes",
         "me": "Napomene o projektu",
@@ -525,6 +537,24 @@
         "ru": "Тип технологии",
         "ua": "Тип технології"
       },
+      "technology": {
+        "en": "Technology",
+        "me": "Tehnologija",
+        "ru": "Технология",
+        "ua": "Технологія"
+      },
+      "selectTechnology": {
+        "en": "Select Technology",
+        "me": "Izaberi tehnologiju",
+        "ru": "Выберите технологию",
+        "ua": "Оберіть технологію"
+      },
+      "override": {
+        "en": "Override",
+        "me": "Predefiniši",
+        "ru": "Переопределить",
+        "ua": "Перевизначити"
+      },
       "timestamp": {
         "en": "Timestamp",
         "me": "Vrijeme",

+ 32 - 6
src/pages/Admin.vue

@@ -295,6 +295,14 @@
                   <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.nameUa") }}</label><input v-model="matForm.name_ua" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
                 </div>
 
+                <div class="space-y-1">
+                  <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.technology") }}</label>
+                  <select v-model="matForm.service_id" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm">
+                    <option :value="null">{{ t("admin.fields.selectTechnology") }}...</option>
+                    <option v-for="s in services" :key="s.id" :value="s.id">{{ s.name_en }} ({{ s.tech_type }})</option>
+                  </select>
+                </div>
+
                 <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
                    <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.longDescEn") }}</label><textarea v-model="matForm.long_desc_en" class="w-full bg-background border border-border/50 rounded-xl px-4 py-2 text-xs h-20 resize-none" /></div>
                    <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.longDescRu") }}</label><textarea v-model="matForm.long_desc_ru" class="w-full bg-background border border-border/50 rounded-xl px-4 py-2 text-xs h-20 resize-none" /></div>
@@ -302,7 +310,20 @@
                    <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.longDescUa") }}</label><textarea v-model="matForm.long_desc_ua" class="w-full bg-background border border-border/50 rounded-xl px-4 py-2 text-xs h-20 resize-none" /></div>
                 </div>
 
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.pricePerCm3") }}</label><input v-model.number="matForm.price_per_cm3" type="number" step="any" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
+                <div class="grid grid-cols-2 gap-4">
+                  <div class="space-y-1">
+                    <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.pricePerCm3") }}</label>
+                    <input v-model.number="matForm.price_per_cm3" type="number" step="any" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" />
+                  </div>
+                  <div class="space-y-1">
+                    <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.pricePerHour") }} ({{ t("admin.fields.override") }})</label>
+                    <input v-model.number="matForm.price_per_hour" type="number" step="any" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" />
+                  </div>
+                  <div class="space-y-1">
+                    <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.speedCoeff") }}</label>
+                    <input v-model.number="matForm.speed_coeff" type="number" step="0.01" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" />
+                  </div>
+                </div>
                 
                 <div class="space-y-1">
                   <label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.colors") }}</label>
@@ -329,7 +350,10 @@
               <form v-if="activeTab === 'services'" @submit.prevent="handleSaveService" class="space-y-4">
                 <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.nameEn") }}</label><input v-model="svcForm.name_en" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
                 <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.nameRu") }}</label><input v-model="svcForm.name_ru" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
-                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.techType") }}</label><input v-model="svcForm.tech_type" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
+                <div class="grid grid-cols-2 gap-4">
+                  <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.techType") }}</label><input v-model="svcForm.tech_type" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
+                  <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.pricePerHour") }}</label><input v-model.number="svcForm.price_per_hour" type="number" step="any" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 text-sm" /></div>
+                </div>
                 <div class="flex gap-3 pt-6 border-t border-border/10">
                   <Button type="button" variant="ghost" class="flex-1" @click="closeModals">{{ t("admin.actions.cancel") }}</Button>
                   <Button type="submit" variant="hero" class="flex-1">{{ t("admin.actions.save") }}</Button>
@@ -489,9 +513,10 @@ const matForm = reactive({
   name_en: "", name_ru: "", name_ua: "", name_me: "", 
   desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", 
   long_desc_en: "", long_desc_ru: "", long_desc_ua: "", long_desc_me: "",
-  price_per_cm3: 0, available_colors: [] as string[], is_active: true 
+  service_id: null as number | null,
+  price_per_cm3: 0, price_per_hour: 0, speed_coeff: 1.0, available_colors: [] as string[], is_active: true 
 });
-const svcForm = reactive({ name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", tech_type: "", is_active: true });
+const svcForm = reactive({ name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", tech_type: "", price_per_hour: 0, is_active: true });
 const postForm = reactive({ slug: "", title_en: "", title_me: "", title_ru: "", title_ua: "", category: "Technology", image_url: "", is_published: true });
 const userForm = reactive({ email: "", password: "", first_name: "", last_name: "", phone: "" });
 const orderForm = reactive({ total_price: 0, material_name: "", color_name: "", quantity: 1, first_name: "", last_name: "", email: "", phone: "", shipping_address: "", notes: "", review_text: "", rating: 0 });
@@ -716,9 +741,10 @@ function closeModals() {
     name_en: "", name_ru: "", name_me: "", name_ua: "", 
     desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", 
     long_desc_en: "", long_desc_ru: "", long_desc_ua: "", long_desc_me: "",
-    price_per_cm3: 0, available_colors: [], is_active: true 
+    service_id: null,
+    price_per_cm3: 0, price_per_hour: 0, speed_coeff: 1.0, available_colors: [], is_active: true 
   });
-  Object.assign(svcForm, { name_en: "", name_ru: "", name_me: "", name_ua: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", tech_type: "", is_active: true });
+  Object.assign(svcForm, { name_en: "", name_ru: "", name_me: "", name_ua: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", tech_type: "", price_per_hour: 0, is_active: true });
   Object.assign(userForm, { email: "", password: "", first_name: "", last_name: "", phone: "" });
   Object.assign(postForm, { slug: "", title_en: "", title_me: "", title_ru: "", title_ua: "", category: "Technology", image_url: "", is_published: true });
   Object.assign(orderForm, { total_price: 0, material_name: "", color_name: "", quantity: 1, first_name: "", last_name: "", email: "", phone: "", shipping_address: "", notes: "", review_text: "", rating: 0 });