invoice_service.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. import os
  2. from fpdf import FPDF
  3. import config
  4. from datetime import datetime
  5. import qrcode
  6. import io
  7. from services.fiscal_service import FiscalService
  8. class PDFGenerator(FPDF):
  9. def __init__(self, **kwargs):
  10. super().__init__(**kwargs)
  11. self.set_auto_page_break(True, margin=15)
  12. def _safe_text(self, text):
  13. """Ensures text is compatible with Latin-1 (Helvetica/Core fonts)"""
  14. if text is None:
  15. return ""
  16. text = str(text)
  17. # Mapping for Cyrillic characters to Latin equivalents
  18. mapping = {
  19. 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'ђ': 'dj', 'е': 'e', 'ж': 'zh',
  20. 'з': 'z', 'и': 'i', 'ј': 'j', 'к': 'k', 'л': 'l', 'љ': 'lj', 'м': 'm', 'н': 'n',
  21. 'њ': 'nj', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'ћ': 'c', 'у': 'u',
  22. 'ф': 'f', 'х': 'h', 'ц': 'c', 'ч': 'ch', 'џ': 'dz', 'ш': 'sh',
  23. 'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Ђ': 'Dj', 'Е': 'E', 'Ж': 'Zh',
  24. 'З': 'Z', 'И': 'I', 'Ј': 'J', 'К': 'K', 'Л': 'L', 'Љ': 'Lj', 'М': 'M', 'Н': 'N',
  25. 'Њ': 'Nj', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'Ћ': 'C', 'У': 'U',
  26. 'Ф': 'F', 'Х': 'H', 'Ц': 'C', 'Ч': 'Ch', 'Џ': 'Dz', 'Ш': 'Sh'
  27. }
  28. table = str.maketrans(mapping)
  29. text = text.translate(table)
  30. # Encode to latin-1 and replace any remaining unsupported chars with '?'
  31. return text.encode('latin-1', 'replace').decode('latin-1')
  32. def cell(self, w=None, h=None, text="", *args, **kwargs):
  33. return super().cell(w, h, self._safe_text(text), *args, **kwargs)
  34. def multi_cell(self, w, h=None, text="", *args, **kwargs):
  35. return super().multi_cell(w, h, self._safe_text(text), *args, **kwargs)
  36. def header_logo(self):
  37. # Placeholder for a logo if needed
  38. self.set_font("helvetica", "B", 20)
  39. self.set_text_color(31, 41, 55) # text-gray-800
  40. self.cell(0, 10, config.COMPANY_NAME, ln=True, align='L')
  41. self.set_font("helvetica", "", 8)
  42. self.set_text_color(107, 114, 128) # text-gray-500
  43. self.cell(0, 4, config.COMPANY_ADDRESS, ln=True)
  44. self.cell(0, 4, f"PIB: {config.COMPANY_PIB} | Ziro racun: {config.ZIRO_RACUN}", ln=True)
  45. self.ln(10)
  46. def draw_table_header(self, columns):
  47. self.set_fill_color(249, 250, 251) # gray-50
  48. self.set_text_color(55, 65, 81) # gray-700
  49. self.set_font("helvetica", "B", 9)
  50. for col in columns:
  51. self.cell(col['w'], 8, col['label'], border=1, fill=True, align=col['align'])
  52. self.ln()
  53. def format_money(self, amount):
  54. return f"{amount:.2f}".replace(".", ",")
  55. class InvoiceService:
  56. @staticmethod
  57. def _get_output_path(filename):
  58. pdf_dir = os.path.join(config.UPLOAD_DIR, "invoices")
  59. os.makedirs(pdf_dir, exist_ok=True)
  60. filepath = os.path.join(pdf_dir, filename)
  61. return filepath, os.path.join("uploads", "invoices", filename).replace("\\", "/")
  62. @staticmethod
  63. def generate_uplatnica(order_id, payer_name, payer_address, amount):
  64. """Generates a standard Payment Slip (Uplatnica) for individuals"""
  65. pdf = PDFGenerator(orientation='P', unit='mm', format='A4')
  66. pdf.set_auto_page_break(False)
  67. pdf.add_page()
  68. pdf.set_font("helvetica", size=7)
  69. # Draw the structure (as in previous version but can be polished)
  70. pdf.rect(0, 0, 210, 99)
  71. pdf.line(105, 0, 105, 99)
  72. # ... (transferring the logic from uplatnica_generator.py) ...
  73. # I will keep the existing precise layout for uplatnica since it's a standard form
  74. labels = ["Hitnost", "Prenos", "Uplata", "Isplata"]
  75. for i, label in enumerate(labels):
  76. x = 110 + i * 22
  77. pdf.set_xy(x, 2)
  78. pdf.cell(14, 4, label)
  79. pdf.rect(x + 14, 2, 4, 4)
  80. if label == "Uplata":
  81. pdf.line(x + 14, 2, x + 18, 6); pdf.line(x + 18, 2, x + 14, 6)
  82. pdf.set_font("helvetica", "B", 8)
  83. pdf.set_xy(5, 5); pdf.cell(95, 5, "NALOG PLATIOCA", align="C")
  84. pdf.rect(5, 12, 95, 14)
  85. pdf.set_font("helvetica", size=9)
  86. pdf.set_xy(7, 14); pdf.cell(90, 4, payer_name[:40])
  87. pdf.set_xy(7, 18); pdf.cell(90, 4, payer_address[:40])
  88. pdf.set_font("helvetica", size=6)
  89. pdf.set_xy(5, 26); pdf.cell(95, 4, "(Naziv platioca)", align="C")
  90. pdf.rect(5, 30, 95, 14)
  91. pdf.set_font("helvetica", size=9)
  92. pdf.set_xy(7, 32); pdf.cell(90, 4, "Usluge 3D stampe")
  93. pdf.set_xy(7, 36); pdf.cell(90, 4, f"Narudzba {order_id}")
  94. pdf.set_font("helvetica", size=6)
  95. pdf.set_xy(5, 44); pdf.cell(95, 4, "(Svrha placanja)", align="C")
  96. pdf.rect(5, 48, 95, 14)
  97. pdf.set_font("helvetica", size=9)
  98. pdf.set_xy(7, 50); pdf.cell(90, 4, config.COMPANY_NAME)
  99. pdf.set_font("helvetica", size=6)
  100. pdf.set_xy(5, 62); pdf.cell(95, 4, "(Naziv primaoca)", align="C")
  101. pdf.set_font("helvetica", size=7)
  102. pdf.set_xy(110, 22); pdf.cell(10, 4, "EUR")
  103. pdf.rect(120, 22, 50, 10); pdf.rect(175, 22, 30, 10)
  104. pdf.set_font("helvetica", "B", 11)
  105. pdf.set_xy(120, 24); pdf.cell(50, 6, f"{amount:.2f}".replace(".", ","), align="C")
  106. pdf.set_xy(175, 24); pdf.cell(30, 6, "121", align="C")
  107. pdf.rect(110, 36, 95, 10)
  108. pdf.set_font("helvetica", size=10)
  109. pdf.set_xy(110, 38); pdf.cell(95, 6, config.ZIRO_RACUN, align="C")
  110. pdf.rect(110, 50, 20, 8); pdf.rect(135, 50, 70, 8)
  111. pdf.set_xy(110, 52); pdf.cell(20, 4, "00", align="C")
  112. pdf.set_xy(135, 52); pdf.cell(70, 4, str(order_id), align="C")
  113. filename = f"uplatnica_order_{order_id}.pdf"
  114. filepath, web_path = InvoiceService._get_output_path(filename)
  115. pdf.output(filepath)
  116. return web_path
  117. @staticmethod
  118. def generate_document(order_data, doc_type="predracun", override_price=None):
  119. """
  120. Generic generator for Predracun (Proforma) or Faktura (Invoice)
  121. Compliant with Montenegro VAT laws.
  122. """
  123. pdf = PDFGenerator()
  124. pdf.add_page()
  125. pdf.header_logo()
  126. title = "PREDRACUN (Proforma Invoice / Avansni racun)" if doc_type == "predracun" else "FAKTURA / RACUN (Invoice)"
  127. doc_id = f"{order_data['id']}/{datetime.now().year}"
  128. now_str = datetime.now().strftime('%d.%m.%Y.')
  129. pdf.set_font("helvetica", "B", 14)
  130. pdf.cell(0, 10, f"{title} #{doc_id}", ln=True)
  131. pdf.set_font("helvetica", "", 9)
  132. pdf.cell(0, 5, f"Mjesto izdavanja: {config.COMPANY_CITY}", ln=True)
  133. pdf.cell(0, 5, f"Datum izdavanja: {now_str}", ln=True)
  134. pdf.cell(0, 5, f"Datum prometa: {now_str}", ln=True)
  135. pdf.ln(10)
  136. # Buyer and Seller horizontal layout
  137. pdf.set_font("helvetica", "B", 10)
  138. pdf.cell(95, 5, "PRODAVAC / SUPPLIER:", ln=False)
  139. pdf.cell(95, 5, "KUPAC / CUSTOMER:", ln=True)
  140. pdf.set_font("helvetica", "", 9)
  141. # Supplier Details
  142. current_y = pdf.get_y()
  143. pdf.multi_cell(90, 4, f"{config.COMPANY_NAME}\n{config.COMPANY_ADDRESS}\nPIB: {config.COMPANY_PIB}\nZiro racun: {config.ZIRO_RACUN}")
  144. # Customer Details
  145. pdf.set_xy(105, current_y)
  146. is_company = bool(order_data.get('is_company'))
  147. buyer_name = order_data.get('company_name') or f"{order_data.get('first_name', '')} {order_data.get('last_name', '')}"
  148. buyer_address = order_data.get('company_address') or order_data.get('shipping_address', 'N/A')
  149. buyer_pib = order_data.get('company_pib') or ""
  150. # Note: Buyer's bank account isn't collected, so we leave it as N/A or empty
  151. pdf.multi_cell(90, 4, f"{buyer_name}\nAddress: {buyer_address}\nPIB/JMBG: {buyer_pib}\nZiro racun: N/A")
  152. pdf.ln(10)
  153. pdf.set_xy(10, pdf.get_y() + 5)
  154. # Items Table
  155. columns = [
  156. {'w': 10, 'label': '#', 'align': 'C'},
  157. {'w': 90, 'label': 'Opis / Description', 'align': 'L'},
  158. {'w': 20, 'label': 'Kol / Qty', 'align': 'C'},
  159. {'w': 35, 'label': 'Iznos / Total', 'align': 'R'},
  160. ]
  161. pdf.draw_table_header(columns)
  162. pdf.set_font("helvetica", "", 9)
  163. # Fetch actual fixed items from DB
  164. items = db.execute_query("SELECT * FROM order_items WHERE order_id = %s", (order_data['id'],))
  165. if not items:
  166. # Fallback to legacy single row if no items found (for old orders)
  167. total_amount = float(override_price if override_price is not None else (order_data.get('total_price') or 0))
  168. pdf.cell(10, 10, "1", border=1, align='C')
  169. pdf.cell(90, 10, f"Usluge 3D stampe (Order #{order_data['id']})", border=1)
  170. pdf.cell(20, 10, "1", border=1, align='C')
  171. pdf.cell(35, 10, pdf.format_money(total_amount), border=1, align='R', ln=True)
  172. else:
  173. total_amount = 0
  174. for idx, item in enumerate(items, 1):
  175. item_total = float(item['total_price'])
  176. total_amount += item_total
  177. pdf.cell(10, 8, str(idx), border=1, align='C')
  178. pdf.cell(90, 8, str(item['description']), border=1)
  179. pdf.cell(20, 8, str(item['quantity']), border=1, align='C')
  180. pdf.cell(35, 8, pdf.format_money(item_total), border=1, align='R', ln=True)
  181. # Summary
  182. pdv_rate = config.PDV_RATE
  183. pdf.ln(5)
  184. pdf.set_font("helvetica", "", 10)
  185. base_amount = total_amount / (1 + pdv_rate/100)
  186. pdv_amount = total_amount - base_amount
  187. pdf.cell(155, 6, "Osnovica / Base Amount (bez PDV):", align='R')
  188. pdf.cell(35, 6, pdf.format_money(base_amount), align='R', ln=True)
  189. pdf.cell(155, 6, f"PDV {pdv_rate}% / VAT Amount:", align='R')
  190. pdf.cell(35, 6, pdf.format_money(pdv_amount), align='R', ln=True)
  191. pdf.set_font("helvetica", "B", 12)
  192. pdf.cell(155, 12, "UKUPNO / TOTAL (EUR):", align='R')
  193. pdf.cell(35, 12, pdf.format_money(total_amount), align='R', ln=True)
  194. pdf.ln(20)
  195. # --- FISCALIZATION BLOCK ---
  196. print(f"DEBUG: Generating document type: {doc_type}")
  197. manual_qr = order_data.get('fiscal_qr_url')
  198. manual_ikof = order_data.get('ikof')
  199. manual_jikr = order_data.get('jikr')
  200. manual_ts = order_data.get('fiscalized_at')
  201. if doc_type == "faktura" or manual_qr:
  202. print("DEBUG: Entering Fiscalization Block")
  203. # Use manual data if available, otherwise generate mock/active
  204. ikof = manual_ikof or FiscalService.generate_ikof(order_data['id'], total_amount)[0]
  205. ts_obj = manual_ts or datetime.now()
  206. ts = ts_obj.strftime("%Y-%m-%dT%H:%M:%S+02:00") if hasattr(ts_obj, 'strftime') else str(ts_obj)
  207. jikr = manual_jikr or "MOCK-JIKR-EFI-STAGING-123"
  208. qr_url = manual_qr or FiscalService.generate_qr_url(jikr, ikof, ts, total_amount)
  209. print(f"DEBUG: QR URL: {qr_url}")
  210. # 4. Create QR Image
  211. qr = qrcode.QRCode(box_size=10, border=0)
  212. qr.add_data(qr_url)
  213. qr.make(fit=True)
  214. img_qr = qr.make_image(fill_color="black", back_color="white")
  215. # Convert to bytes for FPDF
  216. img_byte_arr = io.BytesIO()
  217. img_qr.save(img_byte_arr, format='PNG')
  218. img_byte_arr.seek(0)
  219. # 5. Place QR and Fiscal Info
  220. try:
  221. pdf.image(img_byte_arr, x=10, y=pdf.get_y(), w=30, h=30, type='PNG')
  222. except Exception as e:
  223. print(f"DEBUG: Failed to embed QR image: {e}")
  224. pdf.set_xy(45, pdf.get_y() + 5)
  225. pdf.set_font("helvetica", "B", 8)
  226. pdf.cell(0, 4, f"IKOF: {ikof}", ln=True)
  227. pdf.set_xy(45, pdf.get_y())
  228. pdf.cell(0, 4, f"JIKR: {jikr}", ln=True)
  229. pdf.set_xy(45, pdf.get_y())
  230. pdf.set_font("helvetica", "", 7)
  231. pdf.cell(0, 4, f"Fiskalizovano: {ts}", ln=True)
  232. pdf.set_y(pdf.get_y() + 15)
  233. # ---------------------------
  234. pdf.set_font("helvetica", "", 9)
  235. pdf.cell(95, 10, "Fakturisao (Izdavalac):", ln=False)
  236. pdf.cell(95, 10, "Primio (Kupac):", ln=True)
  237. pdf.ln(5)
  238. pdf.line(10, pdf.get_y(), 80, pdf.get_y())
  239. pdf.line(110, pdf.get_y(), 180, pdf.get_y())
  240. # Footer
  241. pdf.ln(15)
  242. pdf.set_font("helvetica", "I", 8)
  243. if doc_type == "predracun":
  244. msg = "Predracun je validan bez pecata i potpisa shodno clanu 31 Zakona o PDV. Placanje se vrsi u roku od 3 dana na navedeni ziro racun.\nThis is a proforma invoice and is valid without stamp and signature."
  245. else:
  246. msg = "Faktura je validna bez pecata i potpisa shodno clanu 31 Zakona o PDV. Roba/usluga je isporucena u skladu sa ugovorom.\nThis is a final invoice and is valid without stamp and signature."
  247. pdf.multi_cell(0, 5, msg)
  248. filename = f"{doc_type}_order_{order_data['id']}.pdf"
  249. filepath, web_path = InvoiceService._get_output_path(filename)
  250. print(f"DEBUG: Saving PDF to absolute path: {os.path.abspath(filepath)}")
  251. pdf.output(filepath)
  252. return web_path