CaiWuXiTong/app/Services/Auth/SupplierPortalAccess.php
苏木 d04ed7c94d feat: 支持供应商联系人绑定多个厂家 (#922)
同一钉钉联系人可绑定多家供应商,并提供厂家选择、切换和逐厂家授权校验。
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-11-06 10:39:13 +08:00

299 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Auth;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\SupplierPortalIdentity;
use App\Models\Auth\SupplierPortalSupplier;
use App\Models\Auth\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
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
{
$resolved = $this->resolveAll($userId, $unionId);
return count($resolved) === 1 ? $resolved[0] : null;
}
/**
* @return list<array{supplier_name:string,supplier_code:string,dingtalk_group_id:int,dingtalk_group_name:string}>
*/
public function resolveAll(string $userId, string $unionId = ''): array
{
$userId = trim($userId);
$unionId = trim($unionId);
if ($userId === '') {
return [];
}
$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 [];
}
$employee = $employees[0];
$employeeUnionId = trim((string) ($employee['unionid'] ?? ''));
if ($employeeUnionId === '' || ($unionId !== '' && ! hash_equals($employeeUnionId, $unionId))) {
return [];
}
$departmentIds = $this->departmentIds($employee['dept_id_list'] ?? null);
if ($departmentIds === [] || count($departmentIds) > 64) {
return [];
}
$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 [];
}
$groupsByName = [];
foreach ($ancestors as $ancestor) {
$name = trim((string) ($ancestor['name'] ?? ''));
if ($name !== '') {
$groupsByName[$name][] = $ancestor;
}
}
if ($groupsByName === []) {
return [];
}
$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;
}
}
$resolved = [];
foreach (array_keys($matchedSupplierNames) as $supplierName) {
$matchingRows = array_values(array_filter(
$supplierRows,
static fn (object $supplier): bool => trim((string) ($supplier->scmname ?? '')) === $supplierName,
));
if (count($matchingRows) !== 1) {
continue;
}
usort($groupsByName[$supplierName], static fn (array $left, array $right): int =>
(int) ($right['depth'] ?? 0) <=> (int) ($left['depth'] ?? 0));
$group = $groupsByName[$supplierName][0];
$resolved[] = [
'supplier_name' => $supplierName,
'supplier_code' => trim((string) ($matchingRows[0]->scmcode ?? '')),
'dingtalk_group_id' => (int) ($group['Id'] ?? 0),
'dingtalk_group_name' => $supplierName,
];
}
usort($resolved, static fn (array $left, array $right): int =>
strnatcasecmp($left['supplier_name'], $right['supplier_name']));
return $resolved;
}
public function bind(User $user, int $companyId, array $resolved): SupplierPortalIdentity
{
$this->bindMany($user, $companyId, [$resolved]);
return SupplierPortalIdentity::query()->where('user_id', $user->id)->firstOrFail();
}
/** @param list<array{supplier_name:string,supplier_code?:?string,dingtalk_group_id:int,dingtalk_group_name:string}> $resolved */
public function bindMany(User $user, int $companyId, array $resolved): Collection
{
$suppliers = collect($resolved)
->filter(static fn (mixed $row): bool => is_array($row) && trim((string) ($row['supplier_name'] ?? '')) !== '')
->keyBy(static fn (array $row): string => trim((string) $row['supplier_name']))
->sortBy(static fn (array $row): string => trim((string) $row['supplier_name']))
->values();
if ($suppliers->isEmpty()) {
return new Collection();
}
return DB::transaction(function () use ($user, $companyId, $suppliers): Collection {
$first = $suppliers->first();
SupplierPortalIdentity::query()->updateOrCreate(
['user_id' => $user->id],
[
'company_id' => $companyId,
'supplier_code' => $first['supplier_code'] ?? null,
'supplier_name' => $first['supplier_name'],
'dingtalk_group_id' => (int) $first['dingtalk_group_id'],
'dingtalk_group_name' => $first['dingtalk_group_name'],
'last_verified_at' => now(),
],
);
foreach ($suppliers as $resolvedSupplier) {
SupplierPortalSupplier::query()->updateOrCreate(
[
'company_id' => $companyId,
'user_id' => $user->id,
'supplier_name' => $resolvedSupplier['supplier_name'],
],
[
'supplier_code' => $resolvedSupplier['supplier_code'] ?? null,
'dingtalk_group_id' => (int) $resolvedSupplier['dingtalk_group_id'],
'dingtalk_group_name' => $resolvedSupplier['dingtalk_group_name'],
'last_verified_at' => now(),
],
);
}
SupplierPortalSupplier::query()
->where('company_id', $companyId)
->where('user_id', $user->id)
->whereNotIn('supplier_name', $suppliers->pluck('supplier_name')->all())
->delete();
return SupplierPortalSupplier::query()
->where('company_id', $companyId)
->where('user_id', $user->id)
->orderBy('supplier_name')
->get();
});
}
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
{
$names = $this->supplierNamesFor($user);
return count($names) === 1 ? $names[0] : null;
}
public function bindingsFor(User $user): Collection
{
if (! self::isSupplierUser($user)) {
return new Collection();
}
return SupplierPortalSupplier::query()
->where('company_id', company_id())
->where('user_id', $user->id)
->orderBy('supplier_name')
->get();
}
/** @return list<string> */
public function supplierNamesFor(User $user): array
{
return $this->bindingsFor($user)
->pluck('supplier_name')
->map(static fn (mixed $name): string => trim((string) $name))
->filter()
->unique()
->values()
->all();
}
public function canAccessSupplier(User $user, string $supplier): bool
{
return in_array(trim($supplier), $this->supplierNamesFor($user), true);
}
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->resolveAll(
(string) $dingTalk->dingtalk_user_id,
(string) $dingTalk->union_id,
);
if ($resolved === []) {
SupplierPortalSupplier::query()
->where('company_id', $company->id)
->where('user_id', $user->id)
->delete();
return null;
}
$this->bindMany($user, (int) $company->id, $resolved);
return $this->mappingFor($user);
}
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));
}
}