EL GHAFRAOUI AYOUB commited on
Commit
d2aae1f
·
1 Parent(s): 535824e
app/db/__pycache__/database.cpython-312.pyc DELETED
Binary file (5.85 kB)
 
app/db/__pycache__/models.cpython-312.pyc DELETED
Binary file (2.32 kB)
 
app/db/database.py DELETED
@@ -1,118 +0,0 @@
1
- from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
2
- from sqlalchemy.orm import sessionmaker
3
- from sqlalchemy.exc import SQLAlchemyError
4
- from sqlalchemy.pool import QueuePool
5
- from fastapi import HTTPException
6
- import asyncio
7
- import logging
8
- from app.db.models import Base
9
-
10
- # Set up logging
11
- logging.basicConfig(level=logging.INFO)
12
- logger = logging.getLogger(__name__)
13
-
14
- # Use SQLite with aiosqlite and connection pooling
15
- DATABASE_URL = "sqlite+aiosqlite:///./sql_app.db"
16
-
17
- # Configure the engine with connection pooling and timeouts
18
- engine = create_async_engine(
19
- DATABASE_URL,
20
- echo=True,
21
- pool_size=20, # Maximum number of connections in the pool
22
- max_overflow=10, # Maximum number of connections that can be created beyond pool_size
23
- pool_timeout=30, # Timeout for getting a connection from the pool
24
- pool_recycle=1800, # Recycle connections after 30 minutes
25
- pool_pre_ping=True, # Enable connection health checks
26
- poolclass=QueuePool
27
- )
28
-
29
- # Configure session with retry logic
30
- AsyncSessionLocal = sessionmaker(
31
- engine,
32
- class_=AsyncSession,
33
- expire_on_commit=False
34
- )
35
-
36
- # Semaphore to limit concurrent database operations
37
- MAX_CONCURRENT_DB_OPS = 10
38
- db_semaphore = asyncio.Semaphore(MAX_CONCURRENT_DB_OPS)
39
-
40
- async def get_db() -> AsyncSession:
41
- async with db_semaphore: # Limit concurrent database operations
42
- session = AsyncSessionLocal()
43
- try:
44
- yield session
45
- except SQLAlchemyError as e:
46
- logger.error(f"Database error: {str(e)}")
47
- await session.rollback()
48
- raise HTTPException(
49
- status_code=503,
50
- detail="Database service temporarily unavailable. Please try again."
51
- )
52
- except Exception as e:
53
- logger.error(f"Unexpected error: {str(e)}")
54
- await session.rollback()
55
- raise HTTPException(
56
- status_code=500,
57
- detail="An unexpected error occurred. Please try again."
58
- )
59
- finally:
60
- await session.close()
61
-
62
- # Rate limiting configuration
63
- from fastapi import Request
64
- import time
65
- from collections import defaultdict
66
-
67
- class RateLimiter:
68
- def __init__(self, requests_per_minute=60):
69
- self.requests_per_minute = requests_per_minute
70
- self.requests = defaultdict(list)
71
-
72
- def is_allowed(self, client_ip: str) -> bool:
73
- now = time.time()
74
- minute_ago = now - 60
75
-
76
- # Clean old requests
77
- self.requests[client_ip] = [req_time for req_time in self.requests[client_ip]
78
- if req_time > minute_ago]
79
-
80
- # Check if allowed
81
- if len(self.requests[client_ip]) >= self.requests_per_minute:
82
- return False
83
-
84
- # Add new request
85
- self.requests[client_ip].append(now)
86
- return True
87
-
88
- rate_limiter = RateLimiter(requests_per_minute=60)
89
-
90
- # Add this to your database initialization
91
- async def init_db():
92
- try:
93
- async with engine.begin() as conn:
94
- # Check if the database already has tables
95
- if not await database_exists(conn):
96
- logger.info("Initializing database: Creating tables...")
97
- await conn.run_sync(Base.metadata.create_all)
98
- logger.info("Database initialization completed successfully")
99
- else:
100
- logger.info("Database already exists. Skipping initialization.")
101
- except Exception as e:
102
- logger.error(f"Error initializing database: {str(e)}")
103
- raise
104
-
105
- async def database_exists(conn) -> bool:
106
- """Check if any tables exist in the database."""
107
- try:
108
- result = await conn.run_sync(
109
- lambda sync_conn: sync_conn.dialect.has_table(sync_conn, "invoices")
110
- )
111
- return result
112
- except Exception as e:
113
- logger.error(f"Error checking database existence: {str(e)}")
114
- return False
115
-
116
-
117
-
118
- ###sdf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/db/models.py DELETED
@@ -1,45 +0,0 @@
1
- from sqlalchemy.orm import DeclarativeBase, relationship
2
- from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
3
- from sqlalchemy.ext.declarative import declarative_base
4
- from datetime import datetime
5
- from sqlalchemy.sql import func
6
-
7
- Base = declarative_base()
8
-
9
- class Invoice(Base):
10
- __tablename__ = "invoices"
11
-
12
- id = Column(Integer, primary_key=True, index=True)
13
- invoice_number = Column(String)
14
- date = Column(DateTime, default=datetime.now)
15
- project = Column(String)
16
- client_name = Column(String)
17
- phone1 = Column(String) # Changed from client_phone
18
- phone2 = Column(String, nullable=True) # Added second phone number
19
- address = Column(String)
20
- ville = Column(String) # Add ville field
21
- total_ht = Column(Float)
22
- tax = Column(Float)
23
- total_ttc = Column(Float)
24
- frame_number = Column(String, nullable=True)
25
- customer_name = Column(String)
26
- amount = Column(Float)
27
- status = Column(String, default="pending")
28
- created_at = Column(DateTime, default=datetime.utcnow)
29
- commercial = Column(String, default="divers")
30
-
31
- items = relationship("InvoiceItem", back_populates="invoice", cascade="all, delete-orphan")
32
-
33
- class InvoiceItem(Base):
34
- __tablename__ = "invoice_items"
35
-
36
- id = Column(Integer, primary_key=True, index=True)
37
- invoice_id = Column(Integer, ForeignKey("invoices.id"))
38
- description = Column(String)
39
- unit = Column(String)
40
- quantity = Column(Integer)
41
- length = Column(Float)
42
- unit_price = Column(Float)
43
- total_price = Column(Float)
44
-
45
- invoice = relationship("Invoice", back_populates="items")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/routes/__init__.py DELETED
@@ -1 +0,0 @@
1
- # Empty file to make the directory a Python package
 
 
app/routes/__pycache__/__init__.cpython-312.pyc DELETED
Binary file (182 Bytes)
 
app/routes/__pycache__/invoices.cpython-312.pyc DELETED
Binary file (14.4 kB)
 
app/routes/invoices.py DELETED
@@ -1,295 +0,0 @@
1
- from fastapi import APIRouter, Depends, HTTPException, Request
2
- from sqlalchemy.ext.asyncio import AsyncSession
3
- from sqlalchemy import select, func, Sequence, delete
4
- from typing import List, Optional
5
- from sqlalchemy.orm import selectinload, joinedload, Session
6
- from fastapi.responses import StreamingResponse, Response
7
- import logging
8
- from datetime import datetime
9
-
10
- 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
- from app.services.excel_service import ExcelService
15
- #from app.services.invoice_page2_service import InvoiceServicePage2
16
- from app.services.invoice_service_page2 import InvoiceServicePage2
17
- # Merge PDFs using PyPDF2
18
- from pypdf import PdfWriter, PdfReader
19
- from io import BytesIO
20
-
21
- # Set up logging
22
- logger = logging.getLogger(__name__)
23
-
24
- router = APIRouter(prefix="/api/invoices", tags=["invoices"])
25
-
26
- @router.post("/", response_model=InvoiceResponse)
27
- async def create_new_invoice(invoice: InvoiceCreate, db: AsyncSession = Depends(get_db)):
28
- try:
29
- # Create new invoice
30
- new_invoice = Invoice(
31
- invoice_number=invoice.invoice_number,
32
- date=invoice.date or datetime.now(),
33
- project=invoice.project,
34
- client_name=invoice.client_name,
35
- phone1=invoice.phone1,
36
- phone2=invoice.phone2,
37
- address=invoice.address,
38
- frame_number=invoice.frame_number,
39
- total_ht=invoice.total_ht,
40
- tax=invoice.tax,
41
- total_ttc=invoice.total_ttc,
42
- commercial=invoice.commercial,
43
- status=invoice.status
44
- )
45
-
46
- db.add(new_invoice)
47
- await db.commit()
48
- await db.refresh(new_invoice)
49
-
50
- # Create invoice items with total_price calculation
51
- for item in invoice.items:
52
- total_price = item.quantity * item.length * item.unit_price # Calculate total_price
53
- invoice_item = InvoiceItem(
54
- invoice_id=new_invoice.id,
55
- description=item.description,
56
- unit=item.unit,
57
- quantity=item.quantity,
58
- length=item.length,
59
- unit_price=item.unit_price,
60
- total_price=total_price # Set the calculated total_price
61
- )
62
- db.add(invoice_item)
63
-
64
- await db.commit()
65
-
66
- # Fetch the complete invoice with items
67
- result = await db.execute(
68
- select(Invoice)
69
- .options(selectinload(Invoice.items))
70
- .filter(Invoice.id == new_invoice.id)
71
- )
72
- complete_invoice = result.scalar_one()
73
-
74
- return complete_invoice
75
-
76
- except Exception as e:
77
- await db.rollback()
78
- logger.error(f"Error creating invoice: {str(e)}")
79
- raise HTTPException(status_code=500, detail=str(e))
80
-
81
- @router.get("/{invoice_id}", response_model=InvoiceResponse)
82
- async def read_invoice(invoice_id: int, db: AsyncSession = Depends(get_db)):
83
- result = await db.execute(
84
- select(Invoice)
85
- .options(selectinload(Invoice.items))
86
- .filter(Invoice.id == invoice_id)
87
- )
88
- invoice = result.scalar_one_or_none()
89
- if invoice is None:
90
- raise HTTPException(status_code=404, detail="Invoice not found")
91
- return invoice
92
-
93
- @router.get("/", response_model=List[InvoiceResponse])
94
- async def read_invoices(
95
- skip: int = 0,
96
- limit: int = 100,
97
- db: AsyncSession = Depends(get_db)
98
- ):
99
- result = await db.execute(
100
- select(Invoice)
101
- .options(selectinload(Invoice.items))
102
- .offset(skip)
103
- .limit(limit)
104
- )
105
- return result.scalars().all()
106
-
107
- @router.post("/{invoice_id}/generate-pdf")
108
- async def generate_invoice_pdf(
109
- invoice_id: int,
110
- request: Request,
111
- db: AsyncSession = Depends(get_db)
112
- ):
113
- try:
114
- # Get invoice with items
115
- result = await db.execute(
116
- select(Invoice)
117
- .options(selectinload(Invoice.items))
118
- .filter(Invoice.id == invoice_id)
119
- )
120
- invoice = result.scalar_one_or_none()
121
-
122
- if not invoice:
123
- raise HTTPException(status_code=404, detail="Invoice not found")
124
-
125
- logger.info(f"Generating PDF for invoice: {invoice_id}")
126
-
127
- # Generate both pages
128
- pdf_bytes1 = InvoiceService.generate_pdf(invoice)
129
- pdf_bytes2 = InvoiceServicePage2.generate_pdf(invoice)
130
-
131
- # Create a PDF writer object
132
- writer = PdfWriter()
133
-
134
- # Add pages from both PDFs using PdfReader
135
- writer.add_page(PdfReader(BytesIO(pdf_bytes1)).pages[0])
136
- writer.add_page(PdfReader(BytesIO(pdf_bytes2)).pages[0])
137
-
138
- # Write the merged PDF to a BytesIO object
139
- output = BytesIO()
140
- writer.write(output)
141
-
142
- # Get the merged PDF bytes
143
- output.seek(0)
144
- final_pdf = output.getvalue()
145
-
146
- return StreamingResponse(
147
- iter([final_pdf]),
148
- media_type="application/pdf",
149
- headers={"Content-Disposition": f"attachment; filename=devis_{invoice_id}.pdf"}
150
- )
151
- except Exception as e:
152
- logger.error(f"Error generating PDF: {str(e)}", exc_info=True)
153
- raise HTTPException(status_code=500, detail=str(e))
154
-
155
- @router.get("/last-number/{client_type}", response_model=dict)
156
- async def get_last_invoice_number(
157
- client_type: str,
158
- db: AsyncSession = Depends(get_db)
159
- ):
160
- try:
161
- # Use async query syntax
162
- query = select(func.max(Invoice.id))
163
- result = await db.execute(query)
164
- current_max_id = result.scalar() or 0
165
-
166
- # Next ID will be current max + 1
167
- next_id = current_max_id + 1
168
-
169
- # Format the invoice number with the next ID
170
- formatted_number = f"DCP/{client_type}/{next_id:04d}"
171
-
172
- return {
173
- "next_number": next_id,
174
- "formatted_number": formatted_number
175
- }
176
- except Exception as e:
177
- print(f"Error in get_last_invoice_number: {str(e)}")
178
- raise HTTPException(status_code=500, detail=str(e))
179
-
180
- @router.post("/reset-sequence")
181
- def reset_invoice_sequence(db: Session = Depends(get_db)):
182
- try:
183
- # Reset the sequence to 1
184
- db.execute("ALTER SEQUENCE invoice_id_seq RESTART WITH 1")
185
- db.commit()
186
- return {"message": "Sequence reset successfully"}
187
- except Exception as e:
188
- db.rollback()
189
- raise HTTPException(status_code=500, detail=str(e))
190
-
191
- @router.put("/{invoice_id}", response_model=InvoiceResponse)
192
- async def update_invoice(
193
- invoice_id: int,
194
- invoice_update: InvoiceCreate,
195
- db: AsyncSession = Depends(get_db)
196
- ):
197
- try:
198
- # Get existing invoice
199
- result = await db.execute(
200
- select(Invoice)
201
- .options(selectinload(Invoice.items))
202
- .filter(Invoice.id == invoice_id)
203
- )
204
- invoice = result.scalar_one_or_none()
205
-
206
- if not invoice:
207
- raise HTTPException(status_code=404, detail="Invoice not found")
208
-
209
- # Update invoice fields
210
- for field, value in invoice_update.dict(exclude={'items'}).items():
211
- setattr(invoice, field, value)
212
-
213
- # Delete existing items
214
- await db.execute(delete(InvoiceItem).where(InvoiceItem.invoice_id == invoice_id))
215
-
216
- # Create new items with total_price calculation
217
- for item_data in invoice_update.items:
218
- total_price = item_data.quantity * item_data.length * item_data.unit_price
219
- db_item = InvoiceItem(
220
- invoice_id=invoice_id,
221
- description=item_data.description,
222
- unit=item_data.unit,
223
- quantity=item_data.quantity,
224
- length=item_data.length,
225
- unit_price=item_data.unit_price,
226
- total_price=total_price
227
- )
228
- db.add(db_item)
229
-
230
- await db.commit()
231
- await db.refresh(invoice)
232
-
233
- return invoice
234
- except Exception as e:
235
- await db.rollback()
236
- raise HTTPException(status_code=500, detail=str(e))
237
-
238
- @router.put("/{invoice_id}/status")
239
- async def update_invoice_status(
240
- invoice_id: int,
241
- status_update: dict,
242
- db: AsyncSession = Depends(get_db)
243
- ):
244
- try:
245
- # Get existing invoice
246
- result = await db.execute(
247
- select(Invoice)
248
- .filter(Invoice.id == invoice_id)
249
- )
250
- invoice = result.scalar_one_or_none()
251
-
252
- if not invoice:
253
- raise HTTPException(status_code=404, detail="Invoice not found")
254
-
255
- # Update status
256
- invoice.status = status_update.get("status")
257
- await db.commit()
258
-
259
- return {"message": "Status updated successfully"}
260
- except Exception as e:
261
- await db.rollback()
262
- raise HTTPException(status_code=500, detail=str(e))
263
-
264
- @router.post("/{invoice_id}/generate-excel")
265
- async def generate_excel(
266
- invoice_id: int,
267
- db: AsyncSession = Depends(get_db)
268
- ):
269
- try:
270
- # Get invoice with items
271
- result = await db.execute(
272
- select(Invoice)
273
- .options(selectinload(Invoice.items))
274
- .filter(Invoice.id == invoice_id)
275
- )
276
- invoice = result.scalar_one_or_none()
277
-
278
- if not invoice:
279
- raise HTTPException(status_code=404, detail="Invoice not found")
280
-
281
- logger.info(f"Generating Excel for invoice: {invoice_id}")
282
-
283
- # Generate Excel
284
- excel_bytes = ExcelService.generate_excel(invoice)
285
-
286
- return Response(
287
- content=excel_bytes,
288
- media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
289
- headers={
290
- "Content-Disposition": f"attachment; filename=devis_{invoice_id}.xlsx"
291
- }
292
- )
293
- except Exception as e:
294
- logger.error(f"Error generating Excel: {str(e)}", exc_info=True)
295
- raise HTTPException(status_code=500, detail=str(e))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/schemas/__init__.py DELETED
@@ -1 +0,0 @@
1
- # Empty file to make the directory a Python package
 
 
app/schemas/__pycache__/__init__.cpython-312.pyc DELETED
Binary file (183 Bytes)
 
