EL GHAFRAOUI AYOUB commited on
Commit
5760448
·
1 Parent(s): 8fe6367
Files changed (40) hide show
  1. .env +2 -0
  2. Dockerfile +24 -0
  3. alembic.ini +100 -0
  4. alembic/__pycache__/env.cpython-312.pyc +0 -0
  5. alembic/env.py +66 -0
  6. alembic/script.py.mako +26 -0
  7. alembic/versions/3ac87142c0f1_add_invoice_sequence.py +28 -0
  8. alembic/versions/59c6283aeb7f_add_invoice_sequence.py +30 -0
  9. alembic/versions/__pycache__/3ac87142c0f1_add_invoice_sequence.cpython-312.pyc +0 -0
  10. alembic/versions/__pycache__/59c6283aeb7f_add_invoice_sequence.cpython-312.pyc +0 -0
  11. alembic/versions/__pycache__/f8ee2909b4a1_add_client_type.cpython-312.pyc +0 -0
  12. alembic/versions/f8ee2909b4a1_add_client_type.py +30 -0
  13. app/__init__.py +0 -0
  14. app/__pycache__/__init__.cpython-312.pyc +0 -0
  15. app/__pycache__/main.cpython-312.pyc +0 -0
  16. app/db/__pycache__/database.cpython-312.pyc +0 -0
  17. app/db/__pycache__/models.cpython-312.pyc +0 -0
  18. app/db/database.py +28 -0
  19. app/db/models.py +42 -0
  20. app/main.py +52 -0
  21. app/models/invoice.py +10 -0
  22. app/routes/__init__.py +1 -0
  23. app/routes/__pycache__/__init__.cpython-312.pyc +0 -0
  24. app/routes/__pycache__/invoices.cpython-312.pyc +0 -0
  25. app/routes/invoice.py +47 -0
  26. app/routes/invoices.py +177 -0
  27. app/schemas/__init__.py +1 -0
  28. app/schemas/__pycache__/__init__.cpython-312.pyc +0 -0
  29. app/schemas/__pycache__/invoice.cpython-312.pyc +0 -0
  30. app/schemas/invoice.py +49 -0
  31. app/services/__pycache__/invoice_service.cpython-312.pyc +0 -0
  32. app/services/invoice_service.py +236 -0
  33. app/static/logo.png +0 -0
  34. app/templates/index.html +582 -0
  35. migrations/versions/xxxx_add_invoice_sequence.py +28 -0
  36. requirements.txt +12 -0
  37. sql_app.db +0 -0
  38. tests/__init__.py +0 -0
  39. tests/test_api.py +0 -0
  40. tests/test_services.py +0 -0
.env ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ DATABASE_URL=sqlite+aiosqlite:///./app.db
2
+ ENVIRONMENT=production
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.11 slim image
2
+ FROM python:3.11-slim
3
+
4
+ # Set working directory
5
+ WORKDIR /app
6
+
7
+ # Copy requirements first to leverage Docker cache
8
+ COPY requirements.txt .
9
+
10
+ # Install dependencies
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
+
13
+ # Copy the rest of the application
14
+ COPY . .
15
+
16
+ # Create a non-root user
17
+ RUN useradd -m myuser
18
+ USER myuser
19
+
20
+ # Expose port
21
+ EXPOSE 8000
22
+
23
+ # Command to run the application
24
+ CMD ["gunicorn", "app.main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000"]
alembic.ini ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # A generic, single database configuration.
2
+
3
+ [alembic]
4
+ # path to migration scripts
5
+ script_location = alembic
6
+
7
+ # template used to generate migration files
8
+ # file_template = %%(rev)s_%%(slug)s
9
+
10
+ # sys.path path, will be prepended to sys.path if present.
11
+ # defaults to the current working directory.
12
+ prepend_sys_path = .
13
+
14
+ # timezone to use when rendering the date within the migration file
15
+ # as well as the filename.
16
+ # If specified, requires the python-dateutil library that can be
17
+ # installed by adding `alembic[tz]` to the pip requirements
18
+ # timezone =
19
+
20
+ # max length of characters to apply to the
21
+ # "slug" field
22
+ # truncate_slug_length = 40
23
+
24
+ # set to 'true' to run the environment during
25
+ # the 'revision' command, regardless of autogenerate
26
+ # revision_environment = false
27
+
28
+ # set to 'true' to allow .pyc and .pyo files without
29
+ # a source .py file to be detected as revisions in the
30
+ # versions/ directory
31
+ # sourceless = false
32
+
33
+ # version location specification; This defaults
34
+ # to alembic/versions. When using multiple version
35
+ # directories, initial revisions must be specified with --version-path.
36
+ # The path separator used here should be the separator specified by "version_path_separator" below.
37
+ # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
38
+
39
+ # version path separator; As mentioned above, this is the character used to split
40
+ # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
41
+ # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or colons.
42
+ # Valid values for version_path_separator are:
43
+ #
44
+ # version_path_separator = :
45
+ # version_path_separator = ;
46
+ # version_path_separator = space
47
+ version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
48
+
49
+ # the output encoding used when revision files
50
+ # are written from script.py.mako
51
+ # output_encoding = utf-8
52
+
53
+ sqlalchemy.url = sqlite+aiosqlite:///./sql_app.db
54
+
55
+
56
+ [post_write_hooks]
57
+ # post_write_hooks defines scripts or Python functions that are run
58
+ # on newly generated revision scripts. See the documentation for further
59
+ # detail and examples
60
+
61
+ # format using "black" - use the console_scripts runner, against the "black" entrypoint
62
+ # hooks = black
63
+ # black.type = console_scripts
64
+ # black.entrypoint = black
65
+ # black.options = -l 79 REVISION_SCRIPT_FILENAME
66
+
67
+ # Logging configuration
68
+ [loggers]
69
+ keys = root,sqlalchemy,alembic
70
+
71
+ [handlers]
72
+ keys = console
73
+
74
+ [formatters]
75
+ keys = generic
76
+
77
+ [logger_root]
78
+ level = WARN
79
+ handlers = console
80
+ qualname =
81
+
82
+ [logger_sqlalchemy]
83
+ level = WARN
84
+ handlers =
85
+ qualname = sqlalchemy.engine
86
+
87
+ [logger_alembic]
88
+ level = INFO
89
+ handlers =
90
+ qualname = alembic
91
+
92
+ [handler_console]
93
+ class = StreamHandler
94
+ args = (sys.stderr,)
95
+ level = NOTSET
96
+ formatter = generic
97
+
98
+ [formatter_generic]
99
+ format = %(levelname)-5.5s [%(name)s] %(message)s
100
+ datefmt = %H:%M:%S
alembic/__pycache__/env.cpython-312.pyc ADDED
Binary file (3.14 kB). View file
 
