EL GHAFRAOUI AYOUB commited on
Commit
4e55f01
·
1 Parent(s): 73bbeaf
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
- # Center and draw each line of client info
85
- line_height = box_height / (len(client_info) + 1)
86
- for i, text in enumerate(client_info):
87
- text_width = pdf.stringWidth(str(text), "Helvetica", STANDARD_FONT_SIZE)
88
- x = box_x + (box_width - text_width) / 2
89
- y = box_y + box_height - ((i + 1) * line_height)
90
- pdf.drawString(x, y, str(text))
 
 
 
 
 
 
 
 
 
 
 
 
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
- <button type="submit" class="btn btn-success btn-lg">
426
- <i class="fas fa-file-pdf"></i> Générer le Devis
427
- </button>
 
 
 
 
 
 
 
 
 
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