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,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
+ }