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,150 @@
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+ from sqlalchemy import select, func
4
+ from typing import List, Optional
5
+ from datetime import datetime, timedelta
6
+ from uuid import UUID
7
+
8
+ from app.core.database import get_db
9
+ from app.core.deps import get_current_user
10
+ from app.modules.auth.models import User
11
+ from app.modules.inventory.models import InventoryMovement
12
+ from app.modules.inventory.schemas import StockAdjustment, InventoryMovementResponse
13
+ from app.modules.medicines.models import Medicine, MedicineBatch
14
+ from app.modules.organizations.models import Branch
15
+
16
+ router = APIRouter(tags=["inventory"])
17
+
18
+ @router.get("/stock")
19
+ async def get_stock(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
20
+ query = select(
21
+ Medicine,
22
+ func.sum(MedicineBatch.quantity_remaining).label("total_quantity")
23
+ ).outerjoin(MedicineBatch, Medicine.id == MedicineBatch.medicine_id)\
24
+ .group_by(Medicine.id)
25
+ result = await db.execute(query)
26
+
27
+ return [
28
+ {
29
+ "medicine": med,
30
+ "total_quantity": qty or 0
31
+ }
32
+ for med, qty in result.all()
33
+ ]
34
+
35
+ @router.get("/stock/{medicine_id}")
36
+ async def get_medicine_stock(medicine_id: UUID, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
37
+ query = select(MedicineBatch).where(MedicineBatch.medicine_id == medicine_id, MedicineBatch.quantity_remaining > 0)
38
+ result = await db.execute(query)
39
+ batches = result.scalars().all()
40
+ return batches
41
+
42
+ @router.post("/adjustments")
43
+ async def create_stock_adjustment(
44
+ adjustment: StockAdjustment,
45
+ db: AsyncSession = Depends(get_db),
46
+ current_user: User = Depends(get_current_user)
47
+ ):
48
+ query = select(MedicineBatch).where(MedicineBatch.id == adjustment.batch_id, MedicineBatch.medicine_id == adjustment.medicine_id)
49
+ result = await db.execute(query)
50
+ batch = result.scalars().first()
51
+ if not batch:
52
+ raise HTTPException(status_code=404, detail="Batch not found")
53
+
54
+ branch_result = await db.execute(select(Branch))
55
+ branch = branch_result.scalars().first()
56
+ if not branch:
57
+ raise HTTPException(status_code=400, detail="No branch found")
58
+
59
+ movement = InventoryMovement(
60
+ medicine_id=adjustment.medicine_id,
61
+ batch_id=adjustment.batch_id,
62
+ branch_id=branch.id,
63
+ movement_type=adjustment.adjustment_type,
64
+ quantity=adjustment.quantity,
65
+ notes=adjustment.reason,
66
+ created_by=current_user.id
67
+ )
68
+
69
+ if adjustment.adjustment_type in ["IN", "ADD"]:
70
+ batch.quantity_remaining += adjustment.quantity
71
+ elif adjustment.adjustment_type in ["OUT", "SUBTRACT", "REMOVE"]:
72
+ if batch.quantity_remaining < adjustment.quantity:
73
+ raise HTTPException(status_code=400, detail="Insufficient quantity in batch")
74
+ batch.quantity_remaining -= adjustment.quantity
75
+
76
+ db.add(movement)
77
+ await db.commit()
78
+ await db.refresh(movement)
79
+ return movement
80
+
81
+ @router.get("/movements", response_model=List[InventoryMovementResponse])
82
+ async def list_movements(
83
+ medicine_id: Optional[UUID] = None,
84
+ db: AsyncSession = Depends(get_db),
85
+ current_user: User = Depends(get_current_user)
86
+ ):
87
+ query = select(InventoryMovement).order_by(InventoryMovement.created_at.desc())
88
+ if medicine_id:
89
+ query = query.where(InventoryMovement.medicine_id == medicine_id)
90
+ result = await db.execute(query)
91
+ return result.scalars().all()
92
+
93
+ @router.get("/alerts/low-stock")
94
+ async def get_low_stock_alerts(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
95
+ query = select(
96
+ Medicine,
97
+ func.coalesce(func.sum(MedicineBatch.quantity_remaining), 0).label("total_quantity")
98
+ ).outerjoin(MedicineBatch, Medicine.id == MedicineBatch.medicine_id)\
99
+ .group_by(Medicine.id)\
100
+ .having(func.coalesce(func.sum(MedicineBatch.quantity_remaining), 0) <= Medicine.reorder_level)\
101
+ .having(func.coalesce(func.sum(MedicineBatch.quantity_remaining), 0) > 0)
102
+
103
+ result = await db.execute(query)
104
+ return [
105
+ {
106
+ "medicine": med,
107
+ "total_quantity": qty or 0,
108
+ "reorder_level": med.reorder_level
109
+ }
110
+ for med, qty in result.all()
111
+ ]
112
+
113
+ @router.get("/alerts/out-of-stock")
114
+ async def get_out_of_stock_alerts(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
115
+ query = select(
116
+ Medicine,
117
+ func.coalesce(func.sum(MedicineBatch.quantity_remaining), 0).label("total_quantity")
118
+ ).outerjoin(MedicineBatch, Medicine.id == MedicineBatch.medicine_id)\
119
+ .group_by(Medicine.id)\
120
+ .having(func.coalesce(func.sum(MedicineBatch.quantity_remaining), 0) <= 0)
121
+
122
+ result = await db.execute(query)
123
+ return [
124
+ {
125
+ "medicine": med,
126
+ "total_quantity": 0,
127
+ "reorder_level": med.reorder_level
128
+ }
129
+ for med, qty in result.all()
130
+ ]
131
+
132
+ @router.get("/alerts/expiring")
133
+ async def get_expiring_alerts(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
134
+ ninety_days_from_now = datetime.utcnow() + timedelta(days=90)
135
+ query = select(MedicineBatch).where(
136
+ MedicineBatch.expiry_date <= ninety_days_from_now,
137
+ MedicineBatch.expiry_date >= datetime.utcnow(),
138
+ MedicineBatch.quantity_remaining > 0
139
+ )
140
+ result = await db.execute(query)
141
+ return result.scalars().all()
142
+
143
+ @router.get("/alerts/expired")
144
+ async def get_expired_alerts(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
145
+ query = select(MedicineBatch).where(
146
+ MedicineBatch.expiry_date < datetime.utcnow(),
147
+ MedicineBatch.quantity_remaining > 0
148
+ )
149
+ result = await db.execute(query)
150
+ return result.scalars().all()
@@ -0,0 +1,24 @@
1
+ from pydantic import BaseModel
2
+ from typing import Optional
3
+ from uuid import UUID
4
+ from datetime import datetime
5
+ from decimal import Decimal
6
+
7
+ class StockAdjustment(BaseModel):
8
+ medicine_id: UUID
9
+ batch_id: UUID
10
+ quantity: Decimal
11
+ reason: str
12
+ adjustment_type: str
13
+
14
+ class InventoryMovementResponse(BaseModel):
15
+ id: UUID
16
+ medicine_id: UUID
17
+ batch_id: UUID
18
+ movement_type: str
19
+ quantity: Decimal
20
+ notes: Optional[str] = None
21
+ created_at: datetime
22
+
23
+ class Config:
24
+ from_attributes = True
File without changes
@@ -0,0 +1,54 @@
1
+ from sqlalchemy.orm import Mapped, mapped_column
2
+ from sqlalchemy import String, Boolean, Numeric, ForeignKey, Integer, DateTime
3
+ from app.core.database import Base
4
+ import uuid
5
+ from datetime import datetime
6
+
7
+ class MedicineCategory(Base):
8
+ __tablename__ = "medicine_categories"
9
+ name_en: Mapped[str] = mapped_column(String)
10
+ name_ar: Mapped[str] = mapped_column(String, nullable=True)
11
+ parent_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("medicine_categories.id"), nullable=True)
12
+ category_level: Mapped[int] = mapped_column(Integer, default=1)
13
+ icon: Mapped[str] = mapped_column(String, nullable=True)
14
+ sort_order: Mapped[int] = mapped_column(Integer, default=0)
15
+
16
+ class Medicine(Base):
17
+ __tablename__ = "medicines"
18
+ sku: Mapped[str] = mapped_column(String, unique=True)
19
+ barcode: Mapped[str] = mapped_column(String, unique=True, nullable=True)
20
+ name_en: Mapped[str] = mapped_column(String)
21
+ name_ar: Mapped[str] = mapped_column(String, nullable=True)
22
+ generic_name: Mapped[str] = mapped_column(String, nullable=True)
23
+ brand_name: Mapped[str] = mapped_column(String, nullable=True)
24
+ strength: Mapped[str] = mapped_column(String, nullable=True)
25
+ dosage_form: Mapped[str] = mapped_column(String, nullable=True)
26
+ category_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("medicine_categories.id"), nullable=True)
27
+ manufacturer: Mapped[str] = mapped_column(String, nullable=True)
28
+ description: Mapped[str] = mapped_column(String, nullable=True)
29
+ base_unit: Mapped[str] = mapped_column(String)
30
+ units_per_pack: Mapped[int] = mapped_column(Integer, default=1)
31
+ allow_loose_sale: Mapped[bool] = mapped_column(Boolean, default=False)
32
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
33
+ requires_prescription: Mapped[bool] = mapped_column(Boolean, default=False)
34
+ is_controlled: Mapped[bool] = mapped_column(Boolean, default=False)
35
+ is_cold_chain: Mapped[bool] = mapped_column(Boolean, default=False)
36
+ reorder_level: Mapped[int] = mapped_column(Integer, default=0)
37
+ max_stock: Mapped[int] = mapped_column(Integer, default=0)
38
+ selling_price: Mapped[float] = mapped_column(Numeric(10, 2))
39
+ image_url: Mapped[str] = mapped_column(String, nullable=True)
40
+
41
+ class MedicineBatch(Base):
42
+ __tablename__ = "medicine_batches"
43
+ medicine_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("medicines.id"))
44
+ batch_number: Mapped[str] = mapped_column(String)
45
+ expiry_date: Mapped[datetime] = mapped_column(DateTime)
46
+ purchase_price: Mapped[float] = mapped_column(Numeric(10, 2))
47
+ quantity_received: Mapped[float] = mapped_column(Numeric(10, 2))
48
+ quantity_remaining: Mapped[float] = mapped_column(Numeric(10, 2))
49
+ supplier_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("suppliers.id"), nullable=True)
50
+ production_date: Mapped[datetime] = mapped_column(DateTime, nullable=True)
51
+ received_date: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
52
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
53
+ status: Mapped[str] = mapped_column(String, default="ACTIVE")
54
+ notes: Mapped[str] = mapped_column(String, nullable=True)