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,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)
|