alembic/env.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from logging.config import fileConfig
3
+
4
+ from sqlalchemy import pool
5
+ from sqlalchemy.engine import Connection
6
+ from sqlalchemy.ext.asyncio import async_engine_from_config
7
+
8
+ from alembic import context
9
+
10
+ # this is the Alembic Config object, which provides
11
+ # access to the values within the .ini file in use.
12
+ config = context.config
13
+
14
+ # Interpret the config file for Python logging.
15
+ # This line sets up loggers basically.
16
+ if config.config_file_name is not None:
17
+ fileConfig(config.config_file_name)
18
+
19
+ # add your model's MetaData object here
20
+ # for 'autogenerate' support
21
+ from app.db.models import Base
22
+ target_metadata = Base.metadata
23
+
24
+ def run_migrations_offline() -> None:
25
+ """Run migrations in 'offline' mode."""
26
+ url = config.get_main_option("sqlalchemy.url")
27
+ context.configure(
28
+ url=url,
29
+ target_metadata=target_metadata,
30
+ literal_binds=True,
31
+ dialect_opts={"paramstyle": "named"},
32
+ )
33
+
34
+ with context.begin_transaction():
35
+ context.run_migrations()
36
+
37
+ def do_run_migrations(connection: Connection) -> None:
38
+ context.configure(connection=connection, target_metadata=target_metadata)
39
+
40
+ with context.begin_transaction():
41
+ context.run_migrations()
42
+
43
+ async def run_async_migrations() -> None:
44
+ """In this scenario we need to create an Engine
45
+ and associate a connection with the context."""
46
+
47
+ connectable = async_engine_from_config(
48
+ config.get_section(config.config_ini_section, {}),
49
+ prefix="sqlalchemy.",
50
+ poolclass=pool.NullPool,
51
+ )
52
+
53
+ async with connectable.connect() as connection:
54
+ await connection.run_sync(do_run_migrations)
55
+
56
+ await connectable.dispose()
57
+
58
+ def run_migrations_online() -> None:
59
+ """Run migrations in 'online' mode."""
60
+
61
+ asyncio.run(run_async_migrations())
62
+
63
+ if context.is_offline_mode():
64
+ run_migrations_offline()
65
+ else:
66
+ run_migrations_online()
alembic/script.py.mako ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ ${upgrades if upgrades else "pass"}
23
+
24
+
25
+ def downgrade() -> None:
26
+ ${downgrades if downgrades else "pass"}
alembic/versions/3ac87142c0f1_add_invoice_sequence.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """add invoice sequence and client type
2
+
3
+ Revision ID: xxxx
4
+ Revises: previous_revision_id
5
+ Create Date: 2024-xx-xx
6
+ """
7
+ from alembic import op
8
+ import sqlalchemy as sa
9
+
10
+ def upgrade():
11
+ # Create sequence for invoice IDs
12
+ op.execute('CREATE SEQUENCE IF NOT EXISTS invoice_id_seq START 1')
13
+
14
+ # Add client_type column if not exists
15
+ op.add_column('invoices', sa.Column('client_type', sa.String(10), nullable=True))
16
+
17
+ # Make invoice_number unique
18
+ op.create_unique_constraint('uq_invoice_number', 'invoices', ['invoice_number'])
19
+
20
+ def downgrade():
21
+ # Remove unique constraint
22
+ op.drop_constraint('uq_invoice_number', 'invoices')
23
+
24
+ # Drop client_type column
25
+ op.drop_column('invoices', 'client_type')
26
+
27
+ # Drop sequence
28
+ op.execute('DROP SEQUENCE IF EXISTS invoice_id_seq')
alembic/versions/59c6283aeb7f_add_invoice_sequence.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """add invoice sequence
2
+
3
+ Revision ID: 59c6283aeb7f
4
+ Revises: f8ee2909b4a1
5
+ Create Date: 2025-01-28 23:00:46.031532
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = '59c6283aeb7f'
16
+ down_revision: Union[str, None] = 'f8ee2909b4a1'
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ # ### commands auto generated by Alembic - please adjust! ###
23
+ pass
24
+ # ### end Alembic commands ###
25
+
26
+
27
+ def downgrade() -> None:
28
+ # ### commands auto generated by Alembic - please adjust! ###
29
+ pass
30
+ # ### end Alembic commands ###
alembic/versions/__pycache__/3ac87142c0f1_add_invoice_sequence.cpython-312.pyc ADDED
Binary file (1.39 kB). View file
 
alembic/versions/__pycache__/59c6283aeb7f_add_invoice_sequence.cpython-312.pyc ADDED
Binary file (1.01 kB). View file
 
alembic/versions/__pycache__/f8ee2909b4a1_add_client_type.cpython-312.pyc ADDED
Binary file (974 Bytes). View file
 
alembic/versions/f8ee2909b4a1_add_client_type.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """add client type
2
+
3
+ Revision ID: f8ee2909b4a1
4
+ Revises:
5
+ Create Date: 2025-01-28 22:54:07.661020
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = 'f8ee2909b4a1'
16
+ down_revision: Union[str, None] = None
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ # ### commands auto generated by Alembic - please adjust! ###
23
+ pass
24
+ # ### end Alembic commands ###
25
+
26
+
27
+ def downgrade() -> None:
28
+ # ### commands auto generated by Alembic - please adjust! ###
29
+ pass
30
+ # ### end Alembic commands ###
app/__init__.py ADDED
File without changes
app/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (192 Bytes). View file
 
app/__pycache__/main.cpython-312.pyc ADDED
Binary file (1.79 kB). View file
 
app/db/__pycache__/database.cpython-312.pyc ADDED
Binary file (2.19 kB). View file
 
app/db/__pycache__/models.cpython-312.pyc ADDED
Binary file (2.2 kB). View file
 
app/db/database.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
2
+ from sqlalchemy.orm import sessionmaker
3
+ from app.db.models import Base
4
+
5
+ # Use SQLite with aiosqlite
6
+ DATABASE_URL = "sqlite+aiosqlite:///./sql_app.db"
7
+
8
+ engine = create_async_engine(DATABASE_URL, echo=True)
9
+ AsyncSessionLocal = sessionmaker(
10
+ engine, class_=AsyncSession, expire_on_commit=False
11
+ )
12
+
13
+ async def init_db():
14
+ async with engine.begin() as conn:
15
+ # Drop all tables and recreate them
16
+ await conn.run_sync(Base.metadata.drop_all)
17
+ await conn.run_sync(Base.metadata.create_all)
18
+
19
+ async def get_db() -> AsyncSession:
20
+ async with AsyncSessionLocal() as session:
21
+ try:
22
+ yield session
23
+ await session.commit()
24
+ except Exception:
25
+ await session.rollback()
26
+ raise
27
+ finally:
28
+ await session.close()
app/db/models.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=func.now())
15
+ project = Column(String)
16
+ client_name = Column(String)
17
+ client_phone = Column(String)
18
+ address = Column(String)
19
+ total_ht = Column(Float)
20
+ tax = Column(Float)
21
+ total_ttc = Column(Float)
22
+ frame_number = Column(String, nullable=True)
23
+ customer_name = Column(String)
24
+ amount = Column(Float)
25
+ status = Column(String, default="pending")
26
+ created_at = Column(DateTime, default=datetime.utcnow)
27
+
28
+ items = relationship("InvoiceItem", back_populates="invoice")
29
+
30
+ class InvoiceItem(Base):
31
+ __tablename__ = "invoice_items"
32
+
33
+ id = Column(Integer, primary_key=True, index=True)
34
+ invoice_id = Column(Integer, ForeignKey("invoices.id"))
35
+ description = Column(String)
36
+ unit = Column(String)
37
+ quantity = Column(Integer)
38
+ length = Column(Float)
39
+ unit_price = Column(Float)
40
+ total_price = Column(Float)
41
+
42
+ invoice = relationship("Invoice", back_populates="items")
app/main.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, Request
2
+ from fastapi.staticfiles import StaticFiles
3
+ from fastapi.templating import Jinja2Templates
4
+ from .routes import invoices
5
+ from app.db.database import init_db
6
+ import os
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from dotenv import load_dotenv
9
+
10
+ # Load environment variables
11
+ load_dotenv()
12
+
13
+ # Get the absolute path to the app directory
14
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
15
+
16
+ app = FastAPI(
17
+ title="Invoice Generator",
18
+ description="API for generating invoices",
19
+ version="1.0.0"
20
+ )
21
+
22
+ # Configure CORS
23
+ app.add_middleware(
24
+ CORSMiddleware,
25
+ allow_origins=["*"], # In production, replace with your frontend domain
26
+ allow_credentials=True,
27
+ allow_methods=["*"],
28
+ allow_headers=["*"],
29
+ )
30
+
31
+ # Mount static files with absolute path
32
+ app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "app/static")), name="static")
33
+
34
+ # Templates with absolute path
35
+ templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "app/templates"))
36
+
37
+ # Include routers
38
+ app.include_router(invoices.router)
39
+
40
+ @app.on_event("startup")
41
+ async def startup_event():
42
+ await init_db()
43
+
44
+ # Root endpoint to serve the HTML page
45
+ @app.get("/")
46
+ async def root(request: Request):
47
+ return templates.TemplateResponse("index.html", {"request": request})
48
+
49
+ # Health check endpoint
50
+ @app.get("/health")
51
+ async def health_check():
52
+ return {"status": "healthy"}
app/models/invoice.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Column, Integer, String, DateTime, Float, ForeignKey, Sequence
2
+ from sqlalchemy.orm import relationship
3
+ from ..database import Base
4
+
5
+ class Invoice(Base):
6
+ __tablename__ = "invoices"
7
+
8
+ id = Column(Integer, Sequence('invoice_id_seq'), primary_key=True)
9
+ invoice_number = Column(String, unique=True)
10
+ # ... rest of your columns ...
app/routes/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Empty file to make the directory a Python package
app/routes/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (199 Bytes). View file
 
