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,409 @@
1
+ from fastapi import APIRouter, Depends
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+ from sqlalchemy import select, func, desc
4
+ from datetime import datetime, date
5
+ from typing import List, Dict, Any, Optional
6
+
7
+ from app.core.database import get_db
8
+ from app.core.deps import get_current_user
9
+ from app.modules.auth.models import User
10
+ from app.modules.sales.models import Sale, SaleItem
11
+ from app.modules.medicines.models import MedicineBatch, Medicine
12
+ from app.modules.customers.models import Customer
13
+
14
+ router = APIRouter(tags=["reports"])
15
+
16
+ @router.get("/sales/daily")
17
+ async def daily_sales(date: date, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
18
+ query = select(
19
+ func.count(Sale.id).label("total_sales"),
20
+ func.sum(Sale.total_amount).label("total_amount")
21
+ ).where(
22
+ func.date(Sale.created_at) == date,
23
+ Sale.status == "COMPLETED"
24
+ )
25
+ result = await db.execute(query)
26
+ row = result.first()
27
+
28
+ total_count = row.total_sales or 0
29
+ total_amount = row.total_amount or 0
30
+ avg_sale = total_amount / total_count if total_count > 0 else 0
31
+
32
+ return {
33
+ "date": date,
34
+ "total_sales_count": total_count,
35
+ "total_amount": total_amount,
36
+ "average_sale": avg_sale
37
+ }
38
+
39
+ @router.get("/sales/monthly")
40
+ async def monthly_sales(year: int, month: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
41
+ query = select(
42
+ func.count(Sale.id).label("total_sales"),
43
+ func.sum(Sale.total_amount).label("total_amount")
44
+ ).where(
45
+ func.extract('year', Sale.created_at) == year,
46
+ func.extract('month', Sale.created_at) == month,
47
+ Sale.status == "COMPLETED"
48
+ )
49
+ result = await db.execute(query)
50
+ row = result.first()
51
+
52
+ return {
53
+ "year": year,
54
+ "month": month,
55
+ "total_sales_count": row.total_sales or 0,
56
+ "total_amount": row.total_amount or 0
57
+ }
58
+
59
+ @router.get("/inventory/stock-value")
60
+ async def stock_value(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
61
+ query = select(
62
+ func.sum(MedicineBatch.quantity_remaining * MedicineBatch.purchase_price).label("total_value")
63
+ )
64
+ result = await db.execute(query)
65
+ value = result.scalar() or 0
66
+
67
+ return {
68
+ "total_stock_value": value
69
+ }
70
+
71
+ @router.get("/sales/top-products")
72
+ async def top_products(limit: int = 10, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
73
+ query = select(
74
+ Medicine.name_en,
75
+ Medicine.name_ar,
76
+ func.sum(SaleItem.quantity).label("total_quantity"),
77
+ func.sum(SaleItem.line_total).label("total_revenue")
78
+ ).join(SaleItem, SaleItem.medicine_id == Medicine.id)\
79
+ .join(Sale, Sale.id == SaleItem.sale_id)\
80
+ .where(Sale.status == "COMPLETED")\
81
+ .group_by(Medicine.id)\
82
+ .order_by(desc("total_quantity"))\
83
+ .limit(limit)
84
+
85
+ result = await db.execute(query)
86
+ return [
87
+ {
88
+ "name_en": row.name_en,
89
+ "name_ar": row.name_ar,
90
+ "total_quantity": row.total_quantity,
91
+ "total_revenue": row.total_revenue
92
+ }
93
+ for row in result.all()
94
+ ]
95
+
96
+ @router.get("/financial/profit")
97
+ async def financial_profit(
98
+ start_date: Optional[date] = None,
99
+ end_date: Optional[date] = None,
100
+ db: AsyncSession = Depends(get_db),
101
+ current_user: User = Depends(get_current_user)
102
+ ):
103
+ cogs_query = select(
104
+ func.sum(SaleItem.quantity * MedicineBatch.purchase_price).label("cogs")
105
+ ).join(Sale, Sale.id == SaleItem.sale_id).join(MedicineBatch, MedicineBatch.id == SaleItem.batch_id).where(Sale.status == "COMPLETED")
106
+
107
+ if start_date:
108
+ cogs_query = cogs_query.where(func.date(Sale.created_at) >= start_date)
109
+ if end_date:
110
+ cogs_query = cogs_query.where(func.date(Sale.created_at) <= end_date)
111
+
112
+ cogs_result = await db.execute(cogs_query)
113
+ cogs_row = cogs_result.first()
114
+ cogs = float(cogs_row.cogs or 0)
115
+
116
+ revenue_query = select(func.sum(Sale.total_amount).label("revenue")).where(Sale.status == "COMPLETED")
117
+ if start_date:
118
+ revenue_query = revenue_query.where(func.date(Sale.created_at) >= start_date)
119
+ if end_date:
120
+ revenue_query = revenue_query.where(func.date(Sale.created_at) <= end_date)
121
+
122
+ rev_result = await db.execute(revenue_query)
123
+ rev_row = rev_result.first()
124
+ revenue = float(rev_row.revenue or 0)
125
+
126
+ return {
127
+ "revenue": revenue,
128
+ "cogs": cogs,
129
+ "profit": revenue - cogs
130
+ }
131
+
132
+ @router.get("/customers/balances")
133
+ async def customer_balances(
134
+ db: AsyncSession = Depends(get_db),
135
+ current_user: User = Depends(get_current_user)
136
+ ):
137
+ query = select(
138
+ Customer.id,
139
+ Customer.name,
140
+ Customer.phone,
141
+ Customer.credit_limit,
142
+ Customer.credit_balance
143
+ ).where(
144
+ Customer.credit_balance > 0
145
+ ).order_by(desc(Customer.credit_balance))
146
+
147
+ result = await db.execute(query)
148
+
149
+ return [
150
+ {
151
+ "id": row.id,
152
+ "name": row.name,
153
+ "phone": row.phone,
154
+ "credit_limit": float(row.credit_limit or 0),
155
+ "credit_balance": float(row.credit_balance or 0)
156
+ }
157
+ for row in result.all()
158
+ ]
159
+
160
+ @router.get("/inventory/expiry-summary")
161
+ async def expiry_summary(
162
+ db: AsyncSession = Depends(get_db),
163
+ current_user: User = Depends(get_current_user)
164
+ ):
165
+ query = select(MedicineBatch.expiry_date).where(MedicineBatch.quantity_remaining > 0)
166
+ result = await db.execute(query)
167
+ batches = result.scalars().all()
168
+
169
+ today = date.today()
170
+ summary = {
171
+ "expired": 0,
172
+ "expiring_30_days": 0,
173
+ "expiring_60_days": 0,
174
+ "expiring_90_days": 0,
175
+ "safe": 0
176
+ }
177
+
178
+ for exp in batches:
179
+ if exp is None:
180
+ summary["safe"] += 1
181
+ continue
182
+
183
+ # Parse from string if SQLite returned a string instead of date object
184
+ if isinstance(exp, str):
185
+ try:
186
+ exp = datetime.strptime(exp, "%Y-%m-%d").date()
187
+ except ValueError:
188
+ summary["safe"] += 1
189
+ continue
190
+
191
+ days = (exp - today).days
192
+ if days < 0:
193
+ summary["expired"] += 1
194
+ elif days <= 30:
195
+ summary["expiring_30_days"] += 1
196
+ elif days <= 60:
197
+ summary["expiring_60_days"] += 1
198
+ elif days <= 90:
199
+ summary["expiring_90_days"] += 1
200
+ else:
201
+ summary["safe"] += 1
202
+
203
+ return summary
204
+
205
+ @router.get("/sales/range")
206
+ async def sales_range(
207
+ start_date: date,
208
+ end_date: date,
209
+ db: AsyncSession = Depends(get_db),
210
+ current_user: User = Depends(get_current_user)
211
+ ):
212
+ query = select(
213
+ func.count(Sale.id).label("total_sales"),
214
+ func.sum(Sale.total_amount).label("total_revenue"),
215
+ func.sum(Sale.discount_amount).label("total_discount")
216
+ ).where(
217
+ Sale.status == "COMPLETED",
218
+ func.date(Sale.created_at) >= start_date,
219
+ func.date(Sale.created_at) <= end_date
220
+ )
221
+ result = await db.execute(query)
222
+ row = result.first()
223
+
224
+ # Get daily breakdown
225
+ daily_query = select(
226
+ func.date(Sale.created_at).label("date"),
227
+ func.sum(Sale.total_amount).label("revenue")
228
+ ).where(
229
+ Sale.status == "COMPLETED",
230
+ func.date(Sale.created_at) >= start_date,
231
+ func.date(Sale.created_at) <= end_date
232
+ ).group_by(func.date(Sale.created_at)).order_by(func.date(Sale.created_at))
233
+
234
+ daily_result = await db.execute(daily_query)
235
+ daily_breakdown = [
236
+ {"date": str(r.date), "revenue": float(r.revenue or 0)}
237
+ for r in daily_result.all()
238
+ ]
239
+
240
+ return {
241
+ "total_sales_count": row.total_sales or 0,
242
+ "total_revenue": float(row.total_revenue or 0),
243
+ "total_discount": float(row.total_discount or 0),
244
+ "daily_breakdown": daily_breakdown
245
+ }
246
+
247
+
248
+ # Known mobile money providers in Sudan
249
+ MOBILE_METHODS = {"bankak", "fawry", "amin"}
250
+
251
+ def _classify_method(method: str) -> str:
252
+ """Classify a payment method as 'cash' or 'mobile'."""
253
+ m = method.lower().strip()
254
+ if m == "cash":
255
+ return "cash"
256
+ return "mobile"
257
+
258
+ def _label_method(method: str) -> str:
259
+ """Convert stored method string to display label."""
260
+ m = method.lower().strip()
261
+ return m.capitalize()
262
+
263
+
264
+ @router.get("/financial/payment-breakdown")
265
+ async def payment_breakdown(
266
+ start_date: Optional[date] = None,
267
+ end_date: Optional[date] = None,
268
+ db: AsyncSession = Depends(get_db),
269
+ current_user: User = Depends(get_current_user)
270
+ ):
271
+ """Get revenue breakdown grouped by payment method."""
272
+ query = select(
273
+ Sale.payment_method,
274
+ func.count(Sale.id).label("count"),
275
+ func.sum(Sale.total_amount).label("amount")
276
+ ).where(
277
+ Sale.status == "COMPLETED"
278
+ ).group_by(Sale.payment_method)
279
+
280
+ if start_date:
281
+ query = query.where(func.date(Sale.created_at) >= start_date)
282
+ if end_date:
283
+ query = query.where(func.date(Sale.created_at) <= end_date)
284
+
285
+ result = await db.execute(query)
286
+ rows = result.all()
287
+
288
+ total_revenue = sum(float(r.amount or 0) for r in rows)
289
+
290
+ methods = []
291
+ for r in rows:
292
+ method_name = (r.payment_method or "cash").lower().strip()
293
+ amount = float(r.amount or 0)
294
+ methods.append({
295
+ "method": method_name,
296
+ "label": _label_method(method_name),
297
+ "type": _classify_method(method_name),
298
+ "amount": amount,
299
+ "count": r.count or 0,
300
+ "percentage": round((amount / total_revenue * 100), 1) if total_revenue > 0 else 0
301
+ })
302
+
303
+ # Sort: cash first, then by amount descending
304
+ methods.sort(key=lambda x: (0 if x["type"] == "cash" else 1, -x["amount"]))
305
+
306
+ return {
307
+ "total_revenue": total_revenue,
308
+ "methods": methods
309
+ }
310
+
311
+
312
+ @router.get("/medicines/movement")
313
+ async def medicine_movement(
314
+ start_date: Optional[date] = None,
315
+ end_date: Optional[date] = None,
316
+ limit: int = 10,
317
+ db: AsyncSession = Depends(get_db),
318
+ current_user: User = Depends(get_current_user)
319
+ ):
320
+ # 1. Base query for sales within period
321
+ sales_query = select(
322
+ Medicine.id,
323
+ Medicine.name_en,
324
+ Medicine.name_ar,
325
+ func.sum(SaleItem.quantity).label("total_quantity"),
326
+ func.sum(SaleItem.line_total).label("total_revenue")
327
+ ).join(SaleItem, SaleItem.medicine_id == Medicine.id)\
328
+ .join(Sale, Sale.id == SaleItem.sale_id)\
329
+ .where(Sale.status == "COMPLETED")
330
+
331
+ if start_date:
332
+ sales_query = sales_query.where(func.date(Sale.created_at) >= start_date)
333
+ if end_date:
334
+ sales_query = sales_query.where(func.date(Sale.created_at) <= end_date)
335
+
336
+ sales_query = sales_query.group_by(Medicine.id)
337
+
338
+ # 2. Fast Moving (Top N by quantity)
339
+ fast_query = sales_query.order_by(desc("total_quantity")).limit(limit)
340
+ fast_result = await db.execute(fast_query)
341
+ fast_moving = [
342
+ {
343
+ "id": str(r.id),
344
+ "name_en": r.name_en,
345
+ "name_ar": r.name_ar,
346
+ "quantity": float(r.total_quantity or 0),
347
+ "revenue": float(r.total_revenue or 0)
348
+ } for r in fast_result.all()
349
+ ]
350
+
351
+ # 3. Slow Moving (Bottom N by quantity, but > 0)
352
+ slow_query = sales_query.order_by("total_quantity").limit(limit)
353
+ slow_result = await db.execute(slow_query)
354
+ slow_moving = [
355
+ {
356
+ "id": str(r.id),
357
+ "name_en": r.name_en,
358
+ "name_ar": r.name_ar,
359
+ "quantity": float(r.total_quantity or 0),
360
+ "revenue": float(r.total_revenue or 0)
361
+ } for r in slow_result.all()
362
+ ]
363
+
364
+ # 4. Dead Stock (Stock > 0 but NO sales in this period)
365
+ # First, get IDs of medicines sold in this period
366
+ sold_query = select(SaleItem.medicine_id).join(Sale, Sale.id == SaleItem.sale_id).where(Sale.status == "COMPLETED")
367
+ if start_date:
368
+ sold_query = sold_query.where(func.date(Sale.created_at) >= start_date)
369
+ if end_date:
370
+ sold_query = sold_query.where(func.date(Sale.created_at) <= end_date)
371
+
372
+ sold_result = await db.execute(sold_query.distinct())
373
+ sold_ids = [r for r in sold_result.scalars().all()]
374
+
375
+ # Then query medicines with stock > 0 not in sold_ids
376
+ # Get stock per medicine by aggregating batches
377
+ stock_subq = select(
378
+ MedicineBatch.medicine_id,
379
+ func.sum(MedicineBatch.quantity_remaining).label("total_stock")
380
+ ).group_by(MedicineBatch.medicine_id).subquery()
381
+
382
+ dead_query = select(
383
+ Medicine.id,
384
+ Medicine.name_en,
385
+ Medicine.name_ar,
386
+ stock_subq.c.total_stock
387
+ ).join(stock_subq, stock_subq.c.medicine_id == Medicine.id)\
388
+ .where(stock_subq.c.total_stock > 0)
389
+
390
+ if sold_ids:
391
+ dead_query = dead_query.where(Medicine.id.notin_(sold_ids))
392
+
393
+ dead_query = dead_query.order_by(desc(stock_subq.c.total_stock)).limit(limit * 2)
394
+ dead_result = await db.execute(dead_query)
395
+
396
+ dead_stock = [
397
+ {
398
+ "id": str(r.id),
399
+ "name_en": r.name_en,
400
+ "name_ar": r.name_ar,
401
+ "stock": float(r.total_stock or 0)
402
+ } for r in dead_result.all()
403
+ ]
404
+
405
+ return {
406
+ "fast_moving": fast_moving,
407
+ "slow_moving": slow_moving,
408
+ "dead_stock": dead_stock
409
+ }
@@ -0,0 +1 @@
1
+ # Roles module
@@ -0,0 +1,43 @@
1
+ import uuid
2
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
3
+ from sqlalchemy import String, Boolean, Integer, ForeignKey, Table, Column, Text, Uuid
4
+ from app.core.database import Base
5
+
6
+ # Many-to-many association table
7
+ role_permissions = Table(
8
+ 'role_permissions',
9
+ Base.metadata,
10
+ Column('role_id', Uuid, ForeignKey('roles.id'), primary_key=True),
11
+ Column('permission_id', Uuid, ForeignKey('permissions.id'), primary_key=True),
12
+ )
13
+
14
+ class Role(Base):
15
+ __tablename__ = "roles"
16
+
17
+ name: Mapped[str] = mapped_column(String, unique=True, index=True)
18
+ display_name: Mapped[str] = mapped_column(String)
19
+ display_name_ar: Mapped[str] = mapped_column(String, nullable=True)
20
+ description: Mapped[str] = mapped_column(Text, nullable=True)
21
+ description_ar: Mapped[str] = mapped_column(Text, nullable=True)
22
+ is_system: Mapped[bool] = mapped_column(Boolean, default=False)
23
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
24
+ color: Mapped[str] = mapped_column(String, nullable=True)
25
+ sort_order: Mapped[int] = mapped_column(Integer, default=0)
26
+
27
+ permissions: Mapped[list["Permission"]] = relationship(
28
+ secondary=role_permissions, lazy="selectin", back_populates="roles"
29
+ )
30
+
31
+ class Permission(Base):
32
+ __tablename__ = "permissions"
33
+
34
+ code: Mapped[str] = mapped_column(String, unique=True, index=True)
35
+ module: Mapped[str] = mapped_column(String, index=True)
36
+ action: Mapped[str] = mapped_column(String)
37
+ display_name: Mapped[str] = mapped_column(String)
38
+ display_name_ar: Mapped[str] = mapped_column(String, nullable=True)
39
+ description: Mapped[str] = mapped_column(Text, nullable=True)
40
+
41
+ roles: Mapped[list["Role"]] = relationship(
42
+ secondary=role_permissions, lazy="selectin", back_populates="permissions"
43
+ )
@@ -0,0 +1,89 @@
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+ from sqlalchemy import select, func
4
+ from uuid import UUID
5
+ from app.core.database import get_db
6
+ from app.core.deps import get_current_user
7
+ from app.modules.auth.models import User
8
+ from app.modules.roles.models import Role, Permission, role_permissions
9
+ from app.modules.roles.schemas import (
10
+ RoleCreate, RoleUpdate, RoleResponse, PermissionResponse, PermissionsByModule
11
+ )
12
+ from typing import List
13
+ from collections import defaultdict
14
+
15
+ router = APIRouter(tags=["roles"])
16
+
17
+ @router.get("/permissions/all", response_model=List[PermissionResponse])
18
+ async def list_permissions(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
19
+ result = await db.execute(select(Permission).order_by(Permission.module, Permission.action))
20
+ return result.scalars().all()
21
+
22
+ @router.get("/permissions/by-module", response_model=List[PermissionsByModule])
23
+ async def list_permissions_by_module(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
24
+ result = await db.execute(select(Permission).order_by(Permission.module, Permission.action))
25
+ perms = result.scalars().all()
26
+ grouped = defaultdict(list)
27
+ for perm in perms:
28
+ grouped[perm.module].append(PermissionResponse.model_validate(perm))
29
+ return [
30
+ PermissionsByModule(module=module, permissions=permissions)
31
+ for module, permissions in grouped.items()
32
+ ]
33
+
34
+ @router.get("/", response_model=List[RoleResponse])
35
+ async def list_roles(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
36
+ result = await db.execute(select(Role).order_by(Role.sort_order))
37
+ return result.scalars().all()
38
+
39
+ @router.get("/{role_id}", response_model=RoleResponse)
40
+ async def get_role(role_id: UUID, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
41
+ role = await db.get(Role, role_id)
42
+ if not role:
43
+ raise HTTPException(status_code=404, detail="Role not found")
44
+ return role
45
+
46
+ @router.post("/", response_model=RoleResponse)
47
+ async def create_role(data: RoleCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
48
+ existing = await db.execute(select(Role).where(Role.name == data.name))
49
+ if existing.scalar_one_or_none():
50
+ raise HTTPException(status_code=409, detail="Role name already exists")
51
+ role = Role(
52
+ name=data.name, display_name=data.display_name,
53
+ display_name_ar=data.display_name_ar, description=data.description,
54
+ description_ar=data.description_ar, color=data.color, sort_order=data.sort_order,
55
+ )
56
+ if data.permission_ids:
57
+ result = await db.execute(select(Permission).where(Permission.id.in_([str(pid) for pid in data.permission_ids])))
58
+ role.permissions = list(result.scalars().all())
59
+ db.add(role)
60
+ await db.commit()
61
+ await db.refresh(role)
62
+ return role
63
+
64
+ @router.put("/{role_id}", response_model=RoleResponse)
65
+ async def update_role(role_id: UUID, data: RoleUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
66
+ role = await db.get(Role, role_id)
67
+ if not role:
68
+ raise HTTPException(status_code=404, detail="Role not found")
69
+ update_data = data.model_dump(exclude_unset=True)
70
+ permission_ids = update_data.pop('permission_ids', None)
71
+ for key, value in update_data.items():
72
+ setattr(role, key, value)
73
+ if permission_ids is not None:
74
+ result = await db.execute(select(Permission).where(Permission.id.in_([str(pid) for pid in permission_ids])))
75
+ role.permissions = list(result.scalars().all())
76
+ await db.commit()
77
+ await db.refresh(role)
78
+ return role
79
+
80
+ @router.delete("/{role_id}")
81
+ async def delete_role(role_id: UUID, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
82
+ role = await db.get(Role, role_id)
83
+ if not role:
84
+ raise HTTPException(status_code=404, detail="Role not found")
85
+ if role.is_system:
86
+ raise HTTPException(status_code=400, detail="Cannot delete system roles")
87
+ await db.delete(role)
88
+ await db.commit()
89
+ return {"detail": "Role deleted"}
@@ -0,0 +1,55 @@
1
+ from pydantic import BaseModel
2
+ from typing import Optional, List
3
+ from datetime import datetime
4
+ import uuid
5
+
6
+ class PermissionResponse(BaseModel):
7
+ id: uuid.UUID
8
+ code: str
9
+ module: str
10
+ action: str
11
+ display_name: str
12
+ display_name_ar: Optional[str] = None
13
+ description: Optional[str] = None
14
+
15
+ model_config = {"from_attributes": True}
16
+
17
+ class RoleCreate(BaseModel):
18
+ name: str
19
+ display_name: str
20
+ display_name_ar: Optional[str] = None
21
+ description: Optional[str] = None
22
+ description_ar: Optional[str] = None
23
+ color: Optional[str] = None
24
+ sort_order: int = 0
25
+ permission_ids: List[uuid.UUID] = []
26
+
27
+ class RoleUpdate(BaseModel):
28
+ display_name: Optional[str] = None
29
+ display_name_ar: Optional[str] = None
30
+ description: Optional[str] = None
31
+ description_ar: Optional[str] = None
32
+ color: Optional[str] = None
33
+ sort_order: Optional[int] = None
34
+ is_active: Optional[bool] = None
35
+ permission_ids: Optional[List[uuid.UUID]] = None
36
+
37
+ class RoleResponse(BaseModel):
38
+ id: uuid.UUID
39
+ name: str
40
+ display_name: str
41
+ display_name_ar: Optional[str] = None
42
+ description: Optional[str] = None
43
+ description_ar: Optional[str] = None
44
+ is_system: bool
45
+ is_active: bool
46
+ color: Optional[str] = None
47
+ sort_order: int
48
+ permissions: List[PermissionResponse] = []
49
+ created_at: datetime
50
+
51
+ model_config = {"from_attributes": True}
52
+
53
+ class PermissionsByModule(BaseModel):
54
+ module: str
55
+ permissions: List[PermissionResponse]
File without changes
@@ -0,0 +1,44 @@
1
+ from sqlalchemy.orm import Mapped, mapped_column
2
+ from sqlalchemy import String, Numeric, ForeignKey, DateTime
3
+ from sqlalchemy.orm import relationship
4
+ from app.core.database import Base
5
+ import uuid
6
+ from datetime import datetime
7
+
8
+ class Sale(Base):
9
+ __tablename__ = "sales"
10
+ invoice_number: Mapped[str] = mapped_column(String, unique=True)
11
+ branch_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("branches.id"))
12
+ customer_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("customers.id"), nullable=True)
13
+ cashier_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
14
+ cash_session_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("cash_sessions.id"), nullable=True)
15
+ status: Mapped[str] = mapped_column(String)
16
+ subtotal: Mapped[float] = mapped_column(Numeric(10, 2))
17
+ discount_amount: Mapped[float] = mapped_column(Numeric(10, 2), default=0)
18
+ total_amount: Mapped[float] = mapped_column(Numeric(10, 2))
19
+ payment_method: Mapped[str] = mapped_column(String)
20
+ notes: Mapped[str] = mapped_column(String, nullable=True)
21
+
22
+ items = relationship("SaleItem", back_populates="sale", lazy="selectin")
23
+ customer = relationship("Customer", lazy="selectin")
24
+
25
+ class SaleItem(Base):
26
+ __tablename__ = "sale_items"
27
+ sale_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("sales.id"))
28
+ medicine_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("medicines.id"))
29
+ batch_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("medicine_batches.id"))
30
+ quantity: Mapped[float] = mapped_column(Numeric(10, 2))
31
+ unit_price: Mapped[float] = mapped_column(Numeric(10, 2))
32
+ discount_percent: Mapped[float] = mapped_column(Numeric(5, 2), default=0)
33
+ line_total: Mapped[float] = mapped_column(Numeric(10, 2))
34
+
35
+ sale = relationship("Sale", back_populates="items")
36
+
37
+ class HeldSale(Base):
38
+ __tablename__ = "held_sales"
39
+ branch_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("branches.id"))
40
+ cashier_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
41
+ label: Mapped[str] = mapped_column(String)
42
+ items_json: Mapped[str] = mapped_column(String)
43
+ customer_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("customers.id"), nullable=True)
44
+ held_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)