app/schemas/__pycache__/invoice.cpython-312.pyc DELETED
Binary file (2.93 kB)
 
app/schemas/invoice.py DELETED
@@ -1,53 +0,0 @@
1
- from pydantic import BaseModel, computed_field
2
- from typing import List, Optional
3
- from datetime import datetime
4
-
5
- class InvoiceItemCreate(BaseModel):
6
- description: str
7
- unit: str
8
- quantity: int
9
- length: float
10
- unit_price: float
11
-
12
- @computed_field
13
- def total_price(self) -> float:
14
- return self.quantity * self.length * self.unit_price
15
-
16
- class InvoiceCreate(BaseModel):
17
- invoice_number: str
18
- date: Optional[datetime] = None
19
- project: str
20
- client_name: str
21
- phone1: str
22
- phone2: Optional[str] = None
23
- address: str
24
- ville: Optional[str] = None
25
- total_ht: float
26
- tax: float
27
- total_ttc: float
28
- frame_number: Optional[str] = None
29
- items: List[InvoiceItemCreate]
30
- commercial: Optional[str] = "divers"
31
- status: Optional[str] = "pending"
32
-
33
- @computed_field
34
- def customer_name(self) -> str:
35
- return self.client_name
36
-
37
- @computed_field
38
- def amount(self) -> float:
39
- return self.total_ttc
40
-
41
- class InvoiceItemResponse(InvoiceItemCreate):
42
- id: int
43
- invoice_id: int
44
-
45
- class Config:
46
- from_attributes = True
47
-
48
- class InvoiceResponse(InvoiceCreate):
49
- id: int
50
- items: List[InvoiceItemResponse]
51
-
52
- class Config:
53
- from_attributes = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/services/__pycache__/excel_service.cpython-312.pyc DELETED
Binary file (15.8 kB)
 
app/services/__pycache__/invoice_service.cpython-312.pyc DELETED
Binary file (19.5 kB)
 
app/services/__pycache__/invoice_service_page2.cpython-312.pyc DELETED
Binary file (15.4 kB)
 
