From 4525d95132042092e8cf42e89e2cb14f3a4f3f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=8F=E6=9C=A8?= <594326199@qq.com> Date: Wed, 21 Oct 2026 16:18:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E4=BE=9B=E5=BA=94?= =?UTF-8?q?=E5=95=86=E9=92=89=E9=92=89=E9=97=A8=E6=88=B7=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Business/SupplierPortal.php | 18 ++ .../Business/SupplierReconciliation.php | 34 ++- .../EnsureLimitedDingTalkPageAccess.php | 31 ++- .../LogoutIfDingTalkBindingRevoked.php | 19 +- app/Models/Auth/SupplierPortalIdentity.php | 28 +++ app/Models/Auth/User.php | 5 + app/Services/Auth/DingTalkClosedAccess.php | 62 ++++-- app/Services/Auth/SupplierPortalAccess.php | 194 ++++++++++++++++++ app/Utilities/SidebarPermissions.php | 8 +- ...0000_create_supplier_portal_identities.php | 35 ++++ docs/钉钉花名册登录.md | 13 ++ .../SupplierManagementMirrorRepository.php | 23 +++ .../View/SupplierReconciliationPageData.php | 67 +++--- resources/lang/en-GB/auth.php | 4 +- resources/lang/zh-CN/auth.php | 4 +- .../supplier-data.blade.php | 49 +++-- routes/admin.php | 4 + tests/Feature/Auth/DingTalkLoginTest.php | 83 ++++++++ .../Business/SupplierReconciliationTest.php | 158 ++++++++++++++ tests/Unit/Auth/SupplierPortalAccessTest.php | 114 ++++++++++ ...SupplierManagementMirrorRepositoryTest.php | 14 ++ 21 files changed, 890 insertions(+), 77 deletions(-) create mode 100644 app/Http/Controllers/Business/SupplierPortal.php create mode 100644 app/Models/Auth/SupplierPortalIdentity.php create mode 100644 app/Services/Auth/SupplierPortalAccess.php create mode 100644 database/migrations/2026_10_21_150000_create_supplier_portal_identities.php create mode 100644 tests/Unit/Auth/SupplierPortalAccessTest.php diff --git a/app/Http/Controllers/Business/SupplierPortal.php b/app/Http/Controllers/Business/SupplierPortal.php new file mode 100644 index 00000000..623fa791 --- /dev/null +++ b/app/Http/Controllers/Business/SupplierPortal.php @@ -0,0 +1,18 @@ +route('business.pages.show', [ + 'company_id' => company_id(), + 'section' => 'supplier-reconciliation', + 'page' => 'supplier-data', + ]); + } +} diff --git a/app/Http/Controllers/Business/SupplierReconciliation.php b/app/Http/Controllers/Business/SupplierReconciliation.php index 950d9df8..4d60a922 100644 --- a/app/Http/Controllers/Business/SupplierReconciliation.php +++ b/app/Http/Controllers/Business/SupplierReconciliation.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Business; +use App\Services\Auth\SupplierPortalAccess; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -31,6 +32,7 @@ class SupplierReconciliation extends Controller private readonly SupplierReturnCsvExporter $returnCsvExporter, private readonly SupplierDingTalkNotifier $dingTalkNotifier, private readonly SupplierComparisonCsvExporter $comparisonCsvExporter, + private readonly SupplierPortalAccess $supplierPortalAccess, ) { $this->middleware('auth'); $this->middleware('company.identify'); @@ -48,13 +50,9 @@ class SupplierReconciliation extends Controller { $request->validate([ 'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:51200'], - 'supplier' => ['required', 'string', 'max:191'], ]); - $supplier = trim((string) $request->input('supplier')); - if ($supplier === '') { - abort(422, '请选择厂家。'); - } + $supplier = $this->effectiveSupplier($request); $records = $this->template->parse($request->file('import'), $supplier); $rows = $this->statements->save( @@ -213,9 +211,14 @@ class SupplierReconciliation extends Controller { $request->validate(['key' => ['required', 'string', 'max:191']]); [$supplier, $companyId] = $this->returnTarget($request); + $key = (string) $request->input('key'); + $owned = collect($this->pageData->returnRows($supplier, $companyId)) + ->contains(static fn (array $row): bool => (string) ($row['_action_key'] ?? '') === $key); + abort_unless($owned, 403, '该退货明细不属于当前厂家账号。'); + $this->returnConfirmations->mark( $supplier, - (string) $request->input('key'), + $key, $status, $companyId, user_id() ? (int) user_id() : null, @@ -227,9 +230,24 @@ class SupplierReconciliation extends Controller /** @return array{0:string,1:int} */ private function returnTarget(Request $request): array { - $request->validate(['supplier' => ['required', 'string', 'max:191']]); + return [$this->effectiveSupplier($request), (int) (company_id() ?: 1)]; + } - return [trim((string) $request->input('supplier')), (int) (company_id() ?: 1)]; + private function effectiveSupplier(Request $request): string + { + $user = $request->user(); + if ($user && SupplierPortalAccess::isSupplierUser($user)) { + $supplier = $this->supplierPortalAccess->supplierFor($user); + abort_if(! $supplier, 403, '当前钉钉账号未绑定有效供应商,请联系财务人员。'); + + return $supplier; + } + + $request->validate(['supplier' => ['required', 'string', 'max:191']]); + $supplier = trim((string) $request->input('supplier')); + abort_if($supplier === '', 422, '请选择厂家。'); + + return $supplier; } /** @param array $row */ diff --git a/app/Http/Middleware/EnsureLimitedDingTalkPageAccess.php b/app/Http/Middleware/EnsureLimitedDingTalkPageAccess.php index 3fee8f79..a66325bf 100644 --- a/app/Http/Middleware/EnsureLimitedDingTalkPageAccess.php +++ b/app/Http/Middleware/EnsureLimitedDingTalkPageAccess.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Services\Auth\SupplierPortalAccess; use App\Utilities\SidebarPermissions; use Closure; use Illuminate\Http\Request; @@ -12,7 +13,35 @@ class EnsureLimitedDingTalkPageAccess { $user = $request->user(); $route = $request->route(); - if (! $user || ! $route?->hasParameter('company_id') || ! SidebarPermissions::hasLimitedDingTalkAccess($user)) { + if (! $user || ! SidebarPermissions::hasLimitedDingTalkAccess($user)) { + return $next($request); + } + + if (SupplierPortalAccess::isSupplierUser($user)) { + if ($route?->named('logout', 'business.supplier-portal.home')) { + return $next($request); + } + if ($route?->named('dashboard') && $route->hasParameter('company_id')) { + return redirect()->route('business.supplier-portal.home', [ + 'company_id' => $route->parameter('company_id'), + ]); + } + if ($route?->named('business.pages.show') + && $route->parameter('section') === 'supplier-reconciliation' + && $route->parameter('page') === 'supplier-data') { + return $next($request); + } + foreach ($route?->gatherMiddleware() ?? [] as $middleware) { + if (is_string($middleware) && str_starts_with($middleware, 'business.page:') + && str_replace(',', '/', substr($middleware, strlen('business.page:'))) === SupplierPortalAccess::PAGE_KEY) { + return $next($request); + } + } + + abort(403, '当前账号没有该页面权限。'); + } + + if (! $route?->hasParameter('company_id')) { return $next($request); } diff --git a/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php b/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php index 009d72be..6cced738 100644 --- a/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php +++ b/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php @@ -3,8 +3,9 @@ namespace App\Http\Middleware; use App\Models\Auth\DingTalkIdentity; -use App\Services\Auth\FinanceRoster; use App\Services\Auth\DingTalkClosedAccess; +use App\Services\Auth\FinanceRoster; +use App\Services\Auth\SupplierPortalAccess; use Closure; use Throwable; @@ -15,7 +16,11 @@ class LogoutIfDingTalkBindingRevoked $identityId = $request->session()->get('dingtalk_identity_id'); $user = $request->user(); if (! $identityId) { - if (! $user || ! in_array($user->created_from, ['dingtalk_roster', 'dingtalk_roster_limited'], true)) { + if (! $user || ! in_array($user->created_from, [ + 'dingtalk_roster', + 'dingtalk_roster_limited', + SupplierPortalAccess::CREATED_FROM, + ], true)) { return $next($request); } // A password reset must not bypass HR eligibility for provisioned @@ -32,7 +37,9 @@ class LogoutIfDingTalkBindingRevoked return $next($request); } try { - $eligible = app(FinanceRoster::class)->findEligible((string) $identity->dingtalk_user_id); + $eligible = SupplierPortalAccess::isSupplierUser($user) + ? app(SupplierPortalAccess::class)->revalidate($user) !== null + : app(FinanceRoster::class)->findEligible((string) $identity->dingtalk_user_id); } catch (Throwable) { // Retain the session during an HR outage, but do not serve data // using an expired authorization decision. @@ -46,7 +53,11 @@ class LogoutIfDingTalkBindingRevoked } app(DingTalkClosedAccess::class)->audit('session_revoked', 'denied', $user?->id, null, $identity?->union_id, $request, [ - 'reason' => ! $user?->enabled ? 'local_account_ineligible' : (! $identity ? 'binding_revoked' : 'outside_finance_roster'), + 'reason' => ! $user?->enabled + ? 'local_account_ineligible' + : (! $identity + ? 'binding_revoked' + : (SupplierPortalAccess::isSupplierUser($user) ? 'outside_supplier_roster' : 'outside_finance_roster')), ]); auth()->logout(); $request->session()->invalidate(); diff --git a/app/Models/Auth/SupplierPortalIdentity.php b/app/Models/Auth/SupplierPortalIdentity.php new file mode 100644 index 00000000..c40d0cb8 --- /dev/null +++ b/app/Models/Auth/SupplierPortalIdentity.php @@ -0,0 +1,28 @@ + 'integer', + 'last_verified_at' => 'datetime', + ]; + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Auth/User.php b/app/Models/Auth/User.php index 9346684d..a65b1fa6 100644 --- a/app/Models/Auth/User.php +++ b/app/Models/Auth/User.php @@ -83,6 +83,11 @@ class User extends Authenticatable implements HasLocalePreference return $this->hasOne(DingTalkIdentity::class, 'user_id'); } + public function supplierPortalIdentity() + { + return $this->hasOne(SupplierPortalIdentity::class, 'user_id'); + } + public function contact() { return $this->hasOne('App\Models\Common\Contact', 'user_id', 'id'); diff --git a/app/Services/Auth/DingTalkClosedAccess.php b/app/Services/Auth/DingTalkClosedAccess.php index dd8ad949..b3f80c92 100644 --- a/app/Services/Auth/DingTalkClosedAccess.php +++ b/app/Services/Auth/DingTalkClosedAccess.php @@ -28,9 +28,13 @@ class DingTalkClosedAccess } $roster = app(FinanceRoster::class)->findEligible((string) ($profile['dingtalk_user_id'] ?? '')); - if (! $roster) { + $supplierRoster = $roster ? null : app(SupplierPortalAccess::class)->resolve( + (string) ($profile['dingtalk_user_id'] ?? ''), + (string) ($profile['union_id'] ?? ''), + ); + if (! $roster && ! $supplierRoster) { $this->audit('login_denied', 'denied', null, null, $profile['union_id'] ?? null, $request, [ - 'reason' => 'outside_finance_roster', + 'reason' => 'outside_authorized_roster', ]); $request->attributes->set('dingtalk_denial', 'outside_roster'); return null; @@ -49,6 +53,23 @@ class DingTalkClosedAccess return null; } + if (SupplierPortalAccess::isSupplierUser($identity->user)) { + $company = $this->enabledCompany($identity->user); + if (! $supplierRoster || ! $company) { + $this->audit('login_denied', 'denied', $identity->user_id, null, $profile['union_id'], $request, [ + 'reason' => 'outside_supplier_roster', + ]); + + return null; + } + app(SupplierPortalAccess::class)->bind($identity->user, (int) $company->id, $supplierRoster); + } elseif (! $roster) { + $this->audit('login_denied', 'denied', $identity->user_id, null, $profile['union_id'], $request, [ + 'reason' => 'outside_finance_roster', + ]); + + return null; + } $identity->forceFill([ 'open_id' => $profile['open_id'] ?: $identity->open_id, @@ -69,7 +90,7 @@ class DingTalkClosedAccess return null; } - return DB::transaction(function () use ($profile, $roster, $request): ?DingTalkIdentity { + return DB::transaction(function () use ($profile, $roster, $supplierRoster, $request): ?DingTalkIdentity { // Serialize first-time provisioning on the target company. Both the // account and unique identity are committed together, including retries. $companies = Company::enabled() @@ -92,10 +113,11 @@ class DingTalkClosedAccess return null; } - $roleName = FinanceRoster::isDesignatedSuperAdmin((string) $profile['dingtalk_user_id']) + $supplierPortal = $supplierRoster !== null; + $roleName = ! $supplierPortal && FinanceRoster::isDesignatedSuperAdmin((string) $profile['dingtalk_user_id']) ? SystemAccess::SUPER_ADMIN : SystemAccess::USER; $role = Role::where('name', $roleName)->firstOrFail(); - $email = $this->normalizeEmail($profile['email'] ?? null); + $email = $supplierPortal ? null : $this->normalizeEmail($profile['email'] ?? null); if (! $email || User::withTrashed()->where('email', $email)->exists()) { $email = 'ding-'.hash('sha256', $profile['union_id']).'@dingtalk.invalid'; } @@ -104,21 +126,25 @@ class DingTalkClosedAccess return null; } $account = User::create([ - 'name' => Str::limit($profile['nickname'] ?: $roster['display_name'], 191, ''), + 'name' => Str::limit($profile['nickname'] ?: ($roster['display_name'] ?? '供应商人员'), 191, ''), 'email' => $email, 'password' => Str::random(64), 'locale' => 'zh-CN', 'enabled' => true, - 'landing_page' => 'dashboard', + 'landing_page' => $supplierPortal ? 'business.supplier-portal.home' : 'dashboard', 'created_by' => 0, - 'created_from' => FinanceRoster::isLimitedLoginUser((string) $profile['dingtalk_user_id']) - ? 'dingtalk_roster_limited' : 'dingtalk_roster', + 'created_from' => $supplierPortal + ? SupplierPortalAccess::CREATED_FROM + : (FinanceRoster::isLimitedLoginUser((string) $profile['dingtalk_user_id']) + ? 'dingtalk_roster_limited' : 'dingtalk_roster'), ]); $account->roles()->attach($role->id); // Explicit designation controls the role; ordinary roster users get shell access. $account->permissions()->attach(Permission::where('name', 'read-admin-panel')->firstOrFail()->id); $account->companies()->attach($companies->first()->id); - if ($mobile = $this->normalizeMobile($profile['mobile'] ?? null)) { + if ($supplierPortal) { + app(SupplierPortalAccess::class)->bind($account, (int) $companies->first()->id, $supplierRoster); + } elseif ($mobile = $this->normalizeMobile($profile['mobile'] ?? null)) { Contact::create([ 'company_id' => $companies->first()->id, 'type' => Contact::EMPLOYEE_TYPE, 'user_id' => $account->id, 'name' => $account->name, 'email' => $email, @@ -131,7 +157,12 @@ class DingTalkClosedAccess 'open_id' => $profile['open_id'], 'dingtalk_user_id' => $profile['dingtalk_user_id'], 'nickname' => $profile['nickname'], 'bound_at' => now(), ]); - $this->upsertRequest($profile, DingTalkBindingRequest::APPROVED, 'finance_roster_provisioned', $account->id); + $this->upsertRequest( + $profile, + DingTalkBindingRequest::APPROVED, + $supplierPortal ? 'supplier_roster_provisioned' : 'finance_roster_provisioned', + $account->id, + ); $this->audit('account_auto_created', 'success', $account->id, null, $profile['union_id'], $request); return $identity->load('user'); }, 3); @@ -165,9 +196,12 @@ class DingTalkClosedAccess private function isEligible(?User $user): bool { - return $user - && $user->enabled - && $user->withoutEvents(fn () => $user->companies()->enabled()->exists()); + return $user && $user->enabled && $this->enabledCompany($user) !== null; + } + + private function enabledCompany(User $user): ?Company + { + return $user->withoutEvents(fn () => $user->companies()->enabled()->first()); } private function upsertRequest(array $profile, string $status, string $reason, ?int $matchedUserId): void diff --git a/app/Services/Auth/SupplierPortalAccess.php b/app/Services/Auth/SupplierPortalAccess.php new file mode 100644 index 00000000..7d0e3c75 --- /dev/null +++ b/app/Services/Auth/SupplierPortalAccess.php @@ -0,0 +1,194 @@ +created_from === self::CREATED_FROM; + } + + /** @return array{supplier_name:string,supplier_code:string,dingtalk_group_id:int,dingtalk_group_name:string}|null */ + public function resolve(string $userId, string $unionId = ''): ?array + { + $userId = trim($userId); + $unionId = trim($unionId); + if ($userId === '') { + return null; + } + + $query = $this->queries->forSource('operations_replica'); + $employees = $query->select( + 'SELECT TOP (2) userid, unionid, name, active, dept_id_list + FROM rpt.hr_employee WHERE userid = ?', + [$userId], + ); + if (count($employees) !== 1 || ! (bool) ($employees[0]['active'] ?? false)) { + return null; + } + $employee = $employees[0]; + $employeeUnionId = trim((string) ($employee['unionid'] ?? '')); + if ($employeeUnionId === '' || ($unionId !== '' && ! hash_equals($employeeUnionId, $unionId))) { + return null; + } + + $departmentIds = $this->departmentIds($employee['dept_id_list'] ?? null); + if ($departmentIds === [] || count($departmentIds) > 64) { + return null; + } + + $placeholders = implode(',', array_fill(0, count($departmentIds), '?')); + $ancestors = $query->select( + 'WITH lineage AS ( + SELECT Id, parent_id, name, 0 AS depth FROM rpt.hr_groups WHERE Id IN ('.$placeholders.') + UNION ALL + SELECT parent.Id, parent.parent_id, parent.name, child.depth + 1 + FROM rpt.hr_groups parent JOIN lineage child ON parent.Id = child.parent_id + WHERE child.depth < 20 + ) SELECT TOP (256) Id, parent_id, name, depth FROM lineage', + $departmentIds, + ); + if ($ancestors === [] || count($ancestors) >= 256 + || array_filter($ancestors, static fn (array $row): bool => (int) ($row['depth'] ?? 0) >= 20)) { + return null; + } + + $groupsByName = []; + foreach ($ancestors as $ancestor) { + $name = trim((string) ($ancestor['name'] ?? '')); + if ($name !== '') { + $groupsByName[$name][] = $ancestor; + } + } + if ($groupsByName === []) { + return null; + } + + $supplierRows = $this->suppliers->suppliersByNames(array_keys($groupsByName)); + $matchedSupplierNames = []; + foreach ($supplierRows as $supplier) { + $name = trim((string) ($supplier->scmname ?? '')); + if ($name !== '' && isset($groupsByName[$name])) { + $matchedSupplierNames[$name] = true; + } + } + if (count($matchedSupplierNames) !== 1) { + return null; + } + + $supplierName = (string) array_key_first($matchedSupplierNames); + $matchingRows = array_values(array_filter( + $supplierRows, + static fn (object $supplier): bool => trim((string) ($supplier->scmname ?? '')) === $supplierName, + )); + if (count($matchingRows) !== 1) { + return null; + } + + usort($groupsByName[$supplierName], static fn (array $left, array $right): int => + (int) ($right['depth'] ?? 0) <=> (int) ($left['depth'] ?? 0)); + $group = $groupsByName[$supplierName][0]; + + return [ + 'supplier_name' => $supplierName, + 'supplier_code' => trim((string) ($matchingRows[0]->scmcode ?? '')), + 'dingtalk_group_id' => (int) ($group['Id'] ?? 0), + 'dingtalk_group_name' => $supplierName, + ]; + } + + public function bind(User $user, int $companyId, array $resolved): SupplierPortalIdentity + { + return SupplierPortalIdentity::query()->updateOrCreate( + ['user_id' => $user->id], + [ + 'company_id' => $companyId, + 'supplier_code' => $resolved['supplier_code'] ?? null, + 'supplier_name' => $resolved['supplier_name'], + 'dingtalk_group_id' => (int) $resolved['dingtalk_group_id'], + 'dingtalk_group_name' => $resolved['dingtalk_group_name'], + 'last_verified_at' => now(), + ], + ); + } + + public function mappingFor(User $user): ?SupplierPortalIdentity + { + if (! self::isSupplierUser($user)) { + return null; + } + + return SupplierPortalIdentity::query() + ->where('user_id', $user->id) + ->where('company_id', company_id()) + ->first(); + } + + public function supplierFor(User $user): ?string + { + $mapping = $this->mappingFor($user); + $name = trim((string) ($mapping?->supplier_name ?? '')); + + return $name !== '' ? $name : null; + } + + public function revalidate(User $user): ?SupplierPortalIdentity + { + if (! self::isSupplierUser($user)) { + return null; + } + + $dingTalk = DingTalkIdentity::query()->where('user_id', $user->id)->first(); + $company = $user->withoutEvents(fn () => $user->companies()->enabled()->first()); + if (! $dingTalk || ! $company) { + return null; + } + + $resolved = $this->resolve( + (string) $dingTalk->dingtalk_user_id, + (string) $dingTalk->union_id, + ); + + return $resolved ? $this->bind($user, (int) $company->id, $resolved) : null; + } + + private function departmentIds(mixed $value): array + { + $value = trim((string) $value); + if ($value === '') { + return []; + } + $parts = json_decode($value, true); + if (! is_array($parts)) { + $parts = preg_split('/[\[\]\/>,,;;||\s]+/u', $value, -1, PREG_SPLIT_NO_EMPTY); + } + + return array_values(array_filter(array_map( + static fn (mixed $id): string => is_scalar($id) ? trim((string) $id) : '', + is_array($parts) ? $parts : [], + ), static fn (string $id): bool => ctype_digit($id) && (int) $id > 0)); + } +} diff --git a/app/Utilities/SidebarPermissions.php b/app/Utilities/SidebarPermissions.php index 7120b6e5..86238163 100644 --- a/app/Utilities/SidebarPermissions.php +++ b/app/Utilities/SidebarPermissions.php @@ -5,6 +5,7 @@ namespace App\Utilities; use App\Models\Auth\User; use App\Models\Auth\UserAccessGroup; use App\Services\Auth\FinanceRoster; +use App\Services\Auth\SupplierPortalAccess; class SidebarPermissions { @@ -45,6 +46,10 @@ class SidebarPermissions public static function allowedKeys(User $user): ?array { // Limited identities cannot inherit full navigation from roles, groups or user overrides. + if (SupplierPortalAccess::isSupplierUser($user)) { + return [SupplierPortalAccess::PAGE_KEY]; + } + if (self::hasLimitedDingTalkAccess($user)) { return config('dingtalk.limited_login_page_keys', []); } @@ -77,7 +82,8 @@ class SidebarPermissions public static function hasLimitedDingTalkAccess(User $user): bool { - return $user->created_from === 'dingtalk_roster_limited' + return SupplierPortalAccess::isSupplierUser($user) + || $user->created_from === 'dingtalk_roster_limited' || (config('dingtalk.limited_login_user_ids', []) !== [] && FinanceRoster::isLimitedLoginUser((string) $user->dingtalkIdentity?->dingtalk_user_id)); } diff --git a/database/migrations/2026_10_21_150000_create_supplier_portal_identities.php b/database/migrations/2026_10_21_150000_create_supplier_portal_identities.php new file mode 100644 index 00000000..e01edd06 --- /dev/null +++ b/database/migrations/2026_10_21_150000_create_supplier_portal_identities.php @@ -0,0 +1,35 @@ +bigIncrements('id'); + $table->unsignedInteger('company_id'); + $table->unsignedInteger('user_id')->unique(); + $table->string('supplier_code', 191)->nullable(); + $table->string('supplier_name', 191); + $table->unsignedBigInteger('dingtalk_group_id'); + $table->string('dingtalk_group_name', 191); + $table->timestamp('last_verified_at')->nullable(); + $table->timestamps(); + + $table->index(['company_id', 'supplier_name'], 'supplier_portal_company_name'); + $table->index(['company_id', 'dingtalk_group_id'], 'supplier_portal_company_group'); + }); + } + + public function down(): void + { + Schema::dropIfExists('supplier_portal_identities'); + } +}; diff --git a/docs/钉钉花名册登录.md b/docs/钉钉花名册登录.md index e3b32c2b..f013cec2 100644 --- a/docs/钉钉花名册登录.md +++ b/docs/钉钉花名册登录.md @@ -1,5 +1,18 @@ # 钉钉授权与财务花名册自动开户 +## 供应商门户身份 + +供应商人员与财务人员使用不同的准入来源: + +- 财务人员继续按 `hr_flowerName_book`、任职登记和财务部门范围核验。 +- 供应商人员按当前有效的 `hr_employee` 企业成员、`hr_employee.dept_id_list` 与 `hr_groups` 祖先组织核验,不要求进入财务花名册。 +- 部门祖先名称必须与共享供应商主档中的供应商名称精确匹配且唯一;同名多供应商、同一员工命中多个供应商或组织循环时拒绝登录。 +- 首次登录自动创建 `dingtalk_supplier_portal` 普通账号,并写入 `supplier_portal_identities`。不创建财务角色、不创建供应商联系人、不授予财务页面权限。 +- 供应商账号只允许“供应商对账 → 厂家端数据”及其模板、上传、确认、异议和导出接口。服务端始终覆盖请求中的供应商参数,并校验退货明细属于绑定供应商。 +- 厂家页面显示只读供应商名称,不返回其他供应商候选列表。 +- 会话每 5 分钟复核钉钉在职状态和供应商组织;人员停用、离开供应商组织或组织归属不唯一时立即失效。上游故障时返回 503,不使用过期授权继续展示数据。 +- 供应商别名尚未启用;“常州市可乐办公家具有限公司”与“可乐办公”的对应关系未核实前按未授权处理。 + ## 用户流程 首次钉钉授权成功后,系统用企业 `userid` 对应 HR 花名册中的 `flowerNameId`,核验人员状态和组织归属,自动创建普通用户、绑定钉钉身份并登录。不要求填写注册表、设置密码或等待账号绑定审批。 diff --git a/modules/EcommerceFinance/Repositories/SupplierManagementMirrorRepository.php b/modules/EcommerceFinance/Repositories/SupplierManagementMirrorRepository.php index df0b69ed..4a6ca9d8 100644 --- a/modules/EcommerceFinance/Repositories/SupplierManagementMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/SupplierManagementMirrorRepository.php @@ -122,6 +122,29 @@ class SupplierManagementMirrorRepository ->all(); } + /** @param list $names + * @return list + */ + public function suppliersByNames(array $names): array + { + $names = array_values(array_unique(array_filter( + array_map('trim', $names), + static fn (string $name): bool => $name !== '', + ))); + if ($names === []) { + return []; + } + if (! $this->ready()) { + throw new RuntimeException('supplier_management_mirror_not_ready'); + } + + return $this->mirror->connection()->table($this->datasets->get(self::DATASET)->targetTable()) + ->whereIn('scmname', $names) + ->orderBy('Id') + ->get(['Id', 'scmcode', 'scmname']) + ->all(); + } + /** 全部筛选结果,按上游稳定键小批读取,保留编码/账号文本。 */ public function exportRows(array $filters): \Generator { diff --git a/modules/EcommerceFinance/View/SupplierReconciliationPageData.php b/modules/EcommerceFinance/View/SupplierReconciliationPageData.php index a9f24d0c..089eb7ac 100644 --- a/modules/EcommerceFinance/View/SupplierReconciliationPageData.php +++ b/modules/EcommerceFinance/View/SupplierReconciliationPageData.php @@ -3,6 +3,7 @@ namespace Modules\EcommerceFinance\View; use App\Http\Controllers\Business\PayablesFinalActions; +use App\Services\Auth\SupplierPortalAccess; use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier; use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore; use Modules\EcommerceFinance\Services\SupplierReturnAccounting; @@ -33,6 +34,7 @@ class SupplierReconciliationPageData private readonly SupplierReturnConfirmation $returnConfirmations, private readonly SupplierDingTalkNotifier $dingTalkNotifier, private readonly SupplierReturnAccounting $returnAccounting, + private readonly SupplierPortalAccess $supplierPortalAccess, ) { } @@ -163,38 +165,48 @@ class SupplierReconciliationPageData public function supplierData(): array { $companyId = $this->companyId(); - $mine = $this->store->listRows( - PayablesFinalActions::DATASET_PREFIX . 'supplier:payment-reconciliation', - $companyId, - ); + $portalUser = request()->user(); + $supplierLocked = $portalUser && SupplierPortalAccess::isSupplierUser($portalUser); + $supplierNames = []; + $supplierNotFound = false; - $suppliers = []; - foreach ($mine as $row) { - $name = trim((string) ($row['supplier'] ?? '')); - if ($name !== '') { + if ($supplierLocked) { + $selected = $this->supplierPortalAccess->supplierFor($portalUser); + abort_if(! $selected, 403, '当前钉钉账号未绑定有效供应商,请联系财务人员。'); + $supplierQuery = $selected; + } else { + $mine = $this->store->listRows( + PayablesFinalActions::DATASET_PREFIX . 'supplier:payment-reconciliation', + $companyId, + ); + $suppliers = []; + foreach ($mine as $row) { + $name = trim((string) ($row['supplier'] ?? '')); + if ($name !== '') { + $suppliers[$name] = true; + } + } + foreach ($this->statements->uploadedSuppliers($companyId) as $name) { $suppliers[$name] = true; } - } - foreach ($this->statements->uploadedSuppliers($companyId) as $name) { - $suppliers[$name] = true; - } - foreach ($this->store->listRows( - PayablesFinalActions::DATASET_PREFIX . 'supplier:return-reconciliation', - $companyId, - ) as $row) { - $name = trim((string) ($row['supplier'] ?? '')); - if ($name !== '') { - $suppliers[$name] = true; + foreach ($this->store->listRows( + PayablesFinalActions::DATASET_PREFIX . 'supplier:return-reconciliation', + $companyId, + ) as $row) { + $name = trim((string) ($row['supplier'] ?? '')); + if ($name !== '') { + $suppliers[$name] = true; + } } - } - $supplierNames = array_keys($suppliers); - sort($supplierNames); + $supplierNames = array_keys($suppliers); + sort($supplierNames); - $supplierQuery = trim((string) request()->query('supplier', '')); - $selected = in_array($supplierQuery, $supplierNames, true) ? $supplierQuery : ''; - $supplierNotFound = $supplierQuery !== '' && $selected === ''; - if ($supplierQuery === '') { - $selected = $supplierNames[0] ?? ''; + $supplierQuery = trim((string) request()->query('supplier', '')); + $selected = in_array($supplierQuery, $supplierNames, true) ? $supplierQuery : ''; + $supplierNotFound = $supplierQuery !== '' && $selected === ''; + if ($supplierQuery === '') { + $selected = $supplierNames[0] ?? ''; + } } $page = max((int) request()->query('rows_page', 1), 1); @@ -209,6 +221,7 @@ class SupplierReconciliationPageData 'supplierQuery' => $supplierQuery, 'supplierNotFound' => $supplierNotFound, 'selectedSupplier' => $selected, + 'supplierLocked' => $supplierLocked, 'uploads' => $selected !== '' ? $this->statements->uploads($selected, $companyId) : [], 'statementRows' => array_slice($statementRows, ($page - 1) * self::PER_PAGE, self::PER_PAGE), 'statementTotal' => count($statementRows), diff --git a/resources/lang/en-GB/auth.php b/resources/lang/en-GB/auth.php index deb70ab4..c87b501a 100644 --- a/resources/lang/en-GB/auth.php +++ b/resources/lang/en-GB/auth.php @@ -58,8 +58,8 @@ return [ 'rejected' => 'Your DingTalk binding request was rejected. Contact the system administrator for access.', 'revoked' => 'Your DingTalk account binding was removed. Contact the system administrator to restore access.', 'session_expired' => 'Your DingTalk session is no longer valid. Sign in again, or contact the administrator if your account was disabled or unbound.', - 'outside_roster' => 'Access is limited to current staff in the finance departments listed in the HR roster. Contact an administrator if your record needs correction.', - 'roster_unavailable' => 'The finance roster cannot be verified right now. Please try DingTalk sign-in again later.', + 'outside_roster' => 'Access is limited to current finance staff, individually authorized enterprise members, and active DingTalk members in supplier organizations. Contact an administrator if your record needs correction.', + 'roster_unavailable' => 'The DingTalk roster or supplier organization cannot be verified right now. Please try DingTalk sign-in again later.', ], 'notification' => [ diff --git a/resources/lang/zh-CN/auth.php b/resources/lang/zh-CN/auth.php index c8b3fab9..a06a5cbd 100644 --- a/resources/lang/zh-CN/auth.php +++ b/resources/lang/zh-CN/auth.php @@ -58,8 +58,8 @@ return [ 'rejected' => '钉钉账号绑定申请已被拒绝,如需开通请联系系统管理员。', 'revoked' => '钉钉账号绑定已解除,如需恢复请联系系统管理员。', 'session_expired' => '钉钉登录已失效,请重新登录;如账号已停用或解除绑定,请联系系统管理员。', - 'outside_roster' => '当前仅向财务中心及其下级部门的在职人员,以及已单独授权的本公司在职人员开放。如有疑问,请联系管理员核对登录资格。', - 'roster_unavailable' => '暂时无法核验财务花名册,请稍后重新尝试钉钉登录。', + 'outside_roster' => '当前仅向财务中心及其下级部门的在职人员、已单独授权的本公司在职人员,以及供应商组织下的在职钉钉成员开放。如有疑问,请联系管理员核对登录资格。', + 'roster_unavailable' => '暂时无法核验钉钉花名册或供应商组织,请稍后重新尝试钉钉登录。', ], 'notification' => [ diff --git a/resources/views/business/pages/supplier-reconciliation/supplier-data.blade.php b/resources/views/business/pages/supplier-reconciliation/supplier-data.blade.php index b6c50ddb..f24beb44 100644 --- a/resources/views/business/pages/supplier-reconciliation/supplier-data.blade.php +++ b/resources/views/business/pages/supplier-reconciliation/supplier-data.blade.php @@ -19,6 +19,9 @@ .supplier-reconciliation-page .sr-button { display: inline-flex; height: 36px; align-items: center; gap: 6px; padding: 0 14px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: #fff; color: var(--vip-content-ink, #171717); font-size: .8125rem; font-weight: 600; text-decoration: none; cursor: pointer; } .supplier-reconciliation-page .sr-button.is-primary { border-color: var(--vip-content-accent-dark, #856632); background: var(--vip-content-accent-dark, #856632); color: #fff; } .supplier-reconciliation-page .sr-hint { margin: 0; color: var(--vip-content-muted, #595959); font-size: .75rem; line-height: 1.7; } + .supplier-reconciliation-page .sr-locked-supplier { display: grid; gap: 4px; min-width: 260px; } + .supplier-reconciliation-page .sr-locked-supplier span { color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 600; } + .supplier-reconciliation-page .sr-locked-supplier strong { min-height: 36px; padding: 8px 10px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: var(--vip-content-bg, #F8F6F1); font-size: .8125rem; font-weight: 600; } .supplier-reconciliation-page .sr-scroll { min-width: 0; max-width: 100%; overflow-x: auto; scrollbar-width: thin; } .supplier-reconciliation-page table { width: 100%; min-width: 720px; border-collapse: collapse; font-size: .8125rem; } .supplier-reconciliation-page th { padding: 8px 10px; background: var(--vip-content-accent-soft, #F7F0DE); border-bottom: 1px solid var(--vip-border, #D8D2C7); color: #5B5343; font-size: .75rem; font-weight: 600; text-align: left; white-space: nowrap; } @@ -83,24 +86,34 @@

厂家对账单 · 上传与实时数据

-
- - - 下载对账单模板 -
+ @if ($supplierLocked) +
+
+ 当前厂家(由钉钉组织自动确定) + {{ $selectedSupplier }} +
+ 下载对账单模板 +
+ @else +
+ + + 下载对账单模板 +
+ @endif @if ($supplierNotFound)