Merge remote-tracking branch 'origin/main' into feature/sumu-bill-offset
# Conflicts: # tests/Feature/Business/PagesTest.php
This commit is contained in:
commit
5a34782dcd
@ -27,6 +27,7 @@ use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException;
|
||||
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
|
||||
use Modules\EcommerceFinance\Repositories\BillOffsetMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
|
||||
use Modules\EcommerceFinance\Repositories\InventoryRepository;
|
||||
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
|
||||
@ -74,6 +75,7 @@ class Pages extends Controller
|
||||
private readonly ProductCatalogMirrorRepository $productCatalog,
|
||||
private readonly ProductPriceMirrorRepository $productPrices,
|
||||
private readonly CourierInformationMirrorRepository $courierInformation,
|
||||
private readonly CourierFreightReconciliationMirrorRepository $courierFreightReconciliations,
|
||||
private readonly RawBoardPayablesRepository $rawBoardPayables,
|
||||
private readonly DingPayableWorkflowMirrorRepository $dingPayableWorkflows,
|
||||
private readonly SupplierPayableReportRepository $supplierPayableReports,
|
||||
@ -573,6 +575,7 @@ class Pages extends Controller
|
||||
'normal-products' => $this->contentForNormalProducts(),
|
||||
'product-prices' => $this->contentForProductPrices(),
|
||||
'courier-information' => $this->contentForCourierInformation(),
|
||||
'freight-reconciliation' => $this->contentForFreightReconciliation(),
|
||||
'board-deductions' => $this->contentForBoardDeductions(),
|
||||
'supplier-payables-report' => $this->contentForSupplierPayablesReport(),
|
||||
'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(),
|
||||
@ -613,6 +616,71 @@ class Pages extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
protected function contentForFreightReconciliation(): array
|
||||
{
|
||||
$request = request();
|
||||
$date = static function (mixed $value): string {
|
||||
$value = trim((string) $value);
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : '';
|
||||
};
|
||||
$filters = [
|
||||
'express_no' => mb_substr(trim((string) $request->query('express_no', '')), 0, 80),
|
||||
'express_name' => mb_substr(trim((string) $request->query('express_name', '')), 0, 80),
|
||||
'month' => mb_substr(trim((string) $request->query('month', '')), 0, 20),
|
||||
'status' => mb_substr(trim((string) $request->query('status', '')), 0, 40),
|
||||
'modified_from' => $date($request->query('modified_from', '')),
|
||||
'modified_to' => $date($request->query('modified_to', '')),
|
||||
];
|
||||
$page = max((int) $request->query('page', 1), 1);
|
||||
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
|
||||
$rows = new LengthAwarePaginator([], 0, $limit, $page, [
|
||||
'path' => $request->url(),
|
||||
'query' => $request->except('page'),
|
||||
]);
|
||||
$state = null;
|
||||
$sync = ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
|
||||
$summary = ['count' => 0, 'payable_amount' => 0.0, 'actual_payment' => 0.0, 'confirmed_amount' => 0.0, 'reconciliation_difference' => 0.0];
|
||||
$error = null;
|
||||
$pending = false;
|
||||
|
||||
try {
|
||||
if (! $this->courierFreightReconciliations->available()) {
|
||||
$error = '共享业务镜像尚未建立,运费对账暂时无法读取。';
|
||||
} elseif (! $this->courierFreightReconciliations->ready()) {
|
||||
$state = $this->courierFreightReconciliations->state();
|
||||
$sync = $this->courierFreightReconciliations->syncStatus($state);
|
||||
$pending = true;
|
||||
$error = '运费对账镜像正在完成首次同步,完成前不会把未同步数据误判为无数据。';
|
||||
} else {
|
||||
$result = $this->courierFreightReconciliations->paginate($filters, $page, $limit);
|
||||
$rows = new LengthAwarePaginator(
|
||||
Collection::make($result['data'] ?? []),
|
||||
(int) ($result['total'] ?? 0),
|
||||
$limit,
|
||||
$page,
|
||||
['path' => $request->url(), 'query' => $request->except('page')],
|
||||
);
|
||||
$state = $result['state'] ?? null;
|
||||
$sync = $result['sync'] ?? $sync;
|
||||
$summary = $result['summary'] ?? $summary;
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$error = '运费对账数据暂时无法读取,请稍后重试。';
|
||||
}
|
||||
|
||||
return [
|
||||
'view' => 'business.pages.courier-payables.freight-reconciliation',
|
||||
'freightReconciliations' => $rows,
|
||||
'freightReconciliationFilters' => $filters,
|
||||
'freightReconciliationSummary' => $summary,
|
||||
'freightReconciliationState' => $state,
|
||||
'freightReconciliationSync' => $sync,
|
||||
'freightReconciliationError' => $error,
|
||||
'freightReconciliationPending' => $pending,
|
||||
];
|
||||
}
|
||||
|
||||
protected function contentForDingPayableWorkflows(): array
|
||||
{
|
||||
$normalizeDate = static function (mixed $value): string {
|
||||
|
||||
@ -13,7 +13,7 @@ class Navigation extends Controller
|
||||
parent::__construct();
|
||||
|
||||
$this->middleware(function ($request, $next) {
|
||||
abort_unless(user()?->hasRole('admin'), 403);
|
||||
abort_unless(\App\Utilities\SystemAccess::canOpenSettings(), 403);
|
||||
|
||||
return $next($request);
|
||||
});
|
||||
|
||||
@ -21,6 +21,11 @@ class Sidebar extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
return view('system_settings.sidebar.index');
|
||||
}
|
||||
|
||||
public function edit(Request $request)
|
||||
{
|
||||
$subjects = $this->subjects();
|
||||
@ -63,7 +68,7 @@ class Sidebar extends Controller
|
||||
|
||||
flash('保存成功,侧边栏权限已更新。')->success();
|
||||
|
||||
return redirect()->route('dashboard');
|
||||
return redirect()->route('system-settings.sidebar.edit', ['subject' => $subject]);
|
||||
}
|
||||
|
||||
private function subjects(): array
|
||||
@ -72,7 +77,6 @@ class Sidebar extends Controller
|
||||
|
||||
if (SystemAccess::isSuperAdmin()) {
|
||||
$subjects = [
|
||||
'role_super_admin' => '用户类型:' . $labels[SystemAccess::SUPER_ADMIN],
|
||||
'role_admin' => '用户类型:' . $labels[SystemAccess::ADMIN],
|
||||
'role_user' => '用户类型:' . $labels[SystemAccess::USER],
|
||||
];
|
||||
@ -94,6 +98,9 @@ class Sidebar extends Controller
|
||||
|
||||
foreach ($users as $managedUser) {
|
||||
$role = SystemAccess::roleName($managedUser);
|
||||
if ($role === SystemAccess::SUPER_ADMIN) {
|
||||
continue;
|
||||
}
|
||||
$subjects['user_' . $managedUser->id] = '指定用户:' . $managedUser->name . ' - ' . ($labels[$role] ?? $role);
|
||||
}
|
||||
|
||||
|
||||
@ -87,12 +87,14 @@ class Users extends Controller
|
||||
$role = Role::where('name', $data['role'])->firstOrFail();
|
||||
|
||||
if (
|
||||
$managedUser->id === user()->id
|
||||
&& SystemAccess::isSuperAdmin($managedUser)
|
||||
&& $data['role'] !== SystemAccess::SUPER_ADMIN
|
||||
SystemAccess::isSuperAdmin($managedUser)
|
||||
&& $managedUser->enabled
|
||||
&& ($data['role'] !== SystemAccess::SUPER_ADMIN || ! ($data['enabled'] ?? false))
|
||||
&& $this->superAdminCount() <= 1
|
||||
) {
|
||||
return back()->withErrors(['role' => '系统至少需要保留一名超级管理员。'])->withInput();
|
||||
return back()->withErrors([
|
||||
$data['role'] !== SystemAccess::SUPER_ADMIN ? 'role' : 'enabled' => '系统至少需要保留一名启用的主管理员。',
|
||||
])->withInput();
|
||||
}
|
||||
|
||||
if ($managedUser->id === user()->id && ! ($data['enabled'] ?? false)) {
|
||||
@ -111,9 +113,11 @@ class Users extends Controller
|
||||
}
|
||||
|
||||
$managedUser->update($attributes);
|
||||
$managedUser->roles()->sync([$role->id]);
|
||||
$managedUser->syncRoles([$role->id]);
|
||||
});
|
||||
|
||||
$managedUser->flushCache();
|
||||
|
||||
flash('用户已修改。')->success();
|
||||
|
||||
return redirect()->route('system-settings.users.index');
|
||||
@ -126,8 +130,8 @@ class Users extends Controller
|
||||
abort_if($managedUser->id === user()->id, 422, '不能删除当前登录账号。');
|
||||
abort_unless(SystemAccess::canManageUser(user(), $managedUser), 403);
|
||||
|
||||
if (SystemAccess::isSuperAdmin($managedUser) && $this->superAdminCount() <= 1) {
|
||||
return back()->withErrors(['user' => '系统至少需要保留一名超级管理员。']);
|
||||
if (SystemAccess::isSuperAdmin($managedUser) && $managedUser->enabled && $this->superAdminCount() <= 1) {
|
||||
return back()->withErrors(['user' => '系统至少需要保留一名启用的主管理员。']);
|
||||
}
|
||||
|
||||
$managedUser->delete();
|
||||
@ -143,10 +147,6 @@ class Users extends Controller
|
||||
$company->where('companies.id', company_id());
|
||||
});
|
||||
|
||||
if (! SystemAccess::isSuperAdmin()) {
|
||||
$query->whereHas('roles', fn ($roles) => $roles->where('roles.name', SystemAccess::USER));
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
@ -176,6 +176,7 @@ class Users extends Controller
|
||||
private function superAdminCount(): int
|
||||
{
|
||||
return User::query()
|
||||
->where('enabled', true)
|
||||
->whereHas('roles', fn ($roles) => $roles->where('roles.name', SystemAccess::SUPER_ADMIN))
|
||||
->count();
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ class Settings extends Component
|
||||
|
||||
$items = [
|
||||
['system-settings.users.index', '用户管理', 'people', '用户 类型 权限'],
|
||||
['system-settings.sidebar.edit', '侧边栏管理', 'menu', '侧边栏 用户类型 指定管理员'],
|
||||
['system-settings.permissions.index', '权限管理', 'admin_panel_settings', '侧边栏 用户页面 访问权限 管理员 身份'],
|
||||
['system-settings.databases.index', '数据库管理', 'storage', '系统数据库 云数据库 上游 连接 测试'],
|
||||
];
|
||||
|
||||
@ -38,6 +38,8 @@ class Settings extends Component
|
||||
$item = $menu->route($route, $title, [], ($index + 1) * 10, [
|
||||
'icon' => $icon,
|
||||
'search_keywords' => $keywords,
|
||||
'active' => fn () => request()->routeIs(Str::beforeLast($route, '.') . '.*')
|
||||
|| ($route === 'system-settings.permissions.index' && request()->routeIs('system-settings.sidebar.*')),
|
||||
]);
|
||||
|
||||
if ($item->isActive()) {
|
||||
|
||||
@ -14,6 +14,30 @@ class User extends FormRequest
|
||||
*/
|
||||
public function authorize()
|
||||
{
|
||||
$actor = $this->user();
|
||||
if ($actor && ! \App\Utilities\SystemAccess::isSuperAdmin($actor)) {
|
||||
$target = $this->route('user');
|
||||
$target = $target instanceof \App\Models\Auth\User ? $target : \App\Models\Auth\User::find($target);
|
||||
if ($target && $target->id !== $actor->id && \App\Utilities\SystemAccess::canOpenSettings($target)) {
|
||||
return false;
|
||||
}
|
||||
if ($this->filled('permissions')) {
|
||||
return false;
|
||||
}
|
||||
if ($this->has('roles')) {
|
||||
$requested = array_map('strval', (array) $this->input('roles'));
|
||||
$current = $target ? $target->roles()->pluck('id')->map(fn ($id) => (string) $id)->all() : [];
|
||||
sort($requested);
|
||||
sort($current);
|
||||
if ($requested !== $current) {
|
||||
$allowed = \App\Models\Auth\Role::where('name', 'user')->pluck('id')->map(fn ($id) => (string) $id)->all();
|
||||
if (array_diff($requested, $allowed)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ class ShowInSettings
|
||||
{
|
||||
$menu = $event->menu;
|
||||
|
||||
if (user()?->hasRole('admin')) {
|
||||
if (\App\Utilities\SystemAccess::canOpenSettings()) {
|
||||
$menu->route('settings.navigation.manage', '侧边栏功能名称', [], 75, [
|
||||
'icon' => 'menu',
|
||||
'search_keywords' => '侧边栏 功能 名称',
|
||||
|
||||
@ -31,13 +31,14 @@ class SidebarPermissions
|
||||
|
||||
public static function allowedKeys(User $user): ?array
|
||||
{
|
||||
if (SystemAccess::isSuperAdmin($user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$settings = self::settings();
|
||||
$subject = self::subjectKey($user);
|
||||
|
||||
if (! in_array($subject, $settings['configured'] ?? [], true)) {
|
||||
if ($user->created_from === 'dingtalk_roster' && SystemAccess::roleName($user) === SystemAccess::USER) {
|
||||
return [];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -60,7 +61,7 @@ class SidebarPermissions
|
||||
return $section;
|
||||
}
|
||||
|
||||
$pages = array_values(array_filter($section['pages'] ?? [], function (array $page) use ($allowed, $sectionKey) {
|
||||
$pages = array_values(array_filter(array_map(function (array $page) use ($allowed, $sectionKey) {
|
||||
$pageKey = $sectionKey . '/' . $page['slug'];
|
||||
|
||||
if (in_array($pageKey, $allowed, true)) {
|
||||
@ -70,7 +71,7 @@ class SidebarPermissions
|
||||
return in_array($sectionKey . '/' . $child['slug'], $allowed, true);
|
||||
}));
|
||||
}
|
||||
return true;
|
||||
return $page;
|
||||
}
|
||||
|
||||
// 子页面可能在 allowed 列表中(独立 URL),父级分组也允许展开
|
||||
@ -80,12 +81,12 @@ class SidebarPermissions
|
||||
}));
|
||||
if (! empty($visibleChildren)) {
|
||||
$page['children'] = $visibleChildren;
|
||||
return true;
|
||||
return $page;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}));
|
||||
return null;
|
||||
}, $section['pages'] ?? [])));
|
||||
|
||||
if (! empty($pages)) {
|
||||
$section['pages'] = $pages;
|
||||
|
||||
@ -18,8 +18,11 @@ class SystemAccess
|
||||
return '';
|
||||
}
|
||||
|
||||
// 身份变更必须立即生效,不能沿用权限库中已缓存的旧角色。
|
||||
$roles = $user->roles()->pluck('name')->all();
|
||||
|
||||
foreach ([self::SUPER_ADMIN, self::ADMIN, self::USER] as $role) {
|
||||
if ($user->hasRole($role)) {
|
||||
if (in_array($role, $roles, true)) {
|
||||
return $role;
|
||||
}
|
||||
}
|
||||
@ -53,8 +56,8 @@ class SystemAccess
|
||||
|
||||
if (self::isSuperAdmin($actor)) {
|
||||
return [
|
||||
self::SUPER_ADMIN => '超级管理员',
|
||||
self::ADMIN => '普通管理员',
|
||||
self::SUPER_ADMIN => '主管理员(最高权限)',
|
||||
self::ADMIN => '管理员',
|
||||
self::USER => '普通用户',
|
||||
];
|
||||
}
|
||||
@ -65,8 +68,8 @@ class SystemAccess
|
||||
public static function roleLabels(): array
|
||||
{
|
||||
return [
|
||||
self::SUPER_ADMIN => '超级管理员',
|
||||
self::ADMIN => '普通管理员',
|
||||
self::SUPER_ADMIN => '主管理员',
|
||||
self::ADMIN => '管理员',
|
||||
self::USER => '普通用户',
|
||||
];
|
||||
}
|
||||
|
||||
@ -254,7 +254,7 @@ return [
|
||||
['name' => 'account_type', 'label' => '账号类型'],
|
||||
['name' => 'account', 'label' => '账号'],
|
||||
['name' => 'password_configured', 'label' => '密码', 'kind' => 'secret_status'],
|
||||
['name' => 'bound_phone', 'label' => '绑定手机号', 'kind' => 'secret'],
|
||||
['name' => 'bound_phone', 'label' => '绑定手机号'],
|
||||
['name' => 'verification_contact', 'label' => '手机号验证码对接人'],
|
||||
['name' => 'account_group_name', 'label' => '账号分组名称'],
|
||||
['name' => 'created_at', 'label' => '店铺创建时间', 'kind' => 'date'],
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
|
||||
新账号使用钉钉接口返回的姓名、可用邮箱和手机号;邮箱缺失或已被其他账号使用时,生成稳定的 `@dingtalk.invalid` 内部占位地址。系统生成不可预知的随机本地密码,不展示或发送密码,不发送注册邀请。花名册显示名仅作为姓名缺失时的后备。
|
||||
|
||||
新账号赋予现有 `user` 角色及进入应用外壳所需的 `read-admin-panel` 权限,不添加管理权限。业务访问继承管理员明确配置的普通用户权限;尚无配置时默认无业务权限,进入“你已登录,等待分配业务权限”的工作台。管理员可在“侧边栏管理”中为普通用户角色或指定账号配置业务范围。
|
||||
新账号赋予现有 `user` 角色及进入应用外壳所需的 `read-admin-panel` 权限,不添加管理权限。按负责人最新要求,尚无页面权限配置时默认显示完整业务导航;主管理员随后可在“侧边栏管理”中设置指定用户的业务范围。配置优先级为指定用户、角色、默认完整导航;显式配置为空时没有业务页面,不回退到完整导航。管理员专属操作(包括店铺密码读取)继续单独鉴权。
|
||||
|
||||
## 只读数据契约
|
||||
|
||||
|
||||
@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/** 快递应付运费对账的共享业务镜像只读访问。 */
|
||||
class CourierFreightReconciliationMirrorRepository
|
||||
{
|
||||
public const DATASET = 'jiansun.dbo.fys_express_courier_settlement';
|
||||
|
||||
/** Only columns present in js_express_courier_settlement may be exposed. */
|
||||
private const COLUMNS = [
|
||||
'Id', 'month', 'status', 'courier_company_name', 'expresscode', 'expressname',
|
||||
'replenish_courier', 'opening_balance', 'courier_provided_amount',
|
||||
'reconciliation_difference', 'confirmed_amount', 'waybill', 'advance_payment',
|
||||
'payable_amount', 'actual_payment', 'reconciliation_person', 'file_status',
|
||||
'create_time', 'modify_time', 'is_delete', 'taskid', 'guid', 'creator',
|
||||
'compensation_amount', 'other_replenishment', 'corporate_payment_amount',
|
||||
'personal_payment_amount',
|
||||
];
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(
|
||||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||||
private readonly GenericBusinessMirrorRepository $mirror,
|
||||
) {
|
||||
$this->connection = $mirror->connection();
|
||||
}
|
||||
|
||||
public function available(): bool
|
||||
{
|
||||
try {
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
$dataset = $this->datasets->get(self::DATASET);
|
||||
|
||||
return $schema->hasTable('business_mirror_table_states')
|
||||
&& $schema->hasTable($dataset->targetTable());
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function ready(): bool
|
||||
{
|
||||
try {
|
||||
return $this->mirror->ready($this->datasets->get(self::DATASET));
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function state(): ?array
|
||||
{
|
||||
try {
|
||||
return $this->mirror->state($this->datasets->get(self::DATASET));
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{code:string,last_succeeded_at:?string,lag_minutes:?int} */
|
||||
public function syncStatus(?array $state = null): array
|
||||
{
|
||||
$state ??= $this->state();
|
||||
if (! is_array($state)) {
|
||||
return ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
|
||||
}
|
||||
|
||||
$lastSucceeded = $state['last_succeeded_at'] ?? null;
|
||||
$lagMinutes = null;
|
||||
if (is_string($lastSucceeded) && $lastSucceeded !== '') {
|
||||
try {
|
||||
$lagMinutes = (int) ceil(max(0, now()->getTimestamp() - Carbon::parse($lastSucceeded)->getTimestamp()) / 60);
|
||||
} catch (Throwable) {
|
||||
$lagMinutes = null;
|
||||
}
|
||||
}
|
||||
|
||||
$status = (string) ($state['status'] ?? 'pending');
|
||||
if (($state['initial_completed_at'] ?? null) === null) {
|
||||
$code = $status === 'failed' ? 'initial_failed' : ($status === 'running' ? 'initializing' : 'pending');
|
||||
} elseif ($status === 'failed') {
|
||||
$code = 'failed';
|
||||
} elseif ($status === 'running') {
|
||||
$code = 'syncing';
|
||||
} elseif ($lagMinutes === null || $lagMinutes > max((int) config('upstream_mirrors.courier_information.stale_after_minutes', 60), 1)) {
|
||||
$code = 'stale';
|
||||
} else {
|
||||
$code = 'fresh';
|
||||
}
|
||||
|
||||
return ['code' => $code, 'last_succeeded_at' => $lastSucceeded, 'lag_minutes' => $lagMinutes];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $filters
|
||||
* @return array{data:list<array<string,mixed>>,total:int,summary:array<string,float|int>,state:array<string,mixed>|null,sync:array<string,mixed>}
|
||||
*/
|
||||
public function paginate(array $filters, int $page, int $perPage): array
|
||||
{
|
||||
$dataset = $this->datasets->get(self::DATASET);
|
||||
if (! $this->mirror->ready($dataset)) {
|
||||
throw new RuntimeException('courier_freight_reconciliation_mirror_not_ready');
|
||||
}
|
||||
|
||||
$page = max($page, 1);
|
||||
$perPage = min(max($perPage, 1), 100);
|
||||
$query = $this->filteredQuery($dataset->targetTable(), $filters);
|
||||
$total = (clone $query)->count();
|
||||
$summary = (clone $query)->selectRaw(
|
||||
'COUNT(*) AS aggregate_count, '
|
||||
. 'COALESCE(SUM(payable_amount), 0) AS payable_amount, '
|
||||
. 'COALESCE(SUM(actual_payment), 0) AS actual_payment, '
|
||||
. 'COALESCE(SUM(confirmed_amount), 0) AS confirmed_amount, '
|
||||
. 'COALESCE(SUM(reconciliation_difference), 0) AS reconciliation_difference'
|
||||
)->first();
|
||||
$rows = (clone $query)->select(self::COLUMNS)
|
||||
->orderByDesc('Id')
|
||||
->forPage($page, $perPage)
|
||||
->get()
|
||||
->map(fn (object $row): array => $this->mapRow($row))
|
||||
->all();
|
||||
$state = $this->mirror->state($dataset);
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
'total' => $total,
|
||||
'summary' => [
|
||||
'count' => (int) ($summary->aggregate_count ?? 0),
|
||||
'payable_amount' => (float) ($summary->payable_amount ?? 0),
|
||||
'actual_payment' => (float) ($summary->actual_payment ?? 0),
|
||||
'confirmed_amount' => (float) ($summary->confirmed_amount ?? 0),
|
||||
'reconciliation_difference' => (float) ($summary->reconciliation_difference ?? 0),
|
||||
],
|
||||
'state' => $state,
|
||||
'sync' => $this->syncStatus($state),
|
||||
];
|
||||
}
|
||||
|
||||
private function filteredQuery(string $table, array $filters): Builder
|
||||
{
|
||||
$query = $this->connection->table($table)
|
||||
->where(static function (Builder $query): void {
|
||||
$query->whereNull('is_delete')->orWhere('is_delete', '=', 0);
|
||||
});
|
||||
$this->containsFilter($query, 'waybill', $filters['express_no'] ?? '');
|
||||
$this->containsFilter($query, 'expressname', $filters['express_name'] ?? '');
|
||||
|
||||
if (($filters['month'] ?? '') !== '') {
|
||||
$query->where('month', '=', $filters['month']);
|
||||
}
|
||||
if (($filters['status'] ?? '') !== '') {
|
||||
$query->where('status', '=', $filters['status']);
|
||||
}
|
||||
if (($filters['modified_from'] ?? '') !== '') {
|
||||
$query->where('modify_time', '>=', $filters['modified_from'] . ' 00:00:00');
|
||||
}
|
||||
if (($filters['modified_to'] ?? '') !== '') {
|
||||
$query->where('modify_time', '<', date('Y-m-d 00:00:00', strtotime($filters['modified_to'] . ' +1 day')));
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function containsFilter(Builder $query, string $column, mixed $value): void
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$escaped = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value);
|
||||
$query->whereRaw($column . " LIKE ? ESCAPE '!'", ['%' . $escaped . '%']);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function mapRow(object $row): array
|
||||
{
|
||||
$mapped = [];
|
||||
foreach (self::COLUMNS as $column) {
|
||||
$mapped[$column] = $row->{$column} ?? null;
|
||||
}
|
||||
foreach (['opening_balance', 'courier_provided_amount', 'reconciliation_difference', 'confirmed_amount', 'advance_payment', 'payable_amount', 'actual_payment', 'compensation_amount', 'other_replenishment', 'corporate_payment_amount', 'personal_payment_amount'] as $amount) {
|
||||
$mapped[$amount] = $mapped[$amount] === null ? null : (float) $mapped[$amount];
|
||||
}
|
||||
|
||||
return $mapped;
|
||||
}
|
||||
}
|
||||
@ -1,311 +1,87 @@
|
||||
<!--
|
||||
快递应付 - 运费对账
|
||||
表头设计来源: 快递应付-运费对账功能页面的表头.xls (45列)
|
||||
-->
|
||||
@php
|
||||
$filters = $freightReconciliationFilters ?? [];
|
||||
$summary = $freightReconciliationSummary ?? [];
|
||||
$sync = $freightReconciliationSync ?? ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
|
||||
$statusLabels = ['fresh' => '数据新鲜', 'stale' => '数据可能滞后', 'syncing' => '同步中', 'initializing' => '首次同步中', 'pending' => '等待同步', 'failed' => '同步失败', 'initial_failed' => '首次同步失败', 'unavailable' => '镜像不可用'];
|
||||
$money = static fn (mixed $value): string => number_format((float) ($value ?? 0), 2);
|
||||
@endphp
|
||||
|
||||
<div class="courier-reconciliation-page">
|
||||
<style>
|
||||
#main-body > .container {
|
||||
max-width: none;
|
||||
width: auto;
|
||||
margin-left: 1.5rem;
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
.courier-reconciliation-page { color: #29251f; }
|
||||
.courier-reconciliation-page .recon-control { border: 1px solid #d9d0c2; border-radius: .375rem; padding: .5rem .7rem; font-size: .8125rem; background: #fff; outline: none; }
|
||||
.courier-reconciliation-page .recon-control:focus { border-color: #b18a4a; box-shadow: 0 0 0 2px rgba(177,138,74,.15); }
|
||||
.courier-reconciliation-page .recon-button { border-radius: .375rem; padding: .5rem 1rem; font-size: .8125rem; }
|
||||
.courier-reconciliation-page .recon-button--primary { background: #5d492c; color: #fff; }
|
||||
.courier-reconciliation-page .recon-button--primary:hover { background: #49391f; }
|
||||
.courier-reconciliation-page .recon-button--secondary { border: 1px solid #d9d0c2; color: #655b4e; background: #fff; }
|
||||
.courier-reconciliation-page .recon-button--secondary:hover { background: #f7f3ed; }
|
||||
.courier-reconciliation-page .recon-table th { background: #f5f1ea; color: #6f675c; font-weight: 600; }
|
||||
.courier-reconciliation-page .recon-table td, .courier-reconciliation-page .recon-table th { border-bottom: 1px solid #eee8de; padding: .65rem .75rem; white-space: nowrap; }
|
||||
.courier-reconciliation-page .recon-table tbody tr:hover { background: #fcfaf7; }
|
||||
</style>
|
||||
<div class="w-full">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-800">{{ $page['title'] ?? '运费对账' }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 mb-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-800">{{ $page['title'] ?? '运费对账' }}</h1>
|
||||
<p class="mt-1 text-xs text-gray-500">数据来源:JianSun · dbo.fys_express_courier_settlement · 共享业务镜像</p>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
|
||||
<form method="GET" class="flex flex-wrap items-end gap-3">
|
||||
<div class="flex flex-col min-w-[140px]">
|
||||
<label class="text-xs text-gray-500 mb-1">快递单号</label>
|
||||
<input type="text" name="express_no" value="{{ request('express_no') }}" placeholder="搜索快递单号"
|
||||
class="border border-gray-300 rounded px-3 py-1.5 text-sm focus:ring-1 focus:ring-blue-400 focus:border-blue-400 outline-none">
|
||||
</div>
|
||||
<div class="flex flex-col min-w-[120px]">
|
||||
<label class="text-xs text-gray-500 mb-1">条形码</label>
|
||||
<input type="text" name="barcode" value="{{ request('barcode') }}" placeholder="搜索条形码"
|
||||
class="border border-gray-300 rounded px-3 py-1.5 text-sm focus:ring-1 focus:ring-blue-400 focus:border-blue-400 outline-none">
|
||||
</div>
|
||||
<div class="flex flex-col min-w-[120px]">
|
||||
<label class="text-xs text-gray-500 mb-1">快递名称</label>
|
||||
<input type="text" name="express_name" value="{{ request('express_name') }}" placeholder="快递公司"
|
||||
class="border border-gray-300 rounded px-3 py-1.5 text-sm focus:ring-1 focus:ring-blue-400 focus:border-blue-400 outline-none">
|
||||
</div>
|
||||
<div class="flex flex-col min-w-[120px]">
|
||||
<label class="text-xs text-gray-500 mb-1">平台</label>
|
||||
<select name="platform"
|
||||
class="border border-gray-300 rounded px-3 py-1.5 text-sm focus:ring-1 focus:ring-blue-400 focus:border-blue-400 outline-none bg-white">
|
||||
<option value="">全部</option>
|
||||
<option value="淘宝天猫" {{ request('platform') === '淘宝天猫' ? 'selected' : '' }}>淘宝天猫</option>
|
||||
<option value="京东商城" {{ request('platform') === '京东商城' ? 'selected' : '' }}>京东商城</option>
|
||||
<option value="拼多多" {{ request('platform') === '拼多多' ? 'selected' : '' }}>拼多多</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col min-w-[110px]">
|
||||
<label class="text-xs text-gray-500 mb-1">供应商</label>
|
||||
<input type="text" name="supplier" value="{{ request('supplier') }}" placeholder="供应商"
|
||||
class="border border-gray-300 rounded px-3 py-1.5 text-sm focus:ring-1 focus:ring-blue-400 focus:border-blue-400 outline-none">
|
||||
</div>
|
||||
<div class="flex flex-col min-w-[130px]">
|
||||
<label class="text-xs text-gray-500 mb-1">发货时间 从</label>
|
||||
<input type="date" name="ship_from" value="{{ request('ship_from') }}"
|
||||
class="border border-gray-300 rounded px-3 py-1.5 text-sm focus:ring-1 focus:ring-blue-400 focus:border-blue-400 outline-none">
|
||||
</div>
|
||||
<div class="flex flex-col min-w-[130px]">
|
||||
<label class="text-xs text-gray-500 mb-1">发货时间 至</label>
|
||||
<input type="date" name="ship_to" value="{{ request('ship_to') }}"
|
||||
class="border border-gray-300 rounded px-3 py-1.5 text-sm focus:ring-1 focus:ring-blue-400 focus:border-blue-400 outline-none">
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit"
|
||||
class="bg-blue-500 hover:bg-blue-600 text-white text-sm px-4 py-1.5 rounded transition">
|
||||
查询
|
||||
</button>
|
||||
<a href="{{ url()->current() }}"
|
||||
class="border border-gray-300 text-gray-600 text-sm px-4 py-1.5 rounded hover:bg-gray-50 transition">
|
||||
重置
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Data Table - 45 columns with horizontal scroll -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-xs whitespace-nowrap">
|
||||
<thead>
|
||||
<!-- Column Group Headers -->
|
||||
<tr class="bg-gray-100 border-b border-gray-300 text-gray-500 font-medium">
|
||||
<th class="sticky left-0 bg-gray-100 z-10 px-2 py-1.5 text-center border-r border-gray-200" style="min-width:40px">#</th>
|
||||
<!-- 物流信息 (10 cols) -->
|
||||
<th class="px-2 py-1 text-center border-r border-gray-200" colspan="10" style="min-width:800px">物流信息</th>
|
||||
<!-- 订单信息 (8 cols) -->
|
||||
<th class="px-2 py-1 text-center border-r border-gray-200" colspan="8" style="min-width:700px">订单信息</th>
|
||||
<!-- 收件信息 (7 cols) -->
|
||||
<th class="px-2 py-1 text-center border-r border-gray-200" colspan="7" style="min-width:650px">收件信息</th>
|
||||
<!-- 商品规格 (5 cols) -->
|
||||
<th class="px-2 py-1 text-center border-r border-gray-200" colspan="5" style="min-width:380px">商品规格</th>
|
||||
<!-- 平台/店铺 (3 cols) -->
|
||||
<th class="px-2 py-1 text-center border-r border-gray-200" colspan="3" style="min-width:300px">平台/店铺</th>
|
||||
<!-- 供应商/仓库 (5 cols) -->
|
||||
<th class="px-2 py-1 text-center border-r border-gray-200" colspan="5" style="min-width:480px">供应商/仓库</th>
|
||||
<!-- 时间 (3 cols) -->
|
||||
<th class="px-2 py-1 text-center border-r border-gray-200" colspan="3" style="min-width:400px">时间</th>
|
||||
<!-- 备注 (2 cols) -->
|
||||
<th class="px-2 py-1 text-center" colspan="2" style="min-width:220px">备注</th>
|
||||
</tr>
|
||||
<!-- Column Sub-Headers -->
|
||||
<tr class="bg-gray-50 border-b border-gray-200 text-gray-600 font-medium">
|
||||
<th class="sticky left-0 bg-gray-50 z-10 px-2 py-1.5 text-center border-r border-gray-200" style="min-width:40px">#</th>
|
||||
{{-- 物流信息 --}}
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:140px">快递单号</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:100px">快递名称1</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:100px">快递名称2</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:70px">运费</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:80px">费率</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:70px">结算重量</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:80px">结算体积</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:70px">对账重量</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:80px">对账体积</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:120px">换单前单号</th>
|
||||
{{-- 订单信息 --}}
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:140px">交易单号</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:100px">商品ID</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:120px">条形码</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:160px">商品名称</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:50px">数量</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:70px">金额</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:100px">货号</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:100px">配货单号</th>
|
||||
{{-- 收件信息 --}}
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:50px">省</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:70px">市</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:70px">区</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:200px">收货详细地址</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:70px">收货姓名</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:100px">手机号码</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:90px">座机号码</th>
|
||||
{{-- 商品规格 --}}
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:60px">长(cm)</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:60px">宽(cm)</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:60px">高(cm)</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:80px">体积(cm³)</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:80px">重量(kg)</th>
|
||||
{{-- 平台/店铺 --}}
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:90px">平台</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:130px">店铺名称</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:100px">买家昵称</th>
|
||||
{{-- 供应商/仓库 --}}
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:140px">供应商</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:120px">仓库</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:100px">成品条码</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:120px">成品名称</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:120px">成品供应商</th>
|
||||
{{-- 时间 --}}
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:140px">发货时间</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:120px">下载时间</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:120px">付款时间</th>
|
||||
{{-- 备注 --}}
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:130px">买家留言</th>
|
||||
<th class="px-2 py-1.5" style="min-width:130px">卖家备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($freightReconciliations ?? [] as $index => $item)
|
||||
<tr class="border-b border-gray-100 hover:bg-blue-50/40 transition">
|
||||
<td class="sticky left-0 bg-white z-10 px-2 py-2 text-center text-gray-400 border-r border-gray-100">
|
||||
{{ ($freightReconciliations->currentPage() - 1) * $freightReconciliations->perPage() + $index + 1 }}
|
||||
</td>
|
||||
{{-- 物流信息 --}}
|
||||
<td class="px-2 py-2 mono text-blue-600 border-r border-gray-100">{{ $item['express_no'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 border-r border-gray-100">{{ $item['express_name1'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 border-r border-gray-100">{{ $item['express_name2'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-right font-medium border-r border-gray-100 {{ ($item['freight'] ?? 0) > 0 ? 'text-gray-800' : 'text-gray-300' }}">
|
||||
{{ isset($item['freight']) ? number_format((float) $item['freight'], 2) : '-' }}
|
||||
</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ $item['rate'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['settle_weight']) ? number_format((float) $item['settle_weight'], 4) : '-' }}</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['settle_volume']) ? number_format((float) $item['settle_volume'], 4) : '-' }}</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['recon_weight']) ? number_format((float) $item['recon_weight'], 4) : '-' }}</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['recon_volume']) ? number_format((float) $item['recon_volume'], 4) : '-' }}</td>
|
||||
<td class="px-2 py-2 text-gray-400 border-r border-gray-100 text-xs">{{ $item['prev_express_no'] ?? '-' }}</td>
|
||||
{{-- 订单信息 --}}
|
||||
<td class="px-2 py-2 mono text-xs text-gray-500 border-r border-gray-100">{{ $item['trade_no'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 mono text-xs text-gray-500 border-r border-gray-100">{{ $item['product_id'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 mono text-gray-700 border-r border-gray-100">{{ $item['barcode'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 border-r border-gray-100 max-w-[160px] truncate" title="{{ $item['product_name'] ?? '' }}">
|
||||
{{ $item['product_name'] ?? '-' }}
|
||||
</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ $item['qty'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['amount']) ? number_format((float) $item['amount'], 2) : '-' }}</td>
|
||||
<td class="px-2 py-2 text-gray-400 text-xs border-r border-gray-100">{{ $item['item_no'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-gray-400 text-xs border-r border-gray-100">{{ $item['pick_no'] ?? '-' }}</td>
|
||||
{{-- 收件信息 --}}
|
||||
<td class="px-2 py-2 border-r border-gray-100">{{ $item['province'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 border-r border-gray-100">{{ $item['city'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 border-r border-gray-100">{{ $item['district'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-gray-400 text-xs border-r border-gray-100 max-w-[200px] truncate" title="{{ $item['address'] ?? '' }}">
|
||||
{{ $item['address'] ?? '-' }}
|
||||
</td>
|
||||
<td class="px-2 py-2 border-r border-gray-100">{{ $item['receiver_name'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 mono text-xs border-r border-gray-100">{{ $item['receiver_phone'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 mono text-xs text-gray-400 border-r border-gray-100">{{ $item['receiver_tel'] ?? '-' }}</td>
|
||||
{{-- 商品规格 --}}
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['length']) ? number_format((float) $item['length'], 2) : '-' }}</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['width']) ? number_format((float) $item['width'], 2) : '-' }}</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['height']) ? number_format((float) $item['height'], 2) : '-' }}</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['volume']) ? number_format((float) $item['volume'], 4) : '-' }}</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['weight']) ? number_format((float) $item['weight'], 4) : '-' }}</td>
|
||||
{{-- 平台/店铺 --}}
|
||||
<td class="px-2 py-2 border-r border-gray-100">
|
||||
<span class="platform-tag {{ str_contains($item['platform'] ?? '', '天猫') || str_contains($item['platform'] ?? '', '淘宝') ? 'bg-orange-100 text-orange-700' : (str_contains($item['platform'] ?? '', '京东') ? 'bg-red-100 text-red-700' : (str_contains($item['platform'] ?? '', '拼多多') ? 'bg-pink-100 text-pink-700' : 'bg-gray-100 text-gray-600')) }} text-xs px-1.5 py-0.5 rounded">
|
||||
{{ $item['platform'] ?? '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-2 py-2 text-xs text-gray-500 border-r border-gray-100 max-w-[130px] truncate" title="{{ $item['shop_name'] ?? '' }}">
|
||||
{{ $item['shop_name'] ?? '-' }}
|
||||
</td>
|
||||
<td class="px-2 py-2 text-gray-400 text-xs border-r border-gray-100">{{ $item['buyer_nick'] ?? '-' }}</td>
|
||||
{{-- 供应商/仓库 --}}
|
||||
<td class="px-2 py-2 border-r border-gray-100">{{ $item['supplier'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-xs text-gray-500 border-r border-gray-100">{{ $item['warehouse'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 mono text-xs text-gray-400 border-r border-gray-100">{{ $item['finished_barcode'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-xs text-gray-400 border-r border-gray-100">{{ $item['finished_name'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-xs text-gray-400 border-r border-gray-100">{{ $item['finished_supplier'] ?? '-' }}</td>
|
||||
{{-- 时间 --}}
|
||||
<td class="px-2 py-2 text-xs border-r border-gray-100">{{ $item['ship_time'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-xs text-gray-400 border-r border-gray-100">{{ $item['download_time'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-xs text-gray-400 border-r border-gray-100">{{ $item['pay_time'] ?? '-' }}</td>
|
||||
{{-- 备注 --}}
|
||||
<td class="px-2 py-2 text-xs text-gray-400 border-r border-gray-100 max-w-[130px] truncate" title="{{ $item['buyer_msg'] ?? '' }}">
|
||||
{{ $item['buyer_msg'] ?? '-' }}
|
||||
</td>
|
||||
<td class="px-2 py-2 text-xs text-gray-400 max-w-[130px] truncate" title="{{ $item['seller_memo'] ?? '' }}">
|
||||
{{ $item['seller_memo'] ?? '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="46" class="text-center py-16 text-gray-400">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span class="text-sm">暂无运费对账数据</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
@if (isset($freightReconciliations) && $freightReconciliations->hasPages())
|
||||
<div class="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
|
||||
<div class="text-sm text-gray-500">
|
||||
共 <span class="font-medium text-gray-700">{{ $freightReconciliations->total() }}</span> 条记录
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
@if ($freightReconciliations->onFirstPage())
|
||||
<span class="px-3 py-1 text-sm text-gray-300 border border-gray-200 rounded cursor-not-allowed">上一页</span>
|
||||
@else
|
||||
<a href="{{ $freightReconciliations->previousPageUrl() }}" class="px-3 py-1 text-sm text-gray-600 border border-gray-200 rounded hover:bg-gray-50 transition">上一页</a>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$current = $freightReconciliations->currentPage();
|
||||
$last = $freightReconciliations->lastPage();
|
||||
$start = max(1, $current - 2);
|
||||
$end = min($last, $current + 2);
|
||||
@endphp
|
||||
|
||||
@if ($start > 1)
|
||||
<a href="{{ $freightReconciliations->url(1) }}" class="px-3 py-1 text-sm text-gray-600 border border-gray-200 rounded hover:bg-gray-50 transition">1</a>
|
||||
@if ($start > 2)
|
||||
<span class="px-1 text-gray-400">...</span>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@for ($p = $start; $p <= $end; $p++)
|
||||
@if ($p === $current)
|
||||
<span class="px-3 py-1 text-sm text-white bg-blue-500 border border-blue-500 rounded">{{ $p }}</span>
|
||||
@else
|
||||
<a href="{{ $freightReconciliations->url($p) }}" class="px-3 py-1 text-sm text-gray-600 border border-gray-200 rounded hover:bg-gray-50 transition">{{ $p }}</a>
|
||||
@endif
|
||||
@endfor
|
||||
|
||||
@if ($end < $last)
|
||||
@if ($end < $last - 1)
|
||||
<span class="px-1 text-gray-400">...</span>
|
||||
@endif
|
||||
<a href="{{ $freightReconciliations->url($last) }}" class="px-3 py-1 text-sm text-gray-600 border border-gray-200 rounded hover:bg-gray-50 transition">{{ $last }}</a>
|
||||
@endif
|
||||
|
||||
@if ($freightReconciliations->hasMorePages())
|
||||
<a href="{{ $freightReconciliations->nextPageUrl() }}" class="px-3 py-1 text-sm text-gray-600 border border-gray-200 rounded hover:bg-gray-50 transition">下一页</a>
|
||||
@else
|
||||
<span class="px-3 py-1 text-sm text-gray-300 border border-gray-200 rounded cursor-not-allowed">下一页</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="rounded-full border border-[#d9d0c2] bg-white px-3 py-1.5 text-[#6f675c]">{{ $statusLabels[$sync['code'] ?? 'unavailable'] ?? '镜像状态未知' }}</span>
|
||||
@if (!empty($sync['last_succeeded_at'])) <span class="text-gray-500">最近同步 {{ $sync['last_succeeded_at'] }}</span> @endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px; }
|
||||
.platform-tag { white-space: nowrap; }
|
||||
/* Sticky first column on horizontal scroll */
|
||||
@media (max-width: 768px) {
|
||||
.overflow-x-auto table { font-size: 10px; }
|
||||
}
|
||||
</style>
|
||||
@if (!empty($freightReconciliationError))
|
||||
<div class="mb-4 rounded border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{{ $freightReconciliationError }}</div>
|
||||
@endif
|
||||
|
||||
<div class="mb-5 grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||
@foreach ([
|
||||
['label' => '记录数', 'value' => number_format((int) ($summary['count'] ?? 0))],
|
||||
['label' => '应付金额', 'value' => '¥ ' . $money($summary['payable_amount'] ?? 0)],
|
||||
['label' => '已确认金额', 'value' => '¥ ' . $money($summary['confirmed_amount'] ?? 0)],
|
||||
['label' => '实际付款', 'value' => '¥ ' . $money($summary['actual_payment'] ?? 0)],
|
||||
['label' => '对账差额', 'value' => '¥ ' . $money($summary['reconciliation_difference'] ?? 0)],
|
||||
] as $stat)
|
||||
<div class="rounded-lg border border-[#e7e0d5] bg-white px-4 py-3 shadow-sm"><div class="text-xs text-gray-500">{{ $stat['label'] }}</div><div class="mt-1 text-lg font-semibold text-[#4b3a24]">{{ $stat['value'] }}</div></div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mb-5 rounded-lg border border-[#e7e0d5] bg-white p-4 shadow-sm">
|
||||
<form method="GET" class="flex flex-wrap items-end gap-3" aria-label="运费对账筛选">
|
||||
@foreach ([
|
||||
['name' => 'express_no', 'label' => '快递单号', 'placeholder' => '包含匹配'],
|
||||
['name' => 'express_name', 'label' => '快递名称', 'placeholder' => '包含匹配'],
|
||||
['name' => 'month', 'label' => '月份', 'placeholder' => '如 2026-08'],
|
||||
['name' => 'status', 'label' => '状态', 'placeholder' => '精确匹配'],
|
||||
] as $field)
|
||||
<label class="flex min-w-[150px] flex-col gap-1 text-xs text-gray-500"><span>{{ $field['label'] }}</span><input class="recon-control" type="text" name="{{ $field['name'] }}" value="{{ $filters[$field['name']] ?? '' }}" placeholder="{{ $field['placeholder'] }}"></label>
|
||||
@endforeach
|
||||
<label class="flex min-w-[145px] flex-col gap-1 text-xs text-gray-500"><span>修改时间起</span><input class="recon-control" type="date" name="modified_from" value="{{ $filters['modified_from'] ?? '' }}"></label>
|
||||
<label class="flex min-w-[145px] flex-col gap-1 text-xs text-gray-500"><span>修改时间止</span><input class="recon-control" type="date" name="modified_to" value="{{ $filters['modified_to'] ?? '' }}"></label>
|
||||
<div class="flex gap-2"><button class="recon-button recon-button--primary" type="submit">查询</button><a class="recon-button recon-button--secondary" href="{{ url()->current() }}">重置</a></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-[#e7e0d5] bg-white shadow-sm">
|
||||
<div class="overflow-x-auto"><table class="recon-table min-w-full text-sm"><thead><tr>
|
||||
<th class="text-left">快递单号</th><th class="text-left">快递名称</th><th class="text-left">月份</th><th class="text-left">状态</th>
|
||||
<th class="text-right">对账差额</th><th class="text-right">已确认金额</th><th class="text-right">应付金额</th><th class="text-right">实际付款</th><th class="text-right">预付款</th><th class="text-right">赔偿金额</th><th class="text-right">其他补款</th><th class="text-right">公司付款</th><th class="text-right">个人付款</th><th class="text-left">创建时间</th><th class="text-left">修改时间</th>
|
||||
</tr></thead><tbody>
|
||||
@forelse ($freightReconciliations ?? [] as $row)
|
||||
<tr><td class="font-mono text-xs text-[#4b3a24]">{{ $row['waybill'] ?? '-' }}</td><td>{{ $row['expressname'] ?: ($row['courier_company_name'] ?? '-') }}</td><td>{{ $row['month'] ?? '-' }}</td><td>{{ $row['status'] ?? '-' }}</td>
|
||||
@foreach (['reconciliation_difference','confirmed_amount','payable_amount','actual_payment','advance_payment','compensation_amount','other_replenishment','corporate_payment_amount','personal_payment_amount'] as $amount)
|
||||
<td class="text-right tabular-nums">{{ $row[$amount] === null ? '-' : $money($row[$amount]) }}</td>
|
||||
@endforeach
|
||||
<td>{{ $row['create_time'] ?? '-' }}</td><td>{{ $row['modify_time'] ?? '-' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="15" class="px-4 py-16 text-center text-sm text-gray-500">@if ($freightReconciliationPending ?? false) 正在等待镜像首次同步完成… @else 暂无符合条件的运费对账数据 @endif</td></tr>
|
||||
@endforelse
|
||||
</tbody></table></div>
|
||||
@if (($freightReconciliations ?? null)?->hasPages())
|
||||
<div class="flex items-center justify-between border-t border-[#eee8de] px-4 py-3 text-sm text-gray-500"><span>共 {{ $freightReconciliations->total() }} 条记录</span><div>{{ $freightReconciliations->onEachSide(1)->links() }}</div></div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
@once
|
||||
@push('scripts_start')
|
||||
<script>
|
||||
(() => {
|
||||
if (window.caiwuSettingsNavigation) return;
|
||||
window.caiwuSettingsNavigation = true;
|
||||
let pending;
|
||||
const supports = url => url.origin === location.origin
|
||||
&& /\/system-settings\/(?:users(?:\/create|\/\d+\/edit)?|permissions|sidebar)$/.test(url.pathname);
|
||||
async function navigate(url, push = true) {
|
||||
const current = document.querySelector('[data-settings-workspace]');
|
||||
if (!current) return;
|
||||
pending?.abort();
|
||||
const controller = new AbortController();
|
||||
pending = controller;
|
||||
current.setAttribute('aria-busy', 'true');
|
||||
try {
|
||||
const response = await fetch(url, {signal:controller.signal, credentials:'same-origin'});
|
||||
const page = new DOMParser().parseFromString(await response.text(), 'text/html');
|
||||
const next = page.querySelector('[data-settings-workspace]');
|
||||
const header = page.querySelector('#header');
|
||||
if (!response.ok || !next || !header) throw new Error('unavailable');
|
||||
current.replaceWith(next);
|
||||
document.querySelector('#header')?.replaceWith(header);
|
||||
document.title = page.title;
|
||||
document.querySelectorAll('#menu-settings a[id]').forEach(link => {
|
||||
const updated = page.getElementById(link.id);
|
||||
if (updated) link.className = updated.className;
|
||||
});
|
||||
if (push) {
|
||||
history.replaceState({...history.state, settingsPage:true}, '', location.href);
|
||||
history.pushState({settingsPage:true}, '', url);
|
||||
}
|
||||
const title = header.querySelector('h1');
|
||||
title?.setAttribute('tabindex', '-1');
|
||||
title?.focus({preventScroll:true});
|
||||
window.scrollTo(0, 0);
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') return;
|
||||
let alert = current.querySelector('[data-settings-navigation-error]');
|
||||
if (!alert) {
|
||||
alert = document.createElement('p');
|
||||
alert.dataset.settingsNavigationError = '';
|
||||
alert.setAttribute('role', 'alert');
|
||||
alert.className = 'mb-4 text-sm text-red-700';
|
||||
current.prepend(alert);
|
||||
}
|
||||
alert.textContent = '页面暂时无法加载,请重试。';
|
||||
} finally {
|
||||
current.removeAttribute('aria-busy');
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', event => {
|
||||
const link = event.target.closest('a[href]');
|
||||
if (!link || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || link.target) return;
|
||||
const url = new URL(link.href);
|
||||
if (!document.querySelector('[data-settings-workspace]') || !supports(url)) return;
|
||||
event.preventDefault();
|
||||
navigate(url.href);
|
||||
});
|
||||
document.addEventListener('change', event => {
|
||||
if (!event.target.matches('[data-settings-subject]')) return;
|
||||
const form = event.target.form;
|
||||
const url = new URL(form.action);
|
||||
url.search = new URLSearchParams(new FormData(form)).toString();
|
||||
navigate(url.href);
|
||||
});
|
||||
window.addEventListener('popstate', event => {
|
||||
if (event.state?.settingsPage && supports(new URL(location.href))) navigate(location.href, false);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
@endonce
|
||||
@ -1,21 +1,23 @@
|
||||
<nav class="mb-6 flex flex-wrap items-center gap-3 rounded-xl border border-gray-200 bg-white p-3 shadow-sm" aria-label="系统设置">
|
||||
@include('system_settings.partials.styles')
|
||||
@include('system_settings.partials.navigation-script')
|
||||
<nav class="mb-6 flex flex-wrap items-center gap-3 border-b border-gray-200 bg-white p-3" aria-label="系统设置">
|
||||
@php
|
||||
$items = [
|
||||
['system-settings.users.index', '用户管理', 'people'],
|
||||
['system-settings.dingtalk-bindings.index', '钉钉绑定', 'admin_panel_settings'],
|
||||
['system-settings.sidebar.edit', '侧边栏管理', 'menu'],
|
||||
['system-settings.permissions.index', '权限管理', 'admin_panel_settings'],
|
||||
['system-settings.databases.index', '数据库管理', 'storage'],
|
||||
];
|
||||
@endphp
|
||||
|
||||
@foreach ($items as [$routeName, $label, $icon])
|
||||
@php $active = request()->routeIs(Str::beforeLast($routeName, '.') . '.*'); @endphp
|
||||
@php $active = request()->routeIs(Str::beforeLast($routeName, '.') . '.*') || ($routeName === 'system-settings.permissions.index' && request()->routeIs('system-settings.sidebar.*')); @endphp
|
||||
<a
|
||||
href="{{ route($routeName) }}"
|
||||
@class([
|
||||
'inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold transition-colors',
|
||||
'bg-purple-700 text-white' => $active,
|
||||
'text-gray-600 hover:bg-purple-100 hover:text-purple-700' => ! $active,
|
||||
'settings-nav-active' => $active,
|
||||
'text-gray-600 settings-nav-hover' => ! $active,
|
||||
])
|
||||
@if ($active) aria-current="page" @endif
|
||||
>
|
||||
|
||||
15
resources/views/system_settings/partials/styles.blade.php
Normal file
15
resources/views/system_settings/partials/styles.blade.php
Normal file
@ -0,0 +1,15 @@
|
||||
<style>
|
||||
#main-body > .container { max-width: none; width: auto; margin-left: 1.5rem; margin-right: 1.5rem; }
|
||||
.settings-primary { display:inline-flex; align-items:center; justify-content:center; gap:8px; min-height:34px; padding:0 14px; border-radius:8px; background:var(--vip-content-accent-dark, #856632); color:#fff; font-size:13px; }
|
||||
.settings-primary:hover { background:#6A5129; color:#fff; }
|
||||
.settings-accent { color:var(--vip-content-accent-dark, #856632); }
|
||||
.settings-nav-active { background:#F7F0DE; color:#856632; }
|
||||
.settings-nav-hover:hover { background:#F7F0DE; color:#856632; }
|
||||
.settings-field-focus:focus, .settings-primary:focus-visible { outline:2px solid #856632; outline-offset:2px; }
|
||||
.settings-users-table { width:100%; min-width:680px; table-layout:fixed; }
|
||||
.settings-users-table th { white-space:nowrap; }
|
||||
.settings-users-table td { overflow-wrap:anywhere; }
|
||||
.settings-users-table .settings-email { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
@media(min-width:1280px) { #main-body > .container { margin-left:2.5rem; margin-right:2.5rem; } }
|
||||
@media(max-width:767px) { #main-body > .container { margin-left:1rem; margin-right:1rem; } }
|
||||
</style>
|
||||
@ -1,18 +1,19 @@
|
||||
<x-layouts.admin>
|
||||
<x-slot name="title">侧边栏管理</x-slot>
|
||||
<x-slot name="title">用户可访问页面</x-slot>
|
||||
|
||||
<x-slot name="content">
|
||||
<div class="my-6">
|
||||
<div class="my-6" data-settings-workspace>
|
||||
@include('system_settings.partials.navigation')
|
||||
<nav class="mb-4 text-sm" aria-label="页面位置"><a class="settings-accent" href="{{ route('system-settings.permissions.index') }}">权限管理</a><span class="mx-2" aria-hidden="true">/</span><span aria-current="page">用户可访问页面</span></nav>
|
||||
|
||||
<div class="mb-5 rounded-lg border-l-4 border-purple bg-lilac-100 px-4 py-3 text-sm text-gray-700">
|
||||
先选择用户类型或指定用户,再勾选允许显示的一级、二级目录。指定用户配置优先于用户类型配置。
|
||||
<div class="mb-5 rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700">
|
||||
先选择权限身份或指定用户,再勾选可访问页面。指定用户配置优先于角色配置;主管理员拥有全部页面权限。
|
||||
</div>
|
||||
|
||||
<form method="GET" action="{{ route('system-settings.sidebar.edit') }}" class="mb-5 rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<form method="GET" action="{{ route('system-settings.sidebar.edit') }}" class="mb-5 rounded-xl border border-gray-200 bg-white p-5">
|
||||
<label class="block max-w-xl">
|
||||
<span class="text-sm font-semibold text-gray-700">权限对象</span>
|
||||
<select name="subject" onchange="this.form.submit()" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:border-purple focus:ring-purple">
|
||||
<select name="subject" data-settings-subject class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm settings-field-focus">
|
||||
@foreach ($subjects as $key => $label)
|
||||
<option value="{{ $key }}" @selected($subject === $key)>{{ $label }}</option>
|
||||
@endforeach
|
||||
@ -20,7 +21,7 @@
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('system-settings.sidebar.update') }}" class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<form method="POST" action="{{ route('system-settings.sidebar.update') }}" class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<input type="hidden" name="subject" value="{{ $subject }}">
|
||||
@ -33,7 +34,7 @@
|
||||
|
||||
<div class="mb-5 flex flex-wrap items-center justify-between gap-3 border-b border-gray-100 pb-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-800">{{ $subjects[$subject] }}</h2>
|
||||
<h2 class="text-sm font-semibold text-gray-800">{{ $subjects[$subject] }}</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">{{ $configured ? '当前正在使用自定义权限。' : '当前未单独配置,默认显示全部目录。保存后将启用自定义权限。' }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
@ -42,32 +43,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
|
||||
@foreach ($navigation as $section)
|
||||
<details class="group rounded-xl border border-gray-200 bg-white">
|
||||
<summary class="flex cursor-pointer list-none items-center gap-3 p-4 font-semibold text-gray-800">
|
||||
<span class="material-icons-outlined text-xl text-purple">{{ $section['icon'] }}</span>
|
||||
<span class="material-icons-outlined text-xl settings-accent">{{ $section['icon'] }}</span>
|
||||
<span>{{ $section['title'] }}</span>
|
||||
<span class="ml-auto text-xs font-normal text-gray-400">点击展开二级目录</span>
|
||||
<span class="material-icons-outlined text-gray-400 transition-transform group-open:rotate-180">expand_more</span>
|
||||
</summary>
|
||||
|
||||
<div class="border-t border-gray-100 p-4">
|
||||
<label class="mb-3 flex items-center gap-2 rounded-lg bg-lilac-100 px-3 py-2 text-sm font-semibold text-purple">
|
||||
<label class="mb-3 flex items-center gap-2 rounded-lg bg-lilac-100 px-3 py-2 text-sm font-semibold settings-accent">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="permissions[]"
|
||||
value="{{ $section['slug'] }}"
|
||||
data-sidebar-permission
|
||||
@checked(in_array($section['slug'], $selected, true))
|
||||
class="rounded border-gray-300 text-purple focus:ring-purple"
|
||||
class="rounded border-gray-300 settings-accent settings-field-focus"
|
||||
>
|
||||
允许整个一级目录
|
||||
</label>
|
||||
|
||||
@if (! empty($section['pages']))
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
@foreach ($section['pages'] as $page)
|
||||
@foreach (collect($section['pages'])->flatMap(fn ($entry) => empty($entry['children']) ? [$entry] : $entry['children']) as $page)
|
||||
@php $pageKey = $section['slug'] . '/' . $page['slug']; @endphp
|
||||
<label class="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-gray-600 hover:bg-gray-50">
|
||||
<input
|
||||
@ -76,7 +77,7 @@
|
||||
value="{{ $pageKey }}"
|
||||
data-sidebar-permission
|
||||
@checked(in_array($pageKey, $selected, true))
|
||||
class="rounded border-gray-300 text-purple focus:ring-purple"
|
||||
class="rounded border-gray-300 settings-accent settings-field-focus"
|
||||
>
|
||||
{{ $page['title'] }}
|
||||
</label>
|
||||
@ -91,7 +92,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end border-t border-gray-100 pt-5">
|
||||
<button type="submit" class="rounded-lg bg-green px-5 py-2.5 text-sm text-white hover:bg-green-700">保存权限</button>
|
||||
<button type="submit" class="rounded-lg settings-primary">保存权限</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
19
resources/views/system_settings/sidebar/index.blade.php
Normal file
19
resources/views/system_settings/sidebar/index.blade.php
Normal file
@ -0,0 +1,19 @@
|
||||
<x-layouts.admin>
|
||||
<x-slot name="title">权限管理</x-slot>
|
||||
<x-slot name="content">
|
||||
<div class="my-6" data-settings-workspace>
|
||||
@include('system_settings.partials.navigation')
|
||||
<div class="overflow-x-auto rounded-xl border border-gray-200 bg-white">
|
||||
<table class="min-w-full text-sm text-left">
|
||||
<thead class="bg-gray-50"><tr><th class="px-5 py-3">设置项</th><th class="px-5 py-3">作用</th><th class="px-5 py-3 text-right">操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="px-5 py-4 whitespace-nowrap">用户可访问页面</td><td class="px-5 py-4">设置用户或角色可以访问的业务页面。主管理员始终拥有全部页面权限。</td><td class="px-5 py-4 text-right whitespace-nowrap"><a class="settings-accent" href="{{ route('system-settings.sidebar.edit') }}">设置页面权限</a></td></tr>
|
||||
@if (\App\Utilities\SystemAccess::isSuperAdmin())
|
||||
<tr class="border-t border-gray-200"><td class="px-5 py-4 whitespace-nowrap">用户权限身份</td><td class="px-5 py-4">在用户管理中设置普通用户、管理员或主管理员身份。</td><td class="px-5 py-4 text-right whitespace-nowrap"><a class="settings-accent" href="{{ route('system-settings.users.index') }}">管理用户身份</a></td></tr>
|
||||
@endif
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot>
|
||||
</x-layouts.admin>
|
||||
@ -2,10 +2,10 @@
|
||||
<x-slot name="title">{{ $managedUser ? '修改用户' : '新增用户' }}</x-slot>
|
||||
|
||||
<x-slot name="content">
|
||||
<div class="my-6">
|
||||
<div class="my-6" data-settings-workspace>
|
||||
@include('system_settings.partials.navigation')
|
||||
|
||||
<form method="POST" action="{{ $managedUser ? route('system-settings.users.update', $managedUser->id) : route('system-settings.users.store') }}" class="max-w-3xl rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<form method="POST" action="{{ $managedUser ? route('system-settings.users.update', $managedUser->id) : route('system-settings.users.store') }}" class="w-full rounded-xl border border-gray-200 bg-white p-6">
|
||||
@csrf
|
||||
@if ($managedUser) @method('PATCH') @endif
|
||||
|
||||
@ -18,41 +18,44 @@
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="text-sm font-semibold text-gray-700">姓名 *</span>
|
||||
<input name="name" value="{{ old('name', $managedUser?->name) }}" required maxlength="191" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:border-purple focus:ring-purple">
|
||||
<input name="name" value="{{ old('name', $managedUser?->name) }}" required maxlength="191" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm settings-field-focus">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-semibold text-gray-700">邮箱 *</span>
|
||||
<input type="email" name="email" value="{{ old('email', $managedUser?->email) }}" required maxlength="191" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:border-purple focus:ring-purple">
|
||||
<input type="email" name="email" value="{{ old('email', $managedUser?->email) }}" required maxlength="191" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm settings-field-focus">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-semibold text-gray-700">用户类型 *</span>
|
||||
<select name="role" required class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:border-purple focus:ring-purple">
|
||||
<span class="text-sm font-semibold text-gray-700">权限身份 *</span>
|
||||
<select name="role" required class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm settings-field-focus">
|
||||
@foreach ($roles as $role => $label)
|
||||
<option value="{{ $role }}" @selected(old('role', $managedUser ? \App\Utilities\SystemAccess::roleName($managedUser) : \App\Utilities\SystemAccess::USER) === $role)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@if (\App\Utilities\SystemAccess::isSuperAdmin())
|
||||
<span class="mt-1 block text-xs text-gray-600">主管理员拥有最高权限,可管理全部用户,且不受页面访问范围限制。</span>
|
||||
@endif
|
||||
</label>
|
||||
<div class="flex items-end pb-2">
|
||||
<input type="hidden" name="enabled" value="0">
|
||||
<label class="inline-flex items-center gap-2 text-sm font-semibold text-gray-700">
|
||||
<input type="checkbox" name="enabled" value="1" @checked((bool) old('enabled', $managedUser?->enabled ?? true)) class="rounded border-gray-300 text-purple focus:ring-purple">
|
||||
<input type="checkbox" name="enabled" value="1" @checked((bool) old('enabled', $managedUser?->enabled ?? true)) class="rounded border-gray-300 settings-accent settings-field-focus">
|
||||
启用账号
|
||||
</label>
|
||||
</div>
|
||||
<label class="block">
|
||||
<span class="text-sm font-semibold text-gray-700">密码 {{ $managedUser ? '' : '*' }}</span>
|
||||
<input type="password" name="password" @if (! $managedUser) required @endif minlength="8" autocomplete="new-password" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:border-purple focus:ring-purple">
|
||||
<input type="password" name="password" @if (! $managedUser) required @endif minlength="8" autocomplete="new-password" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm settings-field-focus">
|
||||
@if ($managedUser)<span class="mt-1 block text-xs text-gray-400">留空表示不修改密码</span>@endif
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-semibold text-gray-700">确认密码 {{ $managedUser ? '' : '*' }}</span>
|
||||
<input type="password" name="password_confirmation" @if (! $managedUser) required @endif minlength="8" autocomplete="new-password" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:border-purple focus:ring-purple">
|
||||
<input type="password" name="password_confirmation" @if (! $managedUser) required @endif minlength="8" autocomplete="new-password" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm settings-field-focus">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-7 flex justify-end gap-3 border-t border-gray-100 pt-5">
|
||||
<a href="{{ route('system-settings.users.index') }}" class="rounded-lg px-5 py-2.5 text-sm text-gray-600 hover:bg-gray-100">取消</a>
|
||||
<button type="submit" class="rounded-lg bg-green px-5 py-2.5 text-sm text-white hover:bg-green-700">保存</button>
|
||||
<button type="submit" class="rounded-lg settings-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -2,21 +2,21 @@
|
||||
<x-slot name="title">用户管理</x-slot>
|
||||
|
||||
<x-slot name="buttons">
|
||||
<a href="{{ route('system-settings.users.create') }}" class="inline-flex items-center gap-2 rounded-lg bg-green px-5 py-2 text-sm text-white hover:bg-green-700">
|
||||
<a href="{{ route('system-settings.users.create') }}" class="inline-flex items-center gap-2 rounded-lg settings-primary">
|
||||
<span class="material-icons-outlined text-lg">person_add</span>
|
||||
新增用户
|
||||
</a>
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="content">
|
||||
<div class="my-6">
|
||||
<div class="my-6" data-settings-workspace>
|
||||
@include('system_settings.partials.navigation')
|
||||
|
||||
<div class="mb-4 rounded-lg border-l-4 border-purple bg-lilac-100 px-4 py-3 text-sm text-gray-700">
|
||||
<div class="mb-4 rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700">
|
||||
@if (\App\Utilities\SystemAccess::isSuperAdmin())
|
||||
超级管理员可管理所有用户和用户类型。
|
||||
主管理员拥有最高权限,可管理全部用户、设置管理员身份和页面访问范围。
|
||||
@else
|
||||
普通管理员只能新增、修改和删除普通用户。
|
||||
已显示当前公司的全部账号。管理员可管理普通用户;管理员身份由主管理员设置。
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -27,12 +27,13 @@
|
||||
@endif
|
||||
|
||||
<div class="overflow-x-auto rounded-xl border border-gray-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<table class="settings-users-table divide-y divide-gray-200 text-sm">
|
||||
<colgroup><col style="width:18%"><col style="width:32%"><col style="width:16%"><col style="width:10%"><col style="width:24%"></colgroup>
|
||||
<thead class="bg-gray-50 text-left text-gray-500">
|
||||
<tr>
|
||||
<th class="px-5 py-3">姓名</th>
|
||||
<th class="px-5 py-3">邮箱</th>
|
||||
<th class="px-5 py-3">用户类型</th>
|
||||
<th class="px-5 py-3">权限身份</th>
|
||||
<th class="px-5 py-3">状态</th>
|
||||
<th class="px-5 py-3 text-right">操作</th>
|
||||
</tr>
|
||||
@ -44,10 +45,10 @@
|
||||
<td class="px-5 py-4 font-semibold text-gray-800">
|
||||
{{ $managedUser->name }}
|
||||
@if ($managedUser->id === user()->id)
|
||||
<span class="ml-2 rounded-full bg-lilac-100 px-2 py-0.5 text-xs text-purple">当前账号</span>
|
||||
<span class="ml-2 inline-block whitespace-nowrap rounded-full bg-lilac-100 px-2 py-0.5 text-xs settings-accent">当前账号</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-5 py-4 text-gray-600">{{ $managedUser->email }}</td>
|
||||
<td class="px-5 py-4 text-gray-600"><span class="settings-email" title="{{ $managedUser->email }}">{{ $managedUser->email }}</span></td>
|
||||
<td class="px-5 py-4 text-gray-600">{{ $roleLabels[$roleName] ?? ($managedUser->roles->first()?->display_name ?? '-') }}</td>
|
||||
<td class="px-5 py-4">
|
||||
<span @class([
|
||||
@ -58,7 +59,10 @@
|
||||
</td>
|
||||
<td class="px-5 py-4 text-right whitespace-nowrap">
|
||||
@if (\App\Utilities\SystemAccess::canManageUser(user(), $managedUser))
|
||||
<a href="{{ route('system-settings.users.edit', $managedUser->id) }}" class="mr-4 text-purple hover:underline">修改</a>
|
||||
<a href="{{ route('system-settings.users.edit', $managedUser->id) }}" class="mr-4 settings-accent hover:underline">修改</a>
|
||||
@if (in_array($roleName, [\App\Utilities\SystemAccess::ADMIN, \App\Utilities\SystemAccess::USER], true))
|
||||
<a href="{{ route('system-settings.sidebar.edit', ['subject' => 'user_'.$managedUser->id]) }}" class="mr-4 settings-accent hover:underline">页面权限</a>
|
||||
@endif
|
||||
@if ($managedUser->id !== user()->id)
|
||||
<form method="POST" action="{{ route('system-settings.users.destroy', $managedUser->id) }}" class="inline" onsubmit="return confirm('确定删除该用户吗?')">
|
||||
@csrf
|
||||
|
||||
@ -183,6 +183,7 @@ Route::group(['as' => 'system-settings.', 'prefix' => 'system-settings'], functi
|
||||
Route::post('dingtalk-bindings/{bindingRequest}/reject', 'SystemSettings\DingTalkBindings@reject')->name('dingtalk-bindings.reject');
|
||||
Route::delete('dingtalk-bindings/identities/{identity}', 'SystemSettings\DingTalkBindings@destroy')->name('dingtalk-bindings.destroy');
|
||||
|
||||
Route::get('permissions', 'SystemSettings\Sidebar@index')->name('permissions.index');
|
||||
Route::get('sidebar', 'SystemSettings\Sidebar@edit')->name('sidebar.edit');
|
||||
Route::patch('sidebar', 'SystemSettings\Sidebar@update')->name('sidebar.update');
|
||||
|
||||
|
||||
@ -111,7 +111,8 @@ class DingTalkLoginTest extends FeatureTestCase
|
||||
$this->assertNotSame($this->user->id, $created->id);
|
||||
$this->assertTrue($created->hasRole('user'));
|
||||
$this->assertFalse($created->hasRole('super_admin'));
|
||||
$this->assertSame([], SidebarPermissions::allowedKeys($created));
|
||||
$this->assertNull(SidebarPermissions::allowedKeys($created));
|
||||
$this->assertTrue(collect(SidebarPermissions::navigationFor($created))->contains('slug', 'store-management'));
|
||||
$this->assertDatabaseHas('dingtalk_identities', [
|
||||
'user_id' => $created->id,
|
||||
'union_id' => 'union-auto',
|
||||
@ -127,8 +128,7 @@ class DingTalkLoginTest extends FeatureTestCase
|
||||
'outcome' => 'success',
|
||||
'user_id' => $created->id,
|
||||
]);
|
||||
$this->get(route('dashboard', ['company_id' => $this->company->id]))->assertOk()
|
||||
->assertSeeText('你已登录')->assertSeeText('无需再次注册');
|
||||
$this->get(route('dashboard', ['company_id' => $this->company->id]))->assertRedirectContains('/workspace/');
|
||||
}
|
||||
|
||||
public function test_shared_mobile_does_not_claim_an_existing_account(): void
|
||||
@ -430,23 +430,35 @@ class DingTalkLoginTest extends FeatureTestCase
|
||||
|
||||
public function test_admin_can_assign_business_access_to_the_automatically_created_user(): void
|
||||
{
|
||||
app('url')->defaults(['company_id' => $this->company->id]);
|
||||
Http::preventStrayRequests();
|
||||
Http::fake($this->dingtalkResponses('union-grant', 'open-grant'));
|
||||
$state = $this->startAuthorization();
|
||||
$this->get(route('login.dingtalk.callback', ['code' => 'fake-code', 'state' => $state]));
|
||||
$created = auth()->user();
|
||||
$this->withExceptionHandling()->get(route('business.pages.show', [
|
||||
'company_id' => $this->company->id, 'section' => 'store-management', 'page' => 'basic-information',
|
||||
]))->assertForbidden();
|
||||
$this->assertNull(SidebarPermissions::allowedKeys($created));
|
||||
auth()->logout(); session()->invalidate();
|
||||
$this->actingAs($this->user)->get(route('system-settings.sidebar.edit', ['subject' => 'user_'.$created->id]))
|
||||
->assertOk()->assertSee('value="user_'.$created->id.'"', false);
|
||||
$this->patch(route('system-settings.sidebar.update'), [
|
||||
'subject' => 'user_'.$created->id, 'permissions' => ['store-management/basic-information'],
|
||||
])->assertRedirect(route('dashboard'));
|
||||
])->assertRedirect(route('system-settings.sidebar.edit', ['subject' => 'user_'.$created->id]));
|
||||
$this->assertSame(['store-management/basic-information'], SidebarPermissions::allowedKeys($created));
|
||||
$this->actingAs($created)->withSession(['dingtalk_identity_id' => $created->dingtalkIdentity->id, 'dingtalk_roster_checked_at' => now()->timestamp])
|
||||
->get(route('dashboard'))->assertRedirectContains('/workspace/store-management/basic-information');
|
||||
$this->withExceptionHandling()->get(route('business.pages.show', [
|
||||
'company_id' => $this->company->id, 'section' => 'company-management', 'page' => 'basic-information',
|
||||
]))->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_explicit_empty_user_scope_does_not_fall_back_to_all_pages(): void
|
||||
{
|
||||
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
|
||||
setting()->set('system.sidebar_permissions', [
|
||||
'configured' => ['user_'.$this->user->id], 'subjects' => ['user_'.$this->user->id => []],
|
||||
]);
|
||||
$this->assertSame([], SidebarPermissions::navigationFor($this->user));
|
||||
$this->loginAs()->get(route('dashboard', ['company_id' => $this->company->id]))->assertOk()->assertSeeText('尚未分配业务权限');
|
||||
}
|
||||
|
||||
protected function startAuthorization(): string
|
||||
|
||||
@ -219,6 +219,7 @@ class AgreementSigningPageTest extends FeatureTestCase
|
||||
{
|
||||
$subject = 'user_' . $this->user->id;
|
||||
|
||||
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
|
||||
setting()->set('system.sidebar_permissions', json_encode([
|
||||
'configured' => [$subject],
|
||||
'subjects' => [
|
||||
|
||||
@ -254,6 +254,7 @@ class CourierQuoteChangeWorkflowTest extends FeatureTestCase
|
||||
public function test_user_without_quote_page_permission_cannot_use_workflow_routes(): void
|
||||
{
|
||||
$subject = 'user_' . $this->user->id;
|
||||
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
|
||||
setting()->set('system.sidebar_permissions', json_encode([
|
||||
'configured' => [$subject],
|
||||
'subjects' => [$subject => ['courier-payables/freight-reconciliation']],
|
||||
|
||||
@ -139,6 +139,7 @@ class ManagementCrudTest extends FeatureTestCase
|
||||
public function testInstallationChildEndpointRequiresTheSameSidebarPermission(): void
|
||||
{
|
||||
$this->withExceptionHandling();
|
||||
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
|
||||
setting()->set('system.sidebar_permissions', ['configured'=>['user_'.$this->user->id], 'subjects'=>['user_'.$this->user->id=>['store-management/basic-information']]]);
|
||||
$mirror=Mockery::mock(InstallationDetailMirrorRepository::class);
|
||||
$mirror->shouldNotReceive('details');
|
||||
|
||||
@ -308,6 +308,7 @@ class NonJingxiRefundPageTest extends FeatureTestCase
|
||||
public function test_page_rejects_users_without_sidebar_permission(): void
|
||||
{
|
||||
$subject = 'user_' . $this->user->id;
|
||||
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
|
||||
setting()->set('system.sidebar_permissions', json_encode([
|
||||
'configured' => [$subject],
|
||||
'subjects' => [
|
||||
|
||||
@ -5,12 +5,67 @@ namespace Tests\Feature\Business;
|
||||
use App\Models\Auth\Permission;
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Repositories\BillOffsetMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class PagesTest extends FeatureTestCase
|
||||
{
|
||||
public function testFreightReconciliationPageRendersSharedMirrorRowsAndSummary(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
|
||||
$repository = Mockery::mock(CourierFreightReconciliationMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('ready')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('paginate')->once()->with(
|
||||
Mockery::on(static fn (array $filters): bool => $filters['express_no'] === 'FIXTURE'),
|
||||
1,
|
||||
Mockery::type('int'),
|
||||
)->andReturn([
|
||||
'data' => [[
|
||||
'waybill' => 'FIXTURE-WAYBILL',
|
||||
'expressname' => '测试快递',
|
||||
'courier_company_name' => '测试快递',
|
||||
'month' => '2026-08',
|
||||
'status' => '已确认',
|
||||
'reconciliation_difference' => 1.25,
|
||||
'confirmed_amount' => 20.0,
|
||||
'payable_amount' => 20.0,
|
||||
'actual_payment' => 18.75,
|
||||
'advance_payment' => 0.0,
|
||||
'compensation_amount' => 0.0,
|
||||
'other_replenishment' => 0.0,
|
||||
'corporate_payment_amount' => 18.75,
|
||||
'personal_payment_amount' => 0.0,
|
||||
'create_time' => '2026-08-01 09:00:00',
|
||||
'modify_time' => '2026-08-02 09:00:00',
|
||||
]],
|
||||
'total' => 1,
|
||||
'summary' => ['count' => 1, 'payable_amount' => 20.0, 'actual_payment' => 18.75, 'confirmed_amount' => 20.0, 'reconciliation_difference' => 1.25],
|
||||
'state' => ['status' => 'succeeded'],
|
||||
'sync' => ['code' => 'fresh', 'last_succeeded_at' => '2026-08-02 09:00:00', 'lag_minutes' => 1],
|
||||
]);
|
||||
$this->app->instance(CourierFreightReconciliationMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'freight-reconciliation',
|
||||
'express_no' => 'FIXTURE',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('FIXTURE-WAYBILL')
|
||||
->assertSeeText('应付金额')
|
||||
->assertSeeText('20.00')
|
||||
->assertSeeText('数据新鲜')
|
||||
->assertSeeText('共享业务镜像')
|
||||
->assertDontSee('class="business-blank-page"', false);
|
||||
}
|
||||
|
||||
public function testTaobaoAlipayBillsEndpointIsAvailableToWorkspaceUsers()
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
|
||||
@ -331,6 +331,7 @@ class ProductCatalogPageTest extends FeatureTestCase
|
||||
{
|
||||
$subject = 'user_' . $this->user->id;
|
||||
|
||||
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
|
||||
setting()->set('system.sidebar_permissions', json_encode([
|
||||
'configured' => [$subject],
|
||||
'subjects' => [
|
||||
@ -348,6 +349,7 @@ class ProductCatalogPageTest extends FeatureTestCase
|
||||
{
|
||||
$subject = 'user_' . $this->user->id;
|
||||
|
||||
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
|
||||
setting()->set('system.sidebar_permissions', json_encode([
|
||||
'configured' => [$subject],
|
||||
'subjects' => [
|
||||
|
||||
@ -21,7 +21,7 @@ class PrototypePageTest extends FeatureTestCase
|
||||
'profit_center' => '测试利润中心',
|
||||
'account_type' => '主账号',
|
||||
'account' => 'test-account',
|
||||
'password_configured' => null,
|
||||
'password_configured' => true,
|
||||
'bound_phone' => '13800000000',
|
||||
'verification_contact' => '测试对接人',
|
||||
'account_group_name' => '测试分组',
|
||||
@ -44,13 +44,15 @@ class PrototypePageTest extends FeatureTestCase
|
||||
->assertSeeText('共 958 条')
|
||||
->assertSeeText('已从共享业务镜像读取 958 条数据')
|
||||
->assertSeeText('主账号')
|
||||
->assertSeeText('状态未同步')
|
||||
->assertSeeText('已设置')
|
||||
->assertSeeText('13800000000')
|
||||
->assertDontSeeText('MAIN')
|
||||
->assertDontSeeText('共享镜像中暂未读取到店铺账号数据');
|
||||
}
|
||||
|
||||
public function testNavigationUsesConfiguredPermissionsAndTitles(): void
|
||||
{
|
||||
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
|
||||
setting()->set('system.sidebar_permissions', [
|
||||
'configured' => ['user_' . $this->user->id],
|
||||
'subjects' => ['user_' . $this->user->id => ['company-management/subject-operating-report']],
|
||||
|
||||
@ -144,6 +144,7 @@ class QuoteManagementPageTest extends FeatureTestCase
|
||||
{
|
||||
$subject = 'user_' . $this->user->id;
|
||||
|
||||
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
|
||||
setting()->set('system.sidebar_permissions', json_encode([
|
||||
'configured' => [$subject],
|
||||
'subjects' => [
|
||||
|
||||
@ -155,6 +155,7 @@ class SmallPaymentWorkOrderPageTest extends FeatureTestCase
|
||||
public function test_page_rejects_users_without_sidebar_permission(): void
|
||||
{
|
||||
$subject = 'user_' . $this->user->id;
|
||||
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
|
||||
setting()->set('system.sidebar_permissions', json_encode([
|
||||
'configured' => [$subject],
|
||||
'subjects' => [$subject => ['company-management/basic-information']],
|
||||
|
||||
@ -77,6 +77,7 @@ class StoreAccountPasswordsTest extends FeatureTestCase
|
||||
|
||||
public function test_restricted_admin_cannot_bypass_store_page_scope(): void
|
||||
{
|
||||
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
|
||||
setting()->set('system.sidebar_permissions', ['configured' => ['user_'.$this->user->id], 'subjects' => ['user_'.$this->user->id => []]]);
|
||||
$this->mock(StoreAccountPasswordClient::class)->shouldNotReceive('reveal');
|
||||
$this->loginAs()->postJson($this->endpoint(), ['purpose' => 'view'])->assertForbidden();
|
||||
|
||||
@ -250,6 +250,7 @@ class SupplierPayablePagesTest extends FeatureTestCase
|
||||
{
|
||||
$subject = 'user_' . $this->user->id;
|
||||
|
||||
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
|
||||
setting()->set('system.sidebar_permissions', json_encode([
|
||||
'configured' => [$subject],
|
||||
'subjects' => [
|
||||
|
||||
144
tests/Feature/SystemSettings/UserPermissionsTest.php
Normal file
144
tests/Feature/SystemSettings/UserPermissionsTest.php
Normal file
@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\SystemSettings;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use App\Models\Auth\Role;
|
||||
use App\Models\Auth\User;
|
||||
use App\Utilities\BusinessNavigation;
|
||||
use App\Utilities\SidebarPermissions;
|
||||
use App\Utilities\SystemAccess;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class UserPermissionsTest extends FeatureTestCase
|
||||
{
|
||||
public function test_admin_sees_all_company_accounts_but_cannot_edit_administrators(): void
|
||||
{
|
||||
$admin = $this->account('admin');
|
||||
$regular = $this->account('user');
|
||||
$outside = $this->account('user');
|
||||
$outside->companies()->detach();
|
||||
$this->loginAs($admin)->get(route('system-settings.users.index'))
|
||||
->assertOk()->assertSeeText($this->user->name)->assertSeeText($admin->name)->assertSeeText($regular->name)
|
||||
->assertDontSeeText($outside->name)
|
||||
->assertDontSee(route('system-settings.users.edit', $this->user->id), false)
|
||||
->assertSee(route('system-settings.users.edit', $regular->id), false);
|
||||
$this->withExceptionHandling()->get(route('system-settings.users.edit', $this->user->id))->assertForbidden();
|
||||
$this->patch(route('system-settings.users.update', $this->user->id), $this->payload($this->user, 'user'))->assertForbidden();
|
||||
$this->delete(route('system-settings.users.destroy', $this->user->id))->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_super_admin_can_assign_both_administrator_roles(): void
|
||||
{
|
||||
$target = $this->account('user');
|
||||
$this->loginAs()->get(route('system-settings.users.edit', $target->id))->assertOk()
|
||||
->assertSeeText('主管理员(最高权限)')->assertSee('value="admin"', false);
|
||||
foreach (['admin', 'super_admin'] as $role) {
|
||||
$this->patch(route('system-settings.users.update', $target->id), $this->payload($target, $role))
|
||||
->assertRedirect(route('system-settings.users.index'));
|
||||
$target = $target->fresh();
|
||||
$this->assertSame([$role], $target->roles()->pluck('name')->all());
|
||||
}
|
||||
setting()->set('system.sidebar_permissions', ['configured' => ['user_'.$target->id], 'subjects' => ['user_'.$target->id => []]]);
|
||||
$this->assertSame(BusinessNavigation::all(), SidebarPermissions::navigationFor($target));
|
||||
$this->assertTrue(SystemAccess::isSuperAdmin($target));
|
||||
$this->loginAs($target)->get(route('system-settings.users.index'))->assertOk();
|
||||
$this->get(route('settings.navigation.manage'))->assertOk();
|
||||
}
|
||||
|
||||
public function test_admin_cannot_promote_a_user_or_itself(): void
|
||||
{
|
||||
$admin = $this->account('admin');
|
||||
$target = $this->account('user');
|
||||
$this->withExceptionHandling()->loginAs($admin);
|
||||
foreach (['admin', 'super_admin'] as $role) {
|
||||
$this->patchJson(route('system-settings.users.update', $target->id), $this->payload($target, $role))
|
||||
->assertUnprocessable()->assertJsonValidationErrors('role');
|
||||
}
|
||||
$this->patchJson(route('system-settings.users.update', $admin->id), $this->payload($admin, 'super_admin'))->assertForbidden();
|
||||
$this->assertSame(['user'], $target->roles()->pluck('name')->all());
|
||||
}
|
||||
|
||||
public function test_demotion_removes_cached_administrator_access(): void
|
||||
{
|
||||
$target = $this->account('super_admin');
|
||||
$this->assertTrue($target->hasRole('super_admin'));
|
||||
$this->assertTrue($target->can('update-auth-users'));
|
||||
$this->loginAs()->patch(route('system-settings.users.update', $target->id), $this->payload($target, 'user'))
|
||||
->assertRedirect(route('system-settings.users.index'));
|
||||
$this->assertSame('user', SystemAccess::roleName($target));
|
||||
$this->assertFalse($target->fresh()->can('update-auth-users'));
|
||||
$this->withExceptionHandling()->loginAs($target)->get(route('system-settings.users.index'))->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_legacy_user_routes_cannot_grant_administrator_identity(): void
|
||||
{
|
||||
$admin = $this->account('admin');
|
||||
$target = $this->account('user');
|
||||
$this->withExceptionHandling()->loginAs($admin);
|
||||
$role = (string) Role::where('name', 'super_admin')->value('id');
|
||||
$this->patchJson(route('users.update', $target->id), ['roles' => $role])->assertForbidden();
|
||||
$this->patchJson(route('profile.update', $admin->id), ['roles' => $role])->assertForbidden();
|
||||
$this->postJson(route('users.store'), ['roles' => $role])->assertForbidden();
|
||||
$this->patchJson(route('users.update', $this->user->id), ['name' => 'unauthorized'])->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_last_super_admin_cannot_be_demoted_or_disabled(): void
|
||||
{
|
||||
$this->loginAs();
|
||||
$this->patch(route('system-settings.users.update', $this->user->id), $this->payload($this->user, 'admin'))
|
||||
->assertSessionHasErrors('role');
|
||||
$this->patch(route('system-settings.users.update', $this->user->id), array_merge($this->payload($this->user, 'super_admin'), ['enabled' => 0]))
|
||||
->assertSessionHasErrors('enabled');
|
||||
$this->assertSame(['super_admin'], $this->user->roles()->pluck('name')->all());
|
||||
}
|
||||
|
||||
public function test_user_page_settings_are_a_second_level_page_and_save_for_selected_user(): void
|
||||
{
|
||||
$target = $this->account('user');
|
||||
$this->loginAs()->get(route('system-settings.permissions.index'))->assertOk()
|
||||
->assertSeeText('用户可访问页面')->assertSee(route('system-settings.sidebar.edit'), false);
|
||||
$this->get(route('system-settings.sidebar.edit', ['subject' => 'user_'.$target->id]))->assertOk()
|
||||
->assertSee('aria-label="页面位置"', false)->assertSeeText('主体商标规则');
|
||||
$this->patch(route('system-settings.sidebar.update'), [
|
||||
'subject' => 'user_'.$target->id, 'permissions' => ['store-management/basic-information'],
|
||||
])->assertRedirect(route('system-settings.sidebar.edit', ['subject' => 'user_'.$target->id]));
|
||||
$this->assertSame(['store-management/basic-information'], SidebarPermissions::allowedKeys($target));
|
||||
$this->get(route('system-settings.sidebar.edit', ['subject' => 'user_'.$target->id]))->assertOk()->assertSeeText('自定义权限');
|
||||
}
|
||||
|
||||
public function test_super_admin_cannot_be_limited_by_page_settings_and_regular_users_cannot_configure_access(): void
|
||||
{
|
||||
$this->withExceptionHandling()->loginAs()->patchJson(route('system-settings.sidebar.update'), [
|
||||
'subject' => 'user_'.$this->user->id, 'permissions' => [],
|
||||
])->assertUnprocessable();
|
||||
$regular = $this->account('user');
|
||||
$this->loginAs($regular)->get(route('system-settings.permissions.index'))->assertForbidden();
|
||||
$this->get(route('system-settings.users.index'))->assertForbidden();
|
||||
$this->patchJson(route('system-settings.sidebar.update'), ['subject' => 'role_user', 'permissions' => []])->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_child_page_navigation_contains_only_the_selected_child(): void
|
||||
{
|
||||
$regular = $this->account('user');
|
||||
setting()->set('system.sidebar_permissions', ['configured' => ['user_'.$regular->id], 'subjects' => ['user_'.$regular->id => ['trademark-management/subject-trademark-rules']]]);
|
||||
$navigation = SidebarPermissions::navigationFor($regular);
|
||||
$pages = collect($navigation)->flatMap(fn ($section) => $section['pages'])->flatMap(fn ($page) => $page['children'] ?? [$page]);
|
||||
$this->assertSame(['subject-trademark-rules'], $pages->pluck('slug')->all());
|
||||
}
|
||||
|
||||
private function account(string $role): User
|
||||
{
|
||||
$name = 'fixture-'.$role.'-'.User::count();
|
||||
$account = User::create(['name' => $name, 'email' => $name.'@example.test', 'password' => 'fixture-unused-password', 'enabled' => true, 'locale' => 'zh-CN', 'landing_page' => 'dashboard']);
|
||||
$account->roles()->attach(Role::where('name', $role)->firstOrFail()->id);
|
||||
$account->permissions()->attach(Permission::where('name', 'read-admin-panel')->firstOrFail()->id);
|
||||
$account->companies()->attach($this->company->id);
|
||||
return $account;
|
||||
}
|
||||
|
||||
private function payload(User $target, string $role): array
|
||||
{
|
||||
return ['name' => $target->name, 'email' => $target->email, 'role' => $role, 'enabled' => 1];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CourierFreightReconciliationMirrorRepositoryTest extends TestCase
|
||||
{
|
||||
private CourierFreightReconciliationMirrorRepository $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'database.connections.business_mirror' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
],
|
||||
'upstream_mirrors.connection' => 'business_mirror',
|
||||
'upstream_mirrors.datasets' => [
|
||||
CourierFreightReconciliationMirrorRepository::DATASET => [
|
||||
'source_key' => 'jiansun',
|
||||
'schema' => 'dbo',
|
||||
'object' => 'fys_express_courier_settlement',
|
||||
'target' => 'js_express_courier_settlement',
|
||||
'keys' => ['Id'],
|
||||
'watermark' => 'modify_time',
|
||||
],
|
||||
],
|
||||
]);
|
||||
DB::purge('business_mirror');
|
||||
|
||||
Schema::connection('business_mirror')->create('business_mirror_table_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->dateTime('initial_completed_at')->nullable();
|
||||
$table->string('status')->nullable();
|
||||
$table->dateTime('last_succeeded_at')->nullable();
|
||||
$table->string('run_mode')->nullable();
|
||||
});
|
||||
Schema::connection('business_mirror')->create('js_express_courier_settlement', function (Blueprint $table): void {
|
||||
$table->integer('Id');
|
||||
foreach (['month', 'status', 'courier_company_name', 'expresscode', 'expressname', 'replenish_courier', 'reconciliation_person', 'file_status', 'taskid', 'guid', 'creator'] as $column) {
|
||||
$table->string($column)->nullable();
|
||||
}
|
||||
foreach (['opening_balance', 'courier_provided_amount', 'reconciliation_difference', 'confirmed_amount', 'advance_payment', 'payable_amount', 'actual_payment', 'compensation_amount', 'other_replenishment', 'corporate_payment_amount', 'personal_payment_amount'] as $column) {
|
||||
$table->decimal($column, 20, 6)->nullable();
|
||||
}
|
||||
$table->string('waybill')->nullable();
|
||||
$table->dateTime('create_time')->nullable();
|
||||
$table->dateTime('modify_time')->nullable();
|
||||
$table->boolean('is_delete')->nullable();
|
||||
});
|
||||
|
||||
$this->repository = new CourierFreightReconciliationMirrorRepository(
|
||||
new BusinessMirrorDatasetRegistry(),
|
||||
new GenericBusinessMirrorRepository(app('db')),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_rejects_an_unready_mirror(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('courier_freight_reconciliation_mirror_not_ready');
|
||||
|
||||
$this->repository->paginate([], 1, 25);
|
||||
}
|
||||
|
||||
public function test_it_filters_sorts_and_summarizes_only_approved_mirror_fields(): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
|
||||
'dataset_key' => CourierFreightReconciliationMirrorRepository::DATASET,
|
||||
'initial_completed_at' => '2026-09-01 09:00:00',
|
||||
'status' => 'succeeded',
|
||||
'last_succeeded_at' => now()->toDateTimeString(),
|
||||
]);
|
||||
DB::connection('business_mirror')->table('js_express_courier_settlement')->insert([
|
||||
$this->row(1, 'YT-001', '圆通速递', 100, 80, 20, '2026-08'),
|
||||
$this->row(2, 'YT-002', '圆通速递', 50, 50, 0, '2026-08'),
|
||||
$this->row(3, 'ZT-003', '中通礼品单', 30, 25, 5, '2026-09'),
|
||||
]);
|
||||
|
||||
$result = $this->repository->paginate([
|
||||
'express_no' => 'YT',
|
||||
'express_name' => '圆通',
|
||||
'month' => '2026-08',
|
||||
], 1, 1);
|
||||
|
||||
$this->assertSame(2, $result['total']);
|
||||
$this->assertSame('YT-002', $result['data'][0]['waybill']);
|
||||
$this->assertSame(150.0, $result['summary']['payable_amount']);
|
||||
$this->assertSame(130.0, $result['summary']['actual_payment']);
|
||||
$this->assertSame(20.0, $result['summary']['reconciliation_difference']);
|
||||
$this->assertSame('fresh', $result['sync']['code']);
|
||||
$this->assertArrayNotHasKey('password', $result['data'][0]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function row(int $id, string $waybill, string $express, float $payable, float $actual, float $difference, string $month): array
|
||||
{
|
||||
return [
|
||||
'Id' => $id,
|
||||
'waybill' => $waybill,
|
||||
'expressname' => $express,
|
||||
'courier_company_name' => $express,
|
||||
'month' => $month,
|
||||
'status' => '已确认',
|
||||
'payable_amount' => $payable,
|
||||
'actual_payment' => $actual,
|
||||
'confirmed_amount' => $payable,
|
||||
'reconciliation_difference' => $difference,
|
||||
'create_time' => '2026-09-07 09:00:00',
|
||||
'modify_time' => '2026-09-07 10:00:00',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -46,6 +46,7 @@ class StoreAccountMirrorRepositoryTest extends TestCase
|
||||
foreach (['storeName', 'operationOwner', 'operationDepartmentHead', 'operationDirector', 'profitCenter', 'accountType', 'account', 'boundPhone', 'verificationContact', 'accountGroupName', 'createdAt', 'updatedAt'] as $column) {
|
||||
$table->string($column)->nullable();
|
||||
}
|
||||
$table->boolean('passwordConfigured')->nullable();
|
||||
});
|
||||
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
|
||||
'dataset_key' => StoreAccountMirrorRepository::DATASET,
|
||||
@ -66,6 +67,7 @@ class StoreAccountMirrorRepositoryTest extends TestCase
|
||||
'storeName' => '测试店铺',
|
||||
'accountType' => $type,
|
||||
'account' => 'test-account',
|
||||
'passwordConfigured' => $type === 'MAIN' ? true : ($type === 'SUB' ? false : null),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -74,6 +76,8 @@ class StoreAccountMirrorRepositoryTest extends TestCase
|
||||
$this->assertSame('主账号', $rows['account-' . md5('MAIN')]['account_type']);
|
||||
$this->assertSame('子账号', $rows['account-' . md5('SUB')]['account_type']);
|
||||
$this->assertNull($rows['account-' . md5('/')]['account_type']);
|
||||
$this->assertNull($rows['account-' . md5('MAIN')]['password_configured']);
|
||||
$this->assertTrue($rows['account-' . md5('MAIN')]['password_configured']);
|
||||
$this->assertFalse($rows['account-' . md5('SUB')]['password_configured']);
|
||||
$this->assertNull($rows['account-' . md5('/')]['password_configured']);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user