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.
Files changed (137) hide show
  1. package/.env.example +34 -0
  2. package/LICENSE +21 -0
  3. package/README.md +479 -0
  4. package/backend/Dockerfile +44 -0
  5. package/backend/app/__init__.py +1 -0
  6. package/backend/app/core/__init__.py +1 -0
  7. package/backend/app/core/audit.py +33 -0
  8. package/backend/app/core/config.py +16 -0
  9. package/backend/app/core/database.py +26 -0
  10. package/backend/app/core/deps.py +49 -0
  11. package/backend/app/core/exceptions.py +43 -0
  12. package/backend/app/core/security.py +32 -0
  13. package/backend/app/main.py +54 -0
  14. package/backend/app/modules/__init__.py +0 -0
  15. package/backend/app/modules/admin/__init__.py +1 -0
  16. package/backend/app/modules/admin/models.py +15 -0
  17. package/backend/app/modules/admin/router.py +353 -0
  18. package/backend/app/modules/auth/__init__.py +0 -0
  19. package/backend/app/modules/auth/models.py +16 -0
  20. package/backend/app/modules/auth/router.py +73 -0
  21. package/backend/app/modules/auth/schemas.py +29 -0
  22. package/backend/app/modules/cash_sessions/__init__.py +0 -0
  23. package/backend/app/modules/cash_sessions/models.py +19 -0
  24. package/backend/app/modules/cash_sessions/router.py +85 -0
  25. package/backend/app/modules/cash_sessions/schemas.py +31 -0
  26. package/backend/app/modules/customers/__init__.py +0 -0
  27. package/backend/app/modules/customers/models.py +37 -0
  28. package/backend/app/modules/customers/router.py +296 -0
  29. package/backend/app/modules/customers/schemas.py +72 -0
  30. package/backend/app/modules/inventory/__init__.py +0 -0
  31. package/backend/app/modules/inventory/models.py +16 -0
  32. package/backend/app/modules/inventory/router.py +150 -0
  33. package/backend/app/modules/inventory/schemas.py +24 -0
  34. package/backend/app/modules/medicines/__init__.py +0 -0
  35. package/backend/app/modules/medicines/models.py +54 -0
  36. package/backend/app/modules/medicines/router.py +631 -0
  37. package/backend/app/modules/medicines/schemas.py +153 -0
  38. package/backend/app/modules/medicines/template_generator.py +118 -0
  39. package/backend/app/modules/notifications/__init__.py +1 -0
  40. package/backend/app/modules/notifications/models.py +15 -0
  41. package/backend/app/modules/notifications/router.py +89 -0
  42. package/backend/app/modules/notifications/schemas.py +22 -0
  43. package/backend/app/modules/organizations/__init__.py +0 -0
  44. package/backend/app/modules/organizations/models.py +16 -0
  45. package/backend/app/modules/organizations/router.py +49 -0
  46. package/backend/app/modules/organizations/schemas.py +31 -0
  47. package/backend/app/modules/reports/__init__.py +0 -0
  48. package/backend/app/modules/reports/router.py +409 -0
  49. package/backend/app/modules/roles/__init__.py +1 -0
  50. package/backend/app/modules/roles/models.py +43 -0
  51. package/backend/app/modules/roles/router.py +89 -0
  52. package/backend/app/modules/roles/schemas.py +55 -0
  53. package/backend/app/modules/sales/__init__.py +0 -0
  54. package/backend/app/modules/sales/models.py +44 -0
  55. package/backend/app/modules/sales/router.py +63 -0
  56. package/backend/app/modules/sales/schemas.py +42 -0
  57. package/backend/app/modules/sales/service.py +128 -0
  58. package/backend/app/modules/suppliers/__init__.py +0 -0
  59. package/backend/app/modules/suppliers/models.py +14 -0
  60. package/backend/app/modules/suppliers/router.py +56 -0
  61. package/backend/app/modules/suppliers/schemas.py +27 -0
  62. package/backend/app/modules/users/__init__.py +0 -0
  63. package/backend/app/modules/users/router.py +74 -0
  64. package/backend/app/seed.py +275 -0
  65. package/backend/data/.gitkeep +1 -0
  66. package/backend/requirements.txt +17 -0
  67. package/bin/cli.js +288 -0
  68. package/docker-compose.dev.yml +42 -0
  69. package/docker-compose.yml +59 -0
  70. package/frontend/Dockerfile +56 -0
  71. package/frontend/next.config.ts +19 -0
  72. package/frontend/package.json +60 -0
  73. package/frontend/pnpm-lock.yaml +5678 -0
  74. package/frontend/pnpm-workspace.yaml +6 -0
  75. package/frontend/postcss.config.mjs +6 -0
  76. package/frontend/public/logo.png +0 -0
  77. package/frontend/src/app/[locale]/(app)/admin/page.tsx +1426 -0
  78. package/frontend/src/app/[locale]/(app)/catalog/products/[id]/page.tsx +505 -0
  79. package/frontend/src/app/[locale]/(app)/catalog/products/page.tsx +753 -0
  80. package/frontend/src/app/[locale]/(app)/customers/[id]/page.tsx +500 -0
  81. package/frontend/src/app/[locale]/(app)/customers/page.tsx +538 -0
  82. package/frontend/src/app/[locale]/(app)/dashboard/page.tsx +175 -0
  83. package/frontend/src/app/[locale]/(app)/inventory/page.tsx +765 -0
  84. package/frontend/src/app/[locale]/(app)/layout.tsx +60 -0
  85. package/frontend/src/app/[locale]/(app)/purchasing/page.tsx +1 -0
  86. package/frontend/src/app/[locale]/(app)/reports/page.tsx +794 -0
  87. package/frontend/src/app/[locale]/(app)/sales/page.tsx +296 -0
  88. package/frontend/src/app/[locale]/(auth)/login/page.tsx +100 -0
  89. package/frontend/src/app/[locale]/(pos)/layout.tsx +33 -0
  90. package/frontend/src/app/[locale]/(pos)/pos/page.tsx +463 -0
  91. package/frontend/src/app/[locale]/(pos)/pos/pos-data.ts +89 -0
  92. package/frontend/src/app/[locale]/layout.tsx +30 -0
  93. package/frontend/src/app/[locale]/page.tsx +6 -0
  94. package/frontend/src/app/globals.css +77 -0
  95. package/frontend/src/app/layout.tsx +23 -0
  96. package/frontend/src/app/print.css +18 -0
  97. package/frontend/src/components/pos/CartPanel.tsx +525 -0
  98. package/frontend/src/components/pos/CashSessionGuard.tsx +255 -0
  99. package/frontend/src/components/pos/CloseSessionModal.tsx +160 -0
  100. package/frontend/src/components/pos/PaymentModal.tsx +266 -0
  101. package/frontend/src/components/pos/PaymentSettingsModal.tsx +472 -0
  102. package/frontend/src/components/pos/ProductCatalog.tsx +140 -0
  103. package/frontend/src/components/pos/SaleReceipt.tsx +133 -0
  104. package/frontend/src/components/ui/badge.tsx +28 -0
  105. package/frontend/src/components/ui/bulk-import-modal.tsx +400 -0
  106. package/frontend/src/components/ui/button.tsx +60 -0
  107. package/frontend/src/components/ui/card.tsx +13 -0
  108. package/frontend/src/components/ui/confirm-dialog.tsx +50 -0
  109. package/frontend/src/components/ui/input.tsx +32 -0
  110. package/frontend/src/components/ui/notification-center.tsx +228 -0
  111. package/frontend/src/components/ui/select.tsx +59 -0
  112. package/frontend/src/components/ui/sidebar.tsx +91 -0
  113. package/frontend/src/components/ui/slide-over.tsx +43 -0
  114. package/frontend/src/components/ui/stat-card.tsx +40 -0
  115. package/frontend/src/components/ui/switch.tsx +38 -0
  116. package/frontend/src/components/ui/topbar.tsx +97 -0
  117. package/frontend/src/i18n/request.ts +13 -0
  118. package/frontend/src/i18n/routing.ts +9 -0
  119. package/frontend/src/lib/api.ts +240 -0
  120. package/frontend/src/lib/constants.ts +30 -0
  121. package/frontend/src/lib/utils.ts +32 -0
  122. package/frontend/src/messages/ar.json +106 -0
  123. package/frontend/src/messages/en.json +106 -0
  124. package/frontend/src/middleware.ts +8 -0
  125. package/frontend/src/store/app-store.ts +37 -0
  126. package/frontend/src/store/auth-store.ts +52 -0
  127. package/frontend/src/store/payment-methods-store.ts +144 -0
  128. package/frontend/src/store/pos-store.ts +344 -0
  129. package/frontend/tailwind.config.ts +85 -0
  130. package/frontend/tsconfig.json +27 -0
  131. package/img/backgraund-logo.png +0 -0
  132. package/img/full-logo.png +0 -0
  133. package/img/logo.png +0 -0
  134. package/nginx/nginx.conf +67 -0
  135. package/package.json +88 -0
  136. package/start.bat +90 -0
  137. 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"))