Campaign::class, 'categories' => Category::class, 'customers' => Customer::class, 'customer_address' => CustomerAddress::class, 'orders' => Order::class, 'order_items' => OrderItem::class, 'outlets' => Outlet::class, 'outlet_campaigns' => OutletCampaign::class, 'outlet_inventories' => OutletInventory::class, 'products' => Product::class, 'product_likes' => ProductLike::class, 'product_reviews' => ProductReview::class, 'product_variants' => ProductVariant::class, 'purchases' => Purchase::class, 'purchase_items' => PurchaseItem::class, 'raw_materials' => RawMaterial::class, 'receipt_items' => ReceiptItem::class, 'stock_cards' => StockCard::class, 'suppliers' => Supplier::class, 'sync_deletes' => SyncDelete::class, 'tenants' => Tenant::class, 'tenant_payment_methods'=> TenantPaymentMethod::class, 'transactions' => Transaction::class, 'units' => Unit::class, 'users' => User::class, ]; public function getDeltaSync($lastSync) { $tenantId = Auth::user()->tenant_id; return [ 'tenants' => $this->tableMap['tenants']::withoutGlobalScopes() ->where('uuid', $tenantId) ->where('updated_at', '>', $lastSync) ->first(), 'tenant_payment_methods' => $this->tableMap['tenant_payment_methods']::where('updated_at', '>', $lastSync)->get(), 'campaigns' => $this->tableMap['campaigns']::withTrashed()->where('updated_at', '>', $lastSync)->get(), 'outlets' => $this->tableMap['outlets']::withTrashed()->where('updated_at', '>', $lastSync)->get(), 'outlet_campaigns' => $this->tableMap['outlet_campaigns']::withTrashed()->where('updated_at', '>', $lastSync)->get(), 'users' => $this->tableMap['users']::withTrashed()->where('updated_at', '>', $lastSync)->get(), 'units' => $this->tableMap['units']::where('updated_at', '>', $lastSync)->get(), 'categories' => $this->tableMap['categories']::where('updated_at', '>', $lastSync)->get(), 'products' => $this->tableMap['products']::withTrashed()->where('updated_at', '>', $lastSync)->get(), 'product_variants' => $this->tableMap['product_variants']::withTrashed()->where('updated_at', '>', $lastSync)->get(), 'raw_materials' => $this->tableMap['raw_materials']::withTrashed()->where('updated_at', '>', $lastSync)->get(), 'receipt_items' => $this->tableMap['receipt_items']::withTrashed()->where('updated_at', '>', $lastSync)->get(), 'outlet_inventories' => $this->tableMap['outlet_inventories']::withTrashed()->where('updated_at', '>', $lastSync)->get(), 'customers' => $this->tableMap['customers']::withTrashed()->where('updated_at', '>', $lastSync)->get(), 'customer_address' => $this->tableMap['customer_address']::withTrashed()->where('updated_at', '>', $lastSync)->get(), 'product_likes' => $this->tableMap['product_likes']::where('updated_at', '>', $lastSync)->get(), 'product_reviews' => $this->tableMap['product_reviews']::where('updated_at', '>', $lastSync)->get(), 'orders' => $this->tableMap['orders']::where('updated_at', '>', $lastSync)->get(), 'order_items' => $this->tableMap['order_items']::with(['variant' => function($q) { $q->select('uuid','product_id')->withTrashed(); }])->where('updated_at', '>', $lastSync)->get(), 'suppliers' => $this->tableMap['suppliers']::withTrashed()->where('updated_at', '>', $lastSync)->get(), 'purchases' => $this->tableMap['purchases']::where('updated_at', '>', $lastSync)->get(), 'purchase_items' => $this->tableMap['purchase_items']::with(['item' => function (MorphTo $morphTo) { $morphTo->constrain([ ProductVariant::class => fn($q) => $q->select('uuid', 'product_id')->withTrashed(), RawMaterial::class => fn($q) => $q->select('uuid')->withTrashed(), ]); } ])->where('updated_at', '>', $lastSync)->get(), 'transactions' => $this->tableMap['transactions']::where('updated_at', '>', $lastSync)->get(), 'stock_cards' => $this->tableMap['stock_cards']::where('created_at', '>', $lastSync)->get(), 'sync_deletes' => $this->tableMap['sync_deletes']::where('created_at', '>', $lastSync)->get() ]; } public function updateServerTime(Carbon $currentTime) { Auth::user()->update(['last_sync' => $currentTime]); } public function processBatchSync(array $payload) { $items = $payload['items']; $results = ['success_count' => 0, 'ignored_count' => 0, 'errors' => []]; $changedTables = []; foreach ($items as $item) { try { $tableName = $item['table']; $uuid = $item['local_id']; $action = $item['action']; $data = $item['data']; $modelClass = $this->tableMap[$tableName] ?? null; if (!$modelClass) throw new \Exception("Table {$tableName} not mapped."); DB::transaction(function () use ($modelClass, $uuid, $data, $action, $tableName, &$results, &$changedTables) { if ($action === 'bulk_update') { if($tableName === 'product_variants') { $visibleIds = $data['visible_ids'] ?? []; $modelClass::whereNotIn('uuid', $visibleIds) ->update(['is_visible_online' => false, 'updated_at' => $data['updated_at']]); $modelClass::whereIn('uuid', $visibleIds) ->update(['is_visible_online' => true, 'updated_at' => $data['updated_at']]); $results['success_count']++; $changedTables[] = 'product_variants'; return; } if($tableName === 'outlet_campaigns') { $ids = $data['ids'] ?? []; $modelClass::whereIn('uuid', $ids) ->update(['deleted_at' => $data['deleted_at']]); $results['success_count']++; $changedTables = 'outlet_campaigns'; return; } } $usesSoftDeletes = $this->usesSoftDeletes($modelClass); if($tableName === 'tenants') { $query = $modelClass::withoutGlobalScopes()->where('uuid', $uuid); } else { $query = $modelClass::where('uuid', $uuid); } if (in_array($tableName, ['product_variants','outlet_inventories', 'stock_cards'])) { $query->lockForUpdate(); } if($usesSoftDeletes) { $query->withTrashed(); } $existing = $query->first(); $deltaTotalSold = 0; if ($tableName === 'product_variants' && isset($data['qty_sold'])) { $deltaTotalSold = (float) $data['qty_sold']; unset($data['qty_sold']); } $shouldUpdateMetadata = true; if ($existing && isset($data['updated_at'])) { if (Carbon::parse($data['updated_at'])->lte($existing->updated_at)) { $shouldUpdateMetadata = false; $results['ignored_count']++; } } if($shouldUpdateMetadata) { if (strtoupper($action) === 'DELETE') { $this->handleSoftDelete($modelClass, $uuid, $data); } else { if ($tableName === 'stock_cards') { $this->handleDeltaStock($data); $modelClass::create($data); } else { if($tableName === 'outlets' && isset($data['is_main_outlet']) && $data['is_main_outlet'] === true) { Outlet::whereNot('uuid', $uuid)->update(['is_main_outlet' => false]); } $modelClass::updateOrCreate(['uuid' => $uuid], $data); } } $results['success_count']++; } if ($deltaTotalSold != 0) { $targetRecord = $existing ?? $modelClass::where('uuid', $uuid)->first(); if ($targetRecord) { $targetRecord->increment('total_sold', $deltaTotalSold); } } if(!in_array($tableName, $changedTables)) { $changedTables[] = $tableName; } }, 3); } catch (\Exception $e) { $results['errors'][] = [ 'table_name' => $item['table'], 'local_id' => $item['local_id'], 'message' => $e->getMessage() ]; } } if($results['success_count'] > 0 && !empty($changedTables)) { $masterTables = [ 'tenants', 'tenant_payment_methods', 'outlets', 'campaigns', 'categories', 'products', 'product_variants', 'outlet_inventories', 'orders' ]; $relevantChanges = array_intersect($changedTables, $masterTables); $tenantId = Auth::user()->tenant_id; broadcast(new DataBatchUpdated($tenantId, array_values($relevantChanges)))->toOthers(); } return $results; } protected function tableHasColumn(string $table, string $column): bool { $cacheKey = "table_columns_{$table}"; if (isset(static::$columnCache[$table])) { return in_array($column, static::$columnCache[$table]); } $columns = Cache::remember($cacheKey, now()->addDay(), function () use ($table) { return Schema::getColumnListing($table); }); static::$columnCache[$table] = $columns; return in_array($column, $columns); } protected function usesSoftDeletes($modelClass): bool { if (isset(static::$softDeleteCache[$modelClass])) { return static::$softDeleteCache[$modelClass]; } $uses = in_array( 'Illuminate\Database\Eloquent\SoftDeletes', class_uses_recursive($modelClass) ); static::$softDeleteCache[$modelClass] = $uses; return $uses; } private function handleDeltaStock($data) { $inventory = OutletInventory::withTrashed() ->lockForUpdate() ->where('uuid', '=', $data['outlet_inventory_id']) ->first(); if (!$inventory) { throw new \Exception("inventory with id {$data['outlet_inventory_id']} is not found for stock synchronize"); } if($inventory->stock_type === 'unlimited') return; if(isset($data['type']) && isset($data['stock_type']) && $data['stock_type'] === 'fixed') { $type = $data['type']; if (in_array($type, ['adjustment_in', 'adjustment_out'])) { $inventory->stock = $data['current_stock']; $inventory->updated_at = $data['created_at']; $inventory->save(); } else if(in_array($type, ['sale', 'transfer_out'])) { $inventory->decrement('stock', $data['qty_change']); } else if(in_array($type, ['production', 'purchase', 'transfer_in'])) { $inventory->increment('stock', $data['qty_change']); } } } private function handleSoftDelete($modelClass, $uuid, $data) { $tableName = (new $modelClass())->getTable(); $deletedAt = isset($data['deleted_at']) ? Carbon::parse($data['deleted_at']) : now(); $query = $modelClass::withTrashed(); if (empty($data) || (!isset($data['uuid']) && count($data) === 1)) { $query->where('uuid', $uuid); } else { foreach ($data as $key => $value) { if (in_array($key, ['is_active', 'is_delete_by_owner', 'deleted_at'])) continue; $query->where($key, $value); } } $records = $query->get(); if ($records->isNotEmpty()) { foreach ($records as $record) { if (isset($data['is_delete_by_owner']) && $this->tableHasColumn($tableName, 'is_delete_by_owner')) { $record->is_delete_by_owner = $data['is_delete_by_owner']; } if(isset($data['is_active']) && $this->tableHasColumn($tableName, 'is_active')) { $record->is_active = $data['is_active']; } $record->deleted_at = $deletedAt; $record->save(); $record->delete(); $record->updateQuietly(['deleted_at' => $deletedAt]); } } } }