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,631 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
|
|
2
|
+
from app.core.audit import log_action
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
4
|
+
from sqlalchemy import select, or_
|
|
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.medicines.models import Medicine, MedicineBatch, MedicineCategory
|
|
9
|
+
from app.modules.medicines.schemas import (
|
|
10
|
+
MedicineCreate, MedicineUpdate, MedicineResponse,
|
|
11
|
+
BatchCreate, BatchUpdate, BatchResponse, BatchConflictResponse,
|
|
12
|
+
CategoryCreate, CategoryResponse,
|
|
13
|
+
CategoryUpdate, CategoryTreeResponse, MedicineCreateWithBatch, MedicineDetailResponse,
|
|
14
|
+
BulkImportResponse, BulkImportRowError
|
|
15
|
+
)
|
|
16
|
+
from app.modules.inventory.models import InventoryMovement
|
|
17
|
+
from app.modules.organizations.models import Branch
|
|
18
|
+
from typing import List
|
|
19
|
+
from uuid import UUID
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
22
|
+
import csv
|
|
23
|
+
import io
|
|
24
|
+
from openpyxl import load_workbook
|
|
25
|
+
from app.modules.medicines.template_generator import generate_csv_template, generate_xlsx_template, BASE_UNITS, DOSAGE_FORMS
|
|
26
|
+
|
|
27
|
+
router = APIRouter(tags=["Medicines"])
|
|
28
|
+
|
|
29
|
+
@router.get("/", response_model=List[MedicineResponse])
|
|
30
|
+
async def list_medicines(db: AsyncSession = Depends(get_db)):
|
|
31
|
+
res = await db.execute(select(Medicine).where(Medicine.is_active == True))
|
|
32
|
+
return res.scalars().all()
|
|
33
|
+
|
|
34
|
+
@router.get("/search", response_model=List[MedicineResponse])
|
|
35
|
+
async def search_medicines(q: str, db: AsyncSession = Depends(get_db)):
|
|
36
|
+
query = select(Medicine).where(
|
|
37
|
+
Medicine.is_active == True,
|
|
38
|
+
or_(
|
|
39
|
+
Medicine.name_en.ilike(f"%{q}%"),
|
|
40
|
+
Medicine.name_ar.ilike(f"%{q}%"),
|
|
41
|
+
Medicine.barcode == q,
|
|
42
|
+
Medicine.sku == q
|
|
43
|
+
)
|
|
44
|
+
).limit(50)
|
|
45
|
+
res = await db.execute(query)
|
|
46
|
+
return res.scalars().all()
|
|
47
|
+
|
|
48
|
+
@router.post("/", response_model=MedicineResponse)
|
|
49
|
+
async def create_medicine(data: MedicineCreate, request: Request, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
50
|
+
medicine = Medicine(**data.model_dump())
|
|
51
|
+
db.add(medicine)
|
|
52
|
+
await db.commit()
|
|
53
|
+
await db.refresh(medicine)
|
|
54
|
+
|
|
55
|
+
await log_action(
|
|
56
|
+
db=db, action="CREATE", module="Medicine",
|
|
57
|
+
details=f"Created medicine: {medicine.name_en}",
|
|
58
|
+
user=current_user, entity_type="Medicine", entity_id=str(medicine.id),
|
|
59
|
+
ip_address=request.client.host if request.client else None
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return medicine
|
|
63
|
+
|
|
64
|
+
@router.put("/{id}", response_model=MedicineResponse)
|
|
65
|
+
async def update_medicine(id: UUID, data: MedicineUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
66
|
+
medicine = await db.get(Medicine, id)
|
|
67
|
+
if not medicine:
|
|
68
|
+
raise HTTPException(status_code=404, detail="Medicine not found")
|
|
69
|
+
|
|
70
|
+
for key, value in data.model_dump(exclude_unset=True).items():
|
|
71
|
+
setattr(medicine, key, value)
|
|
72
|
+
|
|
73
|
+
await db.commit()
|
|
74
|
+
await db.refresh(medicine)
|
|
75
|
+
return medicine
|
|
76
|
+
|
|
77
|
+
@router.delete("/{id}")
|
|
78
|
+
async def delete_medicine(id: UUID, request: Request, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
79
|
+
medicine = await db.get(Medicine, id)
|
|
80
|
+
if not medicine:
|
|
81
|
+
raise HTTPException(status_code=404, detail="Medicine not found")
|
|
82
|
+
|
|
83
|
+
medicine.is_active = False
|
|
84
|
+
await db.commit()
|
|
85
|
+
|
|
86
|
+
await log_action(
|
|
87
|
+
db=db, action="DELETE", module="Medicine",
|
|
88
|
+
details=f"Deleted medicine: {medicine.name_en}",
|
|
89
|
+
user=current_user, entity_type="Medicine", entity_id=str(medicine.id),
|
|
90
|
+
ip_address=request.client.host if request.client else None
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return {"message": "Medicine deleted successfully"}
|
|
94
|
+
|
|
95
|
+
@router.get("/{id}/batches", response_model=List[BatchResponse])
|
|
96
|
+
async def list_batches(id: UUID, db: AsyncSession = Depends(get_db)):
|
|
97
|
+
res = await db.execute(
|
|
98
|
+
select(MedicineBatch)
|
|
99
|
+
.where(MedicineBatch.medicine_id == id)
|
|
100
|
+
.order_by(MedicineBatch.expiry_date.asc())
|
|
101
|
+
)
|
|
102
|
+
return res.scalars().all()
|
|
103
|
+
|
|
104
|
+
@router.post("/{id}/batches", response_model=BatchResponse)
|
|
105
|
+
async def add_batch(id: UUID, data: BatchCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
106
|
+
"""Add a new batch with smart duplicate detection.
|
|
107
|
+
|
|
108
|
+
- Same batch number + same expiry → merge quantity into existing batch
|
|
109
|
+
- Same batch number + different expiry → return 409 conflict warning
|
|
110
|
+
- New batch number → create new batch
|
|
111
|
+
"""
|
|
112
|
+
medicine = await db.get(Medicine, id)
|
|
113
|
+
if not medicine:
|
|
114
|
+
raise HTTPException(status_code=404, detail="Medicine not found")
|
|
115
|
+
|
|
116
|
+
# Check for existing batch with same batch_number
|
|
117
|
+
existing_res = await db.execute(
|
|
118
|
+
select(MedicineBatch).where(
|
|
119
|
+
MedicineBatch.medicine_id == id,
|
|
120
|
+
MedicineBatch.batch_number == data.batch_number
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
existing_batch = existing_res.scalars().first()
|
|
124
|
+
|
|
125
|
+
if existing_batch:
|
|
126
|
+
# Same batch number exists - check expiry date
|
|
127
|
+
existing_expiry = existing_batch.expiry_date.date() if existing_batch.expiry_date else None
|
|
128
|
+
new_expiry = data.expiry_date.date() if data.expiry_date else None
|
|
129
|
+
|
|
130
|
+
if existing_expiry == new_expiry:
|
|
131
|
+
# MERGE: Same batch + same expiry → add quantity to existing
|
|
132
|
+
existing_batch.quantity_received = float(existing_batch.quantity_received) + float(data.quantity_received)
|
|
133
|
+
existing_batch.quantity_remaining = float(existing_batch.quantity_remaining) + float(data.quantity_received)
|
|
134
|
+
existing_batch.is_active = True
|
|
135
|
+
if existing_batch.status == "OUT_OF_STOCK":
|
|
136
|
+
existing_batch.status = "ACTIVE"
|
|
137
|
+
|
|
138
|
+
# Get branch for movement
|
|
139
|
+
branch_res = await db.execute(select(Branch).limit(1))
|
|
140
|
+
branch = branch_res.scalars().first()
|
|
141
|
+
|
|
142
|
+
# Log stock receipt movement
|
|
143
|
+
movement = InventoryMovement(
|
|
144
|
+
medicine_id=id,
|
|
145
|
+
batch_id=existing_batch.id,
|
|
146
|
+
branch_id=branch.id if branch else None,
|
|
147
|
+
movement_type="STOCK_RECEIPT",
|
|
148
|
+
quantity=float(data.quantity_received),
|
|
149
|
+
reference_type="BATCH_MERGE",
|
|
150
|
+
notes=f"Merged {data.quantity_received} units into existing batch {data.batch_number}",
|
|
151
|
+
created_by=current_user.id
|
|
152
|
+
)
|
|
153
|
+
db.add(movement)
|
|
154
|
+
|
|
155
|
+
await db.commit()
|
|
156
|
+
await db.refresh(existing_batch)
|
|
157
|
+
return existing_batch
|
|
158
|
+
else:
|
|
159
|
+
# CONFLICT: Same batch number but different expiry
|
|
160
|
+
return JSONResponse(
|
|
161
|
+
status_code=409,
|
|
162
|
+
content={
|
|
163
|
+
"conflict": True,
|
|
164
|
+
"message": f"Batch {data.batch_number} already exists with expiry {existing_expiry.isoformat() if existing_expiry else 'unknown'}. The entered expiry date is {new_expiry.isoformat() if new_expiry else 'unknown'}. Please verify.",
|
|
165
|
+
"existing_batch_number": data.batch_number,
|
|
166
|
+
"existing_expiry_date": existing_expiry.isoformat() if existing_expiry else None,
|
|
167
|
+
"entered_expiry_date": new_expiry.isoformat() if new_expiry else None
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# NEW BATCH: No duplicate found
|
|
172
|
+
batch = MedicineBatch(
|
|
173
|
+
medicine_id=id,
|
|
174
|
+
quantity_remaining=data.quantity_received,
|
|
175
|
+
status="ACTIVE",
|
|
176
|
+
**data.model_dump()
|
|
177
|
+
)
|
|
178
|
+
db.add(batch)
|
|
179
|
+
await db.flush()
|
|
180
|
+
|
|
181
|
+
# Get branch for movement
|
|
182
|
+
branch_res = await db.execute(select(Branch).limit(1))
|
|
183
|
+
branch = branch_res.scalars().first()
|
|
184
|
+
|
|
185
|
+
# Log stock receipt movement
|
|
186
|
+
movement = InventoryMovement(
|
|
187
|
+
medicine_id=id,
|
|
188
|
+
batch_id=batch.id,
|
|
189
|
+
branch_id=branch.id if branch else None,
|
|
190
|
+
movement_type="STOCK_RECEIPT",
|
|
191
|
+
quantity=float(data.quantity_received),
|
|
192
|
+
reference_type="NEW_BATCH",
|
|
193
|
+
notes=f"New batch {data.batch_number} added with {data.quantity_received} units",
|
|
194
|
+
created_by=current_user.id
|
|
195
|
+
)
|
|
196
|
+
db.add(movement)
|
|
197
|
+
|
|
198
|
+
await db.commit()
|
|
199
|
+
await db.refresh(batch)
|
|
200
|
+
return batch
|
|
201
|
+
|
|
202
|
+
@router.put("/{id}/batches/{batch_id}", response_model=BatchResponse)
|
|
203
|
+
async def update_batch(
|
|
204
|
+
id: UUID, batch_id: UUID, data: BatchUpdate,
|
|
205
|
+
db: AsyncSession = Depends(get_db),
|
|
206
|
+
current_user: User = Depends(get_current_user)
|
|
207
|
+
):
|
|
208
|
+
"""Edit batch details. Quantity cannot be changed directly — use stock adjustments."""
|
|
209
|
+
batch = await db.get(MedicineBatch, batch_id)
|
|
210
|
+
if not batch or batch.medicine_id != id:
|
|
211
|
+
raise HTTPException(status_code=404, detail="Batch not found")
|
|
212
|
+
|
|
213
|
+
for key, value in data.model_dump(exclude_unset=True).items():
|
|
214
|
+
setattr(batch, key, value)
|
|
215
|
+
|
|
216
|
+
await db.commit()
|
|
217
|
+
await db.refresh(batch)
|
|
218
|
+
return batch
|
|
219
|
+
|
|
220
|
+
@router.delete("/{id}/batches/{batch_id}")
|
|
221
|
+
async def deactivate_batch(
|
|
222
|
+
id: UUID, batch_id: UUID,
|
|
223
|
+
db: AsyncSession = Depends(get_db),
|
|
224
|
+
current_user: User = Depends(get_current_user)
|
|
225
|
+
):
|
|
226
|
+
"""Soft-deactivate a batch. It will no longer be available for sales."""
|
|
227
|
+
batch = await db.get(MedicineBatch, batch_id)
|
|
228
|
+
if not batch or batch.medicine_id != id:
|
|
229
|
+
raise HTTPException(status_code=404, detail="Batch not found")
|
|
230
|
+
|
|
231
|
+
batch.is_active = False
|
|
232
|
+
batch.status = "DEACTIVATED"
|
|
233
|
+
await db.commit()
|
|
234
|
+
return {"message": f"Batch {batch.batch_number} deactivated"}
|
|
235
|
+
|
|
236
|
+
@router.get("/categories", response_model=List[CategoryResponse])
|
|
237
|
+
async def list_categories(db: AsyncSession = Depends(get_db)):
|
|
238
|
+
res = await db.execute(select(MedicineCategory))
|
|
239
|
+
return res.scalars().all()
|
|
240
|
+
|
|
241
|
+
@router.post("/categories", response_model=CategoryResponse)
|
|
242
|
+
async def create_category(data: CategoryCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
243
|
+
category = MedicineCategory(**data.model_dump())
|
|
244
|
+
db.add(category)
|
|
245
|
+
await db.commit()
|
|
246
|
+
await db.refresh(category)
|
|
247
|
+
return category
|
|
248
|
+
|
|
249
|
+
@router.get("/categories/tree")
|
|
250
|
+
async def get_category_tree(db: AsyncSession = Depends(get_db)):
|
|
251
|
+
"""Return categories as a nested tree structure"""
|
|
252
|
+
res = await db.execute(select(MedicineCategory).order_by(MedicineCategory.sort_order))
|
|
253
|
+
all_cats = res.scalars().all()
|
|
254
|
+
|
|
255
|
+
# Build tree: top-level (parent_id is None) with children nested
|
|
256
|
+
cat_map = {}
|
|
257
|
+
for cat in all_cats:
|
|
258
|
+
cat_dict = {
|
|
259
|
+
"id": str(cat.id),
|
|
260
|
+
"name_en": cat.name_en,
|
|
261
|
+
"name_ar": cat.name_ar,
|
|
262
|
+
"parent_id": str(cat.parent_id) if cat.parent_id else None,
|
|
263
|
+
"category_level": cat.category_level,
|
|
264
|
+
"icon": cat.icon,
|
|
265
|
+
"sort_order": cat.sort_order,
|
|
266
|
+
"children": []
|
|
267
|
+
}
|
|
268
|
+
cat_map[str(cat.id)] = cat_dict
|
|
269
|
+
|
|
270
|
+
tree = []
|
|
271
|
+
for cat_id, cat_dict in cat_map.items():
|
|
272
|
+
if cat_dict["parent_id"] and cat_dict["parent_id"] in cat_map:
|
|
273
|
+
cat_map[cat_dict["parent_id"]]["children"].append(cat_dict)
|
|
274
|
+
else:
|
|
275
|
+
tree.append(cat_dict)
|
|
276
|
+
|
|
277
|
+
return tree
|
|
278
|
+
|
|
279
|
+
@router.put("/categories/{id}", response_model=CategoryResponse)
|
|
280
|
+
async def update_category(id: UUID, data: CategoryUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
281
|
+
cat = await db.get(MedicineCategory, id)
|
|
282
|
+
if not cat:
|
|
283
|
+
raise HTTPException(status_code=404, detail="Category not found")
|
|
284
|
+
for key, value in data.model_dump(exclude_unset=True).items():
|
|
285
|
+
setattr(cat, key, value)
|
|
286
|
+
await db.commit()
|
|
287
|
+
await db.refresh(cat)
|
|
288
|
+
return cat
|
|
289
|
+
|
|
290
|
+
@router.delete("/categories/{id}")
|
|
291
|
+
async def delete_category(id: UUID, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
|
292
|
+
# Check if any medicines use this category
|
|
293
|
+
res = await db.execute(select(Medicine).where(Medicine.category_id == id).limit(1))
|
|
294
|
+
if res.scalars().first():
|
|
295
|
+
raise HTTPException(status_code=400, detail="Cannot delete category with assigned medicines")
|
|
296
|
+
cat = await db.get(MedicineCategory, id)
|
|
297
|
+
if not cat:
|
|
298
|
+
raise HTTPException(status_code=404, detail="Category not found")
|
|
299
|
+
await db.delete(cat)
|
|
300
|
+
await db.commit()
|
|
301
|
+
return {"message": "Category deleted"}
|
|
302
|
+
|
|
303
|
+
@router.get("/import-template")
|
|
304
|
+
async def get_import_template(format: str = "csv"):
|
|
305
|
+
if format == "xlsx":
|
|
306
|
+
bio = generate_xlsx_template()
|
|
307
|
+
return StreamingResponse(
|
|
308
|
+
bio,
|
|
309
|
+
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
310
|
+
headers={"Content-Disposition": "attachment; filename=medicines_import_template.xlsx"}
|
|
311
|
+
)
|
|
312
|
+
else:
|
|
313
|
+
bio = generate_csv_template()
|
|
314
|
+
return StreamingResponse(
|
|
315
|
+
bio,
|
|
316
|
+
media_type="text/csv",
|
|
317
|
+
headers={"Content-Disposition": "attachment; filename=medicines_import_template.csv"}
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
@router.post("/bulk-import", response_model=BulkImportResponse)
|
|
321
|
+
async def bulk_import_medicines(
|
|
322
|
+
request: Request,
|
|
323
|
+
file: UploadFile = File(...),
|
|
324
|
+
db: AsyncSession = Depends(get_db),
|
|
325
|
+
current_user: User = Depends(get_current_user)
|
|
326
|
+
):
|
|
327
|
+
content = await file.read()
|
|
328
|
+
filename = file.filename.lower()
|
|
329
|
+
|
|
330
|
+
rows = []
|
|
331
|
+
|
|
332
|
+
if filename.endswith('.csv'):
|
|
333
|
+
text_content = content.decode('utf-8')
|
|
334
|
+
reader = csv.DictReader(io.StringIO(text_content))
|
|
335
|
+
all_rows = list(reader)
|
|
336
|
+
for i, row in enumerate(all_rows):
|
|
337
|
+
name_en_val = str(row.get('name_en *') or '')
|
|
338
|
+
if i < 2 and (name_en_val == 'Amoxicillin 500mg' or 'Required' in name_en_val):
|
|
339
|
+
continue
|
|
340
|
+
mapped_row = {
|
|
341
|
+
'name_en': row.get('name_en *'),
|
|
342
|
+
'name_ar': row.get('name_ar'),
|
|
343
|
+
'sku': row.get('sku'),
|
|
344
|
+
'barcode': row.get('barcode'),
|
|
345
|
+
'selling_price': row.get('selling_price *'),
|
|
346
|
+
'base_unit': row.get('base_unit *'),
|
|
347
|
+
'category': row.get('category'),
|
|
348
|
+
'generic_name': row.get('generic_name'),
|
|
349
|
+
'brand_name': row.get('brand_name'),
|
|
350
|
+
'strength': row.get('strength'),
|
|
351
|
+
'dosage_form': row.get('dosage_form'),
|
|
352
|
+
'manufacturer': row.get('manufacturer'),
|
|
353
|
+
'description': row.get('description'),
|
|
354
|
+
'units_per_pack': row.get('units_per_pack'),
|
|
355
|
+
'reorder_level': row.get('reorder_level'),
|
|
356
|
+
'max_stock': row.get('max_stock'),
|
|
357
|
+
'requires_prescription': row.get('requires_prescription'),
|
|
358
|
+
'is_controlled': row.get('is_controlled'),
|
|
359
|
+
'is_cold_chain': row.get('is_cold_chain'),
|
|
360
|
+
'allow_loose_sale': row.get('allow_loose_sale'),
|
|
361
|
+
'initial_stock': row.get('initial_stock'),
|
|
362
|
+
'purchase_price': row.get('purchase_price'),
|
|
363
|
+
'batch_number': row.get('batch_number'),
|
|
364
|
+
'expiry_date': row.get('expiry_date')
|
|
365
|
+
}
|
|
366
|
+
rows.append((i+2, mapped_row))
|
|
367
|
+
elif filename.endswith('.xlsx'):
|
|
368
|
+
wb = load_workbook(io.BytesIO(content), data_only=True)
|
|
369
|
+
ws = wb.active
|
|
370
|
+
headers = [cell.value for cell in ws[1]]
|
|
371
|
+
for row_idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
|
|
372
|
+
if all(v is None for v in row):
|
|
373
|
+
continue
|
|
374
|
+
|
|
375
|
+
row_dict = dict(zip(headers, row))
|
|
376
|
+
name_en_val = str(row_dict.get('name_en *') or '')
|
|
377
|
+
if row_idx <= 3 and (name_en_val == 'Amoxicillin 500mg' or 'Required' in name_en_val):
|
|
378
|
+
continue
|
|
379
|
+
|
|
380
|
+
mapped_row = {
|
|
381
|
+
'name_en': row_dict.get('name_en *'),
|
|
382
|
+
'name_ar': row_dict.get('name_ar'),
|
|
383
|
+
'sku': row_dict.get('sku'),
|
|
384
|
+
'barcode': row_dict.get('barcode'),
|
|
385
|
+
'selling_price': row_dict.get('selling_price *'),
|
|
386
|
+
'base_unit': row_dict.get('base_unit *'),
|
|
387
|
+
'category': row_dict.get('category'),
|
|
388
|
+
'generic_name': row_dict.get('generic_name'),
|
|
389
|
+
'brand_name': row_dict.get('brand_name'),
|
|
390
|
+
'strength': row_dict.get('strength'),
|
|
391
|
+
'dosage_form': row_dict.get('dosage_form'),
|
|
392
|
+
'manufacturer': row_dict.get('manufacturer'),
|
|
393
|
+
'description': row_dict.get('description'),
|
|
394
|
+
'units_per_pack': row_dict.get('units_per_pack'),
|
|
395
|
+
'reorder_level': row_dict.get('reorder_level'),
|
|
396
|
+
'max_stock': row_dict.get('max_stock'),
|
|
397
|
+
'requires_prescription': row_dict.get('requires_prescription'),
|
|
398
|
+
'is_controlled': row_dict.get('is_controlled'),
|
|
399
|
+
'is_cold_chain': row_dict.get('is_cold_chain'),
|
|
400
|
+
'allow_loose_sale': row_dict.get('allow_loose_sale'),
|
|
401
|
+
'initial_stock': row_dict.get('initial_stock'),
|
|
402
|
+
'purchase_price': row_dict.get('purchase_price'),
|
|
403
|
+
'batch_number': row_dict.get('batch_number'),
|
|
404
|
+
'expiry_date': row_dict.get('expiry_date')
|
|
405
|
+
}
|
|
406
|
+
rows.append((row_idx, mapped_row))
|
|
407
|
+
else:
|
|
408
|
+
raise HTTPException(status_code=400, detail="Unsupported file format")
|
|
409
|
+
|
|
410
|
+
errors = []
|
|
411
|
+
medicines_to_create = []
|
|
412
|
+
valid_rows = []
|
|
413
|
+
|
|
414
|
+
cat_res = await db.execute(select(MedicineCategory))
|
|
415
|
+
categories = {c.name_en.lower(): c.id for c in cat_res.scalars().all()}
|
|
416
|
+
|
|
417
|
+
sku_res = await db.execute(select(Medicine.sku))
|
|
418
|
+
existing_skus = set(sku_res.scalars().all())
|
|
419
|
+
|
|
420
|
+
barcode_res = await db.execute(select(Medicine.barcode).where(Medicine.barcode.isnot(None)))
|
|
421
|
+
existing_barcodes = set(barcode_res.scalars().all())
|
|
422
|
+
|
|
423
|
+
file_skus = set()
|
|
424
|
+
file_barcodes = set()
|
|
425
|
+
|
|
426
|
+
import time
|
|
427
|
+
timestamp = int(time.time())
|
|
428
|
+
|
|
429
|
+
for row_idx, row in rows:
|
|
430
|
+
row_errors = []
|
|
431
|
+
|
|
432
|
+
name_en = row.get('name_en')
|
|
433
|
+
if not name_en:
|
|
434
|
+
row_errors.append(BulkImportRowError(row=row_idx, field="name_en", message="name_en is required"))
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
selling_price = float(row.get('selling_price') or 0)
|
|
438
|
+
if selling_price <= 0:
|
|
439
|
+
row_errors.append(BulkImportRowError(row=row_idx, field="selling_price", message="selling_price must be > 0"))
|
|
440
|
+
except ValueError:
|
|
441
|
+
row_errors.append(BulkImportRowError(row=row_idx, field="selling_price", message="Invalid selling_price"))
|
|
442
|
+
|
|
443
|
+
base_unit = row.get('base_unit')
|
|
444
|
+
if not base_unit or base_unit not in BASE_UNITS:
|
|
445
|
+
row_errors.append(BulkImportRowError(row=row_idx, field="base_unit", message=f"base_unit must be one of {BASE_UNITS}"))
|
|
446
|
+
|
|
447
|
+
dosage_form = row.get('dosage_form')
|
|
448
|
+
if dosage_form and dosage_form not in DOSAGE_FORMS:
|
|
449
|
+
row_errors.append(BulkImportRowError(row=row_idx, field="dosage_form", message=f"dosage_form must be one of {DOSAGE_FORMS}"))
|
|
450
|
+
|
|
451
|
+
sku = str(row.get('sku') or "").strip()
|
|
452
|
+
if not sku:
|
|
453
|
+
sku = f"IMP-{timestamp}-{row_idx}"
|
|
454
|
+
|
|
455
|
+
if sku in existing_skus or sku in file_skus:
|
|
456
|
+
row_errors.append(BulkImportRowError(row=row_idx, field="sku", message=f"Duplicate SKU: {sku}"))
|
|
457
|
+
else:
|
|
458
|
+
file_skus.add(sku)
|
|
459
|
+
|
|
460
|
+
barcode = str(row.get('barcode') or "").strip()
|
|
461
|
+
if barcode:
|
|
462
|
+
if barcode in existing_barcodes or barcode in file_barcodes:
|
|
463
|
+
row_errors.append(BulkImportRowError(row=row_idx, field="barcode", message=f"Duplicate barcode: {barcode}"))
|
|
464
|
+
else:
|
|
465
|
+
file_barcodes.add(barcode)
|
|
466
|
+
else:
|
|
467
|
+
barcode = None
|
|
468
|
+
|
|
469
|
+
category_id = None
|
|
470
|
+
category_name = str(row.get('category') or "").strip()
|
|
471
|
+
if category_name:
|
|
472
|
+
cat_lower = category_name.lower()
|
|
473
|
+
if cat_lower in categories:
|
|
474
|
+
category_id = categories[cat_lower]
|
|
475
|
+
else:
|
|
476
|
+
new_cat = MedicineCategory(name_en=category_name)
|
|
477
|
+
db.add(new_cat)
|
|
478
|
+
await db.flush()
|
|
479
|
+
categories[cat_lower] = new_cat.id
|
|
480
|
+
category_id = new_cat.id
|
|
481
|
+
|
|
482
|
+
def parse_bool(val):
|
|
483
|
+
if not val: return False
|
|
484
|
+
return str(val).strip().lower() in ['yes', 'true', '1']
|
|
485
|
+
|
|
486
|
+
def parse_int(val, default):
|
|
487
|
+
try:
|
|
488
|
+
return int(val)
|
|
489
|
+
except (ValueError, TypeError):
|
|
490
|
+
return default
|
|
491
|
+
|
|
492
|
+
if row_errors:
|
|
493
|
+
errors.extend(row_errors)
|
|
494
|
+
else:
|
|
495
|
+
medicines_to_create.append(
|
|
496
|
+
Medicine(
|
|
497
|
+
name_en=name_en,
|
|
498
|
+
name_ar=row.get('name_ar'),
|
|
499
|
+
sku=sku,
|
|
500
|
+
barcode=barcode,
|
|
501
|
+
selling_price=selling_price,
|
|
502
|
+
base_unit=base_unit,
|
|
503
|
+
category_id=category_id,
|
|
504
|
+
generic_name=row.get('generic_name'),
|
|
505
|
+
brand_name=row.get('brand_name'),
|
|
506
|
+
strength=row.get('strength'),
|
|
507
|
+
dosage_form=dosage_form,
|
|
508
|
+
manufacturer=row.get('manufacturer'),
|
|
509
|
+
description=row.get('description'),
|
|
510
|
+
units_per_pack=parse_int(row.get('units_per_pack'), 1),
|
|
511
|
+
reorder_level=parse_int(row.get('reorder_level'), 0),
|
|
512
|
+
max_stock=parse_int(row.get('max_stock'), 0),
|
|
513
|
+
requires_prescription=parse_bool(row.get('requires_prescription')),
|
|
514
|
+
is_controlled=parse_bool(row.get('is_controlled')),
|
|
515
|
+
is_cold_chain=parse_bool(row.get('is_cold_chain')),
|
|
516
|
+
allow_loose_sale=parse_bool(row.get('allow_loose_sale')),
|
|
517
|
+
is_active=True
|
|
518
|
+
)
|
|
519
|
+
)
|
|
520
|
+
valid_rows.append((row_idx, row))
|
|
521
|
+
|
|
522
|
+
if medicines_to_create:
|
|
523
|
+
db.add_all(medicines_to_create)
|
|
524
|
+
await db.commit()
|
|
525
|
+
for m in medicines_to_create:
|
|
526
|
+
await db.refresh(m)
|
|
527
|
+
|
|
528
|
+
batches_to_create = []
|
|
529
|
+
for m, (row_idx, row_data) in zip(medicines_to_create, valid_rows):
|
|
530
|
+
stock = parse_int(row_data.get('initial_stock'), 0)
|
|
531
|
+
if stock > 0:
|
|
532
|
+
expiry_str = row_data.get('expiry_date')
|
|
533
|
+
parsed_date = None
|
|
534
|
+
if expiry_str:
|
|
535
|
+
try:
|
|
536
|
+
parsed_date = datetime.strptime(str(expiry_str).strip(), "%Y-%m-%d").date()
|
|
537
|
+
except ValueError:
|
|
538
|
+
pass
|
|
539
|
+
|
|
540
|
+
try:
|
|
541
|
+
purchase_price = float(row_data.get('purchase_price') or 0)
|
|
542
|
+
if purchase_price <= 0:
|
|
543
|
+
purchase_price = float(m.selling_price * 0.7)
|
|
544
|
+
except (ValueError, TypeError):
|
|
545
|
+
purchase_price = float(m.selling_price * 0.7)
|
|
546
|
+
|
|
547
|
+
batch = MedicineBatch(
|
|
548
|
+
medicine_id=m.id,
|
|
549
|
+
batch_number=str(row_data.get('batch_number') or "").strip() or f"B-{m.sku}",
|
|
550
|
+
purchase_price=purchase_price,
|
|
551
|
+
quantity_received=stock,
|
|
552
|
+
quantity_remaining=stock,
|
|
553
|
+
expiry_date=parsed_date,
|
|
554
|
+
received_date=datetime.now()
|
|
555
|
+
)
|
|
556
|
+
batches_to_create.append(batch)
|
|
557
|
+
|
|
558
|
+
if batches_to_create:
|
|
559
|
+
db.add_all(batches_to_create)
|
|
560
|
+
await db.commit()
|
|
561
|
+
|
|
562
|
+
await log_action(
|
|
563
|
+
db=db, action="IMPORT", module="Medicine",
|
|
564
|
+
details=f"Bulk imported {len(medicines_to_create)} medicines ({len(set([e.row for e in errors]))} failed)",
|
|
565
|
+
user=current_user, ip_address=request.client.host if request.client else None
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
return BulkImportResponse(
|
|
569
|
+
total_rows=len(rows),
|
|
570
|
+
successful=len(medicines_to_create),
|
|
571
|
+
failed=len(set([e.row for e in errors])),
|
|
572
|
+
errors=errors,
|
|
573
|
+
created_medicines=[MedicineResponse.model_validate(m) for m in medicines_to_create]
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
@router.post("/with-batch", response_model=MedicineResponse)
|
|
577
|
+
async def create_medicine_with_batch(
|
|
578
|
+
data: MedicineCreateWithBatch,
|
|
579
|
+
db: AsyncSession = Depends(get_db),
|
|
580
|
+
current_user: User = Depends(get_current_user)
|
|
581
|
+
):
|
|
582
|
+
"""Create medicine and optionally add first batch in one transaction"""
|
|
583
|
+
batch_data = data.initial_batch
|
|
584
|
+
medicine_data = data.model_dump(exclude={"initial_batch"})
|
|
585
|
+
|
|
586
|
+
medicine = Medicine(**medicine_data)
|
|
587
|
+
db.add(medicine)
|
|
588
|
+
await db.flush() # get medicine.id without committing
|
|
589
|
+
|
|
590
|
+
if batch_data:
|
|
591
|
+
batch_number = batch_data.batch_number or f"BATCH-{medicine.sku}-001"
|
|
592
|
+
batch = MedicineBatch(
|
|
593
|
+
medicine_id=medicine.id,
|
|
594
|
+
batch_number=batch_number,
|
|
595
|
+
expiry_date=batch_data.expiry_date,
|
|
596
|
+
purchase_price=batch_data.purchase_price,
|
|
597
|
+
quantity_received=batch_data.quantity,
|
|
598
|
+
quantity_remaining=batch_data.quantity,
|
|
599
|
+
production_date=batch_data.production_date,
|
|
600
|
+
supplier_id=batch_data.supplier_id
|
|
601
|
+
)
|
|
602
|
+
db.add(batch)
|
|
603
|
+
|
|
604
|
+
await db.commit()
|
|
605
|
+
await db.refresh(medicine)
|
|
606
|
+
return medicine
|
|
607
|
+
|
|
608
|
+
@router.get("/{id}", response_model=MedicineDetailResponse)
|
|
609
|
+
async def get_medicine(id: UUID, db: AsyncSession = Depends(get_db)):
|
|
610
|
+
medicine = await db.get(Medicine, id)
|
|
611
|
+
if not medicine:
|
|
612
|
+
raise HTTPException(status_code=404, detail="Medicine not found")
|
|
613
|
+
|
|
614
|
+
# Get batches
|
|
615
|
+
batch_res = await db.execute(
|
|
616
|
+
select(MedicineBatch).where(MedicineBatch.medicine_id == id)
|
|
617
|
+
)
|
|
618
|
+
batches = batch_res.scalars().all()
|
|
619
|
+
|
|
620
|
+
# Get category name
|
|
621
|
+
category_name = None
|
|
622
|
+
if medicine.category_id:
|
|
623
|
+
cat = await db.get(MedicineCategory, medicine.category_id)
|
|
624
|
+
if cat:
|
|
625
|
+
category_name = cat.name_en
|
|
626
|
+
|
|
627
|
+
return {
|
|
628
|
+
**{c.name: getattr(medicine, c.name) for c in medicine.__table__.columns},
|
|
629
|
+
"batches": batches,
|
|
630
|
+
"category_name": category_name
|
|
631
|
+
}
|