orders.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. import db
  2. import schemas
  3. import auth_utils
  4. import notifications
  5. import config
  6. from typing import List, Optional
  7. import json
  8. import os
  9. import uuid
  10. import shutil
  11. import hashlib
  12. import preview_utils
  13. import slicer_utils
  14. from fastapi import APIRouter, Request, Form, Depends, HTTPException, BackgroundTasks, UploadFile, File
  15. from services import pricing, order_processing, event_hooks
  16. from services.audit_service import audit_service
  17. from pydantic import BaseModel
  18. from datetime import datetime
  19. router = APIRouter(prefix="/orders", tags=["orders"])
  20. @router.post("")
  21. async def create_order(
  22. request: Request,
  23. background_tasks: BackgroundTasks,
  24. first_name: str = Form(...),
  25. last_name: str = Form(...),
  26. phone: str = Form(...),
  27. email: str = Form(...),
  28. shipping_address: str = Form(...),
  29. model_link: Optional[str] = Form(None),
  30. allow_portfolio: bool = Form(False),
  31. notes: Optional[str] = Form(None),
  32. material_id: int = Form(...),
  33. file_ids: str = Form("[]"),
  34. file_quantities: str = Form("[]"),
  35. quantity: int = Form(1),
  36. color_name: Optional[str] = Form(None),
  37. is_company: bool = Form(False),
  38. company_name: Optional[str] = Form(None),
  39. company_pib: Optional[str] = Form(None),
  40. company_address: Optional[str] = Form(None),
  41. token: str = Depends(auth_utils.oauth2_scheme_optional)
  42. ):
  43. user_id = None
  44. if token:
  45. payload = auth_utils.decode_token(token)
  46. if payload:
  47. user_id = payload.get("id")
  48. parsed_ids = []
  49. parsed_quantities = []
  50. if file_ids:
  51. try:
  52. parsed_ids = json.loads(file_ids)
  53. parsed_quantities = json.loads(file_quantities)
  54. except:
  55. pass
  56. lang = request.query_params.get("lang", "en")
  57. name_col = f"name_{lang}" if lang in ["en", "ru", "me"] else "name_en"
  58. mat_info = db.execute_query(f"SELECT {name_col}, price_per_cm3 FROM materials WHERE id = %s", (material_id,))
  59. mat_name = mat_info[0][name_col] if mat_info else "Unknown"
  60. mat_price = mat_info[0]['price_per_cm3'] if mat_info else 0.0
  61. file_sizes = []
  62. if parsed_ids:
  63. format_strings = ','.join(['%s'] * len(parsed_ids))
  64. file_rows = db.execute_query(f"SELECT file_size FROM order_files WHERE id IN ({format_strings})", tuple(parsed_ids))
  65. file_sizes = [r['file_size'] for r in file_rows]
  66. estimated_price, item_prices = pricing.calculate_estimated_price(material_id, file_sizes, parsed_quantities if parsed_quantities else None, return_details=True)
  67. # Snapshoting initial parameters
  68. original_params = json.dumps({
  69. "material_name": mat_name,
  70. "material_price": float(mat_price) if mat_price is not None else 0.0,
  71. "estimated_price": float(estimated_price) if estimated_price is not None else 0.0,
  72. "quantity": quantity,
  73. "color_name": color_name,
  74. "notes": notes,
  75. "first_name": first_name,
  76. "last_name": last_name,
  77. "phone": phone,
  78. "email": email,
  79. "shipping_address": shipping_address,
  80. "model_link": model_link,
  81. "is_company": is_company,
  82. "company_name": company_name,
  83. "company_pib": company_pib,
  84. "company_address": company_address
  85. })
  86. order_query = """
  87. 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)
  88. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  89. """
  90. 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)
  91. try:
  92. order_insert_id = db.execute_commit(order_query, order_params)
  93. if parsed_ids:
  94. for idx, f_id in enumerate(parsed_ids):
  95. qty = parsed_quantities[idx] if idx < len(parsed_quantities) else 1
  96. unit_p = item_prices[idx] if idx < len(item_prices) else 0.0
  97. db.execute_commit("UPDATE order_files SET order_id = %s, quantity = %s, unit_price = %s WHERE id = %s", (order_insert_id, qty, unit_p, f_id))
  98. # Create corresponding fixed order item
  99. file_row = db.execute_query("SELECT filename FROM order_files WHERE id = %s", (f_id,))
  100. fname = file_row[0]['filename'] if file_row else f"File #{f_id}"
  101. db.execute_commit(
  102. "INSERT INTO order_items (order_id, description, quantity, unit_price, total_price) VALUES (%s, %s, %s, %s, %s)",
  103. (order_insert_id, f"3D Print: {fname}", qty, unit_p, round(qty * unit_p, 2))
  104. )
  105. background_tasks.add_task(order_processing.process_order_slicing, order_insert_id)
  106. background_tasks.add_task(event_hooks.on_order_created, order_insert_id)
  107. return {"status": "success", "order_id": order_insert_id, "message": "Order submitted successfully"}
  108. except Exception as e:
  109. print(f"Error creating order: {e}")
  110. raise HTTPException(status_code=500, detail="Internal server error occurred while processing order")
  111. @router.get("/my")
  112. async def get_my_orders(
  113. page: int = 1,
  114. size: int = 10,
  115. token: str = Depends(auth_utils.oauth2_scheme)
  116. ):
  117. payload = auth_utils.decode_token(token)
  118. if not payload:
  119. raise HTTPException(status_code=401, detail="Invalid token")
  120. user_id = payload.get("id")
  121. offset = (page - 1) * size
  122. # Get total count
  123. count_query = "SELECT COUNT(*) as total FROM orders WHERE user_id = %s"
  124. count_res = db.execute_query(count_query, (user_id,))
  125. total = count_res[0]['total'] if count_res else 0
  126. query = """
  127. SELECT o.*,
  128. (SELECT count(*) FROM order_messages om WHERE om.order_id = o.id AND om.is_from_admin = TRUE AND om.is_read = FALSE) as unread_count,
  129. GROUP_CONCAT(IF(f.id IS NOT NULL, JSON_OBJECT('id', f.id, 'filename', f.filename, 'file_path', f.file_path, 'quantity', f.quantity, 'preview_path', f.preview_path, 'print_time', f.print_time, 'filament_g', f.filament_g), NULL)) as files
  130. FROM orders o
  131. LEFT JOIN order_files f ON o.id = f.order_id
  132. WHERE o.user_id = %s
  133. GROUP BY o.id
  134. ORDER BY
  135. CASE
  136. WHEN status IN ('pending', 'processing', 'shipped') THEN 0
  137. ELSE 1
  138. END ASC,
  139. o.created_at DESC
  140. LIMIT %s OFFSET %s
  141. """
  142. results = db.execute_query(query, (user_id, size, offset))
  143. for row in results:
  144. if row['files']:
  145. try: row['files'] = json.loads(f"[{row['files']}]")
  146. except: row['files'] = []
  147. else: row['files'] = []
  148. row['items'] = db.execute_query("SELECT * FROM order_items WHERE order_id = %s", (row['id'],))
  149. return {"orders": results, "total": total}
  150. @router.post("/estimate")
  151. async def get_price_estimate(data: schemas.EstimateRequest):
  152. material = db.execute_query("SELECT price_per_cm3 FROM materials WHERE id = %s", (data.material_id,))
  153. price_per_cm3 = float(material[0]['price_per_cm3']) if material else 0.0
  154. file_prices = []
  155. base_fee = 5.0
  156. for size in data.file_sizes:
  157. total_size_mb = size / (1024 * 1024)
  158. estimated_volume = total_size_mb * 8.0
  159. file_cost = base_fee + (estimated_volume * price_per_cm3)
  160. file_prices.append(round(file_cost, 2))
  161. qts = data.file_quantities if data.file_quantities else [1]*len(data.file_sizes)
  162. return {"file_prices": file_prices, "total_estimate": round(sum(p * q for p, q in zip(file_prices, qts)), 2)}
  163. # --- ADMIN ORDER ENDPOINTS ---
  164. @router.get("/admin/list") # Using /admin/list to avoid conflict with /my
  165. async def get_admin_orders(
  166. search: Optional[str] = None,
  167. status: Optional[str] = None,
  168. date_from: Optional[str] = None,
  169. date_to: Optional[str] = None,
  170. token: str = Depends(auth_utils.oauth2_scheme)
  171. ):
  172. payload = auth_utils.decode_token(token)
  173. if not payload or payload.get("role") != 'admin':
  174. raise HTTPException(status_code=403, detail="Admin role required")
  175. where_clauses = []
  176. params = []
  177. if search:
  178. search_term = f"%{search}%"
  179. where_clauses.append("(o.id LIKE %s OR o.email LIKE %s OR o.first_name LIKE %s OR o.last_name LIKE %s OR o.company_name LIKE %s OR o.phone LIKE %s OR o.shipping_address LIKE %s)")
  180. params.extend([search_term] * 7)
  181. if status and status != 'all':
  182. where_clauses.append("o.status = %s")
  183. params.append(status)
  184. if date_from:
  185. where_clauses.append("o.created_at >= %s")
  186. params.append(date_from)
  187. if date_to:
  188. where_clauses.append("o.created_at <= %s")
  189. params.append(date_to)
  190. where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
  191. query = f"""
  192. SELECT o.*, u.can_chat,
  193. (SELECT count(*) FROM order_messages om WHERE om.order_id = o.id AND om.is_from_admin = FALSE AND om.is_read = FALSE) as unread_count,
  194. GROUP_CONCAT(IF(f.id IS NOT NULL, JSON_OBJECT('id', f.id, 'filename', f.filename, 'file_path', f.file_path, 'file_size', f.file_size, 'quantity', f.quantity, 'preview_path', f.preview_path, 'print_time', f.print_time, 'filament_g', f.filament_g), NULL)) as files
  195. FROM orders o
  196. LEFT JOIN users u ON o.user_id = u.id
  197. LEFT JOIN order_files f ON o.id = f.order_id
  198. {where_sql}
  199. GROUP BY o.id
  200. ORDER BY o.created_at DESC
  201. """
  202. results = db.execute_query(query, tuple(params))
  203. import session_utils
  204. for row in results:
  205. row['is_online'] = session_utils.is_user_online(row['user_id']) if row.get('user_id') else False
  206. if row['files']:
  207. try: row['files'] = json.loads(f"[{row['files']}]")
  208. except: row['files'] = []
  209. else: row['files'] = []
  210. row['items'] = db.execute_query("SELECT * FROM order_items WHERE order_id = %s", (row['id'],))
  211. photos = db.execute_query("SELECT id, file_path, is_public FROM order_photos WHERE order_id = %s", (row['id'],))
  212. row['photos'] = photos
  213. return results
  214. @router.patch("/{order_id}/admin")
  215. async def update_order_admin(
  216. order_id: int,
  217. data: schemas.AdminOrderUpdate,
  218. background_tasks: BackgroundTasks,
  219. request: Request,
  220. token: str = Depends(auth_utils.oauth2_scheme)
  221. ):
  222. payload = auth_utils.decode_token(token)
  223. if not payload or payload.get("role") != 'admin':
  224. raise HTTPException(status_code=403, detail="Admin role required")
  225. update_fields = []
  226. params = []
  227. if data.status:
  228. order_info = db.execute_query("SELECT * FROM orders WHERE id = %s", (order_id,))
  229. if order_info:
  230. background_tasks.add_task(
  231. event_hooks.on_order_status_changed,
  232. order_id,
  233. data.status,
  234. order_info[0],
  235. data.send_notification
  236. )
  237. # Generate Payment Documents based on status transitions
  238. from services.invoice_service import InvoiceService
  239. o = order_info[0]
  240. price_val = data.total_price if data.total_price is not None else (o.get('total_price') or o.get('estimated_price') or 0)
  241. price = float(price_val)
  242. # 1. Proforma / Payment Slip (on 'processing' or any initial active state)
  243. if data.status in ['processing', 'shipped']:
  244. try:
  245. if o.get('is_company'):
  246. pdf_path = InvoiceService.generate_document(o, doc_type="predracun", override_price=price)
  247. else:
  248. payer_name = f"{o['first_name']} {o.get('last_name', '')}".strip()
  249. pdf_path = InvoiceService.generate_uplatnica(order_id, payer_name, o.get('shipping_address', ''), price)
  250. update_fields.append("proforma_path = %s")
  251. params.append(pdf_path)
  252. except Exception as e:
  253. print(f"Failed to generate proforma: {e}")
  254. # 2. Final Invoice (only on 'shipped')
  255. if data.status == 'shipped':
  256. try:
  257. pdf_path = InvoiceService.generate_document(o, doc_type="faktura", override_price=price)
  258. update_fields.append("invoice_path = %s")
  259. params.append(pdf_path)
  260. except Exception as e:
  261. print(f"Failed to generate final invoice: {e}")
  262. if data.status:
  263. update_fields.append("status = %s")
  264. params.append(data.status)
  265. if data.total_price is not None:
  266. update_fields.append("total_price = %s")
  267. params.append(data.total_price)
  268. if data.material_id is not None:
  269. update_fields.append("material_id = %s")
  270. params.append(data.material_id)
  271. # Also update snapshot names and prices from handbook
  272. mat_info = db.execute_query("SELECT name_en, price_per_cm3 FROM materials WHERE id = %s", (data.material_id,))
  273. if mat_info:
  274. update_fields.append("material_name = %s")
  275. params.append(mat_info[0]['name_en'])
  276. update_fields.append("material_price = %s")
  277. params.append(mat_info[0]['price_per_cm3'])
  278. elif data.material_name is not None:
  279. update_fields.append("material_name = %s")
  280. params.append(data.material_name)
  281. if data.color_name is not None:
  282. update_fields.append("color_name = %s")
  283. params.append(data.color_name)
  284. if data.quantity is not None:
  285. update_fields.append("quantity = %s")
  286. params.append(data.quantity)
  287. if data.fiscal_qr_url is not None:
  288. update_fields.append("fiscal_qr_url = %s")
  289. params.append(data.fiscal_qr_url)
  290. # Auto-set fiscalized_at if adding URL for the first time
  291. if order_info[0].get('fiscalized_at') is None:
  292. update_fields.append("fiscalized_at = %s")
  293. params.append(datetime.now())
  294. if data.ikof is not None:
  295. update_fields.append("ikof = %s")
  296. params.append(data.ikof)
  297. if data.jikr is not None:
  298. update_fields.append("jikr = %s")
  299. params.append(data.jikr)
  300. if update_fields:
  301. query = f"UPDATE orders SET {', '.join(update_fields)} WHERE id = %s"
  302. params.append(order_id)
  303. db.execute_commit(query, tuple(params))
  304. # LOG ACTION
  305. await audit_service.log(
  306. user_id=payload.get("id"),
  307. action="update_order",
  308. target_type="order",
  309. target_id=order_id,
  310. details={"updated_fields": {k.split(" = ")[0]: v for k, v in zip(update_fields, params)}},
  311. request=request
  312. )
  313. return {"id": order_id, "status": "updated"}
  314. @router.post("/{order_id}/attach-file")
  315. async def admin_attach_file(
  316. order_id: int,
  317. file: UploadFile = File(...),
  318. token: str = Depends(auth_utils.oauth2_scheme)
  319. ):
  320. payload = auth_utils.decode_token(token)
  321. if not payload or payload.get("role") != 'admin':
  322. raise HTTPException(status_code=403, detail="Admin role required")
  323. unique_filename = f"{uuid.uuid4()}{os.path.splitext(file.filename)[1]}"
  324. file_path = os.path.join(config.UPLOAD_DIR, unique_filename)
  325. db_file_path = f"uploads/{unique_filename}"
  326. sha256_hash = hashlib.sha256()
  327. with open(file_path, "wb") as buffer:
  328. while chunk := file.file.read(8192):
  329. sha256_hash.update(chunk)
  330. buffer.write(chunk)
  331. preview_path = None
  332. db_preview_path = None
  333. if file_path.lower().endswith(".stl"):
  334. preview_filename = f"{uuid.uuid4()}.png"
  335. preview_path = os.path.join(config.PREVIEW_DIR, preview_filename)
  336. db_preview_path = f"uploads/previews/{preview_filename}"
  337. preview_utils.generate_stl_preview(file_path, preview_path)
  338. filament_g = None
  339. print_time = None
  340. if file_path.lower().endswith(".stl"):
  341. result = slicer_utils.slice_model(file_path)
  342. if result and result.get('success'):
  343. filament_g = result.get('filament_g')
  344. print_time = result.get('print_time_str')
  345. query = "INSERT INTO order_files (order_id, filename, file_path, file_size, quantity, file_hash, print_time, filament_g, preview_path) VALUES (%s, %s, %s, %s, 1, %s, %s, %s, %s)"
  346. f_id = db.execute_commit(query, (order_id, file.filename, db_file_path, file.size, sha256_hash.hexdigest(), print_time, filament_g, db_preview_path))
  347. return {"file_id": f_id, "filename": file.filename, "preview_path": db_preview_path, "filament_g": filament_g, "print_time": print_time}
  348. @router.delete("/{order_id}/files/{file_id}")
  349. async def admin_delete_file(
  350. order_id: int,
  351. file_id: int,
  352. request: Request,
  353. token: str = Depends(auth_utils.oauth2_scheme)
  354. ):
  355. payload = auth_utils.decode_token(token)
  356. if not payload or payload.get("role") != 'admin':
  357. raise HTTPException(status_code=403, detail="Admin role required")
  358. file_record = db.execute_query("SELECT file_path, preview_path FROM order_files WHERE id = %s AND order_id = %s", (file_id, order_id))
  359. if not file_record:
  360. raise HTTPException(status_code=404, detail="File not found")
  361. base_dir = config.BASE_DIR
  362. try:
  363. if file_record[0]['file_path']:
  364. os.remove(os.path.join(base_dir, file_record[0]['file_path']))
  365. if file_record[0]['preview_path']:
  366. os.remove(os.path.join(base_dir, file_record[0]['preview_path']))
  367. except Exception as e:
  368. print(f"Error removing file from disk: {e}")
  369. db.execute_commit("DELETE FROM order_files WHERE id = %s AND order_id = %s", (file_id, order_id))
  370. # LOG ACTION
  371. await audit_service.log(
  372. user_id=payload.get("id"),
  373. action="delete_order_file",
  374. target_type="order",
  375. target_id=order_id,
  376. details={"file_id": file_id},
  377. request=request
  378. )
  379. return {"status": "success"}
  380. class OrderItemSchema(BaseModel):
  381. description: str
  382. quantity: int
  383. unit_price: float
  384. @router.get("/{order_id}/items")
  385. async def get_order_items(order_id: int):
  386. items = db.execute_query("SELECT * FROM order_items WHERE order_id = %s", (order_id,))
  387. return items
  388. @router.put("/{order_id}/items")
  389. async def update_order_items(order_id: int, items: List[OrderItemSchema], token: str = Depends(auth_utils.oauth2_scheme)):
  390. payload = auth_utils.decode_token(token)
  391. if not payload or payload.get("role") != 'admin':
  392. raise HTTPException(status_code=403, detail="Admin role required")
  393. db.execute_commit("DELETE FROM order_items WHERE order_id = %s", (order_id,))
  394. total_order_price = 0
  395. for item in items:
  396. tot_p = round(item.quantity * item.unit_price, 2)
  397. total_order_price += tot_p
  398. db.execute_commit(
  399. "INSERT INTO order_items (order_id, description, quantity, unit_price, total_price) VALUES (%s, %s, %s, %s, %s)",
  400. (order_id, item.description, item.quantity, item.unit_price, tot_p)
  401. )
  402. # Sync main order total_price
  403. db.execute_commit("UPDATE orders SET total_price = %s WHERE id = %s", (total_order_price, order_id))
  404. return {"status": "success", "total_price": total_order_price}
  405. @router.delete("/{order_id}/admin")
  406. async def delete_order_admin(
  407. order_id: int,
  408. request: Request,
  409. token: str = Depends(auth_utils.oauth2_scheme)
  410. ):
  411. payload = auth_utils.decode_token(token)
  412. if not payload or payload.get("role") != 'admin':
  413. raise HTTPException(status_code=403, detail="Admin role required")
  414. # 1. Find all related files to delete from disk
  415. files = db.execute_query("SELECT file_path, preview_path FROM order_files WHERE order_id = %s", (order_id,))
  416. photos = db.execute_query("SELECT file_path FROM order_photos WHERE order_id = %s", (order_id,))
  417. base_dir = config.BASE_DIR
  418. # Delete order_files from disk
  419. for f in files:
  420. try:
  421. if f.get('file_path'):
  422. fpath = os.path.join(base_dir, f['file_path'])
  423. if os.path.exists(fpath): os.remove(fpath)
  424. if f.get('preview_path'):
  425. ppath = os.path.join(base_dir, f['preview_path'])
  426. if os.path.exists(ppath): os.remove(ppath)
  427. except Exception as e:
  428. print(f"Error deleting order file {f.get('file_path')}: {e}")
  429. # Delete photos from disk
  430. for p in photos:
  431. try:
  432. if p.get('file_path'):
  433. fpath = os.path.join(base_dir, p['file_path'])
  434. if os.path.exists(fpath): os.remove(fpath)
  435. except Exception as e:
  436. print(f"Error deleting order photo {p.get('file_path')}: {e}")
  437. # 2. Delete from DB tables (due to possible lack of CASCADE or to be explicit)
  438. try:
  439. db.execute_commit("DELETE FROM order_messages WHERE order_id = %s", (order_id,))
  440. db.execute_commit("DELETE FROM order_items WHERE order_id = %s", (order_id,))
  441. db.execute_commit("DELETE FROM order_files WHERE order_id = %s", (order_id,))
  442. db.execute_commit("DELETE FROM order_photos WHERE order_id = %s", (order_id,))
  443. db.execute_commit("DELETE FROM orders WHERE id = %s", (order_id,))
  444. # LOG ACTION
  445. await audit_service.log(
  446. user_id=payload.get("id"),
  447. action="delete_order_entirely",
  448. target_type="order",
  449. target_id=order_id,
  450. details={"order_id": order_id},
  451. request=request
  452. )
  453. return {"status": "success", "message": f"Order {order_id} deleted entirely"}
  454. except Exception as e:
  455. print(f"Failed to delete order {order_id}: {e}")
  456. raise HTTPException(status_code=500, detail=str(e))