ultimatepos/Modules/Maintenance/Services/FieldMissionService.php

592 lines
22 KiB
PHP

<?php
namespace Modules\Maintenance\Services;
use App\Business;
use App\Product;
use App\User;
use App\Utils\ProductUtil;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Modules\Maintenance\Models\FieldMission;
use Modules\Maintenance\Models\InspectionVisit;
use Modules\Maintenance\Models\MaintenanceTool;
use Modules\Maintenance\Models\MissionCrewMember;
use Modules\Maintenance\Models\MissionExpense;
use Modules\Maintenance\Models\MissionItem;
use Modules\Maintenance\Models\Overhaul;
use Modules\Maintenance\Models\RebuildProject;
use Modules\Maintenance\Models\SparePart;
use Modules\Maintenance\Models\WorkOrder;
use Modules\Maintenance\Utils\MaintenanceUtil;
class FieldMissionService
{
public const SOURCE_TYPES = ['overhaul', 'rebuild', 'work_order', 'inspection_visit', 'standalone'];
public const MISSION_TYPES = ['commissioning', 'overhaul', 'repair', 'inspection', 'preventive', 'other'];
public const STATUSES = ['draft', 'planned', 'dispatched', 'on_site', 'returning', 'closed', 'cancelled'];
public function __construct(
protected MaintenanceUtil $maintenanceUtil,
protected SparePartInventoryService $inventoryService,
protected ProductUtil $productUtil,
protected ToolCribService $toolCribService,
protected MaterialStockService $stockService
) {}
public function generateNumber(Business $business): string
{
$prefix = 'FM-'.now()->format('ym');
$last = FieldMission::where('business_id', $business->id)
->where('mission_number', 'like', $prefix.'%')
->orderByDesc('id')
->value('mission_number');
$seq = 1;
if ($last && preg_match('/(\d+)$/', $last, $m)) {
$seq = (int) $m[1] + 1;
}
return $prefix.str_pad((string) $seq, 4, '0', STR_PAD_LEFT);
}
public function getOrCreateForSource(Business $business, string $sourceType, ?int $sourceId, ?User $user = null): FieldMission
{
if ($sourceType === 'standalone') {
throw new InvalidArgumentException('برای مأموریت مستقل از createMission استفاده کنید.');
}
$existing = FieldMission::where('business_id', $business->id)
->where('source_type', $sourceType)
->where('source_id', $sourceId)
->whereNotIn('status', ['closed', 'cancelled'])
->first();
if ($existing) {
return $existing;
}
$meta = $this->resolveSourceMeta($business->id, $sourceType, $sourceId);
return $this->createMission($business, array_merge($meta, [
'source_type' => $sourceType,
'source_id' => $sourceId,
]), $user);
}
public function createMission(Business $business, array $data, ?User $user = null): FieldMission
{
return DB::transaction(function () use ($business, $data, $user) {
$mission = FieldMission::create([
'business_id' => $business->id,
'mission_number' => $data['mission_number'] ?? $this->generateNumber($business),
'mission_type' => $data['mission_type'] ?? 'repair',
'source_type' => $data['source_type'] ?? 'standalone',
'source_id' => $data['source_id'] ?? null,
'title' => $data['title'] ?? 'مأموریت میدانی',
'description' => $data['description'] ?? null,
'equipment_id' => $data['equipment_id'] ?? null,
'destination_location_id' => $data['destination_location_id'] ?? null,
'destination_site' => $data['destination_site'] ?? null,
'status' => $data['status'] ?? 'draft',
'planned_departure_at' => ! empty($data['planned_departure_at']) ? Carbon::parse($data['planned_departure_at']) : null,
'planned_return_at' => ! empty($data['planned_return_at']) ? Carbon::parse($data['planned_return_at']) : null,
'notes' => $data['notes'] ?? null,
'created_by' => $user?->id,
]);
if (! empty($data['seed_crew_from_source'])) {
$this->seedCrewFromSource($mission);
}
return $mission->fresh(['crewMembers.user', 'items', 'expenses']);
});
}
public function seedCrewFromSource(FieldMission $mission): void
{
if (! $mission->source_id || $mission->source_type === 'standalone') {
return;
}
if ($mission->crewMembers()->exists()) {
return;
}
if ($mission->source_type === 'work_order') {
$wo = WorkOrder::find($mission->source_id);
if ($wo?->assigned_technician_id) {
$this->addCrewMember($mission, [
'user_id' => $wo->assigned_technician_id,
'is_lead' => true,
'role' => 'lead',
]);
}
return;
}
if ($mission->source_type === 'inspection_visit') {
$visit = InspectionVisit::find($mission->source_id);
if ($visit?->inspector_id) {
$this->addCrewMember($mission, [
'user_id' => $visit->inspector_id,
'is_lead' => true,
'role' => 'lead',
]);
}
return;
}
if ($mission->source_type === 'overhaul') {
$overhaul = Overhaul::find($mission->source_id);
if ($overhaul?->supervisor_id) {
$this->addCrewMember($mission, [
'user_id' => $overhaul->supervisor_id,
'is_lead' => true,
'role' => 'supervisor',
]);
}
$team = $overhaul->assigned_team ?? [];
if (is_array($team)) {
foreach ($team as $member) {
$userId = is_array($member) ? ($member['user_id'] ?? $member['id'] ?? null) : $member;
if ($userId) {
$this->addCrewMember($mission, [
'user_id' => (int) $userId,
'role' => 'technician',
]);
}
}
}
}
}
public function addCrewMember(FieldMission $mission, array $data): MissionCrewMember
{
if (! empty($data['is_lead'])) {
$mission->crewMembers()->update(['is_lead' => false]);
}
$member = MissionCrewMember::create([
'mission_id' => $mission->id,
'user_id' => $data['user_id'] ?? null,
'person_name' => $data['person_name'] ?? null,
'role' => $data['role'] ?? 'technician',
'is_lead' => ! empty($data['is_lead']),
'labor_hours' => $data['labor_hours'] ?? 0,
'hourly_rate' => $data['hourly_rate'] ?? 0,
'per_diem' => $data['per_diem'] ?? 0,
'notes' => $data['notes'] ?? null,
]);
$member->recalculateLaborCost();
$member->save();
if ($member->is_lead && $member->user_id) {
$mission->update(['lead_user_id' => $member->user_id]);
}
$this->recalculateTotals($mission);
return $member->fresh('user');
}
public function updateCrewMember(MissionCrewMember $member, array $data): MissionCrewMember
{
if (! empty($data['is_lead'])) {
$member->mission->crewMembers()->where('id', '!=', $member->id)->update(['is_lead' => false]);
}
$member->update($data);
$member->recalculateLaborCost();
$member->save();
if ($member->is_lead && $member->user_id) {
$member->mission->update(['lead_user_id' => $member->user_id]);
}
$this->recalculateTotals($member->mission);
return $member->fresh('user');
}
public function removeCrewMember(MissionCrewMember $member): void
{
$mission = $member->mission;
$member->delete();
$this->recalculateTotals($mission);
}
public function addItem(FieldMission $mission, array $data): MissionItem
{
$itemType = $data['item_type'] ?? 'spare_part';
if ($itemType === 'spare_part' && ! empty($data['spare_part_id'])) {
$part = SparePart::where('business_id', $mission->business_id)->findOrFail($data['spare_part_id']);
$data['product_id'] = $part->product_id;
$data['variation_id'] = $data['variation_id'] ?? $this->inventoryService->resolveVariationId($part);
$data['part_name'] = $data['part_name'] ?? $part->name;
$data['part_number'] = $data['part_number'] ?? $part->part_number;
$data['unit'] = $data['unit'] ?? $part->unit;
$data['location_id'] = $data['location_id'] ?? $this->inventoryService->resolveLocationId($part, $mission->business_id);
}
if ($itemType === 'tool' && ! empty($data['tool_id'])) {
$tool = MaintenanceTool::where('business_id', $mission->business_id)->findOrFail($data['tool_id']);
$data['part_name'] = $tool->name;
$data['quantity_issued'] = 1;
}
return MissionItem::create([
'mission_id' => $mission->id,
'item_type' => $itemType,
'spare_part_id' => $data['spare_part_id'] ?? null,
'product_id' => $data['product_id'] ?? null,
'variation_id' => $data['variation_id'] ?? null,
'tool_id' => $data['tool_id'] ?? null,
'part_name' => $data['part_name'] ?? null,
'part_number' => $data['part_number'] ?? null,
'location_id' => $data['location_id'] ?? $mission->destination_location_id
?? $this->stockService->resolveDestinationLocationId($mission->business_id),
'quantity_issued' => $data['quantity_issued'] ?? 1,
'unit' => $data['unit'] ?? null,
'notes' => $data['notes'] ?? null,
'status' => 'planned',
]);
}
public function removeItem(MissionItem $item): void
{
if ($item->stock_deducted) {
throw new InvalidArgumentException('این قلم از انبار خارج شده و قابل حذف نیست.');
}
$item->delete();
}
public function dispatch(FieldMission $mission, ?User $user = null): FieldMission
{
if (! in_array($mission->status, ['draft', 'planned'], true)) {
throw new InvalidArgumentException('وضعیت مأموریت برای اعزام مناسب نیست.');
}
return DB::transaction(function () use ($mission, $user) {
foreach ($mission->items()->where('status', 'planned')->get() as $item) {
$this->issueItemStock($mission, $item, $user);
}
$mission->update([
'status' => 'dispatched',
'actual_departure_at' => now(),
]);
return $mission->fresh(['crewMembers.user', 'items', 'expenses']);
});
}
protected function issueItemStock(FieldMission $mission, MissionItem $item, ?User $user): void
{
if ($item->stock_deducted || $item->quantity_issued <= 0) {
return;
}
if ($item->item_type === 'tool' && $item->tool_id) {
$tool = MaintenanceTool::findOrFail($item->tool_id);
$leadId = $mission->lead_user_id ?? $user?->id ?? auth()->id();
$this->toolCribService->checkout(
$mission->business,
$tool,
(int) $leadId,
$mission->source_type === 'work_order' ? $mission->source_id : null,
$mission->equipment_id,
$mission->planned_return_at?->toDateTimeString(),
'مأموریت '.$mission->mission_number,
$user
);
$item->update(['stock_deducted' => true, 'status' => 'issued']);
return;
}
if ($item->product_id && $item->variation_id && $item->location_id) {
$product = Product::where('business_id', $mission->business_id)->find($item->product_id);
if ($product && (int) $product->enable_stock === 1) {
$this->productUtil->decreaseProductQuantity(
$item->product_id,
$item->variation_id,
$item->location_id,
(float) $item->quantity_issued
);
}
$item->update(['stock_deducted' => true, 'status' => 'issued']);
}
}
public function recordReturns(FieldMission $mission, array $lines): FieldMission
{
return DB::transaction(function () use ($mission, $lines) {
foreach ($lines as $line) {
$item = MissionItem::where('mission_id', $mission->id)->findOrFail($line['item_id']);
$returned = (float) ($line['quantity_returned'] ?? 0);
$lost = (float) ($line['quantity_lost'] ?? 0);
$maxReturn = (float) $item->quantity_issued - (float) $item->quantity_lost;
if ($returned + $lost > $maxReturn) {
throw new InvalidArgumentException('مقدار برگشت/ضایعات بیش از حد مجاز است: '.$item->displayName());
}
$deltaReturn = $returned - (float) $item->quantity_returned;
if ($deltaReturn > 0 && $item->stock_deducted && $item->product_id && $item->variation_id && $item->location_id) {
$this->productUtil->updateProductQuantity(
$item->location_id,
$item->product_id,
$item->variation_id,
$deltaReturn,
0,
null,
false
);
}
$item->update([
'quantity_returned' => $returned,
'quantity_lost' => $lost,
'status' => $returned + $lost >= (float) $item->quantity_issued ? 'closed' : 'partially_returned',
]);
}
$mission->update(['status' => 'returning']);
return $mission->fresh(['items']);
});
}
public function returnTools(FieldMission $mission, ?User $user = null): void
{
foreach ($mission->items()->where('item_type', 'tool')->where('stock_deducted', true)->get() as $item) {
if (! $item->tool_id) {
continue;
}
$tool = MaintenanceTool::find($item->tool_id);
if ($tool && $tool->status === 'checked_out') {
$this->toolCribService->returnTool($mission->business, $tool, 'برگشت مأموریت '.$mission->mission_number, $user);
}
}
}
public function closeMission(FieldMission $mission, ?User $user = null): FieldMission
{
return DB::transaction(function () use ($mission, $user) {
$this->returnTools($mission, $user);
$this->recalculateTotals($mission);
$mission->update([
'status' => 'closed',
'actual_return_at' => now(),
]);
$this->syncSourceCost($mission);
return $mission->fresh(['crewMembers.user', 'items', 'expenses']);
});
}
public function addExpense(FieldMission $mission, array $data): MissionExpense
{
$expense = MissionExpense::create([
'mission_id' => $mission->id,
'crew_member_id' => $data['crew_member_id'] ?? null,
'expense_type' => $data['expense_type'] ?? 'other',
'description' => $data['description'] ?? null,
'amount' => (int) ($data['amount'] ?? 0),
'expense_date' => ! empty($data['expense_date']) ? Carbon::parse($data['expense_date']) : null,
'receipt_ref' => $data['receipt_ref'] ?? null,
'vendor_name' => $data['vendor_name'] ?? null,
'paid_by' => $data['paid_by'] ?? 'company',
'notes' => $data['notes'] ?? null,
]);
$this->recalculateTotals($mission);
return $expense;
}
public function removeExpense(MissionExpense $expense): void
{
$mission = $expense->mission;
$expense->delete();
$this->recalculateTotals($mission);
}
public function recalculateTotals(FieldMission $mission): void
{
$mission->load(['crewMembers', 'expenses']);
$labor = 0;
foreach ($mission->crewMembers as $member) {
$member->recalculateLaborCost();
$member->save();
$labor += (int) $member->labor_cost + (int) $member->per_diem;
}
$expenses = (int) $mission->expenses->sum('amount');
$mission->update([
'total_labor_cost' => $labor,
'total_expense_cost' => $expenses,
'total_cost' => $labor + $expenses,
]);
}
protected function syncSourceCost(FieldMission $mission): void
{
if (! $mission->source_id) {
return;
}
if ($mission->source_type === 'work_order') {
WorkOrder::where('id', $mission->source_id)->update(['cost' => $mission->total_cost]);
} elseif ($mission->source_type === 'overhaul') {
Overhaul::where('id', $mission->source_id)->update(['actual_cost' => $mission->total_cost]);
}
}
protected function resolveSourceMeta(int $businessId, string $sourceType, int $sourceId): array
{
return match ($sourceType) {
'overhaul' => $this->metaFromOverhaul($businessId, $sourceId),
'rebuild' => $this->metaFromRebuild($businessId, $sourceId),
'work_order' => $this->metaFromWorkOrder($businessId, $sourceId),
'inspection_visit' => $this->metaFromInspection($businessId, $sourceId),
default => ['title' => 'مأموریت میدانی', 'mission_type' => 'other'],
};
}
protected function metaFromOverhaul(int $businessId, int $id): array
{
$o = Overhaul::where('business_id', $businessId)->findOrFail($id);
return [
'title' => 'اعزام — '.$o->title,
'mission_type' => 'overhaul',
'equipment_id' => $o->equipment_id,
'description' => $o->description,
'seed_crew_from_source' => true,
];
}
protected function metaFromRebuild(int $businessId, int $id): array
{
$r = RebuildProject::where('business_id', $businessId)->findOrFail($id);
return [
'title' => 'اعزام — '.$r->title,
'mission_type' => 'commissioning',
'description' => $r->description,
];
}
protected function metaFromWorkOrder(int $businessId, int $id): array
{
$wo = WorkOrder::where('business_id', $businessId)->findOrFail($id);
return [
'title' => 'اعزام — '.$wo->work_order_number,
'mission_type' => 'repair',
'equipment_id' => $wo->equipment_id,
'description' => $wo->description,
'seed_crew_from_source' => true,
];
}
protected function metaFromInspection(int $businessId, int $id): array
{
$v = InspectionVisit::where('business_id', $businessId)->findOrFail($id);
return [
'title' => 'بازدید — '.($v->equipment?->name ?? '#'.$id),
'mission_type' => 'inspection',
'equipment_id' => $v->equipment_id,
'seed_crew_from_source' => true,
];
}
/**
* @return array<string, mixed>
*/
public function getPanelData(Business $business, string $sourceType, int $sourceId): array
{
$mission = FieldMission::where('business_id', $business->id)
->where('source_type', $sourceType)
->where('source_id', $sourceId)
->whereNotIn('status', ['closed', 'cancelled'])
->with(['crewMembers.user', 'items.sparePart', 'items.tool', 'expenses.crewMember'])
->first();
return [
'mission' => $mission,
'source_type' => $sourceType,
'source_id' => $sourceId,
'status_labels' => $this->statusLabels(),
'mission_type_labels' => $this->missionTypeLabels(),
'role_labels' => $this->roleLabels(),
'expense_type_labels' => $this->expenseTypeLabels(),
'locations' => $this->stockService->locationsForBusiness($business->id),
];
}
public function statusLabels(): array
{
return [
'draft' => __('maintenance::lang.fm_status_draft'),
'planned' => __('maintenance::lang.fm_status_planned'),
'dispatched' => __('maintenance::lang.fm_status_dispatched'),
'on_site' => __('maintenance::lang.fm_status_on_site'),
'returning' => __('maintenance::lang.fm_status_returning'),
'closed' => __('maintenance::lang.fm_status_closed'),
'cancelled' => __('maintenance::lang.fm_status_cancelled'),
];
}
public function missionTypeLabels(): array
{
return [
'commissioning' => __('maintenance::lang.fm_type_commissioning'),
'overhaul' => __('maintenance::lang.fm_type_overhaul'),
'repair' => __('maintenance::lang.fm_type_repair'),
'inspection' => __('maintenance::lang.fm_type_inspection'),
'preventive' => __('maintenance::lang.fm_type_preventive'),
'other' => __('maintenance::lang.fm_type_other'),
];
}
public function roleLabels(): array
{
return [
'lead' => __('maintenance::lang.fm_role_lead'),
'supervisor' => __('maintenance::lang.fm_role_supervisor'),
'technician' => __('maintenance::lang.fm_role_technician'),
'driver' => __('maintenance::lang.fm_role_driver'),
'other' => __('maintenance::lang.fm_role_other'),
];
}
public function expenseTypeLabels(): array
{
return [
'transport' => __('maintenance::lang.fm_exp_transport'),
'travel' => __('maintenance::lang.fm_exp_travel'),
'accommodation' => __('maintenance::lang.fm_exp_accommodation'),
'per_diem' => __('maintenance::lang.fm_exp_per_diem'),
'labor' => __('maintenance::lang.fm_exp_labor'),
'material' => __('maintenance::lang.fm_exp_material'),
'other' => __('maintenance::lang.fm_exp_other'),
];
}
}