feat: 接入供应商钉钉门户权限 #436

Merged
renshen merged 1 commits from feature/sumu-supplier-dingtalk-portal into main 2026-10-21 16:24:16 +08:00
21 changed files with 890 additions and 77 deletions

View File

@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Controller;
class SupplierPortal extends Controller
{
public function home(): RedirectResponse
{
return redirect()->route('business.pages.show', [
'company_id' => company_id(),
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
]);
}
}

View File

@ -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<string,mixed> $row */

View File

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

View File

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

View File

@ -0,0 +1,28 @@
<?php
namespace App\Models\Auth;
use Illuminate\Database\Eloquent\Model;
class SupplierPortalIdentity extends Model
{
protected $fillable = [
'company_id',
'user_id',
'supplier_code',
'supplier_name',
'dingtalk_group_id',
'dingtalk_group_name',
'last_verified_at',
];
protected $casts = [
'dingtalk_group_id' => 'integer',
'last_verified_at' => 'datetime',
];
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

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

View File

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

View File

@ -0,0 +1,194 @@
<?php
namespace App\Services\Auth;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\SupplierPortalIdentity;
use App\Models\Auth\User;
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
/**
* 钉钉企业组织中的供应商成员映射。
*
* 只有当前有效的钉钉企业员工、且其部门祖先精确对应唯一供应商主档名称时,
* 才建立供应商门户身份。财务花名册不是供应商人员的准入前提。
*/
class SupplierPortalAccess
{
public const CREATED_FROM = 'dingtalk_supplier_portal';
public const PAGE_KEY = 'supplier-reconciliation/supplier-data';
public function __construct(
private readonly UpstreamReadOnlyQueryFactory $queries,
private readonly SupplierManagementMirrorRepository $suppliers,
) {
}
public static function isSupplierUser(User $user): bool
{
return $user->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));
}
}