app/routes/__pycache__/invoices.cpython-312.pyc ADDED
Binary file (8.92 kB). View file
 
app/routes/invoice.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from sqlalchemy.orm import Session
3
+ from sqlalchemy import func
4
+ from typing import Optional
5
+ from ..database import get_db
6
+ from ..models.invoice import Invoice, ClientType
7
+
8
+ router = APIRouter()
9
+
10
+ @router.get("/api/invoices/next-number/{client_type}")
11
+ async def get_next_invoice_number(client_type: str, db: Session = Depends(get_db)):
12
+ if client_type not in ClientType.__members__:
13
+ raise HTTPException(status_code=400, detail="Invalid client type")
14
+
15
+ # Get the last invoice number for this client type
16
+ last_invoice = db.query(Invoice)\
17
+ .filter(Invoice.client_type == ClientType[client_type])\
18
+ .order_by(Invoice.id.desc())\
19
+ .first()
20
+
21
+ if last_invoice:
22
+ # Extract the number from the last invoice number (DCP/TYPE/NUMBER)
23
+ try:
24
+ last_number = int(last_invoice.invoice_number.split('/')[-1])
25
+ next_number = last_number + 1
26
+ except (ValueError, IndexError):
27
+ next_number = 1
28
+ else:
29
+ next_number = 1
30
+
31
+ return {"next_number": f"{next_number:04d}"} # Format as 4 digits
32
+
33
+ @router.post("/api/invoices/")
34
+ async def create_invoice(invoice_data: InvoiceCreate, db: Session = Depends(get_db)):
35
+ # Extract client type from invoice number
36
+ try:
37
+ client_type = invoice_data.invoice_number.split('/')[1]
38
+ if client_type not in ClientType.__members__:
39
+ raise HTTPException(status_code=400, detail="Invalid client type in invoice number")
40
+ except IndexError:
41
+ raise HTTPException(status_code=400, detail="Invalid invoice number format")
42
+
43
+ db_invoice = Invoice(
44
+ client_type=ClientType[client_type],
45
+ **invoice_data.dict()
46
+ )
47
+ # ... rest of your existing create logic ...
app/routes/invoices.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, Request
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+ from sqlalchemy import select, func, Sequence
4
+ from typing import List, Optional
5
+ from sqlalchemy.orm import selectinload, joinedload, Session
6
+ from fastapi.responses import StreamingResponse
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
+
15
+ # Set up logging
16
+ logger = logging.getLogger(__name__)
17
+
18
+ router = APIRouter(prefix="/api/invoices", tags=["invoices"])
19
+
20
+ @router.post("/", response_model=InvoiceResponse)
21
+ async def create_new_invoice(
22
+ invoice: InvoiceCreate,
23
+ db: AsyncSession = Depends(get_db)
24
+ ):
25
+ try:
26
+ # Get current max ID
27
+ query = select(func.max(Invoice.id))
28
+ result = await db.execute(query)
29
+ current_max_id = result.scalar() or 0
30
+ next_id = current_max_id + 1
31
+
32
+ # Extract client type from the invoice number
33
+ client_type = invoice.invoice_number.split('/')[1]
34
+
35
+ # Create new invoice number
36
+ new_invoice_number = f"DCP/{client_type}/{next_id:04d}"
37
+
38
+ # Create invoice with the new number
39
+ db_invoice = Invoice(
40
+ id=next_id,
41
+ invoice_number=new_invoice_number,
42
+ date=invoice.date,
43
+ project=invoice.project,
44
+ client_name=invoice.client_name,
45
+ client_phone=invoice.client_phone,
46
+ address=invoice.address,
47
+ total_ht=invoice.total_ht,
48
+ tax=invoice.tax,
49
+ total_ttc=invoice.total_ttc,
50
+ frame_number=invoice.frame_number
51
+ )
52
+
53
+ db.add(db_invoice)
54
+ await db.commit()
55
+ await db.refresh(db_invoice)
56
+
57
+ # Create invoice items
58
+ for item_data in invoice.items:
59
+ db_item = InvoiceItem(
60
+ invoice_id=db_invoice.id,
61
+ description=item_data.description,
62
+ unit=item_data.unit,
63
+ quantity=item_data.quantity,
64
+ length=item_data.length,
65
+ unit_price=item_data.unit_price,
66
+ total_price=item_data.total_price
67
+ )
68
+ db.add(db_item)
69
+
70
+ await db.commit()
71
+ await db.refresh(db_invoice)
72
+
73
+ # Explicitly load the items relationship
74
+ result = await db.execute(
75
+ select(Invoice)
76
+ .options(selectinload(Invoice.items))
77
+ .filter(Invoice.id == db_invoice.id)
78
+ )
79
+ return result.scalar_one()
80
+
81
+ except Exception as e:
82
+ await db.rollback()
83
+ raise HTTPException(status_code=500, detail=str(e))
84
+
85
+ @router.get("/{invoice_id}", response_model=InvoiceResponse)
86
+ async def read_invoice(invoice_id: int, db: AsyncSession = Depends(get_db)):
87
+ result = await db.execute(
88
+ select(Invoice)
89
+ .options(selectinload(Invoice.items))
90
+ .filter(Invoice.id == invoice_id)
91
+ )
92
+ invoice = result.scalar_one_or_none()
93
+ if invoice is None:
94
+ raise HTTPException(status_code=404, detail="Invoice not found")
95
+ return invoice
96
+
97
+ @router.get("/", response_model=List[InvoiceResponse])
98
+ async def read_invoices(
99
+ skip: int = 0,
100
+ limit: int = 100,
101
+ db: AsyncSession = Depends(get_db)
102
+ ):
103
+ result = await db.execute(
104
+ select(Invoice)
105
+ .options(selectinload(Invoice.items))
106
+ .offset(skip)
107
+ .limit(limit)
108
+ )
109
+ return result.scalars().all()
110
+
111
+ @router.post("/{invoice_id}/generate-pdf")
112
+ async def generate_invoice_pdf(
113
+ invoice_id: int,
114
+ request: Request,
115
+ db: AsyncSession = Depends(get_db)
116
+ ):
117
+ try:
118
+ # Get invoice with items
119
+ result = await db.execute(
120
+ select(Invoice)
121
+ .options(selectinload(Invoice.items))
122
+ .filter(Invoice.id == invoice_id)
123
+ )
124
+ invoice = result.scalar_one_or_none()
125
+
126
+ if not invoice:
127
+ raise HTTPException(status_code=404, detail="Invoice not found")
128
+
129
+ logger.info(f"Generating PDF for invoice: {invoice_id}")
130
+
131
+ # Generate PDF
132
+ pdf_bytes = InvoiceService.generate_pdf(invoice)
133
+
134
+ return StreamingResponse(
135
+ iter([pdf_bytes]),
136
+ media_type="application/pdf",
137
+ headers={"Content-Disposition": f"attachment; filename=devis_{invoice_id}.pdf"}
138
+ )
139
+ except Exception as e:
140
+ logger.error(f"Error generating PDF: {str(e)}", exc_info=True)
141
+ raise HTTPException(status_code=500, detail=str(e))
142
+
143
+ @router.get("/last-number/{client_type}", response_model=dict)
144
+ async def get_last_invoice_number(
145
+ client_type: str,
146
+ db: AsyncSession = Depends(get_db)
147
+ ):
148
+ try:
149
+ # Use async query syntax
150
+ query = select(func.max(Invoice.id))
151
+ result = await db.execute(query)
152
+ current_max_id = result.scalar() or 0
153
+
154
+ # Next ID will be current max + 1
155
+ next_id = current_max_id + 1
156
+
157
+ # Format the invoice number with the next ID
158
+ formatted_number = f"DCP/{client_type}/{next_id:04d}"
159
+
160
+ return {
161
+ "next_number": next_id,
162
+ "formatted_number": formatted_number
163
+ }
164
+ except Exception as e:
165
+ print(f"Error in get_last_invoice_number: {str(e)}")
166
+ raise HTTPException(status_code=500, detail=str(e))
167
+
168
+ @router.post("/reset-sequence")
169
+ def reset_invoice_sequence(db: Session = Depends(get_db)):
170
+ try:
171
+ # Reset the sequence to 1
172
+ db.execute("ALTER SEQUENCE invoice_id_seq RESTART WITH 1")
173
+ db.commit()
174
+ return {"message": "Sequence reset successfully"}
175
+ except Exception as e:
176
+ db.rollback()
177
+ raise HTTPException(status_code=500, detail=str(e))
app/schemas/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Empty file to make the directory a Python package
app/schemas/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (200 Bytes). View file
 