app/services/excel_service copy 2.py DELETED
@@ -1,205 +0,0 @@
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 : carriprefa@gmail.com"
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 DELETED
@@ -1,220 +0,0 @@
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 : carriprefa@gmail.com"
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 DELETED
@@ -1,382 +0,0 @@
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
- import io
11
- import xlsxwriter
12
-
13
- # Set up logging
14
- logger = logging.getLogger(__name__)
15
-
16
- class ExcelService:
17
- @staticmethod
18
- def generate_excel(invoice: Invoice) -> bytes:
19
- # Create an in-memory output file
20
- output = io.BytesIO()
21
-
22
- # Create a new workbook and add a worksheet
23
- workbook = xlsxwriter.Workbook(output)
24
- worksheet = workbook.add_worksheet()
25
-
26
- # Add formats
27
- header_format = workbook.add_format({
28
- 'bold': True,
29
- 'align': 'center',
30
- 'valign': 'vcenter',
31
- 'border': 1
32
- })
33
-
34
- cell_format = workbook.add_format({
35
- 'align': 'center',
36
- 'valign': 'vcenter',
37
- 'border': 1
38
- })
39
-
40
- # Set column widths
41
- worksheet.set_column('A:A', 30) # Description
42
- worksheet.set_column('B:B', 10) # Unit
43
- worksheet.set_column('C:C', 10) # Quantity
44
- worksheet.set_column('D:D', 10) # Length
45
- worksheet.set_column('E:E', 12) # Unit Price
46
- worksheet.set_column('F:F', 15) # Total HT
47
-
48
- # Write headers
49
- headers = ['Description', 'Unité', 'Quantité', 'Longueur', 'P.U', 'Total HT']
50
- for col, header in enumerate(headers):
51
- worksheet.write(0, col, header, header_format)
52
-
53
- # Write invoice information at the top
54
- info_format = workbook.add_format({'bold': True})
55
- worksheet.write('A1', f'Devis N°: {invoice.invoice_number}', info_format)
56
- worksheet.write('A2', f'Date: {invoice.date.strftime("%d/%m/%Y")}', info_format)
57
- worksheet.write('A3', f'Client: {invoice.client_name}', info_format)
58
- worksheet.write('A4', f'Projet: {invoice.project}', info_format)
59
-
60
- # Start writing items from row 6
61
- current_row = 5
62
-
63
- # Write items
64
- for item in invoice.items:
65
- worksheet.write(current_row, 0, item.description, cell_format)
66
- worksheet.write(current_row, 1, item.unit, cell_format)
67
- worksheet.write(current_row, 2, item.quantity, cell_format)
68
- worksheet.write(current_row, 3, item.length, cell_format)
69
- worksheet.write(current_row, 4, item.unit_price, cell_format)
70
- worksheet.write(current_row, 5, item.total_price, cell_format)
71
- current_row += 1
72
-
73
- # Write totals
74
- total_format = workbook.add_format({
75
- 'bold': True,
76
- 'align': 'right',
77
- 'border': 1
78
- })
79
-
80
- current_row += 1
81
- worksheet.write(current_row, 4, 'Total HT:', total_format)
82
- worksheet.write(current_row, 5, invoice.total_ht, cell_format)
83
-
84
- current_row += 1
85
- worksheet.write(current_row, 4, 'TVA (20%):', total_format)
86
- worksheet.write(current_row, 5, invoice.tax, cell_format)
87
-
88
- current_row += 1
89
- worksheet.write(current_row, 4, 'Total TTC:', total_format)
90
- worksheet.write(current_row, 5, invoice.total_ttc, cell_format)
91
-
92
- # Close the workbook
93
- workbook.close()
94
-
95
- # Seek to the beginning of the stream
96
- output.seek(0)
97
-
98
- return output.getvalue()
99
-
100
- @staticmethod
101
- def generate_excel_openpyxl(data: Invoice) -> bytes:
102
- try:
103
- wb = Workbook()
104
- ws = wb.active
105
- ws.title = "Devis"
106
-
107
- # Constants - Match PDF colors and dimensions
108
- HEADER_BLUE = "4A739B" # Match PDF's HEADER_BLUE
109
- WHITE = "FFFFFF"
110
- BLACK = "000000"
111
- STANDARD_FONT_SIZE = 11
112
-
113
- # Helper function for cell styling
114
- def style_cell(cell, bold=False, color=BLACK, bg_color=None, size=STANDARD_FONT_SIZE, wrap_text=False, align='center'):
115
- cell.font = Font(bold=bold, color=color, size=size)
116
- cell.alignment = Alignment(horizontal=align, vertical='center', wrap_text=wrap_text)
117
- if bg_color:
118
- cell.fill = PatternFill(start_color=bg_color, end_color=bg_color, fill_type="solid")
119
-
120
- # Set fixed column widths to match PDF
121
- column_widths = {
122
- 'A': 40, # Description
123
- 'B': 15, # Unité
124
- 'C': 15, # NBRE
125
- 'D': 15, # ML/Qté
126
- 'E': 15, # P.U
127
- 'F': 20, # Total HT
128
- }
129
- for col, width in column_widths.items():
130
- ws.column_dimensions[col].width = width
131
-
132
- # Add logo and company name
133
- current_row = 1
134
- logo_path = os.path.join(os.path.dirname(__file__), "..", "static", "logo.png")
135
- if os.path.exists(logo_path):
136
- from openpyxl.drawing.image import Image
137
- img = Image(logo_path)
138
- img.width = 100
139
- img.height = 100
140
- ws.add_image(img, 'A1')
141
- current_row += 6
142
-
143
- # Add DEVIS header
144
- ws.merge_cells(f'E{current_row}:F{current_row}')
145
- devis_cell = ws[f'E{current_row}']
146
- devis_cell.value = "DEVIS"
147
- style_cell(devis_cell, bold=True, size=36, align='right')
148
- current_row += 2
149
-
150
- # Client Information Box
151
- client_info = [
152
- ("Client:", data.client_name),
153
- ("Projet:", data.project),
154
- ("Adresse:", data.address),
155
- ("Ville:", data.ville),
156
- ("Tél:", data.client_phone)
157
- ]
158
-
159
- # Style client info as a box
160
- client_box_start = current_row
161
- for label, value in client_info:
162
- ws[f'E{current_row}'].value = label
163
- ws[f'F{current_row}'].value = value
164
- style_cell(ws[f'E{current_row}'], bold=True)
165
- style_cell(ws[f'F{current_row}'])
166
- current_row += 1
167
-
168
- # Draw border around client info
169
- for row in range(client_box_start, current_row):
170
- for col in ['E', 'F']:
171
- ws[f'{col}{row}'].border = Border(
172
- left=Side(style='thin'),
173
- right=Side(style='thin'),
174
- top=Side(style='thin') if row == client_box_start else None,
175
- bottom=Side(style='thin') if row == current_row - 1 else None
176
- )
177
-
178
- current_row += 2
179
-
180
- # Invoice Details Box (Date, N° Devis, PLANCHER)
181
- info_rows = [
182
- ("Date du devis :", data.date.strftime("%d/%m/%Y")),
183
- ("N° Devis :", data.invoice_number),
184
- ("PLANCHER :", data.frame_number or "PH RDC")
185
- ]
186
-
187
- for label, value in info_rows:
188
- ws[f'A{current_row}'].value = label
189
- ws[f'B{current_row}'].value = value
190
- style_cell(ws[f'A{current_row}'], bold=True, color=WHITE, bg_color=HEADER_BLUE)
191
- style_cell(ws[f'B{current_row}'])
192
- current_row += 1
193
-
194
- current_row += 1
195
-
196
- # Table Headers
197
- headers = ["Description", "Unité", "NBRE", "ML/Qté", "P.U", "Total HT"]
198
- for col, header in enumerate(headers, 1):
199
- cell = ws.cell(row=current_row, column=col, value=header)
200
- style_cell(cell, bold=True, color=WHITE, bg_color=HEADER_BLUE)
201
- current_row += 1
202
-
203
- # Helper function to extract number from description
204
- def get_description_number(description: str) -> float:
205
- try:
206
- # Extract numbers from strings like "PCP 114N", "HOURDIS TYPE 12", etc.
207
- import re
208
- numbers = re.findall(r'\d+\.?\d*', description)
209
- return float(numbers[0]) if numbers else 0
210
- except:
211
- return 0
212
-
213
- # Modified Sections and Items with sorting
214
- sections = [
215
- ("POUTRELLES", "PCP"),
216
- ("HOURDIS", "HOURDIS"),
217
- ("PANNEAU TREILLIS SOUDES", "PTS"),
218
- ("AGGLOS", "AGGLOS")
219
- ]
220
-
221
- # Modified sorting to be ascending instead of descending
222
- for section_name, keyword in sections:
223
- items = [i for i in data.items if keyword in i.description]
224
- if items:
225
- # Sort items by description number (ascending) and then by length (ascending)
226
- sorted_items = sorted(
227
- items,
228
- key=lambda x: (get_description_number(x.description), x.length)
229
- )
230
-
231
- # Group items by unique description type and epaisseur
232
- unique_items = {}
233
- for item in sorted_items:
234
- key = (item.description, item.length) # Tuple of description and length
235
- if key not in unique_items:
236
- unique_items[key] = item
237
- else:
238
- # If item exists, sum quantities and update total
239
- existing = unique_items[key]
240
- existing.quantity += item.quantity
241
- existing.total_price = existing.quantity * existing.length * existing.unit_price
242
-
243
- # Add section header
244
- ws.merge_cells(f'A{current_row}:F{current_row}')
245
- ws[f'A{current_row}'].value = section_name
246
- style_cell(ws[f'A{current_row}'], bold=True)
247
- current_row += 1
248
-
249
- # Add sorted and unique items
250
- for item in unique_items.values():
251
- ws.cell(row=current_row, column=1).value = item.description
252
- ws.cell(row=current_row, column=2).value = item.unit
253
- ws.cell(row=current_row, column=3).value = item.quantity
254
- ws.cell(row=current_row, column=4).value = item.length
255
- ws.cell(row=current_row, column=5).value = item.unit_price
256
- ws.cell(row=current_row, column=6).value = item.total_price
257
-
258
- for col in range(1, 7):
259
- style_cell(ws.cell(row=current_row, column=col))
260
-
261
- current_row += 1
262
-
263
- current_row += 1
264
-
265
- # NB Section with wrapped text
266
- ws.merge_cells(f'A{current_row}:F{current_row}')
267
- nb_cell = ws[f'A{current_row}']
268
- nb_cell.value = "NB: Toute modification apportée aux plans BA initialement fournis, entraine automatiquement la modification de ce devis."
269
- style_cell(nb_cell, wrap_text=True, align='left')
270
- current_row += 2
271
-
272
- # Totals Section
273
- totals = [
274
- ("Total H.T", data.total_ht),
275
- ("TVA 20%", data.tax),
276
- ("Total TTC", data.total_ttc)
277
- ]
278
-
279
- for label, value in totals:
280
- ws[f'E{current_row}'].value = label
281
- ws[f'F{current_row}'].value = f"{value:,.2f} DH"
282
- style_cell(ws[f'E{current_row}'], bold=True)
283
- style_cell(ws[f'F{current_row}'], bold=True)
284
- current_row += 1
285
-
286
- # Footer
287
- current_row += 1
288
- footer_texts = [
289
- "CARRIPREFA",
290
- "Douar Ait Laarassi Tidili, Cercle El Kelâa, Route de Safi, Km 14-40000 Marrakech",
291
- "Tél: 05 24 01 33 34 Fax : 05 24 01 33 29 E-mail : carriprefa@gmail.com"
292
- ]
293
-
294
- for text in footer_texts:
295
- ws.merge_cells(f'A{current_row}:F{current_row}')
296
- footer_cell = ws[f'A{current_row}']
297
- footer_cell.value = text
298
- style_cell(footer_cell)
299
- current_row += 1
300
-
301
- # Commercial information if available
302
- if data.commercial and data.commercial.lower() != 'divers':
303
- ws.merge_cells(f'A{current_row}:C{current_row}')
304
- ws[f'A{current_row}'].value = f"Commercial: {data.commercial.upper()}"
305
- style_cell(ws[f'A{current_row}'], align='left')
306
-
307
- ws.merge_cells(f'D{current_row}:F{current_row}')
308
- ws[f'D{current_row}'].value = datetime.datetime.now().strftime("%d/%m/%Y %H:%M")
309
- style_cell(ws[f'D{current_row}'], align='right')
310
-
311
-
312
- # Set print area and page setup
313
- ws.page_setup.paperSize = ws.PAPERSIZE_A4
314
- ws.page_setup.orientation = ws.ORIENTATION_PORTRAIT
315
- ws.page_setup.fitToPage = True
316
- ws.page_setup.fitToHeight = 1
317
- ws.page_setup.fitToWidth = 1
318
- ws.page_margins.left = 0.5
319
- ws.page_margins.right = 0.5
320
- ws.page_margins.top = 0.5
321
- ws.page_margins.bottom = 0.5
322
- ws.page_margins.header = 0.3
323
- ws.page_margins.footer = 0.3
324
-
325
- # Set row heights for better spacing
326
- ws.row_dimensions[1].height = 80 # Logo row
327
- for row in range(2, ws.max_row + 1):
328
- ws.row_dimensions[row].height = 20 # Standard row height
329
-
330
- # Add page breaks for better printing
331
- ws.page_breaks = []
332
-
333
- # Group rows for better organization
334
- # Fix: change start_row/end_row to from_/to_
335
- for row in range(client_box_start, current_row):
336
- ws.row_dimensions[row].group = True
337
- ws.row_dimensions[row].outline_level = 1
338
-
339
- # Freeze panes to keep headers visible
340
- ws.freeze_panes = 'A10' # Freeze after headers
341
-
342
- # Print settings
343
- ws.print_options.gridLines = False
344
- ws.print_options.horizontalCentered = True
345
- ws.print_options.verticalCentered = False
346
-
347
- # Add print titles (repeat rows)
348
- ws.print_title_rows = '1:9' # Repeat first 9 rows on each page
349
-
350
- # Set the print area
351
- ws.print_area = [f'A1:F{current_row}']
352
-
353
- # Auto-fit all rows for content
354
- for row in ws.rows:
355
- max_height = 0
356
- for cell in row:
357
- if cell.value:
358
- text_lines = str(cell.value).count('\n') + 1
359
- max_height = max(max_height, text_lines * 15) # 15 points per line
360
- if max_height > 0:
361
- ws.row_dimensions[cell.row].height = max_height
362
-
363
- # Add page break before totals section
364
- ws.row_breaks.append(Break(id=current_row - 4))
365
-
366
- # Ensure footer stays together
367
- # Fix: Replace the old footer grouping with this
368
- # Group footer rows together
369
- for row in range(current_row - 3, current_row + 1):
370
- ws.row_dimensions[row].group = True
371
- ws.row_dimensions[row].outline_level = 1
372
- ws.row_dimensions[row].hidden = False
373
-
374
- # Save to buffer
375
- buffer = BytesIO()
376
- wb.save(buffer)
377
- buffer.seek(0)
378
- return buffer.getvalue()
379
-
380
- except Exception as e:
381
- logger.error(f"Error in Excel generation: {str(e)}", exc_info=True)
382
- raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/services/invoice_service copy 2.py DELETED
@@ -1,396 +0,0 @@
1
- from reportlab.lib.pagesizes import A4
2
- from reportlab.pdfgen import canvas
3
- from reportlab.lib import colors
4
- from reportlab.lib.units import cm
5
- from io import BytesIO
6
- from app.db.models import Invoice
7
- import logging
8
- import os
9
- import datetime
10
- import re
11
-
12
- # Set up logging
13
- logger = logging.getLogger(__name__)
14
-
15
- class InvoiceService:
16
- @staticmethod
17
- def generate_pdf(data: Invoice) -> bytes:
18
- try:
19
- buffer = BytesIO()
20
- pdf = canvas.Canvas(buffer, pagesize=A4)
21
- page_width, page_height = A4
22
-
23
- # Constants
24
- HEADER_BLUE = (0.29, 0.45, 0.68)
25
- BLUE_LIGHT = (1.5, 1.5, 1)
26
- WHITE = (1, 1, 1)
27
- BLACK = (0, 0, 0)
28
- MARGIN = 30
29
- LINE_HEIGHT = 20
30
- BOX_PADDING = 10
31
- STANDARD_FONT_SIZE = 10
32
- BOTTOM_MARGIN = 50 # Minimum margin at the bottom of the page
33
-
34
- # Helper function to draw centered text
35
- def draw_centered_text(pdf, text, x, y, width, font="Helvetica", size=STANDARD_FONT_SIZE):
36
- text_width = pdf.stringWidth(text, font, size)
37
- pdf.drawString(x + (width - text_width) / 2, y, text)
38
-
39
- # Helper function to draw a bordered box
40
- def draw_box(pdf, x, y, width, height, fill_color=None, stroke_color=BLACK):
41
- if fill_color:
42
- pdf.setFillColorRGB(*fill_color)
43
- pdf.rect(x, y, width, height, fill=1, stroke=0)
44
- pdf.setFillColorRGB(*stroke_color)
45
- pdf.rect(x, y, width, height, stroke=1)
46
-
47
- # Add this helper function after the existing helper functions
48
- def draw_wrapped_text(pdf, text, x, y, width, font="Helvetica", size=STANDARD_FONT_SIZE):
49
- """Draw text wrapped to fit within a given width."""
50
- pdf.setFont(font, size)
51
- words = text.split()
52
- lines = []
53
- current_line = []
54
-
55
- for word in words:
56
- current_line.append(word)
57
- line_width = pdf.stringWidth(' '.join(current_line), font, size)
58
- if line_width > width:
59
- current_line.pop() # Remove last word
60
- if current_line: # Only add if there are words
61
- lines.append(' '.join(current_line))
62
- current_line = [word] # Start new line with the word that didn't fit
63
-
64
- if current_line: # Add the last line
65
- lines.append(' '.join(current_line))
66
-
67
- return lines
68
-
69
- # Get the absolute path to the logo file
70
- current_dir = os.path.dirname(os.path.abspath(__file__))
71
- logo_path = os.path.join(current_dir, "..", "static", "logo.png")
72
-
73
- # Top section layout
74
- top_margin = page_height - 120
75
-
76
- # Left side: Logo - moved far left and up
77
- if os.path.exists(logo_path):
78
- pdf.drawImage(logo_path, MARGIN, top_margin - 10, width=100, height=100)
79
-
80
- # Right side: DEVIS and Client Box - moved far right and up
81
- pdf.setFont("Helvetica-Bold", 36)
82
- devis_text = "DEVIS"
83
- devis_width = pdf.stringWidth(devis_text, "Helvetica-Bold", 36)
84
- devis_x = page_width - devis_width - MARGIN - 10
85
- devis_y = top_margin + 50
86
- pdf.drawString(devis_x, devis_y, devis_text)
87
-
88
- # Client info box - moved right under DEVIS
89
- box_width = 200
90
- box_height = 80
91
- box_x = page_width - box_width - MARGIN + 10
92
- box_y = devis_y - 120
93
-
94
- # Draw client box
95
- pdf.rect(box_x, box_y, box_width, box_height, stroke=1)
96
-
97
- # Client Info
98
- pdf.setFont("Helvetica", STANDARD_FONT_SIZE)
99
- client_info = [
100
- data.client_name,
101
- data.project,
102
- data.address,
103
- data.ville, # Add ville here
104
- data.client_phone
105
- ]
106
-
107
- available_height = box_height - BOX_PADDING * 2
108
- y_position = box_y + box_height - BOX_PADDING
109
-
110
- for text in client_info:
111
- if text:
112
- # Calculate available width for text
113
- available_width = box_width - BOX_PADDING * 2
114
- # Get wrapped lines
115
- lines = draw_wrapped_text(pdf, str(text), box_x, y_position, available_width)
116
-
117
- # Draw each line
118
- for line in lines:
119
- text_width = pdf.stringWidth(line, "Helvetica", STANDARD_FONT_SIZE)
120
- x = box_x + (box_width - text_width) / 2
121
- pdf.drawString(x, y_position, line)
122
- y_position -= STANDARD_FONT_SIZE + 2 # Add some spacing between lines
123
-
124
- # Add extra spacing between different info pieces
125
- y_position -= 2
126
-
127
- # Info boxes (Date, N° Devis, PLANCHER) - adjusted starting position
128
- info_y = top_margin - 50
129
- box_label_width = 100 # Reduced label width
130
- box_value_width = 150 # Increased value width for longer text
131
-
132
- for label, value in [
133
- ("Date du devis :", data.date.strftime("%d/%m/%Y")),
134
- ("N° Devis :", data.invoice_number),
135
- ("PLANCHER :", data.frame_number or "PH RDC")
136
- ]:
137
- draw_box(pdf, MARGIN, info_y, box_label_width, LINE_HEIGHT, fill_color=HEADER_BLUE)
138
- pdf.setFillColorRGB(*WHITE)
139
- pdf.drawString(MARGIN + BOX_PADDING, info_y + 6, label)
140
-
141
- draw_box(pdf, MARGIN + box_label_width, info_y, box_value_width, LINE_HEIGHT, fill_color=WHITE)
142
- pdf.setFillColorRGB(*BLACK)
143
- # Draw value text with wrapping if needed
144
- value_str = str(value)
145
- if len(value_str) > 20: # If text is too long
146
- pdf.setFont("Helvetica", 8) # Reduce font size for long text
147
- lines = draw_wrapped_text(pdf, value_str, MARGIN + box_label_width, info_y + 6, box_value_width - 10)
148
- for i, line in enumerate(lines):
149
- draw_centered_text(pdf, line, MARGIN + box_label_width, info_y + 6 - (i * 8), box_value_width)
150
- else:
151
- pdf.setFont("Helvetica", STANDARD_FONT_SIZE)
152
- draw_centered_text(pdf, value_str, MARGIN + box_label_width, info_y + 6, box_value_width)
153
-
154
- info_y -= 25
155
-
156
- # Table headers
157
- table_y = info_y - 30
158
- headers = [
159
- ("Description", 150),
160
- ("Unité", 50),
161
- ("NBRE", 50),
162
- ("ML/Qté", 60),
163
- ("P.U", 60),
164
- ("Total HT", 170)
165
- ]
166
-
167
- total_width = sum(width for _, width in headers)
168
- table_x = (page_width - total_width) / 2 # Center table
169
- draw_box(pdf, table_x, table_y, total_width, LINE_HEIGHT, fill_color=HEADER_BLUE)
170
- pdf.setFillColorRGB(*WHITE)
171
-
172
- current_x = table_x
173
- for title, width in headers:
174
- draw_box(pdf, current_x, table_y, width, LINE_HEIGHT)
175
- pdf.setFillColorRGB(*WHITE)
176
- draw_centered_text(pdf, title, current_x, table_y + 6, width)
177
- current_x += width
178
-
179
- # Draw sections and items
180
- current_y = table_y - LINE_HEIGHT - 10
181
-
182
- def draw_section_header2(title):
183
- nonlocal current_y
184
- draw_box(pdf, table_x, current_y, total_width, LINE_HEIGHT, fill_color=WHITE)
185
- pdf.setFont("Helvetica-Bold", 9)
186
- pdf.setFillColorRGB(*BLACK)
187
- pdf.drawString(table_x + BOX_PADDING, current_y + 6, title)
188
- current_y -= LINE_HEIGHT
189
- pdf.setFont("Helvetica", STANDARD_FONT_SIZE)
190
-
191
- def format_currency(value):
192
- return "{:,.2f}".format(value).replace(",", " ")
193
-
194
- def draw_item_row(item, indent=False):
195
- nonlocal current_y
196
- pdf.setFillColorRGB(*BLACK)
197
- current_x = table_x
198
-
199
- draw_box(pdf, current_x, current_y, total_width, LINE_HEIGHT, fill_color=WHITE)
200
-
201
- cells = [
202
- (" " + item.description if indent else item.description, 150),
203
- (item.unit, 50),
204
- (str(item.quantity), 50),
205
- (f"{item.length:.2f}", 60),
206
- (f"{format_currency(item.unit_price)}", 60),
207
- (f"{format_currency(item.total_price)}", 170)
208
- ]
209
-
210
- for i, (value, width) in enumerate(cells):
211
- draw_box(pdf, current_x, current_y, width, LINE_HEIGHT)
212
-
213
- if i == len(cells) - 1:
214
- pdf.setFont("Helvetica-Bold", STANDARD_FONT_SIZE)
215
- else:
216
- pdf.setFont("Helvetica", STANDARD_FONT_SIZE)
217
-
218
- if isinstance(value, str) and value.startswith(" "):
219
- pdf.drawString(current_x + 20, current_y + 6, value.strip())
220
- else:
221
- draw_centered_text(pdf, str(value), current_x, current_y + 6, width)
222
-
223
- current_x += width
224
-
225
- pdf.setFont("Helvetica", STANDARD_FONT_SIZE)
226
- current_y -= LINE_HEIGHT
227
-
228
- # Check if we need a new page
229
- if current_y < BOTTOM_MARGIN:
230
- pdf.showPage()
231
- current_y = page_height - MARGIN
232
- # Redraw headers on the new page
233
- draw_box(pdf, table_x, current_y, total_width, LINE_HEIGHT, fill_color=HEADER_BLUE)
234
- pdf.setFillColorRGB(*WHITE)
235
- current_x = table_x
236
- for title, width in headers:
237
- draw_box(pdf, current_x, current_y, width, LINE_HEIGHT)
238
- pdf.setFillColorRGB(*WHITE)
239
- draw_centered_text(pdf, title, current_x, current_y + 6, width)
240
- current_x += width
241
- current_y -= LINE_HEIGHT
242
-
243
- # Draw sections
244
- sections = [
245
- ("POUTRELLES", "PCP"),
246
- ("HOURDIS", "HOURDIS"),
247
- ("PANNEAU TREILLIS SOUDES", "PTS"),
248
- ("AGGLOS", "AGGLOS")
249
- ]
250
-
251
- def get_description_number(description: str) -> float:
252
- try:
253
- numbers = re.findall(r'\d+\.?\d*', description)
254
- return float(numbers[0]) if numbers else 0
255
- except:
256
- return 0
257
-
258
- # Modify the section items handling
259
- for section_title, keyword in sections:
260
- # Get items for this section
261
- items = [i for i in data.items if keyword in i.description]
262
- # Only draw section if it has items
263
- if items:
264
- # Sort items by description number (ascending) and length (ascending)
265
- sorted_items = sorted(
266
- items,
267
- key=lambda x: (get_description_number(x.description), x.length)
268
- )
269
-
270
- # Group identical items
271
- grouped_items = {}
272
- for item in sorted_items:
273
- key = (item.description, item.length)
274
- if key not in grouped_items:
275
- grouped_items[key] = item
276
- else:
277
- existing = grouped_items[key]
278
- existing.quantity += item.quantity
279
- existing.total_price = existing.quantity * existing.length * existing.unit_price
280
-
281
- draw_section_header2(section_title)
282
- for item in grouped_items.values():
283
- draw_item_row(item, indent=(keyword != "lfflflflf"))
284
-
285
- # NB box with text
286
- nb_box_width = 200
287
- nb_box_height = 80
288
- pdf.setFillColorRGB(*BLACK)
289
- pdf.rect(20, current_y - nb_box_height, nb_box_width, nb_box_height, stroke=1)
290
-
291
- # Split the text into "NB:" and the rest
292
- pdf.setFont("Helvetica-Bold", STANDARD_FONT_SIZE)
293
- pdf.drawString(30, current_y - nb_box_height + 60, "NB:")
294
-
295
- # Continue with regular font for the rest of the text
296
- pdf.setFont("Helvetica", STANDARD_FONT_SIZE)
297
- nb_text = "Toute modification apportée aux plans BA initialement fournis, entraine automatiquement la modification de ce devis."
298
- words = nb_text.split()
299
- lines = []
300
- current_line = []
301
-
302
- for word in words:
303
- current_line.append(word)
304
- if pdf.stringWidth(' '.join(current_line), "Helvetica", STANDARD_FONT_SIZE) > nb_box_width - 40: # Adjusted width to account for "NB:"
305
- current_line.pop()
306
- lines.append(' '.join(current_line))
307
- current_line = [word]
308
-
309
- if current_line:
310
- lines.append(' '.join(current_line))
311
-
312
- # Draw the text lines after "NB:"
313
- for i, line in enumerate(lines):
314
- pdf.drawString(55, current_y - nb_box_height + 60 - (i * 10), line) # Adjusted x position to account for "NB:"
315
-
316
- # ADD text after the NB box
317
- pdf.setFont("Helvetica-Bold", 9)
318
- pdf.drawString(30, current_y - nb_box_height - 15, "Validité du devis : 1 mois")
319
-
320
-
321
-
322
- # Totals section
323
- pdf.setFont("Helvetica-Bold", 12)
324
- current_y -= 20
325
- totals_table_width = 300
326
- row_height = 20
327
-
328
- # Define the widths for the left and right boxes
329
- left_box_width = 130 # Width of the left box
330
- right_box_width = totals_table_width - left_box_width # Remaining width for the right box
331
-
332
- for i, (label1, label2, value) in enumerate([
333
- ("Total", "H.T", f"{format_currency(data.total_ht)} DH"),
334
- ("TVA", "20%", f"{format_currency(data.tax)} DH"),
335
- ("Total", "TTC", f"{format_currency(data.total_ttc)} DH")
336
- ]):
337
- y = current_y - (i * row_height)
338
- totals_x = (page_width - totals_table_width) - 27
339
-
340
- # Draw the left box
341
- draw_box(pdf, totals_x, y, left_box_width, row_height)
342
-
343
- # Draw the right box
344
- draw_box(pdf, totals_x + left_box_width, y, right_box_width, row_height)
345
-
346
- # Draw left-aligned labels in the left box
347
- pdf.drawString(totals_x + 10, y + 6, f"{label1} {label2}")
348
-
349
- # Calculate the center x position for the right box
350
- center_x = totals_x + left_box_width + (right_box_width / 2)
351
-
352
- # Draw centered value in the right box
353
- pdf.drawCentredString(center_x, y + 6, value)
354
-
355
-
356
- # Footer
357
- ## add the first line text
358
- pdf.setFont("Helvetica", STANDARD_FONT_SIZE - 3)
359
-
360
- footer_text = "CARRIPREFA"
361
- pdf.drawCentredString(page_width / 2, 40, footer_text)
362
-
363
- pdf.setFont("Helvetica", STANDARD_FONT_SIZE - 3)
364
- footer_text = "Douar Ait Toumart Habil, Cercle El Bour, Route de Safi, Km 14 -40000 Marrakech"
365
- pdf.drawCentredString(page_width / 2 + 20, 30, footer_text)
366
-
367
- # Add commercial info to footer
368
- commercial_info = dict(
369
- salah="06 62 29 99 78",
370
- khaled="06 66 24 80 94",
371
- ismail="06 66 24 50 15",
372
- jamal="06 70 08 36 50"
373
- )
374
-
375
- if data.commercial and data.commercial.lower() != 'divers':
376
- commercial_text = f"Commercial: {data.commercial.upper()}"
377
- commercial_phone = f"Tél: {commercial_info.get(data.commercial.lower(), '')}"
378
- pdf.drawString(MARGIN + 10, 30, commercial_text)
379
- pdf.drawString(MARGIN + 10, 20, commercial_phone)
380
- footer_contact = "Tél: 05 24 01 33 34 Fax : 05 24 01 33 29 E-mail : carriprefa@gmail.com"
381
- pdf.drawCentredString(page_width / 2 + 10, 20, footer_contact)
382
- pdf.drawString(page_width - 100, 30, f"Date: {datetime.datetime.now().strftime('%d/%m/%Y %H:%M')}")
383
- pdf.drawString(page_width - 100, 20, f"Page {pdf.getPageNumber()}/{pdf.getPageNumber()}")
384
- else:
385
- footer_contact = "Tél: 05 24 01 33 34 Fax : 05 24 01 33 29 E-mail : carriprefa@gmail.com"
386
- pdf.drawCentredString(page_width / 2 + 10, 20, footer_contact)
387
- pdf.drawString(page_width - 100, 30, f"Date: {datetime.datetime.now().strftime('%d/%m/%Y %H:%M')}")
388
- pdf.drawString(page_width - 100, 20, f"Page {pdf.getPageNumber()}/{pdf.getPageNumber()}")
389
-
390
- pdf.save()
391
- buffer.seek(0)
392
- return buffer.getvalue()
393
-
394
- except Exception as e:
395
- logger.error(f"Error in PDF generation: {str(e)}", exc_info=True)
396
- raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/services/invoice_service copy.py DELETED
@@ -1,308 +0,0 @@
1
- from reportlab.lib.pagesizes import A4
2
- from reportlab.pdfgen import canvas
3
- from reportlab.lib import colors
4
- from reportlab.lib.units import cm
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 InvoiceService:
15
- @staticmethod
16
- def generate_pdf(data: Invoice) -> bytes:
17
- try:
18
- buffer = BytesIO()
19
- pdf = canvas.Canvas(buffer, pagesize=A4)
20
- page_width, page_height = A4
21
-
22
- # Constants
23
- HEADER_BLUE = (0.29, 0.45, 0.68)
24
- BLUE_LIGHT = (1.5, 1.5, 1)
25
- WHITE = (1, 1, 1)
26
- BLACK = (0, 0, 0)
27
- MARGIN = 30
28
- LINE_HEIGHT = 20
29
- BOX_PADDING = 10
30
- STANDARD_FONT_SIZE = 10 # Add standard font size constant
31
-
32
- # Helper function to draw centered text
33
- def draw_centered_text(pdf, text, x, y, width, font="Helvetica", size=STANDARD_FONT_SIZE):
34
- text_width = pdf.stringWidth(text, font, size)
35
- pdf.drawString(x + (width - text_width) / 2, y, text)
36
-
37
- # Helper function to draw a bordered box
38
- def draw_box(pdf, x, y, width, height, fill_color=None, stroke_color=BLACK):
39
- if fill_color:
40
- pdf.setFillColorRGB(*fill_color)
41
- pdf.rect(x, y, width, height, fill=1, stroke=0)
42
- pdf.setFillColorRGB(*stroke_color)
43
- pdf.rect(x, y, width, height, stroke=1)
44
-
45
- # Get the absolute path to the logo file
46
- current_dir = os.path.dirname(os.path.abspath(__file__))
47
- logo_path = os.path.join(current_dir, "..", "static", "logo.png")
48
-
49
- # Top section layout
50
- top_margin = page_height - 100
51
-
52
- # Left side: Logo - moved far left and up
53
- if os.path.exists(logo_path):
54
- pdf.drawImage(logo_path, MARGIN, top_margin + 30, width=100, height=60)
55
-
56
- # Right side: DEVIS and Client Box - moved far right and up
57
- pdf.setFont("Helvetica-Bold", 36) # Reduced from 48 to be more consistent
58
- devis_text = "DEVIS"
59
- devis_width = pdf.stringWidth(devis_text, "Helvetica-Bold", 36)
60
- devis_x = page_width - devis_width - MARGIN - 10
61
- devis_y = top_margin + 50
62
- pdf.drawString(devis_x, devis_y, devis_text)
63
-
64
- # Client info box - moved right under DEVIS
65
- box_width = 200
66
- box_height = 80
67
- box_x = page_width - box_width - MARGIN + 10
68
- box_y = devis_y - 120
69
-
70
- # Draw client box
71
- pdf.rect(box_x, box_y, box_width, box_height, stroke=1)
72
-
73
- # Client Info
74
- pdf.setFont("Helvetica", STANDARD_FONT_SIZE)
75
- client_info = [
76
- data.client_name,
77
- data.project,
78
- data.address,
79
- data.client_phone
80
- ]
81
-
82
- # Center and draw each line of client info
83
- line_height = box_height / (len(client_info) + 1)
84
- for i, text in enumerate(client_info):
85
- text_width = pdf.stringWidth(str(text), "Helvetica", STANDARD_FONT_SIZE)
86
- x = box_x + (box_width - text_width) / 2
87
- y = box_y + box_height - ((i + 1) * line_height)
88
- pdf.drawString(x, y, str(text))
89
-
90
- # Info boxes (Date, N° Devis, PLANCHER) - adjusted starting position
91
- info_y = top_margin - 30
92
- box_label_width = 120
93
- box_value_width = 80
94
-
95
- for label, value in [
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
- draw_box(pdf, MARGIN, info_y, box_label_width, LINE_HEIGHT, fill_color=HEADER_BLUE)
101
- pdf.setFillColorRGB(*WHITE)
102
- pdf.drawString(MARGIN + BOX_PADDING, info_y + 6, label)
103
-
104
- draw_box(pdf, MARGIN + box_label_width, info_y, box_value_width, LINE_HEIGHT, fill_color=WHITE)
105
- pdf.setFillColorRGB(*BLACK)
106
- draw_centered_text(pdf, str(value), MARGIN + box_label_width, info_y + 6, box_value_width)
107
-
108
- info_y -= 25
109
-
110
- # Table headers
111
- table_y = info_y - 30
112
- headers = [
113
- ("Description", 150),
114
- ("Unité", 50),
115
- ("NBRE", 50),
116
- ("LNG/Qté", 60),
117
- ("P.U", 60),
118
- ("Total HT", 170)
119
- ]
120
-
121
- total_width = sum(width for _, width in headers)
122
- table_x = (page_width - total_width) / 2 # Center table
123
- draw_box(pdf, table_x, table_y, total_width, LINE_HEIGHT, fill_color=HEADER_BLUE)
124
- pdf.setFillColorRGB(*WHITE)
125
- # add little bit of space
126
-
127
-
128
- current_x = table_x
129
- for title, width in headers:
130
- draw_box(pdf, current_x, table_y, width, LINE_HEIGHT)
131
- pdf.setFillColorRGB(*WHITE)
132
- draw_centered_text(pdf, title, current_x, table_y + 6, width)
133
- current_x += width
134
-
135
-
136
- # Draw sections and items
137
- current_y = table_y - LINE_HEIGHT - 10
138
-
139
-
140
-
141
- def draw_section_header2(title):
142
- nonlocal current_y
143
- draw_box(pdf, table_x, current_y, total_width, LINE_HEIGHT, fill_color=WHITE)
144
-
145
- # Set the font to a bold variant
146
- pdf.setFont("Helvetica-Bold", 9) # Adjust the font name and size as needed
147
-
148
- # Set the fill color to black
149
- pdf.setFillColorRGB(*BLACK) # RGB values for black
150
-
151
- # Draw the string
152
- pdf.drawString(table_x + BOX_PADDING, current_y + 6, title)
153
-
154
- current_y -= LINE_HEIGHT
155
- pdf.setFont("Helvetica", STANDARD_FONT_SIZE)
156
-
157
- def format_currency(value):
158
- # Format with 2 decimal places and thousands separator
159
- return "{:,.2f}".format(value).replace(",", " ")
160
-
161
-
162
- def draw_item_row(item, indent=False):
163
- nonlocal current_y
164
- pdf.setFillColorRGB(*BLACK)
165
- current_x = table_x
166
-
167
- draw_box(pdf, current_x, current_y, total_width, LINE_HEIGHT, fill_color=WHITE)
168
-
169
- cells = [
170
- (" " + item.description if indent else item.description, 150),
171
- (item.unit, 50),
172
- (str(item.quantity), 50),
173
- (f"{item.length:.2f}", 60),
174
- (f"{format_currency(item.unit_price)}", 60),
175
- (f"{format_currency(item.total_price)} DH", 170) # Total column
176
- ]
177
-
178
- for i, (value, width) in enumerate(cells):
179
- draw_box(pdf, current_x, current_y, width, LINE_HEIGHT)
180
-
181
- if i == len(cells) - 1: # If it's the last column (Total DH)
182
- pdf.setFont("Helvetica-Bold", STANDARD_FONT_SIZE) # Make it bold
183
- else:
184
- pdf.setFont("Helvetica", STANDARD_FONT_SIZE) # Normal font
185
-
186
- if isinstance(value, str) and value.startswith(" "):
187
- pdf.drawString(current_x + 20, current_y + 6, value.strip())
188
- else:
189
- draw_centered_text(pdf, str(value), current_x, current_y + 6, width)
190
-
191
- current_x += width
192
-
193
- pdf.setFont("Helvetica", STANDARD_FONT_SIZE) # Reset font to normal for next rows
194
- current_y -= LINE_HEIGHT
195
-
196
-
197
- # Draw sections
198
- sections = [
199
- ("POUTRELLES :", "PCP"),
200
- ("HOURDIS :", "HOURDIS"),
201
- ("PANNEAU TREILLIS SOUDES :", "PTS"),
202
- ("AGGLOS :", "AGGLOS")
203
- ]
204
-
205
- for section_title, keyword in sections:
206
- print("-------------")
207
- print(f"section_title: {section_title}")
208
- draw_section_header2(section_title)
209
- items = [i for i in data.items if keyword in i.description]
210
- for item in items:
211
- draw_item_row(item, indent=(keyword != "lfflflflf"))
212
-
213
- # NB box with text
214
- nb_box_width = 200
215
- nb_box_height = 80
216
- pdf.setFillColorRGB(*BLACK)
217
- pdf.rect(20, current_y - nb_box_height, nb_box_width, nb_box_height, stroke=1)
218
- pdf.setFont("Helvetica-Bold", STANDARD_FONT_SIZE)
219
- pdf.drawString(30, current_y - nb_box_height + 60, "NB:")
220
-
221
- # Add the new text
222
- pdf.setFont("Helvetica", STANDARD_FONT_SIZE)
223
- nb_text = "Toute modification apportée aux plans BA initialement fournis, entraine automatiquement la modification de ce devis."
224
- # Split text to fit in box
225
- words = nb_text.split()
226
- lines = []
227
- current_line = []
228
-
229
- for word in words:
230
- current_line.append(word)
231
- # Check if current line width exceeds box width
232
- if pdf.stringWidth(' '.join(current_line), "Helvetica", STANDARD_FONT_SIZE) > nb_box_width - 20:
233
- current_line.pop() # Remove last word
234
- lines.append(' '.join(current_line))
235
- current_line = [word]
236
-
237
- if current_line:
238
- lines.append(' '.join(current_line))
239
-
240
- # Draw each line
241
- for i, line in enumerate(lines):
242
- pdf.drawString(30, current_y - nb_box_height + 45 - (i * 10), line)
243
-
244
- # ADD text after the NB box
245
- pdf.setFont("Helvetica-Bold", 9)
246
- pdf.drawString(30 , current_y - nb_box_height - 15, "Validité du devis : 1 mois")
247
-
248
-
249
- # Totals section
250
- # set font to bold
251
- pdf.setFont("Helvetica-Bold", 12)
252
- current_y -= 20
253
- totals_table_width = 300
254
- row_height = 20
255
-
256
- for i, (label1, label2, value) in enumerate([
257
- ("Total", "H.T", f"{format_currency(data.total_ht)} DH"),
258
- ("TVA", "20 %", f"{format_currency(data.tax)} DH"),
259
- ("Total", "TTC", f"{format_currency(data.total_ttc)} DH")
260
- ]):
261
- y = current_y - (i * row_height)
262
- totals_x = (page_width - totals_table_width) - 27
263
- draw_box(pdf, totals_x, y, totals_table_width / 2, row_height)
264
- draw_box(pdf, totals_x + totals_table_width / 2, y, totals_table_width / 2, row_height)
265
- pdf.drawString(totals_x + 10, y + 6, f"{label1} {label2}")
266
- pdf.drawRightString(totals_x + totals_table_width - 10, y + 6, value)
267
-
268
- # Footer
269
- pdf.setFont("Helvetica", STANDARD_FONT_SIZE - 3)
270
- footer_text = "Douar Ait Laarassi Tidili, Cercle El Kelâa, Route de Safi, Km 14-40000 Marrakech"
271
- pdf.drawCentredString(page_width / 2 + 20, 30, footer_text)
272
-
273
- # add the commercial phone number
274
- # i have salah with 0666666666 and khaled with 077777777 and ismale with 08888888 and jamal with 099999999
275
- commercial_info = dict(
276
- salah = "06 62 29 99 78",
277
- khaled= "06 66 24 80 94",
278
- ismail= "06 66 24 50 15",
279
- jamal = "06 70 08 36 50"
280
- )
281
- # Add commercial info to footer
282
- print(f"Commercial value: {data.commercial}") # Add this debug line
283
- if data.commercial and data.commercial.lower() != 'divers':
284
- commercial_text = f"Commercial: {data.commercial.upper()}"
285
- commercial_phone = f"Tél: {commercial_info.get(data.commercial.lower(), '')}"
286
-
287
- pdf.drawString(MARGIN + 10, 30, commercial_text)
288
- #draw under the commercial text :
289
- pdf.drawString(MARGIN + 10, 20, commercial_phone)
290
- footer_contact = "Tél: 05 24 01 55 54 Fax : 05 24 01 55 29 E-mail : compra45@gmail.com"
291
- pdf.drawCentredString(page_width / 2 + 10, 20, footer_contact)
292
- ## add the time and page number
293
-
294
- pdf.drawString(page_width - 100, 30, f"Date: {datetime.datetime.now().strftime('%d/%m/%Y')}")
295
- pdf.drawString(page_width - 100, 20, f"Page {pdf.getPageNumber()}/{pdf.getPageNumber()}")
296
- else:
297
- footer_contact = "Tél: 05 24 01 55 54 Fax : 05 24 01 55 29 E-mail : compra45@gmail.com"
298
- pdf.drawCentredString(page_width / 2 + 10, 20, footer_contact)
299
- pdf.drawString(page_width - 100, 30, f"Date: {datetime.datetime().now.strftime('%d/%m/%Y')}")
300
- pdf.drawString(page_width - 100, 20, f"Page {pdf.getPageNumber()}/{pdf.getPageNumber()}")
301
-
302
- pdf.save()
303
- buffer.seek(0)
304
- return buffer.getvalue()
305
-
306
- except Exception as e:
307
- logger.error(f"Error in PDF generation: {str(e)}", exc_info=True)
308
- raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/services/invoice_service.py DELETED
@@ -1,452 +0,0 @@
1
- from reportlab.lib.pagesizes import A4
2
- from reportlab.pdfgen import canvas
3
- from reportlab.lib import colors
4
- from reportlab.lib.units import cm
5
- from io import BytesIO
6
- from app.db.models import Invoice
7
- import logging
8
- import os
9
- import datetime
10
- import re
11
-
12
- # Set up logging
13
- logger = logging.getLogger(__name__)
14
-
15
- class InvoiceService:
16
- @staticmethod
17
- def generate_pdf(data: Invoice) -> bytes:
18
- try:
19
- buffer = BytesIO()
20
- pdf = canvas.Canvas(buffer, pagesize=A4)
21
- page_width, page_height = A4
22
-
23
- # Constants
24
- HEADER_BLUE = (0.29, 0.45, 0.68)
25
- BLUE_LIGHT = (1.5, 1.5, 1)
26
- WHITE = (1, 1, 1)
27
- BLACK = (0, 0, 0)
28
- MARGIN = 50
29
- LINE_HEIGHT = 20
30
- BOX_PADDING = 10
31
- STANDARD_FONT_SIZE = 9
32
- HEADER_FONT_SIZE = 24
33
- SECTION_FONT_SIZE = 10
34
- FOOTER_FONT_SIZE = 8
35
- LINE_SPACING = 15
36
- VERTICAL_PADDING = 5
37
- BOTTOM_MARGIN = 50
38
-
39
- # Helper function to draw centered text
40
- def draw_centered_text(pdf, text, x, y, width, font="Times-Roman", size=STANDARD_FONT_SIZE):
41
- text_width = pdf.stringWidth(text, font, size)
42
- pdf.drawString(x + (width - text_width) / 2, y, text)
43
-
44
- # Helper function to draw a bordered box
45
- def draw_box(pdf, x, y, width, height, fill_color=None, stroke_color=BLACK):
46
- if fill_color:
47
- pdf.setFillColorRGB(*fill_color)
48
- pdf.rect(x, y, width, height, fill=1, stroke=0)
49
- pdf.setFillColorRGB(*stroke_color)
50
- pdf.rect(x, y, width, height, stroke=1)
51
-
52
- # Add this helper function after the existing helper functions
53
- def draw_wrapped_text(pdf, text, x, y, width, font="Helvetica", size=STANDARD_FONT_SIZE):
54
- """Draw text wrapped to fit within a given width."""
55
- pdf.setFont(font, size)
56
- words = text.split()
57
- lines = []
58
- current_line = []
59
-
60
- for word in words:
61
- current_line.append(word)
62
- line_width = pdf.stringWidth(' '.join(current_line), font, size)
63
- if line_width > width:
64
- current_line.pop() # Remove last word
65
- if current_line: # Only add if there are words
66
- lines.append(' '.join(current_line))
67
- current_line = [word] # Start new line with the word that didn't fit
68
-
69
- if current_line: # Add the last line
70
- lines.append(' '.join(current_line))
71
-
72
- return lines
73
-
74
- # Get the absolute path to the logo file
75
- current_dir = os.path.dirname(os.path.abspath(__file__))
76
- logo_path = os.path.join(current_dir, "..", "static", "logo.png")
77
-
78
- # Top section layout
79
- top_margin = page_height - 120
80
-
81
- # Left side: Logo - moved far left and up, with reduced size
82
- if os.path.exists(logo_path):
83
- pdf.drawImage(logo_path, MARGIN, top_margin - 10, width=80, height=80) # Reduced from 100x100 to 80x80
84
-
85
- # Right side: DEVIS and Client Box - moved more to the left
86
- pdf.setFont("Helvetica-Bold", HEADER_FONT_SIZE)
87
- devis_text = "DEVIS"
88
- devis_width = pdf.stringWidth(devis_text, "Helvetica-Bold", HEADER_FONT_SIZE)
89
- devis_x = page_width - devis_width - MARGIN - 50 # Consistent right margin
90
- devis_y = top_margin + 30
91
- pdf.drawString(devis_x, devis_y, devis_text)
92
-
93
- # Client info box - aligned with DEVIS text
94
- box_width = 150
95
- box_x = page_width - box_width - MARGIN - 10 # Align with right margin
96
- box_y = devis_y - 80
97
-
98
- # Client Info
99
- pdf.setFont("Helvetica-Bold", 10)
100
- client_info = [
101
- data.client_name,
102
- (f"{data.phone1}" + (f" / {data.phone2}" if data.phone2 else "")),
103
- data.address
104
- ]
105
-
106
- # Calculate required height based on content
107
- total_lines = 0
108
- for text in client_info:
109
- if text:
110
- lines = draw_wrapped_text(pdf, text, devis_x, 10, box_width - (BOX_PADDING * 2), font="Helvetica-Bold", size=10)
111
- total_lines += len(lines)
112
-
113
- # Calculate box height based on content
114
- box_height = (total_lines * LINE_SPACING) + (VERTICAL_PADDING)
115
-
116
- # Draw client box
117
- pdf.rect(box_x, box_y, box_width, box_height, stroke=1)
118
-
119
- # Draw client info inside the box - start from top
120
- y_position = box_y + box_height - 10 # Reduced top padding
121
- for text in client_info:
122
- if text:
123
- lines = draw_wrapped_text(pdf, text, box_x, y_position, box_width - (BOX_PADDING * 2) + 8, font="Helvetica-Bold", size=10)
124
- for line in lines:
125
- text_width = pdf.stringWidth(line, "Helvetica-Bold", 10)
126
- x_centered = box_x + (box_width - text_width) / 2
127
- pdf.drawString(x_centered, y_position, line)
128
- y_position -= LINE_SPACING
129
-
130
- # Info boxes (Date, N° Devis, PLANCHER) - adjusted position and size
131
- info_y = top_margin - 70
132
- box_label_width = 77
133
- box_value_width = 100
134
- box_height = 15
135
-
136
- for label, value in [
137
- ("Date du devis :", data.date.strftime("%d/%m/%Y")),
138
- ("N° Devis :", data.invoice_number),
139
- ("PLANCHER :", data.frame_number or "PH RDC")
140
- ]:
141
- # Set dashed line style with thicker lines
142
- pdf.setLineWidth(1.5) # Increased line thickness
143
- pdf.setDash(3, 2) # Longer dash (3 points) with smaller space (2 points)
144
-
145
- # Draw boxes with dashed borders
146
- draw_box(pdf, MARGIN, info_y, box_label_width, box_height, fill_color=HEADER_BLUE)
147
- draw_box(pdf, MARGIN + box_label_width, info_y, box_value_width, box_height, fill_color=WHITE)
148
-
149
- # Reset line style
150
- pdf.setLineWidth(1) # Reset line width
151
- pdf.setDash(1, 0) # Reset to solid line
152
-
153
- # Draw text
154
- pdf.setFillColorRGB(*WHITE)
155
- pdf.setFont("Helvetica-Bold", 9)
156
- pdf.drawString(MARGIN + BOX_PADDING - 2, info_y + 4, label)
157
-
158
- pdf.setFillColorRGB(*BLACK)
159
- value_str = str(value)
160
- if len(value_str) > 20:
161
- pdf.setFont("Helvetica-Bold", 9)
162
- lines = draw_wrapped_text(pdf, value_str, MARGIN + box_label_width, info_y + 4, box_value_width - 10)
163
- for i, line in enumerate(lines):
164
- draw_centered_text(pdf, line, MARGIN + box_label_width, info_y + 4 - (i * 8), box_value_width)
165
- else:
166
- pdf.setFont("Helvetica-Bold", 9)
167
- draw_centered_text(pdf, value_str, MARGIN + box_label_width, info_y + 4, box_value_width)
168
-
169
- info_y -= 20
170
-
171
- # Table headers - adjust table width and position
172
- table_y = info_y - 10
173
- headers = [
174
- ("Description", 180),
175
- ("Unité", 40),
176
- ("NBRE", 40),
177
- ("ML/Qté", 50),
178
- ("P.U", 50),
179
- ("Total HT", 130)
180
- ]
181
-
182
- total_width = sum(width for _, width in headers)
183
- table_x = (page_width - total_width) / 2 # Center table
184
-
185
- # Draw the filled background for the entire header
186
- draw_box(pdf, table_x, table_y, total_width, LINE_HEIGHT, fill_color=HEADER_BLUE)
187
-
188
- # Draw outer border only
189
- pdf.setFillColorRGB(*WHITE)
190
- pdf.rect(table_x, table_y, total_width, LINE_HEIGHT, stroke=1)
191
-
192
- # Draw header text
193
- current_x = table_x
194
- for title, width in headers:
195
- draw_centered_text(pdf, title, current_x, table_y + 6, width, font="Helvetica-Bold", size=10)
196
- current_x += width
197
-
198
- # Start sections immediately after headers
199
- current_y = table_y - LINE_HEIGHT + 12 # Reduced gap between headers and first section
200
-
201
-
202
- def draw_section_header2(title):
203
- nonlocal current_y
204
- section_start_y = current_y
205
- current_y -= 20 # Space before the title
206
- pdf.setFont("Helvetica-Bold", SECTION_FONT_SIZE)
207
- pdf.setFillColorRGB(*BLACK)
208
- pdf.drawString(table_x + BOX_PADDING, current_y + 6, title)
209
- current_y -= LINE_HEIGHT - 3 # Increased space between title and items (was LINE_HEIGHT - 12)
210
- pdf.setFont("Times-Roman", STANDARD_FONT_SIZE)
211
- return section_start_y
212
-
213
-
214
- def format_currency(value):
215
- return "{:,.2f}".format(value).replace(",", " ")
216
-
217
- def format_number(value, section_type=None, is_length=False):
218
- """Format numbers based on section type"""
219
- # For poutrelles, keep decimals for length
220
- if section_type == "POUTRELLES" and is_length:
221
- return f"{value:.2f}"
222
-
223
- # For other sections or non-length values
224
- if isinstance(value, int) or (isinstance(value, float) and value.is_integer()):
225
- return str(int(value))
226
- return str(int(value)) if value.is_integer() else f"{value:.0f}"
227
-
228
- def draw_item_row(item, indent=False, is_last_item=False, section_type=None):
229
- nonlocal current_y
230
- row_values = [
231
- str(item.description),
232
- str(item.unit),
233
- format_number(item.quantity, section_type),
234
- format_number(item.length, section_type, is_length=True),
235
- f"{item.unit_price:.2f}",
236
- f"{item.total_price:.2f}"
237
- ]
238
-
239
- # Calculate positions for perfect alignment
240
- positions = [
241
- table_x + (20 if indent else 0), # Description
242
- table_x + headers[0][1], # Unité
243
- table_x + sum(width for _, width in headers[:2]), # NBRE
244
- table_x + sum(width for _, width in headers[:3]), # ML/Qté
245
- table_x + sum(width for _, width in headers[:4]), # P.U
246
- table_x + sum(width for _, width in headers[:5]) # Total HT
247
- ]
248
-
249
- pdf.setFont("Helvetica", 9)
250
-
251
-
252
- for idx, value in enumerate(row_values):
253
- if idx == 0:
254
- indeent_amount = 30
255
- # Left align description
256
- pdf.drawString(positions[idx] + BOX_PADDING + indeent_amount, current_y + 6, value)
257
- else:
258
- # Center align other columns
259
- text_width = pdf.stringWidth(value, "Helvetica", 9)
260
- x_pos = positions[idx] + (headers[idx][1] - text_width) / 2
261
- pdf.drawString(x_pos, current_y + 6, value)
262
-
263
- if not is_last_item:
264
- current_y -= (LINE_HEIGHT - 8)
265
- else:
266
- current_y -= (LINE_HEIGHT - 20)
267
-
268
- pdf.setFont("Helvetica", STANDARD_FONT_SIZE)
269
-
270
- # Draw sections
271
- sections = [
272
- ("POUTRELLES", "PCP"),
273
- ("HOURDIS", "HOURDIS"),
274
- ("PANNEAU TREILLIS SOUDES", "PTS"),
275
- ("AGGLOS", "AGGLOS")
276
- ]
277
-
278
- def get_description_number(description: str) -> float:
279
- try:
280
- numbers = re.findall(r'\d+\.?\d*', description)
281
- return float(numbers[0]) if numbers else 0
282
- except:
283
- return 0
284
-
285
- # Sort items based on section type and description number
286
- def custom_sort_key(item):
287
- description = item.description.upper()
288
- # For poutrelles, prioritize items with 'N'
289
- if 'POUTRELLE' in description or 'PCP' in description:
290
- has_n = 'N' in description
291
- # Get the number from the description
292
- numbers = re.findall(r'\d+', description)
293
- number = float(numbers[0]) if numbers else 0
294
- # Items with 'N' come first, then sort by number
295
- return (not has_n, number)
296
- # For other items, use the existing logic
297
- return (True, get_description_number(description))
298
-
299
- # Modify the section items handling
300
- for section_title, keyword in sections:
301
- items = [i for i in data.items if keyword in i.description]
302
- if items:
303
- # Sort the items using the custom sort key
304
- if section_title == 'POUTRELLES':
305
- items.sort(key=custom_sort_key)
306
- else:
307
- items.sort(key=lambda x: get_description_number(x.description))
308
-
309
- grouped_items = {}
310
- for item in items:
311
- key = (item.description, item.length)
312
- if key not in grouped_items:
313
- grouped_items[key] = item
314
- else:
315
- existing = grouped_items[key]
316
- existing.quantity += item.quantity
317
- existing.total_price = existing.quantity * existing.length * existing.unit_price
318
-
319
- # Draw section header and save starting position
320
- section_start_y = draw_section_header2(section_title)
321
-
322
- # Get list of items
323
- items_list = list(grouped_items.values())
324
-
325
- # Draw all items in the section
326
- for i, item in enumerate(items_list):
327
- is_last = i == len(items_list) - 1 # Check if this is the last item
328
- draw_item_row(item, indent=(keyword != "lfflflflf"), is_last_item=is_last, section_type=section_title)
329
-
330
- # Draw box around the entire section
331
- section_height = section_start_y - current_y
332
- pdf.setStrokeColorRGB(*BLACK)
333
- pdf.rect(table_x, current_y, total_width, section_height, stroke=1)
334
-
335
- # Common y-position for both NB and totals
336
- current_y -= 20
337
-
338
- # NB box adjustments
339
- nb_box_width = total_width * 0.35 + 40 # the width right of the box adjust by add
340
- nb_box_height = 45
341
- nb_box_x = table_x
342
-
343
- # Set dashed line style with thicker lines
344
- pdf.setLineWidth(1.5) # Increased line thickness
345
- pdf.setDash(3, 2) # Longer dash (3 points) with smaller space (2 points)
346
-
347
- pdf.setFillColorRGB(*BLACK)
348
- pdf.rect(nb_box_x, current_y - nb_box_height, nb_box_width, nb_box_height, stroke=1)
349
-
350
- # Reset line style
351
- pdf.setLineWidth(1) # Reset line width
352
- pdf.setDash(1, 0) # Reset to solid line
353
-
354
- y_offset = current_y - nb_box_height + 35
355
-
356
- # Draw "NB:" in bold with underline
357
- nb_text = "NB:"
358
- pdf.setFont("Helvetica-Bold", STANDARD_FONT_SIZE)
359
- pdf.drawString(nb_box_x + 10, y_offset, nb_text)
360
-
361
- # Add underline
362
- text_width = pdf.stringWidth(nb_text, "Helvetica-Bold", STANDARD_FONT_SIZE)
363
- pdf.line(nb_box_x + 10, y_offset - 2, nb_box_x + 10 + text_width, y_offset - 2)
364
-
365
- # Draw the rest of the text in normal font
366
- pdf.setFont("Helvetica", STANDARD_FONT_SIZE)
367
- remaining_text = """Toute modification apportée aux plans BA
368
- initialement fournis, entraîne automatiquement
369
- la modification de ce devis."""
370
-
371
- # Draw each line after "NB:"
372
- for i, line in enumerate(remaining_text.split('\n')):
373
- if i == 0:
374
- pdf.drawString(nb_box_x + 30, y_offset, line)
375
- else:
376
- pdf.drawString(nb_box_x + 10, y_offset - (i * 12), line)
377
-
378
- # ADD text after the NB box with reduced spacing
379
- pdf.setFont("Helvetica-Bold", 9)
380
- pdf.drawString(nb_box_x + 10, current_y - nb_box_height - 12, "Validité du devis : 1 mois")
381
-
382
- # Totals section adjustments
383
- totals_table_width = total_width * 0.4
384
- totals_x = table_x + total_width - totals_table_width
385
- row_height = 12
386
- left_box_width = totals_table_width * 0.4
387
- right_box_width = totals_table_width * 0.6
388
-
389
- for i, (label1, label2, value) in enumerate([
390
- ("Total", "H.T", f"{format_currency(data.total_ht)} DH"),
391
- ("TVA", "20%", f"{format_currency(data.tax)} DH"),
392
- ("Total", "TTC", f"{format_currency(data.total_ttc)} DH")
393
- ]):
394
- y = current_y - (i * (row_height + 5)) - 12
395
-
396
- # Draw the left box
397
- draw_box(pdf, totals_x, y, left_box_width, row_height)
398
-
399
- # Draw the right box
400
- draw_box(pdf, totals_x + left_box_width, y, right_box_width, row_height)
401
-
402
- # Draw left-aligned labels in the left box with smaller font
403
- pdf.setFont("Helvetica-Bold", 9)
404
- # Center text vertically by adding half of (row_height - font_size)
405
- vertical_center = y + (row_height - 9) / 2
406
- pdf.drawString(totals_x + 8, vertical_center, f"{label1} {label2}")
407
-
408
- # Draw centered value in the right box with same bold font
409
- value_width = pdf.stringWidth(value, "Helvetica-Bold", 9)
410
- value_x = totals_x + left_box_width + (right_box_width - value_width) / 2
411
- pdf.drawString(value_x, vertical_center, value)
412
-
413
- # Footer
414
- ## add the first line text
415
- pdf.setFont("Helvetica", 8) # Changed from Times-Roman to Helvetica
416
-
417
- footer_text = "CARRIPREFA"
418
- pdf.drawCentredString(page_width / 2, 40, footer_text)
419
-
420
- footer_text = "Douar Ait Toumart Habil, Cercle El Bour, Route de Safi, Km 14 -40000 Marrakech"
421
- pdf.drawCentredString(page_width / 2 + 20, 30, footer_text)
422
-
423
- # Add commercial info to footer
424
- commercial_info = dict(
425
- salah="06 62 29 99 78",
426
- khaled="06 66 24 80 94",
427
- ismail="06 66 24 50 15",
428
- jamal="06 70 08 36 50"
429
- )
430
-
431
- if data.commercial and data.commercial.lower() != 'divers':
432
- commercial_text = f"Commercial: {data.commercial.upper()}"
433
- commercial_phone = f"Tél: {commercial_info.get(data.commercial.lower(), '')}"
434
- pdf.drawString(MARGIN + 10, 30, commercial_text)
435
- pdf.drawString(MARGIN + 10, 20, commercial_phone)
436
- footer_contact = "Tél: 05 24 01 33 34 Fax : 05 24 01 33 29 E-mail : carriprefa@gmail.com"
437
- pdf.drawCentredString(page_width / 2 + 10, 20, footer_contact)
438
- pdf.drawString(page_width - 100, 30, f"Date: {datetime.datetime.now().strftime('%d/%m/%Y %H:%M')}")
439
- pdf.drawString(page_width - 100, 20, f"Page 1/2")
440
- else:
441
- footer_contact = "Tél: 05 24 01 33 34 Fax : 05 24 01 33 29 E-mail : carriprefa@gmail.com"
442
- pdf.drawCentredString(page_width / 2 + 10, 20, footer_contact)
443
- pdf.drawString(page_width - 100, 30, f"Date: {datetime.datetime.now().strftime('%d/%m/%Y %H:%M')}")
444
- pdf.drawString(page_width - 100, 20, f"Page 1/2")
445
-
446
- pdf.save()
447
- buffer.seek(0)
448
- return buffer.getvalue()
449
-
450
- except Exception as e:
451
- logger.error(f"Error in PDF generation: {str(e)}", exc_info=True)
452
- raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/services/invoice_service_page2.py DELETED
@@ -1,350 +0,0 @@
1
- from reportlab.lib.pagesizes import A4
2
- from reportlab.pdfgen import canvas
3
- from reportlab.lib import colors
4
- from reportlab.lib.units import cm
5
- from io import BytesIO
6
- from app.db.models import Invoice
7
- import logging
8
- import os
9
- import datetime
10
- import re
11
-
12
- # Set up logging
13
- logger = logging.getLogger(__name__)
14
-
15
- class InvoiceServicePage2:
16
- @staticmethod
17
- def generate_pdf(data: Invoice) -> bytes:
18
- try:
19
- buffer = BytesIO()
20
- pdf = canvas.Canvas(buffer, pagesize=A4)
21
- page_width, page_height = A4
22
-
23
- # Constants
24
- HEADER_BLUE = (0.29, 0.45, 0.68)
25
- BLUE_LIGHT = (1.5, 1.5, 1)
26
- WHITE = (1, 1, 1)
27
- BLACK = (0, 0, 0)
28
- MARGIN = 50
29
- LINE_HEIGHT = 20
30
- BOX_PADDING = 10
31
- STANDARD_FONT_SIZE = 9
32
- HEADER_FONT_SIZE = 24
33
- SECTION_FONT_SIZE = 10
34
- FOOTER_FONT_SIZE = 8
35
- LINE_SPACING = 15
36
- VERTICAL_PADDING = 5
37
- BOTTOM_MARGIN = 50
38
-
39
- # Helper functions (same as original)
40
- def draw_centered_text(pdf, text, x, y, width, font="Helvetica", size=STANDARD_FONT_SIZE):
41
- text_width = pdf.stringWidth(text, font, size)
42
- pdf.drawString(x + (width - text_width) / 2, y, text)
43
-
44
- def draw_box(pdf, x, y, width, height, fill_color=None, stroke_color=BLACK):
45
- if fill_color:
46
- pdf.setFillColorRGB(*fill_color)
47
- pdf.rect(x, y, width, height, fill=1, stroke=0)
48
- pdf.setFillColorRGB(*stroke_color)
49
- pdf.rect(x, y, width, height, stroke=1)
50
-
51
- def draw_wrapped_text(pdf, text, x, y, width, font="Helvetica", size=STANDARD_FONT_SIZE):
52
- pdf.setFont(font, size)
53
- words = text.split()
54
- lines = []
55
- current_line = []
56
-
57
- for word in words:
58
- current_line.append(word)
59
- line_width = pdf.stringWidth(' '.join(current_line), font, size)
60
- if line_width > width:
61
- current_line.pop()
62
- if current_line:
63
- lines.append(' '.join(current_line))
64
- current_line = [word]
65
-
66
- if current_line:
67
- lines.append(' '.join(current_line))
68
-
69
- return lines
70
-
71
- # Get the absolute path to the logo file
72
- current_dir = os.path.dirname(os.path.abspath(__file__))
73
- logo_path = os.path.join(current_dir, "..", "static", "logo.png")
74
-
75
- # Top section layout
76
- top_margin = page_height - 120
77
-
78
- # Left side: Logo - maintain left margin
79
- if os.path.exists(logo_path):
80
- pdf.drawImage(logo_path, MARGIN, top_margin - 10, width=80, height=80)
81
-
82
- # Right side: DEVIS and Client Box - maintain right margin
83
- pdf.setFont("Helvetica-Bold", HEADER_FONT_SIZE)
84
- devis_text = """DEVIS LIVRAISON"""
85
- devis_width = pdf.stringWidth(devis_text, "Helvetica-Bold", HEADER_FONT_SIZE)
86
- devis_x = page_width - devis_width - MARGIN + 10 # Consistent right margin
87
- devis_y = top_margin + 50
88
- pdf.drawString(devis_x, devis_y, devis_text)
89
-
90
- # Client info box - aligned with DEVIS text
91
- box_width = 150
92
- box_x = page_width - box_width - MARGIN - 10 # Align with right margin
93
- box_y = devis_y - 80
94
-
95
- # Client Info
96
- pdf.setFont("Helvetica-Bold", 10)
97
- client_info = [
98
- data.client_name,
99
- (f"{data.phone1}" + (f" / {data.phone2}" if data.phone2 else "")),
100
- data.address
101
- ]
102
-
103
- # Calculate required height based on content
104
- total_lines = 0
105
- for text in client_info:
106
- if text:
107
- lines = draw_wrapped_text(pdf, text, devis_x, 0, box_width - (BOX_PADDING * 2), font="Helvetica-Bold", size=10)
108
- total_lines += len(lines)
109
-
110
- # Calculate box height based on content
111
- box_height = (total_lines * LINE_SPACING) + (VERTICAL_PADDING * 2)
112
-
113
- # Draw client box
114
- pdf.rect(box_x, box_y, box_width, box_height, stroke=1)
115
-
116
- # Draw client info inside the box - start from top
117
- y_position = box_y + box_height - 10 # Reduced top padding
118
- for text in client_info:
119
- if text:
120
- lines = draw_wrapped_text(pdf, text, box_x, y_position, box_width - (BOX_PADDING * 2) + 8, font="Helvetica-Bold", size=10)
121
- for line in lines:
122
- text_width = pdf.stringWidth(line, "Helvetica-Bold", 10)
123
- x_centered = box_x + (box_width - text_width) / 2
124
- pdf.drawString(x_centered, y_position, line)
125
- y_position -= LINE_SPACING
126
-
127
-
128
- # Info boxes (Date, N° Devis, PLANCHER) - maintain left margin
129
- info_y = top_margin - 80
130
- box_label_width = 70
131
- box_value_width = 100
132
- box_height = 15
133
-
134
- for label, value in [
135
- ("Date du devis :", data.date.strftime("%d/%m/%Y")),
136
- ("N° Devis :", data.invoice_number),
137
- ("PLANCHER :", data.frame_number or "PH RDC")
138
- ]:
139
- # Set dashed line style with thicker lines
140
- pdf.setLineWidth(1.5)
141
- pdf.setDash(3, 2)
142
-
143
- # Draw boxes with dashed borders
144
- draw_box(pdf, MARGIN, info_y, box_label_width, box_height, fill_color=HEADER_BLUE)
145
- draw_box(pdf, MARGIN + box_label_width, info_y, box_value_width, box_height, fill_color=WHITE)
146
-
147
- # Reset line style
148
- pdf.setLineWidth(1)
149
- pdf.setDash(1, 0)
150
-
151
- # Draw text
152
- pdf.setFillColorRGB(*WHITE)
153
- pdf.setFont("Helvetica-Bold", 9)
154
- pdf.drawString(MARGIN + BOX_PADDING - 2, info_y + 4, label)
155
-
156
- pdf.setFillColorRGB(*BLACK)
157
- value_str = str(value)
158
- if len(value_str) > 20:
159
- pdf.setFont("Helvetica-Bold", 9)
160
- lines = draw_wrapped_text(pdf, value_str, MARGIN + box_label_width, info_y + 4, box_value_width - 10)
161
- for i, line in enumerate(lines):
162
- draw_centered_text(pdf, line, MARGIN + box_label_width, info_y + 4 - (i * 8), box_value_width)
163
- else:
164
- pdf.setFont("Helvetica-Bold", 9)
165
- draw_centered_text(pdf, value_str, MARGIN + box_label_width, info_y + 4, box_value_width)
166
-
167
- info_y -= 20
168
-
169
- # Table headers - align with info boxes and client box
170
- headers = [
171
- ("Description", 250), # Increased width for better spacing
172
- ("Unité", 80), # Standardized width
173
- ("NBRE", 80), # Standardized width
174
- ("ML/Qté", 80), # Standardized width
175
- ]
176
-
177
- total_width = sum(width for _, width in headers)
178
- table_x = MARGIN
179
- table_y = info_y - 10
180
-
181
- # Draw header background and border
182
- draw_box(pdf, table_x, table_y, total_width, LINE_HEIGHT, fill_color=HEADER_BLUE)
183
- pdf.setFillColorRGB(*WHITE)
184
- pdf.rect(table_x, table_y, total_width, LINE_HEIGHT, stroke=1)
185
-
186
- # Draw header text with consistent spacing
187
- current_x = table_x
188
- for title, width in headers:
189
- draw_centered_text(pdf, title, current_x, table_y + 6, width, font="Helvetica-Bold", size=10)
190
- # Draw vertical separator lines between headers
191
- if current_x > table_x:
192
- pdf.line(current_x, table_y, current_x, table_y + LINE_HEIGHT)
193
- current_x += width
194
-
195
- # Start sections immediately after headers
196
- current_y = table_y - LINE_HEIGHT + 12
197
-
198
- def draw_section_header2(title):
199
- nonlocal current_y
200
- section_start_y = current_y
201
- current_y -= 20 # Space before the title
202
- pdf.setFont("Helvetica-Bold", SECTION_FONT_SIZE)
203
- pdf.setFillColorRGB(*BLACK)
204
- pdf.drawString(table_x + BOX_PADDING, current_y + 6, title)
205
- current_y -= LINE_HEIGHT - 3 # Increased space between title and items (was LINE_HEIGHT - 12)
206
- pdf.setFont("Helvetica", STANDARD_FONT_SIZE)
207
- return section_start_y
208
-
209
- def format_number(value, section_type=None):
210
- """Format numbers based on section type"""
211
- if section_type in ["HOURDIS", "AGGLOS", "PANNEAU TREILLIS SOUDES"]:
212
- if isinstance(value, (int, float)) and value % 1 == 0:
213
- return str(int(value))
214
- return f"{value:.2f}"
215
-
216
- def draw_item_row(item, indent=False, is_last_item=False, section_type=None):
217
- nonlocal current_y
218
- row_values = [
219
- str(item.description),
220
- str(item.unit),
221
- format_number(item.quantity, section_type),
222
- f"{item.length:.2f}",
223
- ]
224
-
225
- # Calculate positions for perfect alignment
226
- positions = [
227
- table_x + (20 if indent else 0),
228
- table_x + headers[0][1],
229
- table_x + headers[0][1] + headers[1][1],
230
- table_x + headers[0][1] + headers[1][1] + headers[2][1]
231
- ]
232
-
233
- pdf.setFont("Helvetica", 9)
234
-
235
- for idx, value in enumerate(row_values):
236
- if idx == 0:
237
- indeent_amount = 30
238
- # Left align description
239
- pdf.drawString(positions[idx] + BOX_PADDING + indeent_amount, current_y + 6, value)
240
- else:
241
- # Center align other columns
242
- text_width = pdf.stringWidth(value, "Helvetica", 9)
243
- x_pos = positions[idx] + (headers[idx][1] - text_width) / 2
244
- pdf.drawString(x_pos, current_y + 6, value)
245
-
246
- if not is_last_item:
247
- current_y -= (LINE_HEIGHT - 8)
248
- else:
249
- current_y -= (LINE_HEIGHT - 20)
250
-
251
- pdf.setFont("Helvetica", STANDARD_FONT_SIZE)
252
-
253
- # Draw sections
254
- sections = [
255
- ("POUTRELLES", "PCP"),
256
- ("HOURDIS", "HOURDIS"),
257
- ("PANNEAU TREILLIS SOUDES", "PTS"),
258
- ("AGGLOS", "AGGLOS")
259
- ]
260
-
261
- def get_description_number(description: str) -> float:
262
- try:
263
- numbers = re.findall(r'\d+\.?\d*', description)
264
- return float(numbers[0]) if numbers else 0
265
- except:
266
- return 0
267
-
268
- def custom_sort_key(item):
269
- description = item.description.upper()
270
- # For poutrelles, prioritize items with 'N'
271
- if 'POUTRELLE' in description or 'PCP' in description:
272
- has_n = 'N' in description
273
- # Get the number from the description
274
- numbers = re.findall(r'\d+', description)
275
- number = float(numbers[0]) if numbers else 0
276
- # Items with 'N' come first, then sort by number
277
- return (not has_n, number)
278
- # For other items, use the existing logic
279
- return (True, get_description_number(description))
280
-
281
- # Draw sections and items
282
- for section_title, keyword in sections:
283
- items = [i for i in data.items if keyword in i.description]
284
- if items:
285
- # Sort the items using the custom sort key
286
- if section_title == 'POUTRELLES':
287
- items.sort(key=custom_sort_key)
288
- else:
289
- items.sort(key=lambda x: get_description_number(x.description))
290
-
291
- grouped_items = {}
292
- for item in items:
293
- key = (item.description, item.length)
294
- if key not in grouped_items:
295
- grouped_items[key] = item
296
- else:
297
- existing = grouped_items[key]
298
- existing.quantity += item.quantity
299
-
300
- section_start_y = draw_section_header2(section_title)
301
- items_list = list(grouped_items.values())
302
-
303
- for i, item in enumerate(items_list):
304
- is_last = i == len(items_list) - 1
305
- draw_item_row(item, indent=(keyword != "lfflflflf"), is_last_item=is_last, section_type=section_title)
306
-
307
- section_height = section_start_y - current_y
308
- pdf.setStrokeColorRGB(*BLACK)
309
- pdf.rect(table_x, current_y, total_width, section_height, stroke=1)
310
-
311
- # Footer
312
- pdf.setFont("Helvetica", FOOTER_FONT_SIZE)
313
-
314
- footer_text = "CARRIPREFA"
315
- pdf.drawCentredString(page_width / 2, 40, footer_text)
316
-
317
- pdf.setFont("Helvetica", FOOTER_FONT_SIZE)
318
- footer_text = "Douar Ait Toumart Habil, Cercle El Bour, Route de Safi, Km 14 -40000 Marrakech"
319
- pdf.drawCentredString(page_width / 2 + 20, 30, footer_text)
320
-
321
- # Add commercial info to footer
322
- commercial_info = dict(
323
- salah="06 62 29 99 78",
324
- khaled="06 66 24 80 94",
325
- ismail="06 66 24 50 15",
326
- jamal="06 70 08 36 50"
327
- )
328
-
329
- if data.commercial and data.commercial.lower() != 'divers':
330
- commercial_text = f"Commercial: {data.commercial.upper()}"
331
- commercial_phone = f"Tél: {commercial_info.get(data.commercial.lower(), '')}"
332
- pdf.drawString(MARGIN + 10, 30, commercial_text)
333
- pdf.drawString(MARGIN + 10, 20, commercial_phone)
334
- footer_contact = "Tél: 05 24 01 33 34 Fax : 05 24 01 33 29 E-mail : carriprefa@gmail.com"
335
- pdf.drawCentredString(page_width / 2 + 10, 20, footer_contact)
336
- pdf.drawString(page_width - 100, 30, f"Date: {datetime.datetime.now().strftime('%d/%m/%Y %H:%M')}")
337
- pdf.drawString(page_width - 100, 20, f"Page 1/2")
338
- else:
339
- footer_contact = "Tél: 05 24 01 33 34 Fax : 05 24 01 33 29 E-mail : carriprefa@gmail.com"
340
- pdf.drawCentredString(page_width / 2 + 10, 20, footer_contact)
341
- pdf.drawString(page_width - 100, 30, f"Date: {datetime.datetime.now().strftime('%d/%m/%Y %H:%M')}")
342
- pdf.drawString(page_width - 100, 20, f"Page 1/2")
343
-
344
- pdf.save()
345
- buffer.seek(0)
346
- return buffer.getvalue()
347
-
348
- except Exception as e:
349
- logger.error(f"Error in PDF generation: {str(e)}", exc_info=True)
350
- raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/static/logo.png DELETED
Binary file (26.8 kB)
 
app/templates/history.html DELETED
@@ -1,192 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Historique des Devis</title>
7
- <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
8
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
9
- </head>
10
- <body class="bg-light">
11
- <header class="bg-primary py-4 mb-4 shadow-sm">
12
- <div class="container">
13
- <div class="row align-items-center">
14
- <div class="col-md-6">
15
- <h1 class="text-white mb-0">Historique des Devis</h1>
16
- </div>
17
- <div class="col-md-6 text-end">
18
- <a href="/" class="btn btn-light me-2">
19
- <i class="fas fa-plus"></i> Nouveau Devis
20
- </a>
21
- </div>
22
- </div>
23
- </div>
24
- </header>
25
-
26
- <div class="container">
27
- <div class="card shadow-sm">
28
- <div class="card-header bg-white py-3">
29
- <div class="row align-items-center">
30
- <div class="col-md-4">
31
- <input type="text" id="searchInput" class="form-control" placeholder="Rechercher par client, projet ou N° devis...">
32
- </div>
33
- <div class="col-md-3">
34
- <select id="statusFilter" class="form-select">
35
- <option value="">Tous les statuts</option>
36
- <option value="pending">En attente</option>
37
- <option value="completed">Complété</option>
38
- </select>
39
- </div>
40
- </div>
41
- </div>
42
- <div class="card-body">
43
- <div class="table-responsive">
44
- <table class="table table-hover">
45
- <thead class="table-primary">
46
- <tr>
47
- <th>N° Devis</th>
48
- <th>Date</th>
49
- <th>Client</th>
50
- <th>Projet</th>
51
- <th>Total TTC</th>
52
- <th>Status</th>
53
- <th>Actions</th>
54
- </tr>
55
- </thead>
56
- <tbody id="invoicesTableBody"></tbody>
57
- </table>
58
- </div>
59
- </div>
60
- </div>
61
- </div>
62
-
63
- <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
64
- <script>
65
- document.addEventListener('DOMContentLoaded', async () => {
66
- const tableBody = document.getElementById('invoicesTableBody');
67
- const searchInput = document.getElementById('searchInput');
68
- const statusFilter = document.getElementById('statusFilter');
69
-
70
- async function loadInvoices() {
71
- try {
72
- const response = await fetch('/api/invoices/');
73
- if (!response.ok) throw new Error('Failed to load invoices');
74
- return await response.json();
75
- } catch (error) {
76
- console.error('Error:', error);
77
- return [];
78
- }
79
- }
80
-
81
- function formatDate(dateString) {
82
- return new Date(dateString).toLocaleDateString('fr-FR');
83
- }
84
-
85
- function formatCurrency(amount) {
86
- return new Intl.NumberFormat('fr-FR', {
87
- style: 'currency',
88
- currency: 'MAD',
89
- minimumFractionDigits: 2
90
- }).format(amount);
91
- }
92
-
93
- function renderInvoices(invoices) {
94
- const searchTerm = searchInput.value.toLowerCase();
95
- const statusValue = statusFilter.value;
96
-
97
- const filteredInvoices = invoices.filter(invoice => {
98
- const matchesSearch = (
99
- invoice.invoice_number.toLowerCase().includes(searchTerm) ||
100
- invoice.client_name.toLowerCase().includes(searchTerm) ||
101
- invoice.project.toLowerCase().includes(searchTerm)
102
- );
103
- const matchesStatus = !statusValue || invoice.status === statusValue;
104
- return matchesSearch && matchesStatus;
105
- });
106
-
107
- tableBody.innerHTML = filteredInvoices.map(invoice => `
108
- <tr>
109
- <td class="fw-bold">${invoice.invoice_number}</td>
110
- <td>${formatDate(invoice.date)}</td>
111
- <td>${invoice.client_name}</td>
112
- <td>${invoice.project}</td>
113
- <td class="fw-bold">${formatCurrency(invoice.total_ttc)}</td>
114
- <td>
115
- <span class="badge ${invoice.status === 'pending' ? 'bg-warning' : 'bg-success'}">
116
- ${invoice.status === 'pending' ? 'En attente' : 'Complété'}
117
- </span>
118
- </td>
119
- <td>
120
- <div class="btn-group">
121
- <button onclick="editInvoice(${invoice.id})" class="btn btn-sm btn-warning" title="Modifier">
122
- <i class="fas fa-edit"></i>
123
- </button>
124
- <button onclick="previewPDF(${invoice.id})" class="btn btn-sm btn-primary" title="Aperçu">
125
- <i class="fas fa-eye"></i>
126
- </button>
127
- <button onclick="downloadPDF(${invoice.id})" class="btn btn-sm btn-success" title="Télécharger">
128
- <i class="fas fa-download"></i>
129
- </button>
130
- <button onclick="duplicateInvoice(${invoice.id})" class="btn btn-sm btn-info" title="Dupliquer">
131
- <i class="fas fa-copy"></i>
132
- </button>
133
- </div>
134
- </td>
135
- </tr>
136
- `).join('');
137
- }
138
-
139
- // Load initial data
140
- const invoices = await loadInvoices();
141
- renderInvoices(invoices);
142
-
143
- // Set up event listeners
144
- searchInput.addEventListener('input', () => renderInvoices(invoices));
145
- statusFilter.addEventListener('change', () => renderInvoices(invoices));
146
-
147
- // Global functions for actions
148
- window.editInvoice = (id) => {
149
- window.location.href = `/?edit=${id}`;
150
- };
151
-
152
- window.previewPDF = async (id) => {
153
- const response = await fetch(`/api/invoices/${id}/generate-pdf`, {
154
- method: 'POST'
155
- });
156
- const blob = await response.blob();
157
- const url = URL.createObjectURL(blob);
158
- window.open(url, '_blank');
159
- };
160
-
161
- window.downloadPDF = async (id) => {
162
- const response = await fetch(`/api/invoices/${id}/generate-pdf`, {
163
- method: 'POST'
164
- });
165
- const blob = await response.blob();
166
- const url = URL.createObjectURL(blob);
167
- const a = document.createElement('a');
168
- a.href = url;
169
- a.download = `devis_${id}.pdf`;
170
- document.body.appendChild(a);
171
- a.click();
172
- document.body.removeChild(a);
173
- URL.revokeObjectURL(url);
174
- };
175
-
176
- window.duplicateInvoice = async (id) => {
177
- try {
178
- const response = await fetch(`/api/invoices/${id}/duplicate`, {
179
- method: 'POST'
180
- });
181
- if (!response.ok) throw new Error('Failed to duplicate invoice');
182
- const newInvoice = await response.json();
183
- window.location.href = `/?edit=${newInvoice.id}`;
184
- } catch (error) {
185
- console.error('Error:', error);
186
- alert('Failed to duplicate invoice');
187
- }
188
- };
189
- });
190
- </script>
191
- </body>
192
- </html>