star-cloud/app/Http/Controllers/Admin/ProductController.php

603 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Product\Product;
use App\Models\Product\ProductCategory;
use App\Models\System\Company;
use App\Models\System\Translation;
use App\Traits\ImageHandler;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use App\Services\Product\ProductCatalogService;
use App\Models\System\SystemOperationLog;
class ProductController extends Controller
{
use \App\Traits\ImageHandler;
public function __construct(
private readonly ProductCatalogService $catalogService
) {}
public function index(Request $request)
{
$user = auth()->user();
$tab = $request->input('tab', 'products');
$per_page = $request->input('per_page', 10);
// Products Query
$productQuery = Product::with(['category.translations', 'translations', 'company']);
// 搜尋
if ($request->filled('search')) {
$search = $request->search;
$productQuery->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('barcode', 'like', "%{$search}%")
->orWhere('spec', 'like', "%{$search}%");
});
}
// 分類篩選
if ($request->filled('category_id')) {
$productQuery->where('category_id', $request->category_id);
}
if ($user->isSystemAdmin()) {
if ($request->filled('product_company_id')) {
$productQuery->where('company_id', $request->product_company_id);
}
}
$products = $productQuery->latest()->paginate($per_page, ['*'], 'product_page')->withQueryString();
// Categories Query
$categoryQuery = ProductCategory::with(['translations', 'company']);
if ($user->isSystemAdmin() && $request->filled('category_company_id')) {
$categoryQuery->where('company_id', $request->category_company_id);
}
if ($request->filled('search')) {
$search = $request->search;
$categoryQuery->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%");
});
}
$categories = $categoryQuery->latest()->paginate($per_page, ['*'], 'category_page')->withQueryString();
$companies = $user->isSystemAdmin() ? Company::all() : collect();
// Settings for Modal (use current user company or fallback)
$selectedCompanyId = $user->isSystemAdmin()
? ($request->input('product_company_id') ?: $request->input('category_company_id'))
: $user->company_id;
$selectedCompany = $selectedCompanyId ? Company::find($selectedCompanyId) : $user->company;
$companySettings = $selectedCompany ? ($selectedCompany->settings ?? []) : [];
$routeName = 'admin.data-config.products.index';
if ($request->ajax() || $request->wantsJson()) {
if ($tab === 'products') {
return response()->json([
'success' => true,
'html' => view('admin.products.partials.tab-products', [
'products' => $products,
'companySettings' => $companySettings,
'companies' => $companies,
'routeName' => $routeName
])->render()
]);
} elseif ($tab === 'categories') {
return response()->json([
'success' => true,
'html' => view('admin.products.partials.tab-categories', [
'categories' => $categories,
'companies' => $companies,
'routeName' => $routeName
])->render()
]);
} elseif ($tab === 'logs') {
$logQuery = SystemOperationLog::with(['user', 'company', 'target'])
->whereIn('module', ['product', 'category']);
if ($user->isSystemAdmin()) {
if ($request->filled('log_company_id')) {
$logQuery->where('company_id', $request->log_company_id);
}
}
if ($request->filled('search_log')) {
$search = $request->search_log;
$logQuery->where(function($q) use ($search) {
$q->where('note', 'like', "%{$search}%")
->orWhere('old_values', 'like', "%{$search}%")
->orWhere('new_values', 'like', "%{$search}%");
});
}
$logDefaultStart = now()->startOfDay()->format('Y-m-d H:i');
$logDefaultEnd = now()->endOfDay()->format('Y-m-d H:i');
if ($request->filled('start_date') || $request->filled('end_date')) {
if ($request->filled('start_date')) {
$logQuery->where('created_at', '>=', \Illuminate\Support\Carbon::parse($request->start_date));
}
if ($request->filled('end_date')) {
$logQuery->where('created_at', '<=', \Illuminate\Support\Carbon::parse($request->end_date)->endOfMinute());
}
} else {
$logQuery->whereBetween('created_at', [now()->startOfDay(), now()->endOfDay()]);
$request->merge([
'start_date' => $logDefaultStart,
'end_date' => $logDefaultEnd
]);
}
$logs = $logQuery->latest()->paginate($per_page, ['*'], 'log_page')->withQueryString();
return response()->json([
'success' => true,
'html' => view('admin.products.partials.tab-logs', [
'logs' => $logs,
'companies' => $companies,
'logDefaultStart' => $logDefaultStart,
'logDefaultEnd' => $logDefaultEnd
])->render()
]);
}
}
$logDefaultStart = now()->startOfDay()->format('Y-m-d H:i');
$logDefaultEnd = now()->endOfDay()->format('Y-m-d H:i');
return view('admin.products.index', [
'products' => $products,
'categories' => $categories,
'companies' => $companies,
'companySettings' => $companySettings,
'routeName' => $routeName,
'logDefaultStart' => $logDefaultStart,
'logDefaultEnd' => $logDefaultEnd
]);
}
public function create(Request $request)
{
$user = auth()->user();
$categories = ProductCategory::with('translations')->get();
$companies = $user->isSystemAdmin() ? Company::all() : collect();
// If system admin, check if company_id is provided in URL to get settings
$companyId = $request->query('company_id') ?? $user->company_id;
$selectedCompany = $companyId ? Company::find($companyId) : $user->company;
$companySettings = $selectedCompany ? ($selectedCompany->settings ?? []) : [];
return view('admin.products.create', [
'categories' => $categories,
'companies' => $companies,
'companySettings' => $companySettings,
]);
}
public function edit($id)
{
$user = auth()->user();
// 繞過 TenantScoped 載入翻譯,確保系統管理員能看到租戶公司的翻譯資料
$product = Product::with(['company'])->findOrFail($id);
$product->setRelation('translations',
Translation::withoutGlobalScopes()
->where('group', 'product')
->where('key', $product->name_dictionary_key)
->get()
);
$categories = ProductCategory::with('translations')->get();
$companies = $user->isSystemAdmin() ? Company::all() : collect();
// Use the product's company settings for editing
$companySettings = $product->company ? ($product->company->settings ?? []) : [];
return view('admin.products.edit', [
'product' => $product,
'categories' => $categories,
'companies' => $companies,
'companySettings' => $companySettings,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'names.zh_TW' => 'required|string|max:255',
'names.en' => 'nullable|string|max:255',
'names.ja' => 'nullable|string|max:255',
'barcode' => 'nullable|string|max:100',
'spec' => 'nullable|string|max:255',
'category_id' => 'nullable|exists:product_categories,id',
'manufacturer' => 'nullable|string|max:255',
'track_limit' => 'required|integer|min:1',
'spring_limit' => 'required|integer|min:1',
'price' => 'required|numeric|min:0',
'cost' => 'required|numeric|min:0',
'member_price' => 'required|numeric|min:0',
'metadata' => 'nullable|array',
'is_active' => 'nullable|boolean',
'company_id' => 'nullable|exists:companies,id',
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240', // Increase to 10MB
]);
try {
DB::beginTransaction();
$dictKey = \Illuminate\Support\Str::uuid()->toString();
// Determine company_id: prioritized from request (for sys admin) then from user
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
? $request->company_id
: auth()->user()->company_id;
// 儲存多語系翻譯(繞過 TenantScoped避免系統管理員操作租戶資料時被過濾
foreach ($request->names as $locale => $name) {
if (empty($name)) continue;
Translation::withoutGlobalScopes()->create([
'group' => 'product',
'key' => $dictKey,
'locale' => $locale,
'value' => $name,
'company_id' => $company_id,
]);
}
$imageUrl = null;
if ($request->hasFile('image')) {
$path = $this->storeAsWebp($request->file('image'), 'products', 80, 320, 320);
$imageUrl = Storage::url($path);
}
$product = Product::create([
'company_id' => $company_id,
'category_id' => $request->category_id,
'name' => $request->names['zh_TW'] ?? (collect($request->names)->first() ?? 'Untitled'), // Fallback if zh_TW is missing
'name_dictionary_key' => $dictKey,
'image_url' => $imageUrl,
'barcode' => $request->barcode,
'spec' => $request->spec,
'manufacturer' => $request->manufacturer,
'track_limit' => $request->track_limit,
'spring_limit' => $request->spring_limit,
'price' => $request->price,
'cost' => $request->cost,
'member_price' => $request->member_price,
'metadata' => $request->metadata ?? [],
'is_active' => $request->boolean('is_active', true),
]);
SystemOperationLog::create([
'company_id' => $product->company_id,
'user_id' => auth()->id(),
'module' => 'product',
'action' => 'create',
'target_id' => $product->id,
'target_type' => Product::class,
'note' => 'product_created',
'new_values' => $product->toArray(),
]);
DB::commit();
// Rebuild catalog cache
$this->catalogService->rebuildCache($product->company_id);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'message' => __('Product created successfully'),
'data' => $product
]);
}
return redirect()->route('admin.data-config.products.index')->with('success', __('Product created successfully'));
} catch (\Exception $e) {
DB::rollBack();
if ($request->wantsJson()) {
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
}
return redirect()->back()->with('error', $e->getMessage())->withInput();
}
}
public function update(Request $request, $id)
{
$product = Product::findOrFail($id);
$validated = $request->validate([
'names.zh_TW' => 'required|string|max:255',
'names.en' => 'nullable|string|max:255',
'names.ja' => 'nullable|string|max:255',
'barcode' => 'nullable|string|max:100',
'spec' => 'nullable|string|max:255',
'category_id' => 'nullable|exists:product_categories,id',
'manufacturer' => 'nullable|string|max:255',
'track_limit' => 'required|integer|min:1',
'spring_limit' => 'required|integer|min:1',
'price' => 'required|numeric|min:0',
'cost' => 'required|numeric|min:0',
'member_price' => 'required|numeric|min:0',
'metadata' => 'nullable|array',
'is_active' => 'nullable|boolean',
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,webp|max:10240', // Increase to 10MB
'remove_image' => 'nullable|boolean',
]);
try {
DB::beginTransaction();
$dictKey = $product->name_dictionary_key ?: \Illuminate\Support\Str::uuid()->toString();
// Determine company_id: prioritized from request (for sys admin) then from product's current company
$company_id = (auth()->user()->isSystemAdmin() && $request->filled('company_id'))
? $request->company_id
: $product->company_id;
// 更新或建立多語系翻譯(繞過 TenantScoped避免系統管理員操作租戶資料時被過濾
foreach ($request->names as $locale => $name) {
if (empty($name)) {
Translation::withoutGlobalScopes()->where([
'group' => 'product',
'key' => $dictKey,
'locale' => $locale
])->delete();
continue;
}
Translation::withoutGlobalScopes()->updateOrCreate(
[
'group' => 'product',
'key' => $dictKey,
'locale' => $locale,
],
[
'value' => $name,
'company_id' => $company_id,
]
);
}
$data = [
'company_id' => $company_id,
'category_id' => $request->category_id,
'name' => $request->names['zh_TW'] ?? ($product->name ?? 'Untitled'),
'name_dictionary_key' => $dictKey,
'barcode' => $request->barcode,
'spec' => $request->spec,
'manufacturer' => $request->manufacturer,
'track_limit' => $request->track_limit,
'spring_limit' => $request->spring_limit,
'price' => $request->price,
'cost' => $request->cost,
'member_price' => $request->member_price,
'metadata' => $request->metadata ?? [],
'is_active' => $request->boolean('is_active', true),
];
if ($request->hasFile('image')) {
// Delete old image
if ($product->image_url) {
$oldPath = str_replace('/storage/', '', $product->image_url);
Storage::disk('public')->delete($oldPath);
}
$path = $this->storeAsWebp($request->file('image'), 'products', 80, 320, 320);
$data['image_url'] = Storage::url($path);
} elseif ($request->boolean('remove_image')) {
if ($product->image_url) {
$oldPath = str_replace('/storage/', '', $product->image_url);
Storage::disk('public')->delete($oldPath);
}
$data['image_url'] = null;
}
$oldValues = $product->toArray();
$product->update($data);
$newValues = $product->fresh()->toArray();
SystemOperationLog::create([
'company_id' => $product->company_id,
'user_id' => auth()->id(),
'module' => 'product',
'action' => 'update',
'target_id' => $product->id,
'target_type' => Product::class,
'note' => 'product_updated',
'old_values' => $oldValues,
'new_values' => $newValues,
]);
DB::commit();
// Rebuild catalog cache
$this->catalogService->rebuildCache($product->company_id);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'message' => __('Product updated successfully'),
'data' => $product
]);
}
return redirect()->route('admin.data-config.products.index')->with('success', __('Product updated successfully'));
} catch (\Exception $e) {
DB::rollBack();
if ($request->wantsJson()) {
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
}
return redirect()->back()->with('error', $e->getMessage())->withInput();
}
}
public function toggleStatus($id)
{
try {
$product = Product::findOrFail($id);
$oldValues = $product->toArray();
$product->is_active = !$product->is_active;
$product->save();
$newValues = $product->fresh()->toArray();
SystemOperationLog::create([
'company_id' => $product->company_id,
'user_id' => auth()->id(),
'module' => 'product',
'action' => 'update',
'target_id' => $product->id,
'target_type' => Product::class,
'note' => 'product_status_toggled',
'old_values' => $oldValues,
'new_values' => $newValues,
]);
// Rebuild catalog cache
$this->catalogService->rebuildCache($product->company_id);
$status = $product->is_active ? __('Enabled') : __('Disabled');
if (request()->ajax()) {
return response()->json([
'success' => true,
'message' => __('Product status updated to :status', ['status' => $status]),
'is_active' => $product->is_active
]);
}
return redirect()->back()->with('success', __('Product status updated to :status', ['status' => $status]));
} catch (\Exception $e) {
if (request()->ajax()) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 500);
}
return redirect()->back()->with('error', $e->getMessage());
}
}
public function destroy($id)
{
try {
$product = Product::findOrFail($id);
// 刪除與此商品關聯的翻譯資料(繞過 TenantScoped
if ($product->name_dictionary_key) {
Translation::withoutGlobalScopes()->where('key', $product->name_dictionary_key)->delete();
}
// Delete image
if ($product->image_url) {
$oldPath = str_replace('/storage/', '', $product->image_url);
Storage::disk('public')->delete($oldPath);
}
$companyId = $product->company_id;
$oldValues = $product->toArray();
$product->delete();
SystemOperationLog::create([
'company_id' => $companyId,
'user_id' => auth()->id(),
'module' => 'product',
'action' => 'delete',
'target_id' => $id,
'target_type' => Product::class,
'note' => 'product_deleted',
'old_values' => $oldValues,
]);
// Rebuild catalog cache
$this->catalogService->rebuildCache($companyId);
if (request()->ajax()) {
return response()->json([
'success' => true,
'message' => __('Product deleted successfully')
]);
}
return redirect()->back()->with('success', __('Product deleted successfully'));
} catch (\Exception $e) {
if (request()->ajax()) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 500);
}
return redirect()->back()->with('error', $e->getMessage());
}
}
/**
* 同步商品目錄至全公司機台 (Phase 2)
*/
public function syncToAllMachines(Request $request)
{
$user = auth()->user();
// 1. 決定目標公司 ID
$companyId = $user->isSystemAdmin()
? $request->input('company_id')
: $user->company_id;
if (!$companyId) {
return response()->json([
'success' => false,
'message' => __('Please select a company.')
], 422);
}
// 3. 頻率限制1 分鐘內不可重複對同一家公司發送同步指令
$machineIds = \App\Models\Machine\Machine::where('company_id', $companyId)->pluck('id');
$recentCommand = \App\Models\Machine\RemoteCommand::whereIn('machine_id', $machineIds)
->where('command_type', 'update_products')
->where('created_at', '>=', now()->subMinute())
->whereIn('status', ['pending', 'sent'])
->exists();
if ($recentCommand) {
return response()->json([
'success' => false,
'message' => __('A sync command was recently sent. Please wait 1 minute.')
], 422);
}
$remark = $request->input('remark') ?: __('Manual Sync Products');
// 2. 派遣延遲廣播 Job
\App\Jobs\Product\SyncProductsToMachinesJob::dispatch(
(int) $companyId,
$remark,
$user->id
);
SystemOperationLog::create([
'company_id' => $companyId,
'user_id' => $user->id,
'module' => 'product',
'action' => 'sync',
'target_id' => $companyId,
'target_type' => Company::class,
'note' => 'product_catalog_synced_to_all_machines',
'new_values' => ['remark' => $remark],
]);
return response()->json([
'success' => true,
'message' => __('Batch sync command has been queued. Machines will be updated sequentially.')
]);
}
}