QueenFruits/Backend/app/Services/SyncService.php

352 lines
15 KiB
PHP

<?php
namespace App\Services;
use App\Events\DataBatchUpdated;
use App\Models\Campaign;
use App\Models\Category;
use App\Models\Customer;
use App\Models\CustomerAddress;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Outlet;
use App\Models\OutletCampaign;
use App\Models\OutletInventory;
use App\Models\Product;
use App\Models\ProductLike;
use App\Models\ProductReview;
use App\Models\ProductVariant;
use App\Models\Purchase;
use App\Models\PurchaseItem;
use App\Models\RawMaterial;
use App\Models\ReceiptItem;
use App\Models\StockCard;
use App\Models\Supplier;
use App\Models\SyncDelete;
use App\Models\Tenant;
use App\Models\TenantPaymentMethod;
use App\Models\Transaction;
use App\Models\Unit;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
class SyncService
{
protected static $columnCache = [];
protected static $softDeleteCache = [];
protected array $tableMap = [
'campaigns' => 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]);
}
}
}
}