نظام إدارة الصيدلية والمبيعات - Event-Driven Integration مع Inventory & DrugCatalog
Pharmacy Module هو نظام متكامل لإدارة الصيدلية والمبيعات الدوائية. يوفر تكامل محكم مع Inventory Module لإدارة المخزون و DrugCatalog للأدوية المسجلة.
Path: Modules/Pharmacy/Entities/PharmacySale.php
الوصف: فاتورة مبيعات الصيدلية
- id - location_id (FK: business_locations) - sale_no (auto-generated: PH-YYYYMM-XXX) - patient_id (FK: patients) - encounter_id (FK: care_encounters) - prescription_id (FK: prescriptions) - status_id (FK: statuses) - sale_date - total_amount - tax_amount - discount_amount - net_amount - payment_status - notes
location() → BusinessLocationpatient() → Patientencounter() → CareEncounterstatus() → Statuslines() → PharmacySaleLine (hasMany)Path: Modules/Pharmacy/Entities/PharmacySaleLine.php
الوصف: سطر في فاتورة الصيدلية - الربط الأساسي مع Inventory
- id - pharmacy_sale_id (FK: pharmacy_sales) - drug_id (FK: drugs) - من DrugCatalog - inventory_product_id (FK: inventory_products) - inventory_batch_id (FK: inventory_batches) - unit_id (FK: inventory_units) - location_id - quantity - cost (from inventory) - price (selling price) - discount - tax - total - inventory_movement_uuid - تتبع حركة المخزون - dispensed_at - dispensed_by
Path: Modules/Pharmacy/Entities/PharmacyReturn.php
الوصف: مرتجعات الصيدلية
- id - location_id - return_no (auto-generated) - pharmacy_sale_id (FK: pharmacy_sales) - return_date - return_type (customer, damaged, expired) - total_amount - refund_amount - status_id - reason - notes
sale() → PharmacySalelines() → PharmacyReturnLine (hasMany)Path: Modules/Pharmacy/Application/Services/PharmacyDispenseService.php
الوصف: الخدمة الأساسية لصرف الأدوية - Enhanced مع Transaction Support
// صرف دواء (مع transaction)
dispense(
PharmacyDispenseDTO $dto,
int $saleLineId
): StockMovementDTO
// إلغاء صرف (مع transaction)
cancel(
PharmacyReturnDTO $dto,
int $saleLineId
): bool
use Modules\Pharmacy\Application\DTOs\PharmacyDispenseDTO;
use Modules\Pharmacy\Application\Services\PharmacyDispenseService;
$dispenseService = app(PharmacyDispenseService::class);
$dto = new PharmacyDispenseDTO(
productId: 5,
unitId: 2,
quantity: 10.0,
locationId: 1,
batchId: null // FIFO auto-selection
);
try {
$movement = $dispenseService->dispense($dto, $saleLineId);
// Success
Log::info("Medication dispensed", [
'movement_uuid' => $movement->movementUuid
]);
} catch (DispenseException $e) {
// معالجة الخطأ
Log::error("Dispense failed: " . $e->getMessage());
}
Path: Modules/Pharmacy/Exceptions/DispenseException.php
الوصف: Exception مخصص لأخطاء الصرف مع Factory Methods
// مخزون غير كافي
DispenseException::insufficientStock(
int $productId,
float $required,
float $available
)
// تم الصرف مسبقاً
DispenseException::alreadyDispensed(int $saleLineId)
// لم يتم الصرف
DispenseException::notDispensed(int $saleLineId)
// السطر غير موجود
DispenseException::saleLineNotFound(int $saleLineId)
if ($availableStock < $requiredQuantity) {
throw DispenseException::insufficientStock(
$productId,
$requiredQuantity,
$availableStock
);
}
if ($saleLine->inventory_movement_uuid) {
throw DispenseException::alreadyDispensed($saleLineId);
}
Path: Modules/Pharmacy/Domain/Events/MedicationDispensedEvent.php
متى يُرسل: عند صرف دواء بنجاح
class MedicationDispensedEvent
{
public function __construct(
public int $saleLineId,
public StockMovementDTO $movement,
public array $metadata = []
) {}
}
Path: Modules/Pharmacy/Domain/Events/MedicationDispenseCancelledEvent.php
متى يُرسل: عند إلغاء صرف دواء
/api/pharmacy
/sales
قائمة فواتير الصيدلية
patient_id - فلترة حسب المريضstatus_id - فلترة حسب الحالةdate_from - من تاريخdate_to - إلى تاريخinclude - patient,lines,status/sales
إنشاء فاتورة صيدلية جديدة
{
"patient_id": 123,
"encounter_id": 456,
"sale_date": "2025-12-06",
"lines": [
{
"drug_id": 1,
"inventory_product_id": 5,
"inventory_batch_id": 10,
"unit_id": 2,
"quantity": 10,
"price": 25.50
}
],
"notes": "Evening medication"
}
/sales/{id}
تفاصيل فاتورة محددة
/sales/{id}/dispense
صرف الأدوية من الفاتورة
{
"lines": [
{
"sale_line_id": 1,
"inventory_product_id": 5,
"unit_id": 2,
"quantity": 10,
"batch_id": null
}
]
}
{
"success": true,
"message": "Medication dispensed successfully",
"data": {
"dispensed_lines": 1,
"movements": [
{
"sale_line_id": 1,
"movement_uuid": "abc-123-def",
"quantity": 10,
"dispensed_at": "2025-12-06T10:30:00Z"
}
]
}
}
/sales/{id}/cancel-dispense
إلغاء صرف دواء
/returns
قائمة المرتجعات
/returns
إنشاء مرتجع جديد
{
"pharmacy_sale_id": 123,
"return_type": "customer",
"reason": "Wrong medication",
"lines": [
{
"sale_line_id": 1,
"quantity": 5,
"refund_amount": 125.00
}
]
}
Pattern: Event-Driven Integration
PharmacyDispenseService
↓
1. Validate (validateNotDispensed)
↓
2. Reserve Stock (StockReservationService)
↓
3. Deduct Stock (StockDeductionService)
↓
4. Update Sale Line (inventory_movement_uuid)
↓
5. Dispatch Event (MedicationDispensedEvent)
↓
[Inventory Module Listener]
↓
6. Log Movement (CreateStockMovementListener)
StockReservationService - حجز المخزونStockDeductionService - خصم المخزونStockReleaseService - تحرير الحجزالربط مع DrugCatalog من خلال drug_id في PharmacySaleLine:
// الحصول على معلومات الدواء
$saleLine = PharmacySaleLine::with('drug')->find($id);
echo $saleLine->drug->trade_name;
echo $saleLine->drug->dosageForm->name;
echo $saleLine->drug->manufacturer->name;
ربط فواتير الصيدلية مع Encounters والـ Prescriptions:
// إنشاء فاتورة من وصفة طبية
$prescription = Prescription::with('lines')->find($id);
$sale = PharmacySale::create([
'patient_id' => $prescription->patient_id,
'encounter_id' => $prescription->encounter_id,
'prescription_id' => $prescription->id,
// ...
]);
// نسخ الأدوية من الوصفة
foreach ($prescription->lines as $prescLine) {
$sale->lines()->create([
'drug_id' => $prescLine->drug_id,
'quantity' => $prescLine->quantity,
// ...
]);
}
batchId null للسماح بـ FIFO auto-selectioninventory_movement_uuid لتتبع الحركاتinventory_movement_uuid يدوياًinventory_product_id قبل الصرفcancel() methoduse Modules\Pharmacy\Application\Services\PharmacyDispenseService;
use Modules\Pharmacy\Application\DTOs\PharmacyDispenseDTO;
use Modules\Pharmacy\Exceptions\DispenseException;
$dispenseService = app(PharmacyDispenseService::class);
try {
DB::transaction(function () use ($dispenseService, $saleLines) {
foreach ($saleLines as $line) {
$dto = new PharmacyDispenseDTO(
productId: $line->inventory_product_id,
unitId: $line->unit_id,
quantity: $line->quantity,
locationId: $line->location_id,
batchId: null // FIFO
);
$movement = $dispenseService->dispense($dto, $line->id);
Log::info('Line dispensed', [
'line_id' => $line->id,
'movement_uuid' => $movement->movementUuid
]);
}
});
return response()->json([
'success' => true,
'message' => 'All medications dispensed successfully'
]);
} catch (DispenseException $e) {
Log::error('Dispense failed', [
'error' => $e->getMessage(),
'sale_id' => $saleId
]);
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 400);
}
File: Modules/Pharmacy/tests/Feature/PharmacyInventoryIntegrationTest.php
# All Pharmacy tests
php artisan test Modules/Pharmacy/tests
# Integration tests only
php artisan test --filter=PharmacyInventoryIntegrationTest
# Specific test
php artisan test --filter=test_complete_dispense_flow_with_stock_deduction