Merge remote-tracking branch 'origin/main' into feature/sumu-bill-offset

# Conflicts:
#	tests/Feature/Business/PagesTest.php
This commit is contained in:
苏木 2026-09-08 13:19:38 +08:00
commit 5a34782dcd
36 changed files with 936 additions and 386 deletions

View File

@ -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 {

View File

@ -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);
});

View File

@ -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);
}

View File

@ -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();
}

View File

@ -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()) {

View File

@ -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;
}

View File

@ -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' => '侧边栏 功能 名称',

View File

@ -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;

View File

@ -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 => '普通用户',
];
}

View File

@ -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'],

View File

@ -15,7 +15,7 @@
新账号使用钉钉接口返回的姓名、可用邮箱和手机号;邮箱缺失或已被其他账号使用时,生成稳定的 `@dingtalk.invalid` 内部占位地址。系统生成不可预知的随机本地密码,不展示或发送密码,不发送注册邀请。花名册显示名仅作为姓名缺失时的后备。
新账号赋予现有 `user` 角色及进入应用外壳所需的 `read-admin-panel` 权限,不添加管理权限。业务访问继承管理员明确配置的普通用户权限;尚无配置时默认无业务权限,进入“你已登录,等待分配业务权限”的工作台。管理员可在“侧边栏管理”中为普通用户角色或指定账号配置业务范围。
新账号赋予现有 `user` 角色及进入应用外壳所需的 `read-admin-panel` 权限,不添加管理权限。按负责人最新要求,尚无页面权限配置时默认显示完整业务导航;主管理员随后可在“侧边栏管理”中设置指定用户的业务范围。配置优先级为指定用户、角色、默认完整导航;显式配置为空时没有业务页面,不回退到完整导航。管理员专属操作(包括店铺密码读取)继续单独鉴权。
## 只读数据契约

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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

View File

@ -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
>

View 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>

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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

View File

@ -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');

View File

@ -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

View File

@ -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' => [

View File

@ -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']],

View File

@ -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');

View File

@ -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' => [

View File

@ -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(

View File

@ -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' => [

View File

@ -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']],

View File

@ -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' => [

View File

@ -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']],

View File

@ -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();

View File

@ -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' => [

View 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];
}
}

View File

@ -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',
];
}
}

View File

@ -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']);
}
}