app/schemas/__pycache__/invoice.cpython-312.pyc ADDED
Binary file (2.75 kB). View file
 
app/schemas/invoice.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ client_phone: str
22
+ address: str
23
+ total_ht: float
24
+ tax: float
25
+ total_ttc: float
26
+ frame_number: Optional[str] = None
27
+ items: List[InvoiceItemCreate]
28
+
29
+ @computed_field
30
+ def customer_name(self) -> str:
31
+ return self.client_name
32
+
33
+ @computed_field
34
+ def amount(self) -> float:
35
+ return self.total_ttc
36
+
37
+ class InvoiceItemResponse(InvoiceItemCreate):
38
+ id: int
39
+ invoice_id: int
40
+
41
+ class Config:
42
+ from_attributes = True
43
+
44
+ class InvoiceResponse(InvoiceCreate):
45
+ id: int
46
+ items: List[InvoiceItemResponse]
47
+
48
+ class Config:
49
+ from_attributes = True
app/services/__pycache__/invoice_service.cpython-312.pyc ADDED
Binary file (10.6 kB). View file
 
app/services/invoice_service.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
10
+ # Set up logging
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class InvoiceService:
14
+ @staticmethod
15
+ def generate_pdf(data: Invoice) -> bytes:
16
+ try:
17
+ buffer = BytesIO()
18
+ pdf = canvas.Canvas(buffer, pagesize=A4)
19
+ page_width, page_height = A4
20
+
21
+ # Constants
22
+ HEADER_BLUE = (0.29, 0.45, 0.68)
23
+ BLUE_LIGHT = (0.8, 0.8, 1)
24
+ WHITE = (1, 1, 1)
25
+ BLACK = (0, 0, 0)
26
+ MARGIN = 30
27
+ LINE_HEIGHT = 20
28
+ BOX_PADDING = 10
29
+
30
+ # Helper function to draw centered text
31
+ def draw_centered_text(pdf, text, x, y, width, font="Helvetica", size=10):
32
+ text_width = pdf.stringWidth(text, font, size)
33
+ pdf.drawString(x + (width - text_width) / 2, y, text)
34
+
35
+ # Helper function to draw a bordered box
36
+ def draw_box(pdf, x, y, width, height, fill_color=None, stroke_color=BLACK):
37
+ if fill_color:
38
+ pdf.setFillColorRGB(*fill_color)
39
+ pdf.rect(x, y, width, height, fill=1, stroke=0)
40
+ pdf.setFillColorRGB(*stroke_color)
41
+ pdf.rect(x, y, width, height, stroke=1)
42
+
43
+ # Get the absolute path to the logo file
44
+ current_dir = os.path.dirname(os.path.abspath(__file__))
45
+ logo_path = os.path.join(current_dir, "..", "static", "logo.png")
46
+
47
+ # Top section layout
48
+ top_margin = page_height - 100
49
+
50
+ # Left side: Logo - moved far left and up
51
+ if os.path.exists(logo_path):
52
+ pdf.drawImage(logo_path, MARGIN, top_margin + 30, width=100, height=60)
53
+
54
+ # Right side: DEVIS and Client Box - moved far right and up
55
+ pdf.setFont("Helvetica-Bold", 48)
56
+ devis_text = "DEVIS"
57
+ devis_width = pdf.stringWidth(devis_text, "Helvetica-Bold", 48)
58
+ devis_x = page_width - devis_width - MARGIN - 10
59
+ devis_y = top_margin + 50
60
+ pdf.drawString(devis_x, devis_y, devis_text)
61
+
62
+ # Client info box - moved right under DEVIS
63
+ box_width = 200
64
+ box_height = 80
65
+ box_x = page_width - box_width - MARGIN + 10
66
+ box_y = devis_y - 90
67
+
68
+ # Draw client box
69
+ pdf.rect(box_x, box_y, box_width, box_height, stroke=1)
70
+
71
+ # Client Info
72
+ pdf.setFont("Helvetica", 10)
73
+ client_info = [
74
+ data.client_name,
75
+ data.project,
76
+ data.address,
77
+ data.client_phone
78
+ ]
79
+
80
+ # Center and draw each line of client info
81
+ line_height = box_height / (len(client_info) + 1)
82
+ for i, text in enumerate(client_info):
83
+ text_width = pdf.stringWidth(str(text), "Helvetica", 10)
84
+ x = box_x + (box_width - text_width) / 2
85
+ y = box_y + box_height - ((i + 1) * line_height)
86
+ pdf.drawString(x, y, str(text))
87
+
88
+ # Info boxes (Date, N° Devis, PLANCHER) - adjusted starting position
89
+ info_y = top_margin
90
+ box_label_width = 120
91
+ box_value_width = 80
92
+
93
+ for label, value in [
94
+ ("Date du devis :", data.date.strftime("%d/%m/%Y")),
95
+ ("N° Devis :", data.invoice_number),
96
+ ("PLANCHER :", data.frame_number or "PH RDC")
97
+ ]:
98
+ draw_box(pdf, MARGIN, info_y, box_label_width, LINE_HEIGHT, fill_color=HEADER_BLUE)
99
+ pdf.setFillColorRGB(*WHITE)
100
+ pdf.drawString(MARGIN + BOX_PADDING, info_y + 6, label)
101
+
102
+ draw_box(pdf, MARGIN + box_label_width, info_y, box_value_width, LINE_HEIGHT, fill_color=WHITE)
103
+ pdf.setFillColorRGB(*BLACK)
104
+ draw_centered_text(pdf, str(value), MARGIN + box_label_width, info_y + 6, box_value_width)
105
+
106
+ info_y -= 25
107
+
108
+ # Table headers
109
+ table_y = info_y - 30
110
+ headers = [
111
+ ("Description", 150),
112
+ ("Unité", 50),
113
+ ("NBRE", 50),
114
+ ("LNG/Qté", 60),
115
+ ("P.U", 60),
116
+ ("Total HT", 170)
117
+ ]
118
+
119
+ total_width = sum(width for _, width in headers)
120
+ table_x = (page_width - total_width) / 2 # Center table
121
+ draw_box(pdf, table_x, table_y, total_width, LINE_HEIGHT, fill_color=HEADER_BLUE)
122
+ pdf.setFillColorRGB(*WHITE)
123
+
124
+ current_x = table_x
125
+ for title, width in headers:
126
+ draw_box(pdf, current_x, table_y, width, LINE_HEIGHT)
127
+ draw_centered_text(pdf, title, current_x, table_y + 6, width)
128
+ current_x += width
129
+
130
+
131
+ # Draw sections and items
132
+ current_y = table_y - LINE_HEIGHT
133
+
134
+ def draw_section_header(title):
135
+ nonlocal current_y
136
+ draw_box(pdf, table_x, current_y, total_width, LINE_HEIGHT, fill_color=BLUE_LIGHT)
137
+ pdf.setFillColorRGB(*BLACK)
138
+ pdf.drawString(table_x + BOX_PADDING, current_y + 6, title)
139
+ current_y -= LINE_HEIGHT
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", 12) # Adjust the font name and size as needed
147
+
148
+ # Set the fill color to black
149
+ pdf.setFillColorRGB(0, 0, 0) # 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
+
156
+ def draw_item_row(item, indent=False):
157
+ nonlocal current_y
158
+ pdf.setFillColorRGB(*BLACK)
159
+ current_x = table_x
160
+
161
+ draw_box(pdf, current_x, current_y, total_width, LINE_HEIGHT, fill_color=WHITE)
162
+
163
+ cells = [
164
+ (" " + item.description if indent else item.description, 150),
165
+ (item.unit, 50),
166
+ (str(item.quantity), 50),
167
+ (f"{item.length:.2f}", 60),
168
+ (f"{item.unit_price:.2f}", 60),
169
+ (f"{item.total_price:.2f}", 170)
170
+ ]
171
+
172
+ for value, width in cells:
173
+ draw_box(pdf, current_x, current_y, width, LINE_HEIGHT)
174
+ if isinstance(value, str) and value.startswith(" "):
175
+ pdf.drawString(current_x + 20, current_y + 6, value.strip())
176
+ else:
177
+ draw_centered_text(pdf, str(value), current_x, current_y + 6, width)
178
+ current_x += width
179
+
180
+ current_y -= LINE_HEIGHT
181
+
182
+ # Draw sections
183
+ sections = [
184
+ ("POUTRELLES", "PCP"),
185
+ ("HOURDIS", "HOURDIS"),
186
+ ("PANNEAU TREILLIS SOUDES", "PTS")
187
+ ]
188
+
189
+ for section_title, keyword in sections:
190
+ draw_section_header(section_title)
191
+ items = [i for i in data.items if keyword in i.description]
192
+ for item in items:
193
+ draw_item_row(item, indent=(keyword != "lfflflflf"))
194
+
195
+ # NB box with just "NB:" text
196
+ nb_box_width = 200
197
+ nb_box_height = 80
198
+ pdf.setFillColorRGB(*BLACK)
199
+ pdf.rect(20, current_y - nb_box_height, nb_box_width, nb_box_height, stroke=1)
200
+ pdf.setFont("Helvetica-Bold", 12) # Made slightly larger for better visibility
201
+ pdf.drawString(60, current_y - nb_box_height + 60, "NB:")
202
+
203
+ # Totals section
204
+ current_y -= 20
205
+ totals_table_width = 300
206
+ row_height = 20
207
+
208
+ for i, (label1, label2, value) in enumerate([
209
+ ("Total", "H.T", f"{data.total_ht:.2f} DH"),
210
+ ("TVA", "20 %", f"{data.tax:.2f} DH"),
211
+ ("Total", "TTC", f"{data.total_ttc:.2f} DH")
212
+ ]):
213
+ y = current_y - (i * row_height)
214
+ totals_x = (page_width - totals_table_width) - 27 # Center totals table
215
+ draw_box(pdf, totals_x, y, totals_table_width / 2, row_height)
216
+ draw_box(pdf, totals_x + totals_table_width / 2, y, totals_table_width / 2, row_height)
217
+ pdf.drawString(totals_x + 10, y + 6, f"{label1} {label2}")
218
+ pdf.drawRightString(totals_x + totals_table_width - 10, y + 6, value)
219
+
220
+ # Footer
221
+ pdf.setFont("Helvetica", 8)
222
+ if os.path.exists(logo_path):
223
+ pdf.drawImage(logo_path, MARGIN, 20, width=30, height=15)
224
+
225
+ footer_text = "Douar Ait Laarassi Tidili, Cercle El Kelâa, Route de Safi, Km 14-40000 Marrakech"
226
+ pdf.drawCentredString(page_width / 2, 30, footer_text)
227
+ footer_contact = "Tél: 05 24 01 55 54 Fax : 05 24 01 55 29 E-mail : [email protected]"
228
+ pdf.drawCentredString(page_width / 2, 20, footer_contact)
229
+
230
+ pdf.save()
231
+ buffer.seek(0)
232
+ return buffer.getvalue()
233
+
234
+ except Exception as e:
235
+ logger.error(f"Error in PDF generation: {str(e)}", exc_info=True)
236
+ raise
app/static/logo.png ADDED
app/templates/index.html ADDED
@@ -0,0 +1,582 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Invoice Generator</title>
7
+ <!-- Bootstrap CSS -->
8
+ <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
9
+ <style>
10
+ .table-fixed {
11
+ table-layout: fixed;
12
+ }
13
+ .table-fixed td {
14
+ overflow: hidden;
15
+ text-overflow: ellipsis;
16
+ white-space: nowrap;
17
+ }
18
+ .section-title {
19
+ background-color: #f8f9fa;
20
+ padding: 10px;
21
+ margin-top: 20px;
22
+ margin-bottom: 10px;
23
+ border-radius: 5px;
24
+ }
25
+ </style>
26
+ </head>
27
+ <body>
28
+ <div class="container mt-4">
29
+ <h2 class="mb-4">Générateur de Devis</h2>
30
+
31
+ <form id="invoiceForm">
32
+ <!-- Client Information -->
33
+ <div class="row mb-4">
34
+ <div class="col-md-6">
35
+ <div class="card">
36
+ <div class="card-header">
37
+ Information Client
38
+ </div>
39
+ <div class="card-body">
40
+ <div class="mb-3">
41
+ <label for="clientName" class="form-label">Nom du Client</label>
42
+ <input type="text" class="form-control" id="clientName" required>
43
+ </div>
44
+ <div class="mb-3">
45
+ <label for="clientPhone" class="form-label">Téléphone</label>
46
+ <input type="tel" class="form-control" id="clientPhone" required>
47
+ </div>
48
+ <div class="mb-3">
49
+ <label for="clientAddress" class="form-label">Adresse</label>
50
+ <input type="text" class="form-control" id="clientAddress" required>
51
+ </div>
52
+ <div class="mb-3">
53
+ <label class="form-label">Type Client</label>
54
+ <div class="btn-group" role="group">
55
+ <input type="radio" class="btn-check" name="clientType" id="EE" value="EE" autocomplete="off">
56
+ <label class="btn btn-outline-primary" for="EE">EE</label>
57
+
58
+ <input type="radio" class="btn-check" name="clientType" id="MED" value="MED" autocomplete="off">
59
+ <label class="btn btn-outline-primary" for="MED">MED</label>
60
+
61
+ <input type="radio" class="btn-check" name="clientType" id="AM" value="AM" autocomplete="off">
62
+ <label class="btn btn-outline-primary" for="AM">AM</label>
63
+
64
+ <input type="radio" class="btn-check" name="clientType" id="DIV" value="DIV" autocomplete="off">
65
+ <label class="btn btn-outline-primary" for="DIV">DIV</label>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ </div>
71
+
72
+ <div class="col-md-6">
73
+ <div class="card">
74
+ <div class="card-header">
75
+ Information Devis
76
+ </div>
77
+ <div class="card-body">
78
+ <div class="mb-3">
79
+ <label for="invoiceNumber" class="form-label">N° Devis</label>
80
+ <input type="text" class="form-control" id="invoiceNumber" required>
81
+ </div>
82
+ <div class="mb-3">
83
+ <label for="date" class="form-label">Date</label>
84
+ <input type="date" class="form-control" id="date" readonly>
85
+ </div>
86
+ <div class="mb-3">
87
+ <label for="project" class="form-label">Type d'ouvrage</label>
88
+ <input type="text" class="form-control" id="project" required>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ </div>
93
+ </div>
94
+
95
+ <!-- Poutrelles Section -->
96
+ <div class="section-title d-flex justify-content-between align-items-center">
97
+ <h4>POUTRELLES</h4>
98
+ <button type="button" class="btn btn-primary" id="addPoutrelles">
99
+ Ajouter Poutrelle
100
+ </button>
101
+ </div>
102
+ <div class="table-responsive">
103
+ <table class="table table-bordered table-fixed">
104
+ <thead class="table-primary">
105
+ <tr>
106
+ <th style="width: 30%">Description</th>
107
+ <th style="width: 10%">Unité</th>
108
+ <th style="width: 10%">Quantité</th>
109
+ <th style="width: 15%">Longueur</th>
110
+ <th style="width: 15%">P.U</th>
111
+ <th style="width: 15%">Total HT</th>
112
+ <th style="width: 5%"></th>
113
+ </tr>
114
+ </thead>
115
+ <tbody id="poutrellesTable"></tbody>
116
+ </table>
117
+ </div>
118
+
119
+ <!-- Hourdis Section -->
120
+ <div class="section-title d-flex justify-content-between align-items-center">
121
+ <h4>HOURDIS</h4>
122
+ <button type="button" class="btn btn-primary" id="addHourdis">
123
+ Ajouter Hourdis
124
+ </button>
125
+ </div>
126
+ <div class="table-responsive">
127
+ <table class="table table-bordered table-fixed">
128
+ <thead class="table-primary">
129
+ <tr>
130
+ <th style="width: 30%">Description</th>
131
+ <th style="width: 10%">Unité</th>
132
+ <th style="width: 10%">Quantité</th>
133
+ <th style="width: 15%">Longueur</th>
134
+ <th style="width: 15%">P.U</th>
135
+ <th style="width: 15%">Total HT</th>
136
+ <th style="width: 5%"></th>
137
+ </tr>
138
+ </thead>
139
+ <tbody id="hourdisTable"></tbody>
140
+ </table>
141
+ </div>
142
+
143
+ <!-- Panneau Section -->
144
+ <div class="section-title d-flex justify-content-between align-items-center">
145
+ <h4>PANNEAU TREILLIS SOUDES</h4>
146
+ <button type="button" class="btn btn-primary" id="addPanneau">
147
+ Ajouter Panneau
148
+ </button>
149
+ </div>
150
+ <div class="table-responsive">
151
+ <table class="table table-bordered table-fixed">
152
+ <thead class="table-primary">
153
+ <tr>
154
+ <th style="width: 30%">Description</th>
155
+ <th style="width: 10%">Unité</th>
156
+ <th style="width: 10%">Quantité</th>
157
+ <th style="width: 15%">Longueur</th>
158
+ <th style="width: 15%">P.U</th>
159
+ <th style="width: 15%">Total HT</th>
160
+ <th style="width: 5%"></th>
161
+ </tr>
162
+ </thead>
163
+ <tbody id="panneauTable"></tbody>
164
+ </table>
165
+ </div>
166
+
167
+ <!-- Totals -->
168
+ <div class="row mt-4">
169
+ <div class="col-md-6">
170
+ <!-- Empty for spacing -->
171
+ </div>
172
+ <div class="col-md-6">
173
+ <div class="card">
174
+ <div class="card-body">
175
+ <div class="mb-3">
176
+ <label for="totalHT" class="form-label">Total HT</label>
177
+ <input type="number" class="form-control" id="totalHT" readonly>
178
+ </div>
179
+ <div class="mb-3">
180
+ <label for="tax" class="form-label">TVA 20%</label>
181
+ <input type="number" class="form-control" id="tax" readonly>
182
+ </div>
183
+ <div class="mb-3">
184
+ <label for="totalTTC" class="form-label">Total TTC</label>
185
+ <input type="number" class="form-control" id="totalTTC" readonly>
186
+ </div>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ </div>
191
+
192
+ <div class="d-grid gap-2 col-md-6 mx-auto mt-4">
193
+ <button type="submit" class="btn btn-success btn-lg">Générer le Devis</button>
194
+ </div>
195
+ </form>
196
+ </div>
197
+
198
+ <!-- Poutrelles Modal -->
199
+ <div class="modal fade" id="poutrellesModal" tabindex="-1">
200
+ <div class="modal-dialog modal-lg">
201
+ <div class="modal-content">
202
+ <div class="modal-header">
203
+ <h5 class="modal-title">Sélectionner Poutrelle</h5>
204
+ <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
205
+ </div>
206
+ <div class="modal-body">
207
+ <div class="table-responsive">
208
+ <table class="table">
209
+ <thead>
210
+ <tr>
211
+ <th>Description</th>
212
+ <th>Unité</th>
213
+ <th>Quantité</th>
214
+ <th>Longueur</th>
215
+ <th>P.U</th>
216
+ <th>Action</th>
217
+ </tr>
218
+ </thead>
219
+ <tbody></tbody>
220
+ </table>
221
+ </div>
222
+ </div>
223
+ </div>
224
+ </div>
225
+ </div>
226
+
227
+ <!-- Hourdis Modal -->
228
+ <div class="modal fade" id="hourdisModal" tabindex="-1">
229
+ <div class="modal-dialog modal-lg">
230
+ <div class="modal-content">
231
+ <div class="modal-header">
232
+ <h5 class="modal-title">Sélectionner Hourdis</h5>
233
+ <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
234
+ </div>
235
+ <div class="modal-body">
236
+ <div class="table-responsive">
237
+ <table class="table">
238
+ <thead>
239
+ <tr>
240
+ <th>Description</th>
241
+ <th>Unité</th>
242
+ <th>Quantité</th>
243
+ <th>Longueur</th>
244
+ <th>P.U</th>
245
+ <th>Action</th>
246
+ </tr>
247
+ </thead>
248
+ <tbody></tbody>
249
+ </table>
250
+ </div>
251
+ </div>
252
+ </div>
253
+ </div>
254
+ </div>
255
+
256
+ <!-- Panneau Modal -->
257
+ <div class="modal fade" id="panneauModal" tabindex="-1">
258
+ <div class="modal-dialog modal-lg">
259
+ <div class="modal-content">
260
+ <div class="modal-header">
261
+ <h5 class="modal-title">Sélectionner Panneau</h5>
262
+ <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
263
+ </div>
264
+ <div class="modal-body">
265
+ <div class="table-responsive">
266
+ <table class="table">
267
+ <thead>
268
+ <tr>
269
+ <th>Description</th>
270
+ <th>Unité</th>
271
+ <th>Quantité</th>
272
+ <th>Longueur</th>
273
+ <th>P.U</th>
274
+ <th>Action</th>
275
+ </tr>
276
+ </thead>
277
+ <tbody></tbody>
278
+ </table>
279
+ </div>
280
+ </div>
281
+ </div>
282
+ </div>
283
+ </div>
284
+
285
+ <!-- Bootstrap JS -->
286
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
287
+
288
+ <script>
289
+ document.addEventListener("DOMContentLoaded", function() {
290
+ const poutrellesTable = document.getElementById("poutrellesTable");
291
+ const hourdisTable = document.getElementById("hourdisTable");
292
+ const panneauTable = document.getElementById("panneauTable");
293
+ const invoiceForm = document.getElementById("invoiceForm");
294
+
295
+ // Default items data
296
+ const defaultItems = {
297
+ poutrelles: [
298
+ { description: "PCP 114N", unit: "ML", quantity: 21, length: 3.00, unit_price: 26.00 },
299
+ { description: "PCP 113N", unit: "ML", quantity: 12, length: 4.00, unit_price: 26.00 }
300
+ ],
301
+ hourdis: [
302
+ { description: "HOURDIS TYPE 12", unit: "U", quantity: 1, length: 300, unit_price: 3.50 }
303
+ ],
304
+ panneaux: [
305
+ { description: "PTS SISMIQUE 5*3,5", unit: "U", quantity: 1, length: 9, unit_price: 195.00 }
306
+ ]
307
+ };
308
+
309
+ // Function to add a new item row
310
+ function addItemRow(item, tableBody) {
311
+ const row = document.createElement("tr");
312
+ const total = (item.quantity * item.length * item.unit_price).toFixed(2);
313
+
314
+ row.innerHTML = `
315
+ <td><input type="text" class="form-control" name="description" value="${item.description}" required></td>
316
+ <td><input type="text" class="form-control" name="unit" value="${item.unit}" required></td>
317
+ <td><input type="number" class="form-control" name="quantity" value="${item.quantity}" required></td>
318
+ <td><input type="number" class="form-control" name="length" value="${item.length}" step="0.01" required></td>
319
+ <td><input type="number" class="form-control" name="unitPrice" value="${item.unit_price}" step="0.01" required></td>
320
+ <td><input type="number" class="form-control" name="totalHT" value="${total}" readonly></td>
321
+ <td><button type="button" class="btn btn-danger btn-sm remove-item">×</button></td>
322
+ `;
323
+
324
+ row.querySelector(".remove-item").addEventListener("click", () => {
325
+ row.remove();
326
+ updateTotals();
327
+ });
328
+
329
+ // Add input event listeners to update total
330
+ ['quantity', 'length', 'unitPrice'].forEach(field => {
331
+ row.querySelector(`input[name='${field}']`).addEventListener('input', () => {
332
+ const qty = parseFloat(row.querySelector('input[name="quantity"]').value) || 0;
333
+ const length = parseFloat(row.querySelector('input[name="length"]').value) || 0;
334
+ const price = parseFloat(row.querySelector('input[name="unitPrice"]').value) || 0;
335
+ const rowTotal = (qty * length * price).toFixed(2);
336
+ row.querySelector('input[name="totalHT"]').value = rowTotal;
337
+ updateTotals();
338
+ });
339
+ });
340
+
341
+ tableBody.appendChild(row);
342
+ updateTotals();
343
+ }
344
+
345
+ // Function to update totals
346
+ function updateTotals() {
347
+ const totalHT = Array.from(document.querySelectorAll('input[name="totalHT"]'))
348
+ .reduce((sum, input) => sum + (parseFloat(input.value) || 0), 0);
349
+
350
+ const formattedTotalHT = totalHT.toFixed(2);
351
+ const tax = (totalHT * 0.20).toFixed(2);
352
+ const totalTTC = (totalHT * 1.20).toFixed(2);
353
+
354
+ document.querySelector("#totalHT").value = formattedTotalHT;
355
+ document.querySelector("#tax").value = tax;
356
+ document.querySelector("#totalTTC").value = totalTTC;
357
+ }
358
+
359
+ // Initialize tables with default items
360
+ defaultItems.poutrelles.forEach(item => addItemRow(item, poutrellesTable));
361
+ defaultItems.hourdis.forEach(item => addItemRow(item, hourdisTable));
362
+ defaultItems.panneaux.forEach(item => addItemRow(item, panneauTable));
363
+
364
+ // Update initial totals
365
+ updateTotals();
366
+
367
+ // Predefined items data
368
+ const predefinedItems = {
369
+ poutrelles: [
370
+ { description: "PCP 114N", unit: "ML", quantity: 21, length: 3.00, unit_price: 26.00 },
371
+ { description: "PCP 113N", unit: "ML", quantity: 12, length: 4.00, unit_price: 26.00 },
372
+ { description: "PCP 113B SISMIQUE", unit: "ML", quantity: 22, length: 4.20, unit_price: 26.00 },
373
+ { description: "PCP 114B SISMIQUE", unit: "ML", quantity: 12, length: 4.90, unit_price: 30.00 },
374
+ { description: "PCP 135 SISMIQUE", unit: "ML", quantity: 8, length: 5.00, unit_price: 38.00 },
375
+ { description: "PCP 156 SISMIQUE", unit: "ML", quantity: 12, length: 5.10, unit_price: 38.00 },
376
+ { description: "PCP 158 SISMIQUE", unit: "ML", quantity: 12, length: 5.50, unit_price: 51.00 }
377
+ ],
378
+ hourdis: [
379
+ { description: "HOURDIS TYPE 08", unit: "U", quantity: 1, length: 200, unit_price: 3.40 },
380
+ { description: "HOURDIS TYPE 12", unit: "U", quantity: 1, length: 300, unit_price: 3.50 },
381
+ { description: "HOURDIS TYPE 16", unit: "U", quantity: 1, length: 0, unit_price: 0 },
382
+ { description: "HOURDIS TYPE 20", unit: "U", quantity: 1, length: 0, unit_price: 0 },
383
+ { description: "HOURDIS TYPE 25", unit: "U", quantity: 1, length: 450, unit_price: 4.80 },
384
+ { description: "HOURDIS TYPE 30", unit: "U", quantity: 1, length: 0, unit_price: 0 }
385
+ ],
386
+ panneaux: [
387
+ { description: "PTS Normal 3,5*3,5", unit: "U", quantity: 1, length: 0, unit_price: 0 },
388
+ { description: "PTS SISMIQUE 5*3,5", unit: "U", quantity: 1, length: 9, unit_price: 195.00 }
389
+ ]
390
+ };
391
+
392
+ // Function to populate modal with items
393
+ function populateModal(modalId, items, targetTable) {
394
+ const modal = document.querySelector(modalId);
395
+ const tbody = modal.querySelector('tbody');
396
+ tbody.innerHTML = '';
397
+
398
+ items.forEach(item => {
399
+ const row = document.createElement('tr');
400
+ row.innerHTML = `
401
+ <td>${item.description}</td>
402
+ <td>${item.unit}</td>
403
+ <td><input type="number" class="form-control form-control-sm" value="${item.quantity}" name="modal-quantity"></td>
404
+ <td><input type="number" class="form-control form-control-sm" value="${item.length}" step="0.01" name="modal-length"></td>
405
+ <td><input type="number" class="form-control form-control-sm" value="${item.unit_price}" step="0.01" name="modal-price"></td>
406
+ <td>
407
+ <button class="btn btn-primary btn-sm add-item">
408
+ Ajouter
409
+ </button>
410
+ </td>
411
+ `;
412
+
413
+ row.querySelector('.add-item').addEventListener('click', () => {
414
+ const newItem = {
415
+ ...item,
416
+ quantity: parseFloat(row.querySelector('[name="modal-quantity"]').value) || 0,
417
+ length: parseFloat(row.querySelector('[name="modal-length"]').value) || 0,
418
+ unit_price: parseFloat(row.querySelector('[name="modal-price"]').value) || 0
419
+ };
420
+ addItemRow(newItem, targetTable);
421
+ bootstrap.Modal.getInstance(modal).hide();
422
+ });
423
+
424
+ tbody.appendChild(row);
425
+ });
426
+ }
427
+
428
+ // Update button click handlers
429
+ document.querySelector("#addPoutrelles").addEventListener("click", () => {
430
+ populateModal('#poutrellesModal', predefinedItems.poutrelles, poutrellesTable);
431
+ new bootstrap.Modal('#poutrellesModal').show();
432
+ });
433
+
434
+ document.querySelector("#addHourdis").addEventListener("click", () => {
435
+ populateModal('#hourdisModal', predefinedItems.hourdis, hourdisTable);
436
+ new bootstrap.Modal('#hourdisModal').show();
437
+ });
438
+
439
+ document.querySelector("#addPanneau").addEventListener("click", () => {
440
+ populateModal('#panneauModal', predefinedItems.panneaux, panneauTable);
441
+ new bootstrap.Modal('#panneauModal').show();
442
+ });
443
+
444
+ // Form submission
445
+ invoiceForm.addEventListener("submit", async (event) => {
446
+ event.preventDefault();
447
+
448
+ const getAllItems = () => {
449
+ const items = [];
450
+ [poutrellesTable, hourdisTable, panneauTable].forEach(table => {
451
+ items.push(...Array.from(table.querySelectorAll("tr")).map(row => ({
452
+ description: row.querySelector("input[name='description']").value,
453
+ unit: row.querySelector("input[name='unit']").value,
454
+ quantity: parseInt(row.querySelector("input[name='quantity']").value, 10),
455
+ length: parseFloat(row.querySelector("input[name='length']").value),
456
+ unit_price: parseFloat(row.querySelector("input[name='unitPrice']").value),
457
+ total_price: parseFloat(row.querySelector("input[name='totalHT']").value)
458
+ })));
459
+ });
460
+ return items;
461
+ };
462
+
463
+ const data = {
464
+ invoice_number: document.querySelector("#invoiceNumber").value,
465
+ date: new Date(document.querySelector("#date").value).toISOString(),
466
+ project: document.querySelector("#project").value,
467
+ client_name: document.querySelector("#clientName").value,
468
+ client_phone: document.querySelector("#clientPhone").value,
469
+ address: document.querySelector("#clientAddress").value,
470
+ total_ht: parseFloat(document.querySelector("#totalHT").value || 0),
471
+ tax: parseFloat(document.querySelector("#tax").value || 0),
472
+ total_ttc: parseFloat(document.querySelector("#totalTTC").value || 0),
473
+ items: getAllItems(),
474
+ frame_number: "",
475
+ status: "pending",
476
+ created_at: new Date().toISOString()
477
+ };
478
+
479
+ console.log("Sending invoice data:", data);
480
+
481
+ try {
482
+ // First create the invoice
483
+ const createResponse = await fetch("/api/invoices/", {
484
+ method: "POST",
485
+ headers: { "Content-Type": "application/json" },
486
+ body: JSON.stringify(data)
487
+ });
488
+
489
+ if (!createResponse.ok) {
490
+ const errorData = await createResponse.json();
491
+ console.error("Server error:", errorData);
492
+ throw new Error(`Failed to create invoice: ${JSON.stringify(errorData)}`);
493
+ }
494
+
495
+ const invoice = await createResponse.json();
496
+ console.log("Created invoice:", invoice);
497
+
498
+ // Then generate the PDF
499
+ const pdfResponse = await fetch(`/api/invoices/${invoice.id}/generate-pdf`, {
500
+ method: "POST",
501
+ headers: {
502
+ "Content-Type": "application/json"
503
+ },
504
+ body: JSON.stringify(invoice) // Send the created invoice data for PDF generation
505
+ });
506
+
507
+ if (!pdfResponse.ok) {
508
+ const errorData = await pdfResponse.text();
509
+ console.error("PDF generation error:", errorData);
510
+ throw new Error(`Failed to generate PDF: ${errorData}`);
511
+ }
512
+
513
+ // Handle PDF download
514
+ const blob = await pdfResponse.blob();
515
+ const url = window.URL.createObjectURL(blob);
516
+ const a = document.createElement("a");
517
+ a.href = url;
518
+ a.download = `devis_${invoice.invoice_number}.pdf`;
519
+ document.body.appendChild(a);
520
+ a.click();
521
+ document.body.removeChild(a);
522
+ window.URL.revokeObjectURL(url);
523
+
524
+ } catch (error) {
525
+ console.error("Error:", error);
526
+ alert(`Error: ${error.message}`);
527
+ }
528
+
529
+ // After successful submission, update the invoice number for the current type
530
+ const selectedType = document.querySelector('input[name="clientType"]:checked')?.value;
531
+ if (selectedType) {
532
+ await updateInvoiceNumber(selectedType);
533
+ }
534
+ });
535
+
536
+ // Set today's date automatically
537
+ const today = new Date();
538
+ const formattedDate = today.toISOString().split('T')[0];
539
+ document.querySelector("#date").value = formattedDate;
540
+
541
+ // Auto-generate invoice number when client type is selected
542
+ const clientTypeInputs = document.querySelectorAll('input[name="clientType"]');
543
+ const invoiceNumberInput = document.querySelector("#invoiceNumber");
544
+
545
+ // Function to get and update the invoice number
546
+ async function updateInvoiceNumber(selectedType) {
547
+ try {
548
+ // Disable the input while fetching
549
+ invoiceNumberInput.disabled = true;
550
+
551
+ const response = await fetch(`/api/invoices/last-number/${selectedType}`);
552
+ if (response.ok) {
553
+ const data = await response.json();
554
+ invoiceNumberInput.value = data.formatted_number;
555
+ } else {
556
+ throw new Error('Failed to get invoice number');
557
+ }
558
+ } catch (error) {
559
+ console.error('Error:', error);
560
+ invoiceNumberInput.value = `DCP/${selectedType}/0001`;
561
+ } finally {
562
+ // Re-enable the input
563
+ invoiceNumberInput.disabled = false;
564
+ }
565
+ }
566
+
567
+ // Update invoice number when client type is selected
568
+ clientTypeInputs.forEach(input => {
569
+ input.addEventListener('change', () => {
570
+ updateInvoiceNumber(input.value);
571
+ });
572
+ });
573
+
574
+ // Optional: Update the number when the page loads if a type is already selected
575
+ const selectedType = document.querySelector('input[name="clientType"]:checked')?.value;
576
+ if (selectedType) {
577
+ updateInvoiceNumber(selectedType);
578
+ }
579
+ });
580
+ </script>
581
+ </body>
582
+ </html>
migrations/versions/xxxx_add_invoice_sequence.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """add invoice sequence and client type
2
+
3
+ Revision ID: xxxx
4
+ Revises: previous_revision_id
5
+ Create Date: 2024-xx-xx
6
+ """
7
+ from alembic import op
8
+ import sqlalchemy as sa
9
+
10
+ def upgrade():
11
+ # Create sequence for invoice IDs
12
+ op.execute('CREATE SEQUENCE IF NOT EXISTS invoice_id_seq START 1')
13
+
14
+ # Add client_type column if not exists
15
+ op.add_column('invoices', sa.Column('client_type', sa.String(10), nullable=True))
16
+
17
+ # Make invoice_number unique
18
+ op.create_unique_constraint('uq_invoice_number', 'invoices', ['invoice_number'])
19
+
20
+ def downgrade():
21
+ # Remove unique constraint
22
+ op.drop_constraint('uq_invoice_number', 'invoices')
23
+
24
+ # Drop client_type column
25
+ op.drop_column('invoices', 'client_type')
26
+
27
+ # Drop sequence
28
+ op.execute('DROP SEQUENCE IF EXISTS invoice_id_seq')
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.109.0
2
+ uvicorn==0.27.0
3
+ sqlalchemy==2.0.25
4
+ aiosqlite==0.19.0
5
+ pydantic==2.5.3
6
+ pydantic-settings==2.1.0
7
+ jinja2==3.1.3
8
+ python-multipart==0.0.6
9
+ reportlab==4.0.8
10
+ python-jose==3.3.0
11
+ passlib==1.7.4
12
+ alembic==1.13.1
sql_app.db ADDED
Binary file (28.7 kB). View file
 
tests/__init__.py ADDED
File without changes
tests/test_api.py ADDED
File without changes
tests/test_services.py ADDED
File without changes