Spaces:
Sleeping
Sleeping
EL GHAFRAOUI AYOUB
commited on
Commit
·
4e55f01
1
Parent(s):
73bbeaf
- app/routes/__pycache__/invoices.cpython-312.pyc +0 -0
- app/routes/invoices.py +43 -0
- app/services/__pycache__/excel_service.cpython-312.pyc +0 -0
- app/services/__pycache__/invoice_service.cpython-312.pyc +0 -0
- app/services/excel_service copy 2.py +205 -0
- app/services/excel_service copy.py +220 -0
- app/services/excel_service.py +269 -0
- app/services/invoice_service.py +41 -7
- app/templates/index.html +116 -3
- requirements.txt +2 -1
- sql_app.db +0 -0
app/routes/__pycache__/invoices.cpython-312.pyc
CHANGED
Binary files a/app/routes/__pycache__/invoices.cpython-312.pyc and b/app/routes/__pycache__/invoices.cpython-312.pyc differ
|
|
app/routes/invoices.py
CHANGED
@@ -11,6 +11,7 @@ from app.db.database import get_db
|
|
11 |
from app.schemas.invoice import InvoiceCreate, InvoiceResponse
|
12 |
from app.db.models import Invoice, InvoiceItem
|
13 |
from app.services.invoice_service import InvoiceService
|
|
|
14 |
|
15 |
# Set up logging
|
16 |
logger = logging.getLogger(__name__)
|
@@ -142,6 +143,48 @@ async def generate_invoice_pdf(
|
|
142 |
logger.error(f"Error generating PDF: {str(e)}", exc_info=True)
|
143 |
raise HTTPException(status_code=500, detail=str(e))
|
144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
145 |
@router.get("/last-number/{client_type}", response_model=dict)
|
146 |
async def get_last_invoice_number(
|
147 |
client_type: str,
|
|
|
11 |
from app.schemas.invoice import InvoiceCreate, InvoiceResponse
|
12 |
from app.db.models import Invoice, InvoiceItem
|
13 |
from app.services.invoice_service import InvoiceService
|
14 |
+
from app.services.excel_service import ExcelService
|
15 |
|
16 |
# Set up logging
|
17 |
logger = logging.getLogger(__name__)
|
|
|
143 |
logger.error(f"Error generating PDF: {str(e)}", exc_info=True)
|
144 |
raise HTTPException(status_code=500, detail=str(e))
|
145 |
|
146 |
+
@router.post("/{invoice_id}/generate-excel")
|
147 |
+
async def generate_invoice_excel(
|
148 |
+
invoice_id: int,
|
149 |
+
request: Request,
|
150 |
+
db: AsyncSession = Depends(get_db)
|
151 |
+
):
|
152 |
+
try:
|
153 |
+
logger.info(f"Starting Excel generation for invoice ID: {invoice_id}")
|
154 |
+
logger.info(f"Request from IP: {request.client.host}")
|
155 |
+
|
156 |
+
result = await db.execute(
|
157 |
+
select(Invoice)
|
158 |
+
.options(selectinload(Invoice.items))
|
159 |
+
.filter(Invoice.id == invoice_id)
|
160 |
+
)
|
161 |
+
invoice = result.scalar_one_or_none()
|
162 |
+
|
163 |
+
if not invoice:
|
164 |
+
logger.error(f"Invoice not found: {invoice_id}")
|
165 |
+
raise HTTPException(status_code=404, detail="Invoice not found")
|
166 |
+
|
167 |
+
logger.info(f"Found invoice: {invoice.invoice_number}")
|
168 |
+
logger.info(f"Client: {invoice.client_name}")
|
169 |
+
logger.info(f"Number of items: {len(invoice.items)}")
|
170 |
+
|
171 |
+
logger.info("Starting Excel file generation")
|
172 |
+
excel_bytes = ExcelService.generate_excel(invoice)
|
173 |
+
logger.info("Excel file generated successfully")
|
174 |
+
|
175 |
+
filename = f"devis_{invoice.invoice_number}.xlsx"
|
176 |
+
logger.info(f"Sending Excel file: {filename}")
|
177 |
+
|
178 |
+
return StreamingResponse(
|
179 |
+
iter([excel_bytes]),
|
180 |
+
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
181 |
+
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
182 |
+
)
|
183 |
+
except Exception as e:
|
184 |
+
logger.error(f"Error generating Excel for invoice {invoice_id}: {str(e)}", exc_info=True)
|
185 |
+
logger.error("Stack trace:", exc_info=True)
|
186 |
+
raise HTTPException(status_code=500, detail=str(e))
|
187 |
+
|
188 |
@router.get("/last-number/{client_type}", response_model=dict)
|
189 |
async def get_last_invoice_number(
|
190 |
client_type: str,
|
app/services/__pycache__/excel_service.cpython-312.pyc
ADDED
Binary file (11.4 kB). View file
|
|
app/services/__pycache__/invoice_service.cpython-312.pyc
CHANGED
Binary files a/app/services/__pycache__/invoice_service.cpython-312.pyc and b/app/services/__pycache__/invoice_service.cpython-312.pyc differ
|
|
app/services/excel_service copy 2.py
ADDED
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from openpyxl import Workbook
|
2 |
+
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
|
3 |
+
from openpyxl.utils import get_column_letter
|
4 |
+
from io import BytesIO
|
5 |
+
from app.db.models import Invoice
|
6 |
+
import logging
|
7 |
+
import os
|
8 |
+
import datetime
|
9 |
+
|
10 |
+
# Set up logging
|
11 |
+
logger = logging.getLogger(__name__)
|
12 |
+
|
13 |
+
class ExcelService:
|
14 |
+
@staticmethod
|
15 |
+
def generate_excel(data: Invoice) -> bytes:
|
16 |
+
try:
|
17 |
+
wb = Workbook()
|
18 |
+
ws = wb.active
|
19 |
+
ws.title = "Devis"
|
20 |
+
|
21 |
+
# Constants - Match PDF colors and dimensions
|
22 |
+
HEADER_BLUE = "4A739B" # Match PDF's HEADER_BLUE
|
23 |
+
WHITE = "FFFFFF"
|
24 |
+
BLACK = "000000"
|
25 |
+
STANDARD_FONT_SIZE = 11
|
26 |
+
|
27 |
+
# Helper function for cell styling
|
28 |
+
def style_cell(cell, bold=False, color=BLACK, bg_color=None, size=STANDARD_FONT_SIZE, wrap_text=False, align='center'):
|
29 |
+
cell.font = Font(bold=bold, color=color, size=size)
|
30 |
+
cell.alignment = Alignment(horizontal=align, vertical='center', wrap_text=wrap_text)
|
31 |
+
if bg_color:
|
32 |
+
cell.fill = PatternFill(start_color=bg_color, end_color=bg_color, fill_type="solid")
|
33 |
+
|
34 |
+
# Set fixed column widths to match PDF
|
35 |
+
column_widths = {
|
36 |
+
'A': 40, # Description
|
37 |
+
'B': 15, # Unité
|
38 |
+
'C': 15, # NBRE
|
39 |
+
'D': 15, # ML/Qté
|
40 |
+
'E': 15, # P.U
|
41 |
+
'F': 20, # Total HT
|
42 |
+
}
|
43 |
+
for col, width in column_widths.items():
|
44 |
+
ws.column_dimensions[col].width = width
|
45 |
+
|
46 |
+
# Add logo and company name
|
47 |
+
current_row = 1
|
48 |
+
logo_path = os.path.join(os.path.dirname(__file__), "..", "static", "logo.png")
|
49 |
+
if os.path.exists(logo_path):
|
50 |
+
from openpyxl.drawing.image import Image
|
51 |
+
img = Image(logo_path)
|
52 |
+
img.width = 100
|
53 |
+
img.height = 100
|
54 |
+
ws.add_image(img, 'A1')
|
55 |
+
current_row += 6
|
56 |
+
|
57 |
+
# Add DEVIS header
|
58 |
+
ws.merge_cells(f'E{current_row}:F{current_row}')
|
59 |
+
devis_cell = ws[f'E{current_row}']
|
60 |
+
devis_cell.value = "DEVIS"
|
61 |
+
style_cell(devis_cell, bold=True, size=36, align='right')
|
62 |
+
current_row += 2
|
63 |
+
|
64 |
+
# Client Information Box
|
65 |
+
client_info = [
|
66 |
+
("Client:", data.client_name),
|
67 |
+
("Projet:", data.project),
|
68 |
+
("Adresse:", data.address),
|
69 |
+
("Ville:", data.ville),
|
70 |
+
("Tél:", data.client_phone)
|
71 |
+
]
|
72 |
+
|
73 |
+
# Style client info as a box
|
74 |
+
client_box_start = current_row
|
75 |
+
for label, value in client_info:
|
76 |
+
ws[f'E{current_row}'].value = label
|
77 |
+
ws[f'F{current_row}'].value = value
|
78 |
+
style_cell(ws[f'E{current_row}'], bold=True)
|
79 |
+
style_cell(ws[f'F{current_row}'])
|
80 |
+
current_row += 1
|
81 |
+
|
82 |
+
# Draw border around client info
|
83 |
+
for row in range(client_box_start, current_row):
|
84 |
+
for col in ['E', 'F']:
|
85 |
+
ws[f'{col}{row}'].border = Border(
|
86 |
+
left=Side(style='thin'),
|
87 |
+
right=Side(style='thin'),
|
88 |
+
top=Side(style='thin') if row == client_box_start else None,
|
89 |
+
bottom=Side(style='thin') if row == current_row - 1 else None
|
90 |
+
)
|
91 |
+
|
92 |
+
current_row += 2
|
93 |
+
|
94 |
+
# Invoice Details Box (Date, N° Devis, PLANCHER)
|
95 |
+
info_rows = [
|
96 |
+
("Date du devis :", data.date.strftime("%d/%m/%Y")),
|
97 |
+
("N° Devis :", data.invoice_number),
|
98 |
+
("PLANCHER :", data.frame_number or "PH RDC")
|
99 |
+
]
|
100 |
+
|
101 |
+
for label, value in info_rows:
|
102 |
+
ws[f'A{current_row}'].value = label
|
103 |
+
ws[f'B{current_row}'].value = value
|
104 |
+
style_cell(ws[f'A{current_row}'], bold=True, color=WHITE, bg_color=HEADER_BLUE)
|
105 |
+
style_cell(ws[f'B{current_row}'])
|
106 |
+
current_row += 1
|
107 |
+
|
108 |
+
current_row += 1
|
109 |
+
|
110 |
+
# Table Headers
|
111 |
+
headers = ["Description", "Unité", "NBRE", "ML/Qté", "P.U", "Total HT"]
|
112 |
+
for col, header in enumerate(headers, 1):
|
113 |
+
cell = ws.cell(row=current_row, column=col, value=header)
|
114 |
+
style_cell(cell, bold=True, color=WHITE, bg_color=HEADER_BLUE)
|
115 |
+
current_row += 1
|
116 |
+
|
117 |
+
# Sections and Items
|
118 |
+
sections = [
|
119 |
+
("POUTRELLES", "PCP"),
|
120 |
+
("HOURDIS", "HOURDIS"),
|
121 |
+
("PANNEAU TREILLIS SOUDES", "PTS"),
|
122 |
+
("AGGLOS", "AGGLOS")
|
123 |
+
]
|
124 |
+
|
125 |
+
for section_name, keyword in sections:
|
126 |
+
items = [i for i in data.items if keyword in i.description]
|
127 |
+
if items:
|
128 |
+
# Section Header
|
129 |
+
ws.merge_cells(f'A{current_row}:F{current_row}')
|
130 |
+
ws[f'A{current_row}'].value = section_name
|
131 |
+
style_cell(ws[f'A{current_row}'], bold=True)
|
132 |
+
current_row += 1
|
133 |
+
|
134 |
+
# Items
|
135 |
+
for item in items:
|
136 |
+
ws.cell(row=current_row, column=1).value = item.description
|
137 |
+
ws.cell(row=current_row, column=2).value = item.unit
|
138 |
+
ws.cell(row=current_row, column=3).value = item.quantity
|
139 |
+
ws.cell(row=current_row, column=4).value = item.length
|
140 |
+
ws.cell(row=current_row, column=5).value = item.unit_price
|
141 |
+
ws.cell(row=current_row, column=6).value = item.total_price
|
142 |
+
|
143 |
+
# Style each cell in the row
|
144 |
+
for col in range(1, 7):
|
145 |
+
style_cell(ws.cell(row=current_row, column=col))
|
146 |
+
|
147 |
+
current_row += 1
|
148 |
+
|
149 |
+
current_row += 1
|
150 |
+
|
151 |
+
# NB Section with wrapped text
|
152 |
+
ws.merge_cells(f'A{current_row}:F{current_row}')
|
153 |
+
nb_cell = ws[f'A{current_row}']
|
154 |
+
nb_cell.value = "NB: Toute modification apportée aux plans BA initialement fournis, entraine automatiquement la modification de ce devis."
|
155 |
+
style_cell(nb_cell, wrap_text=True, align='left')
|
156 |
+
current_row += 2
|
157 |
+
|
158 |
+
# Totals Section
|
159 |
+
totals = [
|
160 |
+
("Total H.T", data.total_ht),
|
161 |
+
("TVA 20%", data.tax),
|
162 |
+
("Total TTC", data.total_ttc)
|
163 |
+
]
|
164 |
+
|
165 |
+
for label, value in totals:
|
166 |
+
ws[f'E{current_row}'].value = label
|
167 |
+
ws[f'F{current_row}'].value = f"{value:,.2f} DH"
|
168 |
+
style_cell(ws[f'E{current_row}'], bold=True)
|
169 |
+
style_cell(ws[f'F{current_row}'], bold=True)
|
170 |
+
current_row += 1
|
171 |
+
|
172 |
+
# Footer
|
173 |
+
current_row += 1
|
174 |
+
footer_texts = [
|
175 |
+
"CARRIPREFA",
|
176 |
+
"Douar Ait Laarassi Tidili, Cercle El Kelâa, Route de Safi, Km 14-40000 Marrakech",
|
177 |
+
"Tél: 05 24 01 33 34 Fax : 05 24 01 33 29 E-mail : [email protected]"
|
178 |
+
]
|
179 |
+
|
180 |
+
for text in footer_texts:
|
181 |
+
ws.merge_cells(f'A{current_row}:F{current_row}')
|
182 |
+
footer_cell = ws[f'A{current_row}']
|
183 |
+
footer_cell.value = text
|
184 |
+
style_cell(footer_cell)
|
185 |
+
current_row += 1
|
186 |
+
|
187 |
+
# Commercial information if available
|
188 |
+
if data.commercial and data.commercial.lower() != 'divers':
|
189 |
+
ws.merge_cells(f'A{current_row}:C{current_row}')
|
190 |
+
ws[f'A{current_row}'].value = f"Commercial: {data.commercial.upper()}"
|
191 |
+
style_cell(ws[f'A{current_row}'], align='left')
|
192 |
+
|
193 |
+
ws.merge_cells(f'D{current_row}:F{current_row}')
|
194 |
+
ws[f'D{current_row}'].value = datetime.datetime.now().strftime("%d/%m/%Y %H:%M")
|
195 |
+
style_cell(ws[f'D{current_row}'], align='right')
|
196 |
+
|
197 |
+
# Save to buffer
|
198 |
+
buffer = BytesIO()
|
199 |
+
wb.save(buffer)
|
200 |
+
buffer.seek(0)
|
201 |
+
return buffer.getvalue()
|
202 |
+
|
203 |
+
except Exception as e:
|
204 |
+
logger.error(f"Error in Excel generation: {str(e)}", exc_info=True)
|
205 |
+
raise
|
app/services/excel_service copy.py
ADDED
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
from openpyxl import Workbook
|
3 |
+
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
|
4 |
+
from openpyxl.utils import get_column_letter
|
5 |
+
from io import BytesIO
|
6 |
+
from app.db.models import Invoice
|
7 |
+
import logging
|
8 |
+
import os
|
9 |
+
import datetime
|
10 |
+
|
11 |
+
# Set up logging
|
12 |
+
logger = logging.getLogger(__name__)
|
13 |
+
|
14 |
+
class ExcelService:
|
15 |
+
@staticmethod
|
16 |
+
def generate_excel(data: Invoice) -> bytes:
|
17 |
+
try:
|
18 |
+
# Create a workbook and select the active worksheet
|
19 |
+
wb = Workbook()
|
20 |
+
ws = wb.active
|
21 |
+
|
22 |
+
# Constants
|
23 |
+
HEADER_BLUE = "00468B" # RGB for header blue
|
24 |
+
WHITE = "FFFFFF"
|
25 |
+
BLACK = "000000"
|
26 |
+
MARGIN = 2 # Excel doesn't use pixels, so we simulate margins with empty rows/columns
|
27 |
+
LINE_HEIGHT = 15 # Row height
|
28 |
+
STANDARD_FONT_SIZE = 10
|
29 |
+
BOTTOM_MARGIN = 5 # Minimum margin at the bottom of the sheet
|
30 |
+
|
31 |
+
# Helper function to apply borders to a cell range
|
32 |
+
def apply_border(cell_range):
|
33 |
+
thin_border = Border(
|
34 |
+
left=Side(style='thin'),
|
35 |
+
right=Side(style='thin'),
|
36 |
+
top=Side(style='thin'),
|
37 |
+
bottom=Side(style='thin')
|
38 |
+
)
|
39 |
+
for row in ws[cell_range]:
|
40 |
+
for cell in row:
|
41 |
+
cell.border = thin_border
|
42 |
+
|
43 |
+
# Helper function to merge cells and center text
|
44 |
+
def merge_and_center(cell_range, text, font=None, fill=None):
|
45 |
+
ws.merge_cells(cell_range)
|
46 |
+
cell = ws[cell_range.split(":")[0]]
|
47 |
+
cell.value = text
|
48 |
+
cell.alignment = Alignment(horizontal="center", vertical="center")
|
49 |
+
if font:
|
50 |
+
cell.font = font
|
51 |
+
if fill:
|
52 |
+
cell.fill = PatternFill(start_color=fill, end_color=fill, fill_type="solid")
|
53 |
+
|
54 |
+
# Set column widths and row heights
|
55 |
+
ws.column_dimensions['A'].width = 30
|
56 |
+
ws.column_dimensions['B'].width = 10
|
57 |
+
ws.column_dimensions['C'].width = 10
|
58 |
+
ws.column_dimensions['D'].width = 12
|
59 |
+
ws.column_dimensions['E'].width = 12
|
60 |
+
ws.column_dimensions['F'].width = 15
|
61 |
+
|
62 |
+
# Top section layout
|
63 |
+
current_row = 1
|
64 |
+
|
65 |
+
# Add logo (if available)
|
66 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
67 |
+
logo_path = os.path.join(current_dir, "..", "static", "logo.png")
|
68 |
+
if os.path.exists(logo_path):
|
69 |
+
from openpyxl.drawing.image import Image
|
70 |
+
img = Image(logo_path)
|
71 |
+
img.width = 100
|
72 |
+
img.height = 100
|
73 |
+
ws.add_image(img, 'A1')
|
74 |
+
current_row += 6 # Adjust row position after logo
|
75 |
+
|
76 |
+
# Add "DEVIS" text
|
77 |
+
ws.merge_cells(f"F{current_row}:G{current_row}")
|
78 |
+
ws[f"F{current_row}"].value = "DEVIS"
|
79 |
+
ws[f"F{current_row}"].font = Font(bold=True, size=36)
|
80 |
+
ws[f"F{current_row}"].alignment = Alignment(horizontal="right")
|
81 |
+
|
82 |
+
# Client info box
|
83 |
+
current_row += 1
|
84 |
+
client_box_start = f"F{current_row}"
|
85 |
+
client_box_end = f"G{current_row + 4}"
|
86 |
+
apply_border(f"{client_box_start}:{client_box_end}")
|
87 |
+
|
88 |
+
client_info = [
|
89 |
+
data.client_name,
|
90 |
+
data.project,
|
91 |
+
data.address,
|
92 |
+
data.ville,
|
93 |
+
data.client_phone
|
94 |
+
]
|
95 |
+
for i, info in enumerate(client_info):
|
96 |
+
ws[f"F{current_row + i}"].value = info
|
97 |
+
ws[f"F{current_row + i}"].alignment = Alignment(horizontal="center")
|
98 |
+
|
99 |
+
current_row += 6
|
100 |
+
|
101 |
+
# Info boxes (Date, N° Devis, PLANCHER)
|
102 |
+
for label, value in [
|
103 |
+
("Date du devis :", data.date.strftime("%d/%m/%Y")),
|
104 |
+
("N° Devis :", data.invoice_number),
|
105 |
+
("PLANCHER :", data.frame_number or "PH RDC")
|
106 |
+
]:
|
107 |
+
ws[f"A{current_row}"].value = label
|
108 |
+
ws[f"A{current_row}"].font = Font(bold=True, color=WHITE)
|
109 |
+
ws[f"A{current_row}"].fill = PatternFill(start_color=HEADER_BLUE, end_color=HEADER_BLUE, fill_type="solid")
|
110 |
+
ws[f"B{current_row}"].value = value
|
111 |
+
apply_border(f"A{current_row}:B{current_row}")
|
112 |
+
current_row += 1
|
113 |
+
|
114 |
+
# Table headers
|
115 |
+
headers = [
|
116 |
+
("Description", 30),
|
117 |
+
("Unité", 10),
|
118 |
+
("NBRE", 10),
|
119 |
+
("ML/Qté", 12),
|
120 |
+
("P.U", 12),
|
121 |
+
("Total HT", 15)
|
122 |
+
]
|
123 |
+
for col, (title, width) in enumerate(headers, start=1):
|
124 |
+
cell = ws.cell(row=current_row, column=col, value=title)
|
125 |
+
cell.font = Font(bold=True, color=WHITE)
|
126 |
+
cell.fill = PatternFill(start_color=HEADER_BLUE, end_color=HEADER_BLUE, fill_type="solid")
|
127 |
+
cell.alignment = Alignment(horizontal="center")
|
128 |
+
apply_border(f"A{current_row}:F{current_row}")
|
129 |
+
current_row += 1
|
130 |
+
|
131 |
+
# Draw sections and items
|
132 |
+
sections = [
|
133 |
+
("POUTRELLES", "PCP"),
|
134 |
+
("HOURDIS", "HOURDIS"),
|
135 |
+
("PANNEAU TREILLIS SOUDES", "PTS"),
|
136 |
+
("AGGLOS", "AGGLOS")
|
137 |
+
]
|
138 |
+
|
139 |
+
for section_title, keyword in sections:
|
140 |
+
items = [i for i in data.items if keyword in i.description]
|
141 |
+
if items:
|
142 |
+
ws[f"A{current_row}"].value = section_title
|
143 |
+
ws[f"A{current_row}"].font = Font(bold=True)
|
144 |
+
current_row += 1
|
145 |
+
|
146 |
+
for item in items:
|
147 |
+
ws[f"A{current_row}"].value = item.description
|
148 |
+
ws[f"B{current_row}"].value = item.unit
|
149 |
+
ws[f"C{current_row}"].value = item.quantity
|
150 |
+
ws[f"D{current_row}"].value = item.length
|
151 |
+
ws[f"E{current_row}"].value = item.unit_price
|
152 |
+
ws[f"F{current_row}"].value = item.total_price
|
153 |
+
apply_border(f"A{current_row}:F{current_row}")
|
154 |
+
current_row += 1
|
155 |
+
|
156 |
+
# NB box with text
|
157 |
+
ws[f"A{current_row}"].value = "NB:"
|
158 |
+
ws[f"A{current_row}"].font = Font(bold=True)
|
159 |
+
ws.merge_cells(f"B{current_row}:F{current_row}")
|
160 |
+
ws[f"B{current_row}"].value = "Toute modification apportée aux plans BA initialement fournis, entraine automatiquement la modification de ce devis."
|
161 |
+
ws[f"B{current_row}"].alignment = Alignment(wrap_text=True)
|
162 |
+
apply_border(f"A{current_row}:F{current_row}")
|
163 |
+
current_row += 2
|
164 |
+
|
165 |
+
# Totals section
|
166 |
+
totals = [
|
167 |
+
("Total H.T", data.total_ht),
|
168 |
+
("TVA 20%", data.tax),
|
169 |
+
("Total TTC", data.total_ttc)
|
170 |
+
]
|
171 |
+
for label, value in totals:
|
172 |
+
ws[f"E{current_row}"].value = label
|
173 |
+
ws[f"F{current_row}"].value = value
|
174 |
+
ws[f"F{current_row}"].font = Font(bold=True)
|
175 |
+
apply_border(f"E{current_row}:F{current_row}")
|
176 |
+
current_row += 1
|
177 |
+
|
178 |
+
# Footer
|
179 |
+
ws[f"A{current_row}"].value = "CARRIPREFA"
|
180 |
+
ws[f"A{current_row}"].font = Font(bold=True)
|
181 |
+
ws.merge_cells(f"A{current_row}:F{current_row}")
|
182 |
+
ws[f"A{current_row}"].alignment = Alignment(horizontal="center")
|
183 |
+
current_row += 1
|
184 |
+
|
185 |
+
ws[f"A{current_row}"].value = "Douar Ait Laarassi Tidili, Cercle El Kelâa, Route de Safi, Km 14-40000 Marrakech"
|
186 |
+
ws.merge_cells(f"A{current_row}:F{current_row}")
|
187 |
+
ws[f"A{current_row}"].alignment = Alignment(horizontal="center")
|
188 |
+
current_row += 1
|
189 |
+
|
190 |
+
# Commercial info
|
191 |
+
commercial_info = {
|
192 |
+
"salah": "06 62 29 99 78",
|
193 |
+
"khaled": "06 66 24 80 94",
|
194 |
+
"ismail": "06 66 24 50 15",
|
195 |
+
"jamal": "06 70 08 36 50"
|
196 |
+
}
|
197 |
+
if data.commercial and data.commercial.lower() != 'divers':
|
198 |
+
ws[f"A{current_row}"].value = f"Commercial: {data.commercial.upper()}"
|
199 |
+
ws[f"B{current_row}"].value = f"Tél: {commercial_info.get(data.commercial.lower(), '')}"
|
200 |
+
current_row += 1
|
201 |
+
|
202 |
+
ws[f"A{current_row}"].value = "Tél: 05 24 01 33 34 Fax : 05 24 01 33 29 E-mail : [email protected]"
|
203 |
+
ws.merge_cells(f"A{current_row}:F{current_row}")
|
204 |
+
ws[f"A{current_row}"].alignment = Alignment(horizontal="center")
|
205 |
+
current_row += 1
|
206 |
+
|
207 |
+
ws[f"A{current_row}"].value = f"Date: {datetime.datetime.now().strftime('%d/%m/%Y %H:%M')}"
|
208 |
+
ws[f"F{current_row}"].value = f"Page 1/1"
|
209 |
+
apply_border(f"A{current_row}:F{current_row}")
|
210 |
+
|
211 |
+
# Save to BytesIO buffer
|
212 |
+
buffer = BytesIO()
|
213 |
+
wb.save(buffer)
|
214 |
+
buffer.seek(0)
|
215 |
+
return buffer.getvalue()
|
216 |
+
|
217 |
+
except Exception as e:
|
218 |
+
logger.error(f"Error in Excel generation: {str(e)}", exc_info=True)
|
219 |
+
raise
|
220 |
+
|
app/services/excel_service.py
ADDED
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from openpyxl import Workbook
|
2 |
+
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
|
3 |
+
from openpyxl.utils import get_column_letter
|
4 |
+
from openpyxl.worksheet.pagebreak import Break # Add this import
|
5 |
+
from io import BytesIO
|
6 |
+
from app.db.models import Invoice
|
7 |
+
import logging
|
8 |
+
import os
|
9 |
+
import datetime
|
10 |
+
|
11 |
+
# Set up logging
|
12 |
+
logger = logging.getLogger(__name__)
|
13 |
+
|
14 |
+
class ExcelService:
|
15 |
+
@staticmethod
|
16 |
+
def generate_excel(data: Invoice) -> bytes:
|
17 |
+
try:
|
18 |
+
wb = Workbook()
|
19 |
+
ws = wb.active
|
20 |
+
ws.title = "Devis"
|
21 |
+
|
22 |
+
# Constants - Match PDF colors and dimensions
|
23 |
+
HEADER_BLUE = "4A739B" # Match PDF's HEADER_BLUE
|
24 |
+
WHITE = "FFFFFF"
|
25 |
+
BLACK = "000000"
|
26 |
+
STANDARD_FONT_SIZE = 11
|
27 |
+
|
28 |
+
# Helper function for cell styling
|
29 |
+
def style_cell(cell, bold=False, color=BLACK, bg_color=None, size=STANDARD_FONT_SIZE, wrap_text=False, align='center'):
|
30 |
+
cell.font = Font(bold=bold, color=color, size=size)
|
31 |
+
cell.alignment = Alignment(horizontal=align, vertical='center', wrap_text=wrap_text)
|
32 |
+
if bg_color:
|
33 |
+
cell.fill = PatternFill(start_color=bg_color, end_color=bg_color, fill_type="solid")
|
34 |
+
|
35 |
+
# Set fixed column widths to match PDF
|
36 |
+
column_widths = {
|
37 |
+
'A': 40, # Description
|
38 |
+
'B': 15, # Unité
|
39 |
+
'C': 15, # NBRE
|
40 |
+
'D': 15, # ML/Qté
|
41 |
+
'E': 15, # P.U
|
42 |
+
'F': 20, # Total HT
|
43 |
+
}
|
44 |
+
for col, width in column_widths.items():
|
45 |
+
ws.column_dimensions[col].width = width
|
46 |
+
|
47 |
+
# Add logo and company name
|
48 |
+
current_row = 1
|
49 |
+
logo_path = os.path.join(os.path.dirname(__file__), "..", "static", "logo.png")
|
50 |
+
if os.path.exists(logo_path):
|
51 |
+
from openpyxl.drawing.image import Image
|
52 |
+
img = Image(logo_path)
|
53 |
+
img.width = 100
|
54 |
+
img.height = 100
|
55 |
+
ws.add_image(img, 'A1')
|
56 |
+
current_row += 6
|
57 |
+
|
58 |
+
# Add DEVIS header
|
59 |
+
ws.merge_cells(f'E{current_row}:F{current_row}')
|
60 |
+
devis_cell = ws[f'E{current_row}']
|
61 |
+
devis_cell.value = "DEVIS"
|
62 |
+
style_cell(devis_cell, bold=True, size=36, align='right')
|
63 |
+
current_row += 2
|
64 |
+
|
65 |
+
# Client Information Box
|
66 |
+
client_info = [
|
67 |
+
("Client:", data.client_name),
|
68 |
+
("Projet:", data.project),
|
69 |
+
("Adresse:", data.address),
|
70 |
+
("Ville:", data.ville),
|
71 |
+
("Tél:", data.client_phone)
|
72 |
+
]
|
73 |
+
|
74 |
+
# Style client info as a box
|
75 |
+
client_box_start = current_row
|
76 |
+
for label, value in client_info:
|
77 |
+
ws[f'E{current_row}'].value = label
|
78 |
+
ws[f'F{current_row}'].value = value
|
79 |
+
style_cell(ws[f'E{current_row}'], bold=True)
|
80 |
+
style_cell(ws[f'F{current_row}'])
|
81 |
+
current_row += 1
|
82 |
+
|
83 |
+
# Draw border around client info
|
84 |
+
for row in range(client_box_start, current_row):
|
85 |
+
for col in ['E', 'F']:
|
86 |
+
ws[f'{col}{row}'].border = Border(
|
87 |
+
left=Side(style='thin'),
|
88 |
+
right=Side(style='thin'),
|
89 |
+
top=Side(style='thin') if row == client_box_start else None,
|
90 |
+
bottom=Side(style='thin') if row == current_row - 1 else None
|
91 |
+
)
|
92 |
+
|
93 |
+
current_row += 2
|
94 |
+
|
95 |
+
# Invoice Details Box (Date, N° Devis, PLANCHER)
|
96 |
+
info_rows = [
|
97 |
+
("Date du devis :", data.date.strftime("%d/%m/%Y")),
|
98 |
+
("N° Devis :", data.invoice_number),
|
99 |
+
("PLANCHER :", data.frame_number or "PH RDC")
|
100 |
+
]
|
101 |
+
|
102 |
+
for label, value in info_rows:
|
103 |
+
ws[f'A{current_row}'].value = label
|
104 |
+
ws[f'B{current_row}'].value = value
|
105 |
+
style_cell(ws[f'A{current_row}'], bold=True, color=WHITE, bg_color=HEADER_BLUE)
|
106 |
+
style_cell(ws[f'B{current_row}'])
|
107 |
+
current_row += 1
|
108 |
+
|
109 |
+
current_row += 1
|
110 |
+
|
111 |
+
# Table Headers
|
112 |
+
headers = ["Description", "Unité", "NBRE", "ML/Qté", "P.U", "Total HT"]
|
113 |
+
for col, header in enumerate(headers, 1):
|
114 |
+
cell = ws.cell(row=current_row, column=col, value=header)
|
115 |
+
style_cell(cell, bold=True, color=WHITE, bg_color=HEADER_BLUE)
|
116 |
+
current_row += 1
|
117 |
+
|
118 |
+
# Sections and Items
|
119 |
+
sections = [
|
120 |
+
("POUTRELLES", "PCP"),
|
121 |
+
("HOURDIS", "HOURDIS"),
|
122 |
+
("PANNEAU TREILLIS SOUDES", "PTS"),
|
123 |
+
("AGGLOS", "AGGLOS")
|
124 |
+
]
|
125 |
+
|
126 |
+
for section_name, keyword in sections:
|
127 |
+
items = [i for i in data.items if keyword in i.description]
|
128 |
+
if items:
|
129 |
+
# Section Header
|
130 |
+
ws.merge_cells(f'A{current_row}:F{current_row}')
|
131 |
+
ws[f'A{current_row}'].value = section_name
|
132 |
+
style_cell(ws[f'A{current_row}'], bold=True)
|
133 |
+
current_row += 1
|
134 |
+
|
135 |
+
# Items
|
136 |
+
for item in items:
|
137 |
+
ws.cell(row=current_row, column=1).value = item.description
|
138 |
+
ws.cell(row=current_row, column=2).value = item.unit
|
139 |
+
ws.cell(row=current_row, column=3).value = item.quantity
|
140 |
+
ws.cell(row=current_row, column=4).value = item.length
|
141 |
+
ws.cell(row=current_row, column=5).value = item.unit_price
|
142 |
+
ws.cell(row=current_row, column=6).value = item.total_price
|
143 |
+
|
144 |
+
# Style each cell in the row
|
145 |
+
for col in range(1, 7):
|
146 |
+
style_cell(ws.cell(row=current_row, column=col))
|
147 |
+
|
148 |
+
current_row += 1
|
149 |
+
|
150 |
+
current_row += 1
|
151 |
+
|
152 |
+
# NB Section with wrapped text
|
153 |
+
ws.merge_cells(f'A{current_row}:F{current_row}')
|
154 |
+
nb_cell = ws[f'A{current_row}']
|
155 |
+
nb_cell.value = "NB: Toute modification apportée aux plans BA initialement fournis, entraine automatiquement la modification de ce devis."
|
156 |
+
style_cell(nb_cell, wrap_text=True, align='left')
|
157 |
+
current_row += 2
|
158 |
+
|
159 |
+
# Totals Section
|
160 |
+
totals = [
|
161 |
+
("Total H.T", data.total_ht),
|
162 |
+
("TVA 20%", data.tax),
|
163 |
+
("Total TTC", data.total_ttc)
|
164 |
+
]
|
165 |
+
|
166 |
+
for label, value in totals:
|
167 |
+
ws[f'E{current_row}'].value = label
|
168 |
+
ws[f'F{current_row}'].value = f"{value:,.2f} DH"
|
169 |
+
style_cell(ws[f'E{current_row}'], bold=True)
|
170 |
+
style_cell(ws[f'F{current_row}'], bold=True)
|
171 |
+
current_row += 1
|
172 |
+
|
173 |
+
# Footer
|
174 |
+
current_row += 1
|
175 |
+
footer_texts = [
|
176 |
+
"CARRIPREFA",
|
177 |
+
"Douar Ait Laarassi Tidili, Cercle El Kelâa, Route de Safi, Km 14-40000 Marrakech",
|
178 |
+
"Tél: 05 24 01 33 34 Fax : 05 24 01 33 29 E-mail : [email protected]"
|
179 |
+
]
|
180 |
+
|
181 |
+
for text in footer_texts:
|
182 |
+
ws.merge_cells(f'A{current_row}:F{current_row}')
|
183 |
+
footer_cell = ws[f'A{current_row}']
|
184 |
+
footer_cell.value = text
|
185 |
+
style_cell(footer_cell)
|
186 |
+
current_row += 1
|
187 |
+
|
188 |
+
# Commercial information if available
|
189 |
+
if data.commercial and data.commercial.lower() != 'divers':
|
190 |
+
ws.merge_cells(f'A{current_row}:C{current_row}')
|
191 |
+
ws[f'A{current_row}'].value = f"Commercial: {data.commercial.upper()}"
|
192 |
+
style_cell(ws[f'A{current_row}'], align='left')
|
193 |
+
|
194 |
+
ws.merge_cells(f'D{current_row}:F{current_row}')
|
195 |
+
ws[f'D{current_row}'].value = datetime.datetime.now().strftime("%d/%m/%Y %H:%M")
|
196 |
+
style_cell(ws[f'D{current_row}'], align='right')
|
197 |
+
|
198 |
+
|
199 |
+
# Set print area and page setup
|
200 |
+
ws.page_setup.paperSize = ws.PAPERSIZE_A4
|
201 |
+
ws.page_setup.orientation = ws.ORIENTATION_PORTRAIT
|
202 |
+
ws.page_setup.fitToPage = True
|
203 |
+
ws.page_setup.fitToHeight = 1
|
204 |
+
ws.page_setup.fitToWidth = 1
|
205 |
+
ws.page_margins.left = 0.5
|
206 |
+
ws.page_margins.right = 0.5
|
207 |
+
ws.page_margins.top = 0.5
|
208 |
+
ws.page_margins.bottom = 0.5
|
209 |
+
ws.page_margins.header = 0.3
|
210 |
+
ws.page_margins.footer = 0.3
|
211 |
+
|
212 |
+
# Set row heights for better spacing
|
213 |
+
ws.row_dimensions[1].height = 80 # Logo row
|
214 |
+
for row in range(2, ws.max_row + 1):
|
215 |
+
ws.row_dimensions[row].height = 20 # Standard row height
|
216 |
+
|
217 |
+
# Add page breaks for better printing
|
218 |
+
ws.page_breaks = []
|
219 |
+
|
220 |
+
# Group rows for better organization
|
221 |
+
# Fix: change start_row/end_row to from_/to_
|
222 |
+
for row in range(client_box_start, current_row):
|
223 |
+
ws.row_dimensions[row].group = True
|
224 |
+
ws.row_dimensions[row].outline_level = 1
|
225 |
+
|
226 |
+
# Freeze panes to keep headers visible
|
227 |
+
ws.freeze_panes = 'A10' # Freeze after headers
|
228 |
+
|
229 |
+
# Print settings
|
230 |
+
ws.print_options.gridLines = False
|
231 |
+
ws.print_options.horizontalCentered = True
|
232 |
+
ws.print_options.verticalCentered = False
|
233 |
+
|
234 |
+
# Add print titles (repeat rows)
|
235 |
+
ws.print_title_rows = '1:9' # Repeat first 9 rows on each page
|
236 |
+
|
237 |
+
# Set the print area
|
238 |
+
ws.print_area = [f'A1:F{current_row}']
|
239 |
+
|
240 |
+
# Auto-fit all rows for content
|
241 |
+
for row in ws.rows:
|
242 |
+
max_height = 0
|
243 |
+
for cell in row:
|
244 |
+
if cell.value:
|
245 |
+
text_lines = str(cell.value).count('\n') + 1
|
246 |
+
max_height = max(max_height, text_lines * 15) # 15 points per line
|
247 |
+
if max_height > 0:
|
248 |
+
ws.row_dimensions[cell.row].height = max_height
|
249 |
+
|
250 |
+
# Add page break before totals section
|
251 |
+
ws.row_breaks.append(Break(id=current_row - 4))
|
252 |
+
|
253 |
+
# Ensure footer stays together
|
254 |
+
# Fix: Replace the old footer grouping with this
|
255 |
+
# Group footer rows together
|
256 |
+
for row in range(current_row - 3, current_row + 1):
|
257 |
+
ws.row_dimensions[row].group = True
|
258 |
+
ws.row_dimensions[row].outline_level = 1
|
259 |
+
ws.row_dimensions[row].hidden = False
|
260 |
+
|
261 |
+
# Save to buffer
|
262 |
+
buffer = BytesIO()
|
263 |
+
wb.save(buffer)
|
264 |
+
buffer.seek(0)
|
265 |
+
return buffer.getvalue()
|
266 |
+
|
267 |
+
except Exception as e:
|
268 |
+
logger.error(f"Error in Excel generation: {str(e)}", exc_info=True)
|
269 |
+
raise
|
app/services/invoice_service.py
CHANGED
@@ -43,6 +43,28 @@ class InvoiceService:
|
|
43 |
pdf.setFillColorRGB(*stroke_color)
|
44 |
pdf.rect(x, y, width, height, stroke=1)
|
45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
46 |
# Get the absolute path to the logo file
|
47 |
current_dir = os.path.dirname(os.path.abspath(__file__))
|
48 |
logo_path = os.path.join(current_dir, "..", "static", "logo.png")
|
@@ -81,13 +103,25 @@ class InvoiceService:
|
|
81 |
data.client_phone
|
82 |
]
|
83 |
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
91 |
|
92 |
# Info boxes (Date, N° Devis, PLANCHER) - adjusted starting position
|
93 |
info_y = top_margin - 50
|
|
|
43 |
pdf.setFillColorRGB(*stroke_color)
|
44 |
pdf.rect(x, y, width, height, stroke=1)
|
45 |
|
46 |
+
# Add this helper function after the existing helper functions
|
47 |
+
def draw_wrapped_text(pdf, text, x, y, width, font="Helvetica", size=STANDARD_FONT_SIZE):
|
48 |
+
"""Draw text wrapped to fit within a given width."""
|
49 |
+
pdf.setFont(font, size)
|
50 |
+
words = text.split()
|
51 |
+
lines = []
|
52 |
+
current_line = []
|
53 |
+
|
54 |
+
for word in words:
|
55 |
+
current_line.append(word)
|
56 |
+
line_width = pdf.stringWidth(' '.join(current_line), font, size)
|
57 |
+
if line_width > width:
|
58 |
+
current_line.pop() # Remove last word
|
59 |
+
if current_line: # Only add if there are words
|
60 |
+
lines.append(' '.join(current_line))
|
61 |
+
current_line = [word] # Start new line with the word that didn't fit
|
62 |
+
|
63 |
+
if current_line: # Add the last line
|
64 |
+
lines.append(' '.join(current_line))
|
65 |
+
|
66 |
+
return lines
|
67 |
+
|
68 |
# Get the absolute path to the logo file
|
69 |
current_dir = os.path.dirname(os.path.abspath(__file__))
|
70 |
logo_path = os.path.join(current_dir, "..", "static", "logo.png")
|
|
|
103 |
data.client_phone
|
104 |
]
|
105 |
|
106 |
+
available_height = box_height - BOX_PADDING * 2
|
107 |
+
y_position = box_y + box_height - BOX_PADDING
|
108 |
+
|
109 |
+
for text in client_info:
|
110 |
+
if text:
|
111 |
+
# Calculate available width for text
|
112 |
+
available_width = box_width - BOX_PADDING * 2
|
113 |
+
# Get wrapped lines
|
114 |
+
lines = draw_wrapped_text(pdf, str(text), box_x, y_position, available_width)
|
115 |
+
|
116 |
+
# Draw each line
|
117 |
+
for line in lines:
|
118 |
+
text_width = pdf.stringWidth(line, "Helvetica", STANDARD_FONT_SIZE)
|
119 |
+
x = box_x + (box_width - text_width) / 2
|
120 |
+
pdf.drawString(x, y_position, line)
|
121 |
+
y_position -= STANDARD_FONT_SIZE + 2 # Add some spacing between lines
|
122 |
+
|
123 |
+
# Add extra spacing between different info pieces
|
124 |
+
y_position -= 2
|
125 |
|
126 |
# Info boxes (Date, N° Devis, PLANCHER) - adjusted starting position
|
127 |
info_y = top_margin - 50
|
app/templates/index.html
CHANGED
@@ -422,9 +422,18 @@
|
|
422 |
|
423 |
<!-- Submit Button -->
|
424 |
<div class="d-grid gap-2 col-md-6 mx-auto mt-4">
|
425 |
-
<
|
426 |
-
<
|
427 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
428 |
</div>
|
429 |
</form>
|
430 |
</div>
|
@@ -907,6 +916,110 @@
|
|
907 |
if (selectedType) {
|
908 |
updateInvoiceNumber(selectedType);
|
909 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
910 |
});
|
911 |
</script>
|
912 |
</body>
|
|
|
422 |
|
423 |
<!-- Submit Button -->
|
424 |
<div class="d-grid gap-2 col-md-6 mx-auto mt-4">
|
425 |
+
<div class="row">
|
426 |
+
<div class="col-md-6">
|
427 |
+
<button type="submit" class="btn btn-success btn-lg w-100">
|
428 |
+
<i class="fas fa-file-pdf"></i> Générer PDF
|
429 |
+
</button>
|
430 |
+
</div>
|
431 |
+
<div class="col-md-6">
|
432 |
+
<button type="button" class="btn btn-primary btn-lg w-100" id="generateExcel">
|
433 |
+
<i class="fas fa-file-excel"></i> Générer Excel
|
434 |
+
</button>
|
435 |
+
</div>
|
436 |
+
</div>
|
437 |
</div>
|
438 |
</form>
|
439 |
</div>
|
|
|
916 |
if (selectedType) {
|
917 |
updateInvoiceNumber(selectedType);
|
918 |
}
|
919 |
+
|
920 |
+
// Replace the Excel generation handler with this updated version
|
921 |
+
document.getElementById('generateExcel').addEventListener('click', async () => {
|
922 |
+
try {
|
923 |
+
console.log('Starting Excel generation process...');
|
924 |
+
|
925 |
+
// Get all form data
|
926 |
+
const getAllItems = () => {
|
927 |
+
const items = [];
|
928 |
+
[poutrellesTable, hourdisTable, panneauTable, agglosTable].forEach(table => {
|
929 |
+
const tableItems = Array.from(table.querySelectorAll("tr")).map(row => ({
|
930 |
+
description: row.querySelector("input[name='description']").value,
|
931 |
+
unit: row.querySelector("input[name='unit']").value,
|
932 |
+
quantity: parseInt(row.querySelector("input[name='quantity']").value, 10),
|
933 |
+
length: parseFloat(row.querySelector("input[name='length']").value),
|
934 |
+
unit_price: parseFloat(row.querySelector("input[name='unitPrice']").value),
|
935 |
+
total_price: parseFloat(row.querySelector("input[name='totalHT']").value)
|
936 |
+
}));
|
937 |
+
console.log(`Found ${tableItems.length} items in ${table.id}`);
|
938 |
+
items.push(...tableItems);
|
939 |
+
});
|
940 |
+
return items;
|
941 |
+
};
|
942 |
+
|
943 |
+
const data = {
|
944 |
+
invoice_number: document.querySelector("#invoiceNumber").value,
|
945 |
+
date: new Date(document.querySelector("#date").value).toISOString(),
|
946 |
+
project: document.querySelector("#project").value,
|
947 |
+
client_name: document.querySelector("#clientName").value,
|
948 |
+
client_phone: document.querySelector("#clientPhone").value,
|
949 |
+
address: document.querySelector("#clientAddress").value,
|
950 |
+
ville: document.querySelector("#ville").value,
|
951 |
+
total_ht: parseFloat(document.querySelector("#totalHT").value || 0),
|
952 |
+
tax: parseFloat(document.querySelector("#tax").value || 0),
|
953 |
+
total_ttc: parseFloat(document.querySelector("#totalTTC").value || 0),
|
954 |
+
items: getAllItems(),
|
955 |
+
frame_number: document.querySelector("#plancher").value || "PH RDC",
|
956 |
+
status: "pending",
|
957 |
+
created_at: new Date().toISOString(),
|
958 |
+
commercial: document.querySelector("#commercial").value || "divers"
|
959 |
+
};
|
960 |
+
|
961 |
+
console.log('Form data collected:', {
|
962 |
+
invoice_number: data.invoice_number,
|
963 |
+
client_name: data.client_name,
|
964 |
+
items_count: data.items.length,
|
965 |
+
total_ttc: data.total_ttc
|
966 |
+
});
|
967 |
+
|
968 |
+
// First create the invoice if it doesn't exist
|
969 |
+
console.log('Creating invoice...');
|
970 |
+
const createResponse = await fetch("/api/invoices/", {
|
971 |
+
method: "POST",
|
972 |
+
headers: { "Content-Type": "application/json" },
|
973 |
+
body: JSON.stringify(data)
|
974 |
+
});
|
975 |
+
|
976 |
+
if (!createResponse.ok) {
|
977 |
+
throw new Error('Failed to create invoice');
|
978 |
+
}
|
979 |
+
|
980 |
+
const invoice = await createResponse.json();
|
981 |
+
console.log("Created invoice:", invoice.id);
|
982 |
+
|
983 |
+
// Then generate the Excel file
|
984 |
+
console.log('Generating Excel file...');
|
985 |
+
const response = await fetch(`/api/invoices/${invoice.id}/generate-excel`, {
|
986 |
+
method: 'POST',
|
987 |
+
headers: { 'Content-Type': 'application/json' },
|
988 |
+
body: JSON.stringify(invoice)
|
989 |
+
});
|
990 |
+
|
991 |
+
if (!response.ok) {
|
992 |
+
throw new Error('Failed to generate Excel file');
|
993 |
+
}
|
994 |
+
|
995 |
+
console.log('Excel file generated successfully');
|
996 |
+
|
997 |
+
// Handle file download
|
998 |
+
const blob = await response.blob();
|
999 |
+
const url = window.URL.createObjectURL(blob);
|
1000 |
+
const a = document.createElement('a');
|
1001 |
+
a.href = url;
|
1002 |
+
a.download = `devis_${invoice.invoice_number}.xlsx`;
|
1003 |
+
document.body.appendChild(a);
|
1004 |
+
a.click();
|
1005 |
+
document.body.removeChild(a);
|
1006 |
+
window.URL.revokeObjectURL(url);
|
1007 |
+
console.log('Excel file downloaded');
|
1008 |
+
|
1009 |
+
// Update invoice number after successful generation
|
1010 |
+
const selectedType = document.querySelector('input[name="clientType"]:checked')?.value;
|
1011 |
+
if (selectedType) {
|
1012 |
+
console.log('Updating invoice number...');
|
1013 |
+
await updateInvoiceNumber(selectedType);
|
1014 |
+
}
|
1015 |
+
|
1016 |
+
console.log('Excel generation process completed successfully');
|
1017 |
+
|
1018 |
+
} catch (error) {
|
1019 |
+
console.error('Error in Excel generation:', error);
|
1020 |
+
alert('Error generating Excel file: ' + error.message);
|
1021 |
+
}
|
1022 |
+
});
|
1023 |
});
|
1024 |
</script>
|
1025 |
</body>
|
requirements.txt
CHANGED
@@ -9,4 +9,5 @@ python-multipart==0.0.6
|
|
9 |
reportlab==4.0.8
|
10 |
python-jose==3.3.0
|
11 |
passlib==1.7.4
|
12 |
-
alembic==1.13.1
|
|
|
|
9 |
reportlab==4.0.8
|
10 |
python-jose==3.3.0
|
11 |
passlib==1.7.4
|
12 |
+
alembic==1.13.1
|
13 |
+
openpyxl
|
sql_app.db
ADDED
Binary file (20.5 kB). View file
|
|