同一钉钉联系人可绑定多家供应商,并提供厂家选择、切换和逐厂家授权校验。 Co-authored-by: 苏木 <594326199@qq.com> Co-committed-by: 苏木 <594326199@qq.com>
299 lines
11 KiB
PHP
299 lines
11 KiB
PHP
<?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));
|
||
}
|
||
}
|