Spaces:
Sleeping
Sleeping
EL GHAFRAOUI AYOUB commited on
Commit ·
d2aae1f
1
Parent(s): 535824e
'C'
Browse files- app/db/__pycache__/database.cpython-312.pyc +0 -0
- app/db/__pycache__/models.cpython-312.pyc +0 -0
- app/db/database.py +0 -118
- app/db/models.py +0 -45
- app/routes/__init__.py +0 -1
- app/routes/__pycache__/__init__.cpython-312.pyc +0 -0
- app/routes/__pycache__/invoices.cpython-312.pyc +0 -0
- app/routes/invoices.py +0 -295
- app/schemas/__init__.py +0 -1
- app/schemas/__pycache__/__init__.cpython-312.pyc +0 -0
- app/schemas/__pycache__/invoice.cpython-312.pyc +0 -0
- app/schemas/invoice.py +0 -53
- app/services/__pycache__/excel_service.cpython-312.pyc +0 -0
- app/services/__pycache__/invoice_service.cpython-312.pyc +0 -0
- app/services/__pycache__/invoice_service_page2.cpython-312.pyc +0 -0
- app/services/excel_service copy 2.py +0 -205
- app/services/excel_service copy.py +0 -220
- app/services/excel_service.py +0 -382
- app/services/invoice_service copy 2.py +0 -396
- app/services/invoice_service copy.py +0 -308
- app/services/invoice_service.py +0 -452
- app/services/invoice_service_page2.py +0 -350
- app/static/logo.png +0 -0
- app/templates/history.html +0 -192
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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|