Add multi-branch POS with team roles and branch-scoped cashiers.
Deploy Ladill POS / deploy (push) Successful in 1m58s
Deploy Ladill POS / deploy (push) Successful in 1m58s
Pro and Business users can manage branches, invite cashiers with branch assignment, and switch registers; sales and register flows respect acting location. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AccountSwitchController extends Controller
|
||||
{
|
||||
public function switch(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'account' => ['required', 'integer'],
|
||||
]);
|
||||
|
||||
abort_unless($request->user()->canAccessAccount($data['account']), 403);
|
||||
|
||||
$request->session()->put('ladill_account', $data['account']);
|
||||
$request->session()->forget('ladill_pos_location');
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,7 @@ class SsoLoginController extends Controller
|
||||
}
|
||||
|
||||
QrTeamMember::linkPendingInvitesFor($user);
|
||||
app(\App\Services\Pos\TeamMemberProvisioner::class)->sync($user);
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->regenerate();
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
namespace App\Http\Controllers\Pos\Concerns;
|
||||
|
||||
use App\Models\PosLocation;
|
||||
use App\Models\PosMember;
|
||||
use App\Models\User;
|
||||
use App\Services\Pos\PosMemberResolver;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -14,8 +19,63 @@ trait ScopesToAccount
|
||||
return (string) $user->public_id;
|
||||
}
|
||||
|
||||
protected function actor(Request $request): User
|
||||
{
|
||||
return $request->user();
|
||||
}
|
||||
|
||||
protected function posMember(Request $request): ?PosMember
|
||||
{
|
||||
return $request->attributes->get('pos.member')
|
||||
?? app(PosMemberResolver::class)->memberFor($this->actor($request), $this->ownerRef($request));
|
||||
}
|
||||
|
||||
protected function locationScope(Request $request): ?int
|
||||
{
|
||||
return $request->attributes->get('pos.locationScope')
|
||||
?? app(PosMemberResolver::class)->locationScope(
|
||||
$this->posMember($request),
|
||||
$this->ownerRef($request),
|
||||
$this->actor($request),
|
||||
);
|
||||
}
|
||||
|
||||
protected function location(Request $request): PosLocation
|
||||
{
|
||||
$acting = $request->attributes->get('actingLocation');
|
||||
if ($acting instanceof PosLocation) {
|
||||
return $acting;
|
||||
}
|
||||
|
||||
return app(\App\Services\Pos\PosLocationService::class)->resolve($request);
|
||||
}
|
||||
|
||||
protected function authorizeOwner(Request $request, Model $model): void
|
||||
{
|
||||
abort_unless($model->getAttribute('owner_ref') === $this->ownerRef($request), 404);
|
||||
$this->authorizeLocation($request, $model);
|
||||
}
|
||||
|
||||
protected function authorizeLocation(Request $request, Model $model): void
|
||||
{
|
||||
$scope = $this->locationScope($request);
|
||||
if ($scope === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$locationId = $model->getAttribute('location_id');
|
||||
if ($locationId !== null) {
|
||||
abort_unless((int) $locationId === $scope, 404);
|
||||
}
|
||||
}
|
||||
|
||||
protected function scopeToLocation(Request $request, Builder $query, string $column = 'location_id'): Builder
|
||||
{
|
||||
$scope = $this->locationScope($request);
|
||||
if ($scope !== null) {
|
||||
$query->where($column, $scope);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class DashboardController extends Controller
|
||||
$owner = $this->ownerRef($request);
|
||||
$todayStart = now()->startOfDay();
|
||||
|
||||
$todaySales = PosSale::owned($owner)
|
||||
$todaySales = $this->scopeToLocation($request, PosSale::owned($owner))
|
||||
->where('status', PosSale::STATUS_PAID)
|
||||
->where('paid_at', '>=', $todayStart);
|
||||
|
||||
@@ -29,10 +29,11 @@ class DashboardController extends Controller
|
||||
'today_total_minor' => (int) (clone $todaySales)->sum('total_minor'),
|
||||
'today_count' => (clone $todaySales)->count(),
|
||||
'product_count' => PosProduct::owned($owner)->active()->count(),
|
||||
'open_pending' => PosSale::owned($owner)->where('status', PosSale::STATUS_PENDING)->count(),
|
||||
'open_pending' => $this->scopeToLocation($request, PosSale::owned($owner))
|
||||
->where('status', PosSale::STATUS_PENDING)->count(),
|
||||
];
|
||||
|
||||
$recentSales = PosSale::owned($owner)
|
||||
$recentSales = $this->scopeToLocation($request, PosSale::owned($owner))
|
||||
->with('lines')
|
||||
->latest()
|
||||
->limit(8)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Pos;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
|
||||
use App\Models\PosLocation;
|
||||
use App\Models\PosSale;
|
||||
use App\Models\PosTable;
|
||||
use App\Services\Pos\PosLocationService;
|
||||
use App\Services\Pos\SubscriptionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class LocationController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function __construct(
|
||||
private PosLocationService $locations,
|
||||
private SubscriptionService $subscriptions,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$owner = $this->ownerRef($request);
|
||||
$account = ladill_account() ?? $request->user();
|
||||
|
||||
abort_unless(
|
||||
app(\App\Services\Pos\PosMemberResolver::class)->canManageBranches(
|
||||
$this->posMember($request),
|
||||
$owner,
|
||||
$this->actor($request),
|
||||
),
|
||||
403,
|
||||
);
|
||||
|
||||
$branches = PosLocation::owned($owner)->orderBy('name')->get();
|
||||
|
||||
return view('pos.branches.index', [
|
||||
'branches' => $branches,
|
||||
'canAddBranch' => $this->subscriptions->canAddLocation($account, $owner),
|
||||
'hasMultiLocation' => $this->subscriptions->canUseMultiLocation($account),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): View|RedirectResponse
|
||||
{
|
||||
return $this->guardManage($request) ?? view('pos.branches.create');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if ($redirect = $this->guardManage($request)) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
$owner = $this->ownerRef($request);
|
||||
$account = ladill_account() ?? $request->user();
|
||||
|
||||
if (! $this->subscriptions->canAddLocation($account, $owner)) {
|
||||
return redirect()->route('pos.pro.index')
|
||||
->with('upsell', 'Multi-branch POS requires Ladill POS Pro or Business.');
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'currency' => ['required', 'string', 'size:3'],
|
||||
'service_style' => ['required', 'in:retail,restaurant'],
|
||||
]);
|
||||
|
||||
if ($data['service_style'] === 'restaurant' && ! $this->subscriptions->canUseRestaurantMode($account)) {
|
||||
return back()->withInput()->with('upsell', 'Restaurant mode is part of Ladill POS Pro.');
|
||||
}
|
||||
|
||||
$isFirst = $this->subscriptions->locationCount($owner) === 0;
|
||||
|
||||
PosLocation::create([
|
||||
'owner_ref' => $owner,
|
||||
'name' => $data['name'],
|
||||
'currency' => strtoupper($data['currency']),
|
||||
'service_style' => $data['service_style'],
|
||||
'is_default' => $isFirst,
|
||||
]);
|
||||
|
||||
return redirect()->route('pos.branches.index')->with('success', 'Branch created.');
|
||||
}
|
||||
|
||||
public function edit(Request $request, PosLocation $location): View|RedirectResponse
|
||||
{
|
||||
$this->authorizeOwner($request, $location);
|
||||
if ($redirect = $this->guardManage($request)) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
return view('pos.branches.edit', compact('location'));
|
||||
}
|
||||
|
||||
public function update(Request $request, PosLocation $location): RedirectResponse
|
||||
{
|
||||
$this->authorizeOwner($request, $location);
|
||||
if ($redirect = $this->guardManage($request)) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
$account = ladill_account() ?? $request->user();
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'currency' => ['required', 'string', 'size:3'],
|
||||
'service_style' => ['required', 'in:retail,restaurant'],
|
||||
'is_default' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
if ($data['service_style'] === 'restaurant' && ! $this->subscriptions->canUseRestaurantMode($account)) {
|
||||
return back()->withInput()->with('upsell', 'Restaurant mode is part of Ladill POS Pro.');
|
||||
}
|
||||
|
||||
if ($request->boolean('is_default')) {
|
||||
PosLocation::owned($this->ownerRef($request))
|
||||
->where('id', '!=', $location->id)
|
||||
->update(['is_default' => false]);
|
||||
$location->is_default = true;
|
||||
}
|
||||
|
||||
$location->update([
|
||||
'name' => $data['name'],
|
||||
'currency' => strtoupper($data['currency']),
|
||||
'service_style' => $data['service_style'],
|
||||
]);
|
||||
|
||||
return redirect()->route('pos.branches.index')->with('success', 'Branch updated.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, PosLocation $location): RedirectResponse
|
||||
{
|
||||
$this->authorizeOwner($request, $location);
|
||||
if ($redirect = $this->guardManage($request)) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
$owner = $this->ownerRef($request);
|
||||
|
||||
if (PosLocation::owned($owner)->count() <= 1) {
|
||||
return back()->with('error', 'You must keep at least one branch.');
|
||||
}
|
||||
|
||||
if (PosSale::owned($owner)->where('location_id', $location->id)->exists()) {
|
||||
return back()->with('error', 'This branch has sales history and cannot be removed.');
|
||||
}
|
||||
|
||||
if (PosTable::owned($owner)->where('location_id', $location->id)->where('status', '!=', PosTable::STATUS_FREE)->exists()) {
|
||||
return back()->with('error', 'Settle open tickets on this branch before removing it.');
|
||||
}
|
||||
|
||||
$wasDefault = $location->is_default;
|
||||
$location->delete();
|
||||
|
||||
if ($wasDefault) {
|
||||
PosLocation::owned($owner)->orderBy('id')->first()?->update(['is_default' => true]);
|
||||
}
|
||||
|
||||
if ((int) $request->session()->get('ladill_pos_location') === (int) $location->id) {
|
||||
$request->session()->forget('ladill_pos_location');
|
||||
}
|
||||
|
||||
return redirect()->route('pos.branches.index')->with('success', 'Branch removed.');
|
||||
}
|
||||
|
||||
public function switch(Request $request): RedirectResponse
|
||||
{
|
||||
$owner = $this->ownerRef($request);
|
||||
$data = $request->validate([
|
||||
'location' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('pos_locations', 'id')->where('owner_ref', $owner),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->locations->switch($request, (int) $data['location']);
|
||||
|
||||
return back()->with('success', 'Switched branch.');
|
||||
}
|
||||
|
||||
private function guardManage(Request $request): ?RedirectResponse
|
||||
{
|
||||
$owner = $this->ownerRef($request);
|
||||
|
||||
if (! app(\App\Services\Pos\PosMemberResolver::class)->canManageBranches(
|
||||
$this->posMember($request),
|
||||
$owner,
|
||||
$this->actor($request),
|
||||
)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$account = ladill_account() ?? $request->user();
|
||||
if (! $this->subscriptions->canUseMultiLocation($account)) {
|
||||
return redirect()->route('pos.pro.index')
|
||||
->with('upsell', 'Multi-branch POS requires Ladill POS Pro or Business.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Pos;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
|
||||
use App\Models\PosLocation;
|
||||
use App\Models\PosMember;
|
||||
use App\Models\User;
|
||||
use App\Services\Identity\IdentityTeamClient;
|
||||
use App\Services\Pos\SubscriptionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MemberController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function __construct(private SubscriptionService $subscriptions) {}
|
||||
|
||||
public function index(Request $request): View|RedirectResponse
|
||||
{
|
||||
if ($redirect = $this->guardManage($request)) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
$owner = $this->ownerRef($request);
|
||||
$members = PosMember::owned($owner)->with('location')->orderBy('created_at')->get();
|
||||
|
||||
return view('pos.team.index', [
|
||||
'members' => $members,
|
||||
'roles' => config('pos.roles', []),
|
||||
'branches' => PosLocation::owned($owner)->orderBy('name')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, IdentityTeamClient $identity): RedirectResponse
|
||||
{
|
||||
if ($redirect = $this->guardManage($request)) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
$owner = $this->ownerRef($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'role' => ['required', 'string', Rule::in(array_keys(config('pos.roles', [])))],
|
||||
'location_id' => [
|
||||
Rule::requiredIf(fn () => $request->input('role') === PosMember::ROLE_CASHIER),
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('pos_locations', 'id')->where('owner_ref', $owner),
|
||||
],
|
||||
]);
|
||||
|
||||
$email = strtolower(trim($validated['email']));
|
||||
$actor = $request->user();
|
||||
|
||||
if ($email === strtolower((string) $actor->email)) {
|
||||
return back()->withErrors(['email' => 'You cannot invite yourself.']);
|
||||
}
|
||||
|
||||
try {
|
||||
$identity->inviteAppMember(
|
||||
$owner,
|
||||
$email,
|
||||
['pos'],
|
||||
[
|
||||
'pos' => [
|
||||
'role' => $validated['role'],
|
||||
'location_id' => $validated['location_id'] ?? null,
|
||||
],
|
||||
],
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
PosMember::updateOrCreate(
|
||||
[
|
||||
'owner_ref' => $owner,
|
||||
'user_ref' => $email,
|
||||
],
|
||||
[
|
||||
'role' => $validated['role'],
|
||||
'location_id' => $validated['location_id'] ?? null,
|
||||
],
|
||||
);
|
||||
|
||||
return back()->with('success', 'Invitation sent to '.$email.'.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, PosMember $member): RedirectResponse
|
||||
{
|
||||
if ($redirect = $this->guardManage($request)) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
$this->authorizeOwner($request, $member);
|
||||
|
||||
$actor = $request->user();
|
||||
if ($member->user_ref === $actor->public_id || $member->user_ref === strtolower((string) $actor->email)) {
|
||||
return back()->with('error', 'You cannot remove yourself.');
|
||||
}
|
||||
|
||||
$member->delete();
|
||||
|
||||
return back()->with('success', 'Team member removed.');
|
||||
}
|
||||
|
||||
private function guardManage(Request $request): ?RedirectResponse
|
||||
{
|
||||
$owner = $this->ownerRef($request);
|
||||
$resolver = app(\App\Services\Pos\PosMemberResolver::class);
|
||||
|
||||
if (! $resolver->canManageTeam($this->posMember($request), $owner, $this->actor($request))) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$account = ladill_account() ?? $request->user();
|
||||
if (! $this->subscriptions->canManageTeam($account)) {
|
||||
return redirect()->route('pos.pro.index')
|
||||
->with('upsell', 'Team members and branch assignment require Ladill POS Pro or Business.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ class RegisterController extends Controller
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$owner = $this->ownerRef($request);
|
||||
$location = $this->locations->ensureDefault($owner);
|
||||
$location = $this->location($request);
|
||||
|
||||
return view('pos.register', [
|
||||
'products' => $this->registerProducts($owner, $location),
|
||||
@@ -47,7 +47,7 @@ class RegisterController extends Controller
|
||||
]);
|
||||
|
||||
$owner = $this->ownerRef($request);
|
||||
$location = $this->locations->ensureDefault($owner);
|
||||
$location = $this->location($request);
|
||||
$product = $this->barcodes->lookup($owner, $location, $data['code']);
|
||||
|
||||
if (! $product || $product['price_minor'] <= 0 || $product['name'] === '') {
|
||||
@@ -66,7 +66,14 @@ class RegisterController extends Controller
|
||||
private function registerProducts(string $owner, PosLocation $location): array
|
||||
{
|
||||
if ($location->isRestaurant()) {
|
||||
return PosProduct::owned($owner)->active()->orderBy('name')->get()
|
||||
return PosProduct::owned($owner)
|
||||
->active()
|
||||
->where(function ($query) use ($location) {
|
||||
$query->where('location_id', $location->id)
|
||||
->orWhereNull('location_id');
|
||||
})
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (PosProduct $p) => [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
@@ -99,7 +106,7 @@ class RegisterController extends Controller
|
||||
{
|
||||
$data = $request->validate(['style' => ['required', 'in:retail,restaurant']]);
|
||||
|
||||
$location = $this->locations->ensureDefault($this->ownerRef($request));
|
||||
$location = $this->location($request);
|
||||
$location->update(['service_style' => $data['style']]);
|
||||
|
||||
if ($data['style'] === PosLocation::STYLE_RESTAURANT) {
|
||||
@@ -135,7 +142,7 @@ class RegisterController extends Controller
|
||||
]);
|
||||
|
||||
$merchant = ladill_account() ?? $request->user();
|
||||
$location = $this->locations->ensureDefault($this->ownerRef($request));
|
||||
$location = $this->location($request);
|
||||
|
||||
$lines = $data['lines'];
|
||||
if (! $location->isRestaurant()) {
|
||||
|
||||
@@ -23,7 +23,7 @@ class SaleController extends Controller
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$sales = PosSale::owned($this->ownerRef($request))
|
||||
$sales = $this->scopeToLocation($request, PosSale::owned($this->ownerRef($request)))
|
||||
->with('lines')
|
||||
->latest()
|
||||
->paginate(25)
|
||||
|
||||
@@ -27,12 +27,13 @@ class SettingsController extends Controller
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$owner = $this->ownerRef($request);
|
||||
$location = $this->locations->ensureDefault($owner);
|
||||
$location = $this->location($request);
|
||||
|
||||
return view('pos.settings', [
|
||||
'location' => $location,
|
||||
'merchantImportEnabled' => (bool) config('pos.merchant_import_enabled', true),
|
||||
'tables' => PosTable::owned($owner)->orderBy('area')->orderBy('position')->orderBy('label')->get(),
|
||||
'tables' => $this->scopeToLocation($request, PosTable::owned($owner))
|
||||
->orderBy('area')->orderBy('position')->orderBy('label')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -56,7 +57,7 @@ class SettingsController extends Controller
|
||||
->with('upsell', 'Restaurant mode is part of Ladill POS Pro.');
|
||||
}
|
||||
|
||||
$location = $this->locations->ensureDefault($this->ownerRef($request));
|
||||
$location = $this->location($request);
|
||||
|
||||
if ($request->boolean('remove_receipt_logo') && $location->receipt_logo_path) {
|
||||
Storage::disk('public')->delete($location->receipt_logo_path);
|
||||
@@ -96,7 +97,7 @@ class SettingsController extends Controller
|
||||
]);
|
||||
|
||||
$owner = $this->ownerRef($request);
|
||||
$location = $this->locations->ensureDefault($owner);
|
||||
$location = $this->location($request);
|
||||
|
||||
PosTable::create([
|
||||
'owner_ref' => $owner,
|
||||
|
||||
@@ -19,16 +19,16 @@ class TableController extends Controller
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$owner = $this->ownerRef($request);
|
||||
$location = $this->locations->ensureDefault($owner);
|
||||
$location = $this->location($request);
|
||||
|
||||
$tables = PosTable::owned($owner)
|
||||
$tables = $this->scopeToLocation($request, PosTable::owned($owner))
|
||||
->with('currentSale')
|
||||
->orderBy('area')
|
||||
->orderBy('position')
|
||||
->orderBy('label')
|
||||
->get();
|
||||
|
||||
$openTickets = PosSale::owned($owner)
|
||||
$openTickets = $this->scopeToLocation($request, PosSale::owned($owner))
|
||||
->openTickets()
|
||||
->with('table')
|
||||
->withCount('lines')
|
||||
|
||||
Reference in New Issue
Block a user