View File

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

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('supplier_portal_identities')) {
return;
}
Schema::create('supplier_portal_identities', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -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`,核验人员状态和组织归属,自动创建普通用户、绑定钉钉身份并登录。不要求填写注册表、设置密码或等待账号绑定审批。

View File

@ -122,6 +122,29 @@ class SupplierManagementMirrorRepository
->all();
}
/** @param list<string> $names
* @return list<object>
*/
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
{

View File

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

View File

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

View File

@ -58,8 +58,8 @@ return [
'rejected' => '钉钉账号绑定申请已被拒绝,如需开通请联系系统管理员。',
'revoked' => '钉钉账号绑定已解除,如需恢复请联系系统管理员。',
'session_expired' => '钉钉登录已失效,请重新登录;如账号已停用或解除绑定,请联系系统管理员。',
'outside_roster' => '当前仅向财务中心及其下级部门的在职人员,以及已单独授权的本公司在职人员开放。如有疑问,请联系管理员核对登录资格。',
'roster_unavailable' => '暂时无法核验财务花名册,请稍后重新尝试钉钉登录。',
'outside_roster' => '当前仅向财务中心及其下级部门的在职人员、已单独授权的本公司在职人员,以及供应商组织下的在职钉钉成员开放。如有疑问,请联系管理员核对登录资格。',
'roster_unavailable' => '暂时无法核验钉钉花名册或供应商组织,请稍后重新尝试钉钉登录。',
],
'notification' => [

View File

@ -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 @@
<div class="sr-card">
<h2>厂家对账单 · 上传与实时数据</h2>
<form method="GET" class="sr-toolbar" aria-label="查询厂家">
<label class="sr-field"><span>厂家名称</span>
<input type="search"
name="supplier"
value="{{ $supplierQuery !== '' ? $supplierQuery : $selectedSupplier }}"
list="supplier-name-options"
placeholder="输入厂家名称搜索"
autocomplete="off"
required>
<datalist id="supplier-name-options">
@foreach ($supplierNames as $supplier)
<option value="{{ $supplier }}"></option>
@endforeach
</datalist>
</label>
<button type="submit" class="sr-button is-primary">查询厂家</button>
<a href="{{ $statementTemplateUrl }}" class="sr-button">下载对账单模板</a>
</form>
@if ($supplierLocked)
<div class="sr-toolbar" aria-label="当前厂家">
<div class="sr-locked-supplier">
<span>当前厂家(由钉钉组织自动确定)</span>
<strong>{{ $selectedSupplier }}</strong>
</div>
<a href="{{ $statementTemplateUrl }}" class="sr-button">下载对账单模板</a>
</div>
@else
<form method="GET" class="sr-toolbar" aria-label="查询厂家">
<label class="sr-field"><span>厂家名称</span>
<input type="search"
name="supplier"
value="{{ $supplierQuery !== '' ? $supplierQuery : $selectedSupplier }}"
list="supplier-name-options"
placeholder="输入厂家名称搜索"
autocomplete="off"
required>
<datalist id="supplier-name-options">
@foreach ($supplierNames as $supplier)
<option value="{{ $supplier }}"></option>
@endforeach
</datalist>
</label>
<button type="submit" class="sr-button is-primary">查询厂家</button>
<a href="{{ $statementTemplateUrl }}" class="sr-button">下载对账单模板</a>
</form>
@endif
@if ($supplierNotFound)
<div class="biz-alert biz-alert-error" role="alert">
未找到名为「{{ $supplierQuery }}」的厂家,请输入完整名称并从搜索建议中选择。

View File

@ -398,6 +398,10 @@ foreach ($payablesFinalDatasets as $finalSection => $finalDatasets) {
}
// 供应商对账:对账单模板下载 + 厂家对账单上传(严格按模板校验)
Route::get('workspace/supplier-portal', [\App\Http\Controllers\Business\SupplierPortal::class, 'home'])
->middleware('business.page:supplier-reconciliation,supplier-data')
->name('business.supplier-portal.home');
Route::get('workspace/supplier-reconciliation/statement-template', [SupplierReconciliationActions::class, 'template'])
->middleware('business.page:supplier-reconciliation,supplier-data')
->name('business.supplier-reconciliation.template');

View File

@ -5,14 +5,17 @@ namespace Tests\Feature\Auth;
use App\Models\Auth\DingTalkBindingRequest;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\DingTalkOAuthState;
use App\Models\Auth\SupplierPortalIdentity;
use App\Models\Auth\User;
use App\Models\Common\Contact;
use App\Services\Auth\FinanceRoster;
use App\Services\Auth\SupplierPortalAccess;
use App\Utilities\SidebarPermissions;
use Illuminate\Http\Client\Request as ClientRequest;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Mockery;
use Tests\Feature\FeatureTestCase;
class DingTalkLoginTest extends FeatureTestCase
@ -40,6 +43,7 @@ class DingTalkLoginTest extends FeatureTestCase
$this->mock(FinanceRoster::class)->shouldReceive('findEligible')->andReturn([
'roster_id' => 'fixture-1', 'display_name' => '财务测试员工', 'department_ids' => ['22848291'],
])->byDefault();
$this->mock(SupplierPortalAccess::class)->shouldReceive('resolve')->andReturnNull()->byDefault();
}
public function test_login_page_shows_dingtalk_entry_without_password_binding_fields(): void
@ -224,6 +228,56 @@ class DingTalkLoginTest extends FeatureTestCase
$this->assertGuest();
}
public function test_supplier_dingtalk_member_gets_only_the_locked_supplier_page(): void
{
$this->mock(FinanceRoster::class)
->shouldReceive('findEligible')->with('ding-user-1')->andReturnNull();
$supplierAccess = $this->mock(SupplierPortalAccess::class);
$supplierAccess->shouldReceive('resolve')
->once()
->with('ding-user-1', 'union-supplier')
->andReturn([
'supplier_name' => '晨丰',
'supplier_code' => 'CF-001',
'dingtalk_group_id' => 902833372,
'dingtalk_group_name' => '晨丰',
]);
$supplierAccess->shouldReceive('bind')
->once()
->andReturnUsing(function (User $user, int $companyId, array $resolved): SupplierPortalIdentity {
return SupplierPortalIdentity::create([
'company_id' => $companyId,
'user_id' => $user->id,
'supplier_code' => $resolved['supplier_code'],
'supplier_name' => $resolved['supplier_name'],
'dingtalk_group_id' => $resolved['dingtalk_group_id'],
'dingtalk_group_name' => $resolved['dingtalk_group_name'],
'last_verified_at' => now(),
]);
});
Http::preventStrayRequests();
Http::fake($this->dingtalkResponses('union-supplier', 'open-supplier'));
$state = $this->startAuthorization();
$this->get(route('login.dingtalk.callback', ['code' => 'fake-code', 'state' => $state]))
->assertRedirect(route('business.supplier-portal.home', ['company_id' => $this->company->id]));
$created = auth()->user();
$this->assertSame(SupplierPortalAccess::CREATED_FROM, $created->created_from);
$this->assertSame('business.supplier-portal.home', $created->landing_page);
$this->assertTrue($created->hasRole('user'));
$this->assertFalse($created->hasRole('admin'));
$this->assertFalse($created->hasRole('super_admin'));
$this->assertSame([SupplierPortalAccess::PAGE_KEY], SidebarPermissions::allowedKeys($created));
$this->assertDatabaseHas('supplier_portal_identities', [
'company_id' => $this->company->id,
'user_id' => $created->id,
'supplier_name' => '晨丰',
'supplier_code' => 'CF-001',
]);
$this->assertDatabaseMissing('contacts', ['user_id' => $created->id]);
}
public function test_limited_password_login_still_checks_current_roster(): void
{
$this->user->update(['created_from' => 'dingtalk_roster_limited']);
@ -508,6 +562,35 @@ class DingTalkLoginTest extends FeatureTestCase
$this->assertGuest();
}
public function test_supplier_group_removal_invalidates_the_limited_session(): void
{
$this->user->update(['created_from' => SupplierPortalAccess::CREATED_FROM]);
$identity = DingTalkIdentity::create([
'user_id' => $this->user->id,
'union_id' => 'union-supplier-removed',
'dingtalk_user_id' => 'ding-supplier-removed',
]);
SupplierPortalIdentity::create([
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'supplier_name' => '晨丰',
'dingtalk_group_id' => 1,
'dingtalk_group_name' => '晨丰',
]);
$this->mock(SupplierPortalAccess::class)
->shouldReceive('revalidate')->with(Mockery::type(User::class))->once()->andReturnNull();
$this->actingAs($this->user)->withSession([
'dingtalk_identity_id' => $identity->id,
'dingtalk_roster_checked_at' => now()->subMinutes(6)->timestamp,
])->getJson(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
]))->assertUnauthorized();
$this->assertGuest();
}
public function test_disabled_account_cannot_keep_using_dingtalk_session(): void
{
$identity = DingTalkIdentity::create(['user_id' => $this->user->id, 'union_id' => 'union-disabled']);

View File

@ -3,9 +3,13 @@
namespace Tests\Feature\Business;
use App\Jobs\Common\CreateContact;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\Permission;
use App\Models\Auth\Role;
use App\Models\Auth\SupplierPortalIdentity;
use App\Models\Auth\User;
use App\Models\Common\Contact;
use App\Services\Auth\SupplierPortalAccess;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
@ -354,6 +358,115 @@ class SupplierReconciliationTest extends FeatureTestCase
->assertDontSeeText('鸣飞家具 · 上传记录');
}
public function test_supplier_portal_is_locked_to_its_own_page_and_blocks_core_routes(): void
{
$this->seedStatement('晨丰', [
$this->statementRow('ZSJ-1', '128.00', '2', '256.00'),
]);
$supplierUser = $this->createSupplierPortalUser('晨丰');
$pageUrl = route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
'supplier' => '鸣飞家具',
]);
$this->actingAsSupplierPortal($supplierUser)->get($pageUrl)
->assertOk()
->assertSeeText('当前厂家(由钉钉组织自动确定)')
->assertSeeText('晨丰')
->assertDontSee('list="supplier-name-options"', false)
->assertDontSeeText('权限管理')
->assertDontSeeText('数据库管理')
->assertDontSeeText('鸣飞家具 · 上传记录');
$this->withExceptionHandling();
$this->actingAsSupplierPortal($supplierUser)
->get(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
]))
->assertForbidden();
$this->actingAsSupplierPortal($supplierUser)
->get(route('business.supplier-reconciliation.comparison.export', ['company_id' => $this->company->id]))
->assertForbidden();
$this->actingAsSupplierPortal($supplierUser)
->get(route('dashboard', ['company_id' => $this->company->id]))
->assertRedirect(route('business.supplier-portal.home', [
'company_id' => $this->company->id,
]));
$this->actingAsSupplierPortal($supplierUser)
->get(route('business.supplier-portal.home', ['company_id' => $this->company->id]))
->assertRedirect(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
]));
}
public function test_supplier_portal_upload_and_return_actions_ignore_spoofed_supplier(): void
{
$supplierUser = $this->createSupplierPortalUser('晨丰');
$file = UploadedFile::fake()->createWithContent(
'statement.csv',
"\xEF\xBB\xBF" . self::HEADER . "\n"
. ",,PORTAL-UPLOAD,2026-09-30,晨丰,ZSJ-1,规格甲,2,128.00,256.00\n",
);
$this->actingAsSupplierPortal($supplierUser)
->post(route('business.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [
'supplier' => '鸣飞家具',
'import' => $file,
])
->assertStatus(302)
->assertSessionHas('success', '对账单已保存:晨丰 共 1 行。');
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
$store->save('final:supplier:return-reconciliation', 'portal:own-confirm', [
'summary_month' => '2026-09', 'send_time' => '2026-09-20', 'supplier' => '晨丰',
'barcode' => 'OWN-CONFIRM', 'qty' => '1', 'unit_price' => '100', 'amount' => '100',
], $companyId, null);
$store->save('final:supplier:return-reconciliation', 'portal:own-dispute', [
'summary_month' => '2026-09', 'send_time' => '2026-09-20', 'supplier' => '晨丰',
'barcode' => 'OWN-DISPUTE', 'qty' => '1', 'unit_price' => '100', 'amount' => '100',
], $companyId, null);
$store->save('final:supplier:return-reconciliation', 'portal:foreign', [
'summary_month' => '2026-09', 'send_time' => '2026-09-20', 'supplier' => '鸣飞家具',
'barcode' => 'FOREIGN-RETURN', 'qty' => '1', 'unit_price' => '100', 'amount' => '100',
], $companyId, null);
$this->actingAsSupplierPortal($supplierUser)
->post(route('business.supplier-reconciliation.return.confirm', ['company_id' => $companyId]), [
'supplier' => '鸣飞家具',
'key' => 'portal:own-confirm',
])
->assertStatus(302)
->assertSessionHas('success', '已确认该行退货扣减。');
$this->actingAsSupplierPortal($supplierUser)
->post(route('business.supplier-reconciliation.return.dispute', ['company_id' => $companyId]), [
'supplier' => '鸣飞家具',
'key' => 'portal:own-dispute',
])
->assertStatus(302)
->assertSessionHas('success', '已提交异议,复核通过前该行暂不扣减。');
$this->withExceptionHandling();
$this->actingAsSupplierPortal($supplierUser)
->post(route('business.supplier-reconciliation.return.confirm', ['company_id' => $companyId]), [
'key' => 'portal:foreign',
])
->assertForbidden();
$this->assertCount(1, app(SupplierStatementStore::class)->rows('晨丰', $companyId));
$this->assertCount(0, app(SupplierStatementStore::class)->rows('鸣飞家具', $companyId));
}
public function test_vendor_portal_shows_only_all_returns_for_the_logged_in_supplier(): void
{
$store = app(SupplierPayableOverrideStore::class);
@ -1189,6 +1302,51 @@ class SupplierReconciliationTest extends FeatureTestCase
return $contact->user;
}
private function createSupplierPortalUser(string $supplier): User
{
$supplierUser = User::create([
'name' => '供应商测试人员',
'email' => Str::lower(Str::random(12)) . '@supplier.test',
'password' => Str::random(32),
'locale' => 'zh-CN',
'enabled' => true,
'created_from' => SupplierPortalAccess::CREATED_FROM,
'landing_page' => 'business.supplier-portal.home',
'created_by' => 0,
]);
$supplierUser->roles()->attach(Role::where('name', 'user')->firstOrFail()->id);
$supplierUser->permissions()->attach(
Permission::where('name', 'read-admin-panel')->firstOrFail()->id
);
$supplierUser->companies()->attach($this->company->id);
DingTalkIdentity::create([
'user_id' => $supplierUser->id,
'union_id' => 'union-supplier-fixture',
'dingtalk_user_id' => 'ding-supplier-fixture',
]);
SupplierPortalIdentity::create([
'company_id' => $this->company->id,
'user_id' => $supplierUser->id,
'supplier_code' => 'SUPPLIER-TEST',
'supplier_name' => $supplier,
'dingtalk_group_id' => 1,
'dingtalk_group_name' => $supplier,
'last_verified_at' => now(),
]);
return $supplierUser;
}
private function actingAsSupplierPortal(User $user): self
{
$identity = DingTalkIdentity::query()->where('user_id', $user->id)->firstOrFail();
return $this->loginAs($user)->withSession([
'dingtalk_identity_id' => $identity->id,
'dingtalk_roster_checked_at' => now()->timestamp,
]);
}
private function statementRow(string $barcode, string $unitPrice, string $qty, string $amount): array
{
return [

View File

@ -0,0 +1,114 @@
<?php
namespace Tests\Unit\Auth;
use App\Services\Auth\SupplierPortalAccess;
use Mockery;
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use Tests\TestCase;
class SupplierPortalAccessTest extends TestCase
{
public function test_exact_supplier_group_resolves_to_one_supplier(): void
{
$query = Mockery::mock(UpstreamReadOnlyQuery::class);
$query->shouldReceive('select')->twice()->andReturnUsing(function (string $sql, array $bindings): array {
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
if (str_contains($sql, 'hr_employee')) {
$this->assertSame(['ding-supplier'], $bindings);
return [[
'userid' => 'ding-supplier',
'unionid' => 'union-supplier',
'active' => 1,
'dept_id_list' => '55248182',
]];
}
$this->assertContains('55248182', $bindings);
return [
['Id' => 55248182, 'parent_id' => 900, 'name' => '晨丰', 'depth' => 0],
['Id' => 900, 'parent_id' => 1, 'name' => '供应商', 'depth' => 1],
];
});
$factory = Mockery::mock(UpstreamReadOnlyQueryFactory::class);
$factory->shouldReceive('forSource')->once()->with('operations_replica')->andReturn($query);
$suppliers = Mockery::mock(SupplierManagementMirrorRepository::class);
$suppliers->shouldReceive('suppliersByNames')->once()
->with(['晨丰', '供应商'])
->andReturn([(object) ['Id' => 9, 'scmcode' => 'CF-001', 'scmname' => '晨丰']]);
$resolved = (new SupplierPortalAccess($factory, $suppliers))->resolve('ding-supplier', 'union-supplier');
$this->assertSame('晨丰', $resolved['supplier_name']);
$this->assertSame('CF-001', $resolved['supplier_code']);
$this->assertSame(55248182, $resolved['dingtalk_group_id']);
}
public function test_inactive_or_union_mismatched_employee_is_denied(): void
{
foreach ([
[['userid' => 'ding-supplier', 'unionid' => 'union-supplier', 'active' => 0, 'dept_id_list' => '1']],
[['userid' => 'ding-supplier', 'unionid' => 'different-union', 'active' => 1, 'dept_id_list' => '1']],
] as $employees) {
$query = Mockery::mock(UpstreamReadOnlyQuery::class);
$query->shouldReceive('select')->once()->with(Mockery::on(function (string $sql): bool {
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
return str_contains($sql, 'hr_employee');
}), ['ding-supplier'])->andReturn($employees);
$factory = Mockery::mock(UpstreamReadOnlyQueryFactory::class);
$factory->shouldReceive('forSource')->andReturn($query);
$suppliers = Mockery::mock(SupplierManagementMirrorRepository::class);
$suppliers->shouldNotReceive('suppliersByNames');
$this->assertNull((new SupplierPortalAccess($factory, $suppliers))->resolve('ding-supplier', 'union-supplier'));
}
}
public function test_multiple_supplier_ancestors_are_ambiguous_and_denied(): void
{
$query = Mockery::mock(UpstreamReadOnlyQuery::class);
$query->shouldReceive('select')->twice()->andReturnUsing(function (string $sql): array {
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
if (str_contains($sql, 'hr_employee')) {
return [['userid' => 'ding-supplier', 'unionid' => 'union-supplier', 'active' => 1, 'dept_id_list' => '10,20']];
}
return [
['Id' => 10, 'parent_id' => 1, 'name' => '晨丰', 'depth' => 0],
['Id' => 20, 'parent_id' => 1, 'name' => '鸣飞家具', 'depth' => 0],
];
});
$factory = Mockery::mock(UpstreamReadOnlyQueryFactory::class);
$factory->shouldReceive('forSource')->once()->andReturn($query);
$suppliers = Mockery::mock(SupplierManagementMirrorRepository::class);
$suppliers->shouldReceive('suppliersByNames')->once()->andReturn([
(object) ['Id' => 1, 'scmcode' => 'CF', 'scmname' => '晨丰'],
(object) ['Id' => 2, 'scmcode' => 'MF', 'scmname' => '鸣飞家具'],
]);
$this->assertNull((new SupplierPortalAccess($factory, $suppliers))->resolve('ding-supplier', 'union-supplier'));
}
public function test_organization_cycle_is_rejected(): void
{
$query = Mockery::mock(UpstreamReadOnlyQuery::class);
$query->shouldReceive('select')->twice()->andReturnUsing(function (string $sql): array {
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
if (str_contains($sql, 'hr_employee')) {
return [['userid' => 'ding-supplier', 'unionid' => 'union-supplier', 'active' => 1, 'dept_id_list' => '30']];
}
return [['Id' => 30, 'parent_id' => 30, 'name' => '晨丰', 'depth' => 20]];
});
$factory = Mockery::mock(UpstreamReadOnlyQueryFactory::class);
$factory->shouldReceive('forSource')->once()->andReturn($query);
$suppliers = Mockery::mock(SupplierManagementMirrorRepository::class);
$suppliers->shouldNotReceive('suppliersByNames');
$this->assertNull((new SupplierPortalAccess($factory, $suppliers))->resolve('ding-supplier', 'union-supplier'));
}
}

View File

@ -153,6 +153,20 @@ class SupplierManagementMirrorRepositoryTest extends TestCase
$this->assertNull($this->repository->supplierById(999));
}
public function test_supplier_lookup_by_exact_names_supports_portal_roster_mapping(): void
{
$this->ready(SupplierManagementMirrorRepository::DATASET);
$this->table('js_supplier_management')->insert([
['Id' => 9, 'scmcode' => 'N901', 'scmname' => '一泰'],
['Id' => 11, 'scmcode' => 'Y411', 'scmname' => '七彩'],
]);
$rows = $this->repository->suppliersByNames(['一泰', '不存在']);
$this->assertCount(1, $rows);
$this->assertSame('N901', $rows[0]->scmcode);
}
private function ready(string $key): void
{
$this->table('business_mirror_table_states')->insert(['dataset_key' => $key, 'initial_completed_at' => '2026-10-11 00:00:00']);