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