pharmacy-erp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +34 -0
- package/LICENSE +21 -0
- package/README.md +479 -0
- package/backend/Dockerfile +44 -0
- package/backend/app/__init__.py +1 -0
- package/backend/app/core/__init__.py +1 -0
- package/backend/app/core/audit.py +33 -0
- package/backend/app/core/config.py +16 -0
- package/backend/app/core/database.py +26 -0
- package/backend/app/core/deps.py +49 -0
- package/backend/app/core/exceptions.py +43 -0
- package/backend/app/core/security.py +32 -0
- package/backend/app/main.py +54 -0
- package/backend/app/modules/__init__.py +0 -0
- package/backend/app/modules/admin/__init__.py +1 -0
- package/backend/app/modules/admin/models.py +15 -0
- package/backend/app/modules/admin/router.py +353 -0
- package/backend/app/modules/auth/__init__.py +0 -0
- package/backend/app/modules/auth/models.py +16 -0
- package/backend/app/modules/auth/router.py +73 -0
- package/backend/app/modules/auth/schemas.py +29 -0
- package/backend/app/modules/cash_sessions/__init__.py +0 -0
- package/backend/app/modules/cash_sessions/models.py +19 -0
- package/backend/app/modules/cash_sessions/router.py +85 -0
- package/backend/app/modules/cash_sessions/schemas.py +31 -0
- package/backend/app/modules/customers/__init__.py +0 -0
- package/backend/app/modules/customers/models.py +37 -0
- package/backend/app/modules/customers/router.py +296 -0
- package/backend/app/modules/customers/schemas.py +72 -0
- package/backend/app/modules/inventory/__init__.py +0 -0
- package/backend/app/modules/inventory/models.py +16 -0
- package/backend/app/modules/inventory/router.py +150 -0
- package/backend/app/modules/inventory/schemas.py +24 -0
- package/backend/app/modules/medicines/__init__.py +0 -0
- package/backend/app/modules/medicines/models.py +54 -0
- package/backend/app/modules/medicines/router.py +631 -0
- package/backend/app/modules/medicines/schemas.py +153 -0
- package/backend/app/modules/medicines/template_generator.py +118 -0
- package/backend/app/modules/notifications/__init__.py +1 -0
- package/backend/app/modules/notifications/models.py +15 -0
- package/backend/app/modules/notifications/router.py +89 -0
- package/backend/app/modules/notifications/schemas.py +22 -0
- package/backend/app/modules/organizations/__init__.py +0 -0
- package/backend/app/modules/organizations/models.py +16 -0
- package/backend/app/modules/organizations/router.py +49 -0
- package/backend/app/modules/organizations/schemas.py +31 -0
- package/backend/app/modules/reports/__init__.py +0 -0
- package/backend/app/modules/reports/router.py +409 -0
- package/backend/app/modules/roles/__init__.py +1 -0
- package/backend/app/modules/roles/models.py +43 -0
- package/backend/app/modules/roles/router.py +89 -0
- package/backend/app/modules/roles/schemas.py +55 -0
- package/backend/app/modules/sales/__init__.py +0 -0
- package/backend/app/modules/sales/models.py +44 -0
- package/backend/app/modules/sales/router.py +63 -0
- package/backend/app/modules/sales/schemas.py +42 -0
- package/backend/app/modules/sales/service.py +128 -0
- package/backend/app/modules/suppliers/__init__.py +0 -0
- package/backend/app/modules/suppliers/models.py +14 -0
- package/backend/app/modules/suppliers/router.py +56 -0
- package/backend/app/modules/suppliers/schemas.py +27 -0
- package/backend/app/modules/users/__init__.py +0 -0
- package/backend/app/modules/users/router.py +74 -0
- package/backend/app/seed.py +275 -0
- package/backend/data/.gitkeep +1 -0
- package/backend/requirements.txt +17 -0
- package/bin/cli.js +288 -0
- package/docker-compose.dev.yml +42 -0
- package/docker-compose.yml +59 -0
- package/frontend/Dockerfile +56 -0
- package/frontend/next.config.ts +19 -0
- package/frontend/package.json +60 -0
- package/frontend/pnpm-lock.yaml +5678 -0
- package/frontend/pnpm-workspace.yaml +6 -0
- package/frontend/postcss.config.mjs +6 -0
- package/frontend/public/logo.png +0 -0
- package/frontend/src/app/[locale]/(app)/admin/page.tsx +1426 -0
- package/frontend/src/app/[locale]/(app)/catalog/products/[id]/page.tsx +505 -0
- package/frontend/src/app/[locale]/(app)/catalog/products/page.tsx +753 -0
- package/frontend/src/app/[locale]/(app)/customers/[id]/page.tsx +500 -0
- package/frontend/src/app/[locale]/(app)/customers/page.tsx +538 -0
- package/frontend/src/app/[locale]/(app)/dashboard/page.tsx +175 -0
- package/frontend/src/app/[locale]/(app)/inventory/page.tsx +765 -0
- package/frontend/src/app/[locale]/(app)/layout.tsx +60 -0
- package/frontend/src/app/[locale]/(app)/purchasing/page.tsx +1 -0
- package/frontend/src/app/[locale]/(app)/reports/page.tsx +794 -0
- package/frontend/src/app/[locale]/(app)/sales/page.tsx +296 -0
- package/frontend/src/app/[locale]/(auth)/login/page.tsx +100 -0
- package/frontend/src/app/[locale]/(pos)/layout.tsx +33 -0
- package/frontend/src/app/[locale]/(pos)/pos/page.tsx +463 -0
- package/frontend/src/app/[locale]/(pos)/pos/pos-data.ts +89 -0
- package/frontend/src/app/[locale]/layout.tsx +30 -0
- package/frontend/src/app/[locale]/page.tsx +6 -0
- package/frontend/src/app/globals.css +77 -0
- package/frontend/src/app/layout.tsx +23 -0
- package/frontend/src/app/print.css +18 -0
- package/frontend/src/components/pos/CartPanel.tsx +525 -0
- package/frontend/src/components/pos/CashSessionGuard.tsx +255 -0
- package/frontend/src/components/pos/CloseSessionModal.tsx +160 -0
- package/frontend/src/components/pos/PaymentModal.tsx +266 -0
- package/frontend/src/components/pos/PaymentSettingsModal.tsx +472 -0
- package/frontend/src/components/pos/ProductCatalog.tsx +140 -0
- package/frontend/src/components/pos/SaleReceipt.tsx +133 -0
- package/frontend/src/components/ui/badge.tsx +28 -0
- package/frontend/src/components/ui/bulk-import-modal.tsx +400 -0
- package/frontend/src/components/ui/button.tsx +60 -0
- package/frontend/src/components/ui/card.tsx +13 -0
- package/frontend/src/components/ui/confirm-dialog.tsx +50 -0
- package/frontend/src/components/ui/input.tsx +32 -0
- package/frontend/src/components/ui/notification-center.tsx +228 -0
- package/frontend/src/components/ui/select.tsx +59 -0
- package/frontend/src/components/ui/sidebar.tsx +91 -0
- package/frontend/src/components/ui/slide-over.tsx +43 -0
- package/frontend/src/components/ui/stat-card.tsx +40 -0
- package/frontend/src/components/ui/switch.tsx +38 -0
- package/frontend/src/components/ui/topbar.tsx +97 -0
- package/frontend/src/i18n/request.ts +13 -0
- package/frontend/src/i18n/routing.ts +9 -0
- package/frontend/src/lib/api.ts +240 -0
- package/frontend/src/lib/constants.ts +30 -0
- package/frontend/src/lib/utils.ts +32 -0
- package/frontend/src/messages/ar.json +106 -0
- package/frontend/src/messages/en.json +106 -0
- package/frontend/src/middleware.ts +8 -0
- package/frontend/src/store/app-store.ts +37 -0
- package/frontend/src/store/auth-store.ts +52 -0
- package/frontend/src/store/payment-methods-store.ts +144 -0
- package/frontend/src/store/pos-store.ts +344 -0
- package/frontend/tailwind.config.ts +85 -0
- package/frontend/tsconfig.json +27 -0
- package/img/backgraund-logo.png +0 -0
- package/img/full-logo.png +0 -0
- package/img/logo.png +0 -0
- package/nginx/nginx.conf +67 -0
- package/package.json +88 -0
- package/start.bat +90 -0
- package/start.sh +64 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
2
|
+
from sqlalchemy import String, Numeric, ForeignKey, DateTime
|
|
3
|
+
from app.core.database import Base
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
class CashSession(Base):
|
|
8
|
+
__tablename__ = "cash_sessions"
|
|
9
|
+
branch_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("branches.id"))
|
|
10
|
+
cashier_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
|
|
11
|
+
terminal_name: Mapped[str] = mapped_column(String)
|
|
12
|
+
status: Mapped[str] = mapped_column(String, default="OPEN")
|
|
13
|
+
opening_amount: Mapped[float] = mapped_column(Numeric(10, 2))
|
|
14
|
+
closing_amount: Mapped[float] = mapped_column(Numeric(10, 2), nullable=True)
|
|
15
|
+
expected_amount: Mapped[float] = mapped_column(Numeric(10, 2), nullable=True)
|
|
16
|
+
variance: Mapped[float] = mapped_column(Numeric(10, 2), nullable=True)
|
|
17
|
+
opened_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
|
18
|
+
closed_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
|
|
19
|
+
notes: Mapped[str] = mapped_column(String, nullable=True)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
2
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
3
|
+
from sqlalchemy import select
|
|
4
|
+
from typing import List
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from app.core.database import get_db
|
|
9
|
+
from app.core.deps import get_current_user
|
|
10
|
+
from app.modules.auth.models import User
|
|
11
|
+
from app.modules.cash_sessions.models import CashSession
|
|
12
|
+
from app.modules.cash_sessions.schemas import CashSessionOpen, CashSessionClose, CashSessionResponse
|
|
13
|
+
|
|
14
|
+
router = APIRouter(tags=["cash-sessions"])
|
|
15
|
+
|
|
16
|
+
@router.post("/open", response_model=CashSessionResponse)
|
|
17
|
+
async def open_session(data: CashSessionOpen, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
18
|
+
query = select(CashSession).where(
|
|
19
|
+
CashSession.cashier_id == current_user.id,
|
|
20
|
+
CashSession.status == "OPEN"
|
|
21
|
+
)
|
|
22
|
+
result = await db.execute(query)
|
|
23
|
+
if result.scalars().first():
|
|
24
|
+
raise HTTPException(status_code=400, detail="User already has an open cash session")
|
|
25
|
+
|
|
26
|
+
session = CashSession(
|
|
27
|
+
branch_id=data.branch_id,
|
|
28
|
+
cashier_id=current_user.id,
|
|
29
|
+
terminal_name=data.terminal_name,
|
|
30
|
+
opening_amount=data.opening_amount,
|
|
31
|
+
status="OPEN"
|
|
32
|
+
)
|
|
33
|
+
db.add(session)
|
|
34
|
+
await db.commit()
|
|
35
|
+
await db.refresh(session)
|
|
36
|
+
return session
|
|
37
|
+
|
|
38
|
+
@router.post("/{id}/close", response_model=CashSessionResponse)
|
|
39
|
+
async def close_session(id: UUID, data: CashSessionClose, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
40
|
+
session = await db.get(CashSession, id)
|
|
41
|
+
if not session:
|
|
42
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
43
|
+
|
|
44
|
+
if session.status != "OPEN":
|
|
45
|
+
raise HTTPException(status_code=400, detail="Session is not open")
|
|
46
|
+
|
|
47
|
+
expected = session.opening_amount
|
|
48
|
+
variance = data.closing_amount - expected
|
|
49
|
+
|
|
50
|
+
session.closing_amount = data.closing_amount
|
|
51
|
+
session.expected_amount = expected
|
|
52
|
+
session.variance = variance
|
|
53
|
+
session.status = "CLOSED"
|
|
54
|
+
session.closed_at = datetime.utcnow()
|
|
55
|
+
if data.notes:
|
|
56
|
+
session.notes = data.notes
|
|
57
|
+
|
|
58
|
+
await db.commit()
|
|
59
|
+
await db.refresh(session)
|
|
60
|
+
return session
|
|
61
|
+
|
|
62
|
+
@router.get("/active", response_model=CashSessionResponse)
|
|
63
|
+
async def get_active_session(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
64
|
+
query = select(CashSession).where(
|
|
65
|
+
CashSession.cashier_id == current_user.id,
|
|
66
|
+
CashSession.status == "OPEN"
|
|
67
|
+
)
|
|
68
|
+
result = await db.execute(query)
|
|
69
|
+
session = result.scalars().first()
|
|
70
|
+
if not session:
|
|
71
|
+
raise HTTPException(status_code=404, detail="No active session found")
|
|
72
|
+
return session
|
|
73
|
+
|
|
74
|
+
@router.get("/", response_model=List[CashSessionResponse])
|
|
75
|
+
async def list_sessions(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
76
|
+
query = select(CashSession).order_by(CashSession.opened_at.desc())
|
|
77
|
+
result = await db.execute(query)
|
|
78
|
+
return result.scalars().all()
|
|
79
|
+
|
|
80
|
+
@router.get("/{id}", response_model=CashSessionResponse)
|
|
81
|
+
async def get_session(id: UUID, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
82
|
+
session = await db.get(CashSession, id)
|
|
83
|
+
if not session:
|
|
84
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
85
|
+
return session
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
|
|
7
|
+
class CashSessionOpen(BaseModel):
|
|
8
|
+
branch_id: UUID
|
|
9
|
+
terminal_name: str
|
|
10
|
+
opening_amount: Decimal
|
|
11
|
+
|
|
12
|
+
class CashSessionClose(BaseModel):
|
|
13
|
+
closing_amount: Decimal
|
|
14
|
+
notes: Optional[str] = None
|
|
15
|
+
|
|
16
|
+
class CashSessionResponse(BaseModel):
|
|
17
|
+
id: UUID
|
|
18
|
+
branch_id: UUID
|
|
19
|
+
cashier_id: UUID
|
|
20
|
+
terminal_name: str
|
|
21
|
+
status: str
|
|
22
|
+
opening_amount: Decimal
|
|
23
|
+
closing_amount: Optional[Decimal] = None
|
|
24
|
+
expected_amount: Optional[Decimal] = None
|
|
25
|
+
variance: Optional[Decimal] = None
|
|
26
|
+
opened_at: datetime
|
|
27
|
+
closed_at: Optional[datetime] = None
|
|
28
|
+
notes: Optional[str] = None
|
|
29
|
+
|
|
30
|
+
class Config:
|
|
31
|
+
from_attributes = True
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
2
|
+
from sqlalchemy import String, Boolean, Numeric, ForeignKey, Enum as SAEnum
|
|
3
|
+
from app.core.database import Base
|
|
4
|
+
import enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Customer(Base):
|
|
8
|
+
__tablename__ = "customers"
|
|
9
|
+
name: Mapped[str] = mapped_column(String)
|
|
10
|
+
name_ar: Mapped[str] = mapped_column(String, nullable=True)
|
|
11
|
+
phone: Mapped[str] = mapped_column(String, nullable=True)
|
|
12
|
+
email: Mapped[str] = mapped_column(String, nullable=True)
|
|
13
|
+
address: Mapped[str] = mapped_column(String, nullable=True)
|
|
14
|
+
credit_limit: Mapped[float] = mapped_column(Numeric(10, 2), default=0)
|
|
15
|
+
credit_balance: Mapped[float] = mapped_column(Numeric(10, 2), default=0)
|
|
16
|
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
17
|
+
notes: Mapped[str] = mapped_column(String, nullable=True)
|
|
18
|
+
|
|
19
|
+
payments: Mapped[list["CustomerPayment"]] = relationship(back_populates="customer", lazy="selectin")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PaymentMethod(str, enum.Enum):
|
|
23
|
+
CASH = "CASH"
|
|
24
|
+
CARD = "CARD"
|
|
25
|
+
BANK_TRANSFER = "BANK_TRANSFER"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CustomerPayment(Base):
|
|
29
|
+
__tablename__ = "customer_payments"
|
|
30
|
+
customer_id: Mapped[str] = mapped_column(ForeignKey("customers.id"), index=True)
|
|
31
|
+
amount: Mapped[float] = mapped_column(Numeric(10, 2))
|
|
32
|
+
payment_method: Mapped[str] = mapped_column(String, default="CASH")
|
|
33
|
+
reference: Mapped[str] = mapped_column(String, nullable=True)
|
|
34
|
+
notes: Mapped[str] = mapped_column(String, nullable=True)
|
|
35
|
+
created_by: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=True)
|
|
36
|
+
|
|
37
|
+
customer: Mapped["Customer"] = relationship(back_populates="payments")
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
2
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
3
|
+
from sqlalchemy import select, or_, func, desc
|
|
4
|
+
from typing import List
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
|
|
8
|
+
from app.core.database import get_db
|
|
9
|
+
from app.core.deps import get_current_user
|
|
10
|
+
from app.modules.auth.models import User
|
|
11
|
+
from app.modules.customers.models import Customer, CustomerPayment
|
|
12
|
+
from app.modules.customers.schemas import (
|
|
13
|
+
CustomerCreate, CustomerUpdate, CustomerResponse,
|
|
14
|
+
PaymentCreate, PaymentResponse, CreditLedgerEntry, NotesUpdate,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
router = APIRouter(tags=["customers"])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
21
|
+
# CUSTOMER CRUD
|
|
22
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
23
|
+
|
|
24
|
+
@router.get("/", response_model=List[CustomerResponse])
|
|
25
|
+
async def list_customers(
|
|
26
|
+
db: AsyncSession = Depends(get_db),
|
|
27
|
+
current_user: User = Depends(get_current_user),
|
|
28
|
+
):
|
|
29
|
+
result = await db.execute(select(Customer).order_by(desc(Customer.created_at)))
|
|
30
|
+
return result.scalars().all()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get("/search", response_model=List[CustomerResponse])
|
|
34
|
+
async def search_customers(
|
|
35
|
+
q: str,
|
|
36
|
+
db: AsyncSession = Depends(get_db),
|
|
37
|
+
current_user: User = Depends(get_current_user),
|
|
38
|
+
):
|
|
39
|
+
query = select(Customer).where(
|
|
40
|
+
or_(
|
|
41
|
+
Customer.name.ilike(f"%{q}%"),
|
|
42
|
+
Customer.name_ar.ilike(f"%{q}%"),
|
|
43
|
+
Customer.phone.ilike(f"%{q}%"),
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
result = await db.execute(query)
|
|
47
|
+
return result.scalars().all()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@router.get("/{id}", response_model=CustomerResponse)
|
|
51
|
+
async def get_customer(
|
|
52
|
+
id: UUID,
|
|
53
|
+
db: AsyncSession = Depends(get_db),
|
|
54
|
+
current_user: User = Depends(get_current_user),
|
|
55
|
+
):
|
|
56
|
+
customer = await db.get(Customer, str(id))
|
|
57
|
+
if not customer:
|
|
58
|
+
raise HTTPException(status_code=404, detail="Customer not found")
|
|
59
|
+
return customer
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@router.post("/", response_model=CustomerResponse)
|
|
63
|
+
async def create_customer(
|
|
64
|
+
data: CustomerCreate,
|
|
65
|
+
db: AsyncSession = Depends(get_db),
|
|
66
|
+
current_user: User = Depends(get_current_user),
|
|
67
|
+
):
|
|
68
|
+
create_data = data.model_dump(exclude={"opening_balance"})
|
|
69
|
+
customer = Customer(**create_data)
|
|
70
|
+
# Set opening balance if provided
|
|
71
|
+
if data.opening_balance and data.opening_balance > 0:
|
|
72
|
+
customer.credit_balance = float(data.opening_balance)
|
|
73
|
+
db.add(customer)
|
|
74
|
+
await db.commit()
|
|
75
|
+
await db.refresh(customer)
|
|
76
|
+
return customer
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@router.put("/{id}", response_model=CustomerResponse)
|
|
80
|
+
async def update_customer(
|
|
81
|
+
id: UUID,
|
|
82
|
+
data: CustomerUpdate,
|
|
83
|
+
db: AsyncSession = Depends(get_db),
|
|
84
|
+
current_user: User = Depends(get_current_user),
|
|
85
|
+
):
|
|
86
|
+
customer = await db.get(Customer, str(id))
|
|
87
|
+
if not customer:
|
|
88
|
+
raise HTTPException(status_code=404, detail="Customer not found")
|
|
89
|
+
|
|
90
|
+
for key, value in data.model_dump(exclude_unset=True).items():
|
|
91
|
+
setattr(customer, key, value)
|
|
92
|
+
|
|
93
|
+
await db.commit()
|
|
94
|
+
await db.refresh(customer)
|
|
95
|
+
return customer
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@router.delete("/{id}")
|
|
99
|
+
async def delete_customer(
|
|
100
|
+
id: UUID,
|
|
101
|
+
db: AsyncSession = Depends(get_db),
|
|
102
|
+
current_user: User = Depends(get_current_user),
|
|
103
|
+
):
|
|
104
|
+
customer = await db.get(Customer, str(id))
|
|
105
|
+
if not customer:
|
|
106
|
+
raise HTTPException(status_code=404, detail="Customer not found")
|
|
107
|
+
customer.is_active = False
|
|
108
|
+
await db.commit()
|
|
109
|
+
return {"message": "Customer deactivated successfully"}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
113
|
+
# PURCHASE HISTORY
|
|
114
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
115
|
+
|
|
116
|
+
@router.get("/{id}/purchases")
|
|
117
|
+
async def get_customer_purchases(
|
|
118
|
+
id: UUID,
|
|
119
|
+
db: AsyncSession = Depends(get_db),
|
|
120
|
+
current_user: User = Depends(get_current_user),
|
|
121
|
+
):
|
|
122
|
+
"""Return all sales linked to this customer."""
|
|
123
|
+
from app.modules.sales.models import Sale, SaleItem
|
|
124
|
+
|
|
125
|
+
customer = await db.get(Customer, str(id))
|
|
126
|
+
if not customer:
|
|
127
|
+
raise HTTPException(status_code=404, detail="Customer not found")
|
|
128
|
+
|
|
129
|
+
result = await db.execute(
|
|
130
|
+
select(Sale)
|
|
131
|
+
.where(Sale.customer_id == str(id))
|
|
132
|
+
.order_by(desc(Sale.created_at))
|
|
133
|
+
)
|
|
134
|
+
sales = result.scalars().all()
|
|
135
|
+
|
|
136
|
+
purchases = []
|
|
137
|
+
for sale in sales:
|
|
138
|
+
# Get items count
|
|
139
|
+
items_result = await db.execute(
|
|
140
|
+
select(func.count()).where(SaleItem.sale_id == sale.id)
|
|
141
|
+
)
|
|
142
|
+
items_count = items_result.scalar() or 0
|
|
143
|
+
|
|
144
|
+
purchases.append({
|
|
145
|
+
"id": sale.id,
|
|
146
|
+
"invoice_number": sale.invoice_number,
|
|
147
|
+
"date": sale.created_at.isoformat(),
|
|
148
|
+
"items_count": items_count,
|
|
149
|
+
"total": float(sale.total_amount),
|
|
150
|
+
"paid": float(sale.total_amount) if sale.status == "COMPLETED" else 0,
|
|
151
|
+
"status": sale.status,
|
|
152
|
+
"payment_method": sale.payment_method,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
return purchases
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
159
|
+
# PAYMENTS
|
|
160
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
161
|
+
|
|
162
|
+
@router.get("/{id}/payments", response_model=List[PaymentResponse])
|
|
163
|
+
async def get_customer_payments(
|
|
164
|
+
id: UUID,
|
|
165
|
+
db: AsyncSession = Depends(get_db),
|
|
166
|
+
current_user: User = Depends(get_current_user),
|
|
167
|
+
):
|
|
168
|
+
customer = await db.get(Customer, str(id))
|
|
169
|
+
if not customer:
|
|
170
|
+
raise HTTPException(status_code=404, detail="Customer not found")
|
|
171
|
+
|
|
172
|
+
result = await db.execute(
|
|
173
|
+
select(CustomerPayment)
|
|
174
|
+
.where(CustomerPayment.customer_id == str(id))
|
|
175
|
+
.order_by(desc(CustomerPayment.created_at))
|
|
176
|
+
)
|
|
177
|
+
return result.scalars().all()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@router.post("/{id}/payments", response_model=PaymentResponse)
|
|
181
|
+
async def add_customer_payment(
|
|
182
|
+
id: UUID,
|
|
183
|
+
data: PaymentCreate,
|
|
184
|
+
db: AsyncSession = Depends(get_db),
|
|
185
|
+
current_user: User = Depends(get_current_user),
|
|
186
|
+
):
|
|
187
|
+
customer = await db.get(Customer, str(id))
|
|
188
|
+
if not customer:
|
|
189
|
+
raise HTTPException(status_code=404, detail="Customer not found")
|
|
190
|
+
|
|
191
|
+
if float(data.amount) <= 0:
|
|
192
|
+
raise HTTPException(status_code=400, detail="Payment amount must be positive")
|
|
193
|
+
|
|
194
|
+
# Create payment record
|
|
195
|
+
payment = CustomerPayment(
|
|
196
|
+
customer_id=str(id),
|
|
197
|
+
amount=float(data.amount),
|
|
198
|
+
payment_method=data.payment_method,
|
|
199
|
+
reference=data.reference,
|
|
200
|
+
notes=data.notes,
|
|
201
|
+
created_by=current_user.id,
|
|
202
|
+
)
|
|
203
|
+
db.add(payment)
|
|
204
|
+
|
|
205
|
+
# Reduce customer balance
|
|
206
|
+
new_balance = float(customer.credit_balance or 0) - float(data.amount)
|
|
207
|
+
customer.credit_balance = max(new_balance, 0)
|
|
208
|
+
|
|
209
|
+
await db.commit()
|
|
210
|
+
await db.refresh(payment)
|
|
211
|
+
return payment
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
215
|
+
# CREDIT LEDGER
|
|
216
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
217
|
+
|
|
218
|
+
@router.get("/{id}/credit-ledger")
|
|
219
|
+
async def get_credit_ledger(
|
|
220
|
+
id: UUID,
|
|
221
|
+
db: AsyncSession = Depends(get_db),
|
|
222
|
+
current_user: User = Depends(get_current_user),
|
|
223
|
+
):
|
|
224
|
+
"""Build credit ledger from sales and payments, sorted by date."""
|
|
225
|
+
from app.modules.sales.models import Sale
|
|
226
|
+
|
|
227
|
+
customer = await db.get(Customer, str(id))
|
|
228
|
+
if not customer:
|
|
229
|
+
raise HTTPException(status_code=404, detail="Customer not found")
|
|
230
|
+
|
|
231
|
+
entries = []
|
|
232
|
+
|
|
233
|
+
# Sales that charged to credit (customer_id linked sales)
|
|
234
|
+
sales_result = await db.execute(
|
|
235
|
+
select(Sale)
|
|
236
|
+
.where(Sale.customer_id == str(id))
|
|
237
|
+
.order_by(Sale.created_at)
|
|
238
|
+
)
|
|
239
|
+
for sale in sales_result.scalars().all():
|
|
240
|
+
entries.append({
|
|
241
|
+
"date": sale.created_at.isoformat(),
|
|
242
|
+
"type": "SALE",
|
|
243
|
+
"reference": sale.invoice_number or f"SALE-{str(sale.id)[:8]}",
|
|
244
|
+
"debit": float(sale.total_amount),
|
|
245
|
+
"credit": None,
|
|
246
|
+
"sort_key": sale.created_at,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
# Payments
|
|
250
|
+
payments_result = await db.execute(
|
|
251
|
+
select(CustomerPayment)
|
|
252
|
+
.where(CustomerPayment.customer_id == str(id))
|
|
253
|
+
.order_by(CustomerPayment.created_at)
|
|
254
|
+
)
|
|
255
|
+
for payment in payments_result.scalars().all():
|
|
256
|
+
entries.append({
|
|
257
|
+
"date": payment.created_at.isoformat(),
|
|
258
|
+
"type": "PAYMENT",
|
|
259
|
+
"reference": payment.reference or f"PAY-{str(payment.id)[:8]}",
|
|
260
|
+
"debit": None,
|
|
261
|
+
"credit": float(payment.amount),
|
|
262
|
+
"sort_key": payment.created_at,
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
# Sort by date and calculate running balance
|
|
266
|
+
entries.sort(key=lambda e: e["sort_key"])
|
|
267
|
+
balance = Decimal("0")
|
|
268
|
+
for entry in entries:
|
|
269
|
+
if entry["debit"]:
|
|
270
|
+
balance += Decimal(str(entry["debit"]))
|
|
271
|
+
if entry["credit"]:
|
|
272
|
+
balance -= Decimal(str(entry["credit"]))
|
|
273
|
+
entry["balance"] = float(balance)
|
|
274
|
+
del entry["sort_key"]
|
|
275
|
+
|
|
276
|
+
return entries
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
280
|
+
# NOTES
|
|
281
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
282
|
+
|
|
283
|
+
@router.patch("/{id}/notes", response_model=CustomerResponse)
|
|
284
|
+
async def update_notes(
|
|
285
|
+
id: UUID,
|
|
286
|
+
data: NotesUpdate,
|
|
287
|
+
db: AsyncSession = Depends(get_db),
|
|
288
|
+
current_user: User = Depends(get_current_user),
|
|
289
|
+
):
|
|
290
|
+
customer = await db.get(Customer, str(id))
|
|
291
|
+
if not customer:
|
|
292
|
+
raise HTTPException(status_code=404, detail="Customer not found")
|
|
293
|
+
customer.notes = data.notes
|
|
294
|
+
await db.commit()
|
|
295
|
+
await db.refresh(customer)
|
|
296
|
+
return customer
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Optional, List
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CustomerBase(BaseModel):
|
|
9
|
+
name: str
|
|
10
|
+
name_ar: Optional[str] = None
|
|
11
|
+
phone: Optional[str] = None
|
|
12
|
+
email: Optional[str] = None
|
|
13
|
+
address: Optional[str] = None
|
|
14
|
+
credit_limit: Optional[Decimal] = Decimal("0")
|
|
15
|
+
notes: Optional[str] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CustomerCreate(CustomerBase):
|
|
19
|
+
opening_balance: Optional[Decimal] = Decimal("0")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CustomerUpdate(CustomerBase):
|
|
23
|
+
name: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CustomerResponse(CustomerBase):
|
|
27
|
+
id: UUID
|
|
28
|
+
credit_balance: Decimal
|
|
29
|
+
is_active: bool
|
|
30
|
+
created_at: datetime
|
|
31
|
+
|
|
32
|
+
class Config:
|
|
33
|
+
from_attributes = True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── Payments ──────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
class PaymentCreate(BaseModel):
|
|
39
|
+
amount: Decimal
|
|
40
|
+
payment_method: str = "CASH"
|
|
41
|
+
reference: Optional[str] = None
|
|
42
|
+
notes: Optional[str] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PaymentResponse(BaseModel):
|
|
46
|
+
id: UUID
|
|
47
|
+
customer_id: UUID
|
|
48
|
+
amount: Decimal
|
|
49
|
+
payment_method: str
|
|
50
|
+
reference: Optional[str] = None
|
|
51
|
+
notes: Optional[str] = None
|
|
52
|
+
created_at: datetime
|
|
53
|
+
|
|
54
|
+
class Config:
|
|
55
|
+
from_attributes = True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── Credit Ledger ─────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
class CreditLedgerEntry(BaseModel):
|
|
61
|
+
date: datetime
|
|
62
|
+
type: str # "SALE" or "PAYMENT"
|
|
63
|
+
reference: str
|
|
64
|
+
debit: Optional[Decimal] = None
|
|
65
|
+
credit: Optional[Decimal] = None
|
|
66
|
+
balance: Decimal
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── Notes ─────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
class NotesUpdate(BaseModel):
|
|
72
|
+
notes: str
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
2
|
+
from sqlalchemy import String, Numeric, ForeignKey
|
|
3
|
+
from app.core.database import Base
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
class InventoryMovement(Base):
|
|
7
|
+
__tablename__ = "inventory_movements"
|
|
8
|
+
medicine_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("medicines.id"))
|
|
9
|
+
batch_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("medicine_batches.id"))
|
|
10
|
+
branch_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("branches.id"))
|
|
11
|
+
movement_type: Mapped[str] = mapped_column(String)
|
|
12
|
+
quantity: Mapped[float] = mapped_column(Numeric(10, 2))
|
|
13
|
+
reference_type: Mapped[str] = mapped_column(String, nullable=True)
|
|
14
|
+
reference_id: Mapped[str] = mapped_column(String, nullable=True)
|
|
15
|
+
notes: Mapped[str] = mapped_column(String, nullable=True)
|
|
16
|
+
created_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
|