换成 jiansun 实时 OA 流程表后,旧的 v3 缓存(30 分钟)里还留着 7 月之后 审批状态为空的结果,页面会继续显示横杠;升版缓存键让所有环境重新计算。
438 lines
20 KiB
PHP
438 lines
20 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Repositories;
|
||
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||
use Modules\EcommerceFinance\Services\FollowedUpstreamColumns;
|
||
use RuntimeException;
|
||
|
||
class StoreAccountMirrorRepository
|
||
{
|
||
public const DATASET = 'database_4.external_store_accounts.accounts_v1';
|
||
|
||
/** 页面已接入的字段:页面字段名 => 镜像列名。 */
|
||
private const FIELD_COLUMNS = [
|
||
'store_name' => 'storeName', 'operation_owner' => 'operationOwner',
|
||
'operation_department_head' => 'operationDepartmentHead', 'operation_director' => 'operationDirector',
|
||
'profit_center' => 'profitCenter', 'account_type' => 'accountType', 'account' => 'account',
|
||
'password_configured' => 'passwordConfigured', 'bound_phone' => 'boundPhone',
|
||
'verification_contact' => 'verificationContact', 'account_group_name' => 'accountGroupName',
|
||
'created_at' => 'createdAt', 'updated_at' => 'updatedAt',
|
||
];
|
||
|
||
/** 店铺档案(fys_shopInfo.shopState)到页面文案的映射。 */
|
||
private const STORE_STATUS_LABELS = [0 => '正常', 3 => '关闭中', 6 => '已关闭'];
|
||
|
||
/** 授权状态文案;顺序同时用于下拉和排序。 */
|
||
private const AUTHORIZATION_LABELS = ['已授权', '已过期', '未授权'];
|
||
|
||
/** 店铺申请流程(开店/变更/关店/主体注销自动)所在的数据集。 */
|
||
private const FLOW_FORM_DATASET = 'jiansun.dbo.fys_shopInfo_flow';
|
||
private const FLOW_OA_DATASET = 'jiansun.dbo.flow_flow';
|
||
|
||
/** 流程类型 / 审批状态文案;顺序同时用于下拉和排序。 */
|
||
private const FLOW_TYPE_LABELS = ['店铺开店', '店铺变更', '店铺关店', '主体注销自动流程'];
|
||
private const FLOW_STATUS_LABELS = ['流程中', '待审批', '完成', '拒绝', '撤回', '暂存', '待签署', '已签署'];
|
||
|
||
/** 账号店铺名 -> 状态 的缓存键(两张档案表要全表读,缓存 10 分钟)。 */
|
||
private const STATUS_CACHE_KEY = 'store-accounts.statuses:v4';
|
||
|
||
public function __construct(
|
||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||
private readonly GenericBusinessMirrorRepository $mirror,
|
||
) {}
|
||
|
||
/** @return array{data:array<int,array<string,mixed>>,total:int} */
|
||
public function paginate(int $page, int $perPage, array $filters = []): array
|
||
{
|
||
$dataset = $this->datasets->get(self::DATASET);
|
||
if (! $this->mirror->ready($dataset)) {
|
||
throw new RuntimeException('store_account_mirror_not_ready');
|
||
}
|
||
|
||
$page = max($page, 1);
|
||
$perPage = min(max($perPage, 1), 100);
|
||
$query = $this->mirror->connection()->table($dataset->targetTable());
|
||
$columns = [
|
||
'store_name' => 'storeName', 'operation_owner' => 'operationOwner',
|
||
'operation_department_head' => 'operationDepartmentHead', 'operation_director' => 'operationDirector',
|
||
'profit_center' => 'profitCenter', 'account' => 'account', 'bound_phone' => 'boundPhone',
|
||
'verification_contact' => 'verificationContact', 'account_group_name' => 'accountGroupName',
|
||
];
|
||
foreach ($columns as $key => $column) {
|
||
$value = mb_substr(trim((string) ($filters[$key] ?? '')), 0, 191);
|
||
if ($value !== '') $query->where($column, 'like', '%' . addcslashes($value, '%_\\') . '%');
|
||
}
|
||
$accountType = trim((string) ($filters['account_type'] ?? ''));
|
||
if ($accountType !== '') $query->where('accountType', '=', $accountType === '主账号' ? 'MAIN' : ($accountType === '子账号' ? 'SUB' : $accountType));
|
||
$passwordState = trim((string) ($filters['password_configured'] ?? ''));
|
||
if (in_array($passwordState, ['已设置', '未设置', '状态未同步'], true)) {
|
||
if ($passwordState === '状态未同步') $query->whereNull('passwordConfigured');
|
||
else $query->where('passwordConfigured', $passwordState === '已设置');
|
||
}
|
||
foreach (['created_at' => 'createdAt', 'updated_at' => 'updatedAt'] as $key => $column) {
|
||
foreach (['_from' => '>=', '_to' => '<='] as $suffix => $operator) {
|
||
$date = trim((string) ($filters[$key . $suffix] ?? ''));
|
||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/D', $date)) $query->whereDate($column, $operator, $date);
|
||
}
|
||
}
|
||
|
||
// 授权状态 / 店铺状态来自另两张镜像表(按店铺名关联),这里先在 PHP 里
|
||
// 把"账号店铺名 -> 状态"算出来,再用 whereIn 下推到 SQL,分页和总数不受影响。
|
||
$statusFilters = [
|
||
'store_status' => $this->filterValues($filters['store_status'] ?? ''),
|
||
'authorization_status' => $this->filterValues($filters['authorization_status'] ?? ''),
|
||
'flow_type' => $this->filterValues($filters['flow_type'] ?? ''),
|
||
'approval_status' => $this->filterValues($filters['approval_status'] ?? ''),
|
||
];
|
||
$statuses = ($statusFilters['store_status'] !== []
|
||
|| $statusFilters['authorization_status'] !== []
|
||
|| $statusFilters['flow_type'] !== []
|
||
|| $statusFilters['approval_status'] !== [])
|
||
? $this->statusesByStoreName()
|
||
: null;
|
||
if ($statuses !== null) {
|
||
foreach ([
|
||
'store_status' => 'store',
|
||
'authorization_status' => 'authorization',
|
||
'flow_type' => 'flow_type',
|
||
'approval_status' => 'flow',
|
||
] as $key => $bucket) {
|
||
$expected = $statusFilters[$key];
|
||
if ($expected === []) {
|
||
continue;
|
||
}
|
||
$query->whereIn('storeName', $this->storeNamesWithStatuses($statuses[$bucket], $expected));
|
||
}
|
||
}
|
||
$total = (clone $query)->count();
|
||
$sortColumns = [
|
||
'store_name' => 'storeName', 'operation_owner' => 'operationOwner',
|
||
'operation_department_head' => 'operationDepartmentHead', 'operation_director' => 'operationDirector',
|
||
'profit_center' => 'profitCenter', 'account_type' => 'accountType', 'account' => 'account',
|
||
'password_configured' => 'passwordConfigured', 'bound_phone' => 'boundPhone',
|
||
'verification_contact' => 'verificationContact', 'account_group_name' => 'accountGroupName',
|
||
'created_at' => 'createdAt', 'updated_at' => 'updatedAt',
|
||
];
|
||
$sortColumn = $sortColumns[$filters['_sort'] ?? ''] ?? null;
|
||
$direction = ($filters['_direction'] ?? 'asc') === 'desc' ? 'desc' : 'asc';
|
||
if ($sortColumn !== null) {
|
||
$query->orderBy($sortColumn, $direction)->orderByDesc('id');
|
||
} elseif (in_array($filters['_sort'] ?? '', ['store_status', 'authorization_status'], true)) {
|
||
// 关联出来的状态列按业务顺序排(正常/关闭中/已关闭、已授权/已过期/未授权)。
|
||
$sortKey = $filters['_sort'] === 'store_status' ? 'store' : 'authorization';
|
||
$labels = $filters['_sort'] === 'store_status'
|
||
? array_values(self::STORE_STATUS_LABELS)
|
||
: self::AUTHORIZATION_LABELS;
|
||
$statuses ??= $this->statusesByStoreName();
|
||
[$sql, $bindings] = $this->statusOrderBy($statuses[$sortKey], $labels, $direction);
|
||
$query->orderByRaw($sql, $bindings)->orderByDesc('id');
|
||
} elseif (in_array($filters['_sort'] ?? '', ['flow_type', 'approval_status'], true)) {
|
||
$statuses ??= $this->statusesByStoreName();
|
||
$sortedKey = $filters['_sort'] === 'flow_type' ? 'flow_type' : 'flow';
|
||
$labels = $filters['_sort'] === 'flow_type' ? self::FLOW_TYPE_LABELS : self::FLOW_STATUS_LABELS;
|
||
[$sql, $bindings] = $this->statusOrderBy($statuses[$sortedKey], $labels, $direction);
|
||
$query->orderByRaw($sql, $bindings)->orderByDesc('id');
|
||
} else {
|
||
$query->orderByDesc('id');
|
||
}
|
||
$statuses ??= $this->statusesByStoreName();
|
||
$followedColumns = $this->followedColumns();
|
||
$rows = $query
|
||
->forPage($page, $perPage)
|
||
->get()
|
||
->map(function (object $row) use ($statuses, $followedColumns): array {
|
||
$mapped = [
|
||
'id' => $row->id,
|
||
'store_name' => $row->storeName,
|
||
'operation_owner' => $row->operationOwner,
|
||
'operation_department_head' => $row->operationDepartmentHead,
|
||
'operation_director' => $row->operationDirector,
|
||
'profit_center' => $row->profitCenter,
|
||
'account_type' => $this->accountType($row->accountType),
|
||
'account' => $row->account,
|
||
// Only the safe boolean may enter the mirror, never a password.
|
||
'password_configured' => isset($row->passwordConfigured) ? (bool) $row->passwordConfigured : null,
|
||
'bound_phone' => $row->boundPhone,
|
||
'verification_contact' => $row->verificationContact,
|
||
'account_group_name' => $row->accountGroupName,
|
||
'authorization_status' => $statuses['authorization'][$row->storeName] ?? '',
|
||
'store_status' => $statuses['store'][$row->storeName] ?? '',
|
||
'flow_type' => $statuses['flow_type'][$row->storeName] ?? '',
|
||
'approval_status' => $statuses['flow'][$row->storeName] ?? '',
|
||
'created_at' => $row->createdAt,
|
||
'updated_at' => $row->updatedAt,
|
||
];
|
||
|
||
// 自动跟随上游补进来的字段:镜像里有什么就带什么,页面据此多出对应列。
|
||
foreach ($followedColumns as $column) {
|
||
$mapped[$column] = $row->{$column} ?? null;
|
||
}
|
||
|
||
return $mapped;
|
||
})
|
||
->all();
|
||
|
||
return ['data' => $rows, 'total' => $total];
|
||
}
|
||
|
||
/**
|
||
* 账号店铺名 -> 授权状态 / 店铺状态。
|
||
*
|
||
* - 授权状态:聚水潭店铺档案(jst_shopInfo)的 session_expired —— 无授权时间为未授权,
|
||
* 已过期;否则为已授权。
|
||
* - 店铺状态:店铺档案(fys_shopInfo)的 shopState —— 0 正常 / 3 关闭中 / 6 已关闭。
|
||
*
|
||
* 两张表按店铺名(把下划线、空格统一成短横线后)关联,实测账号表 966 个店铺里
|
||
* 授权命中 963、店铺状态命中 948。
|
||
*
|
||
* @return array{store:array<string,string>,authorization:array<string,string>}
|
||
*/
|
||
private function statusesByStoreName(): array
|
||
{
|
||
if (is_array($cached = Cache::get(self::STATUS_CACHE_KEY))) {
|
||
return $cached;
|
||
}
|
||
|
||
$storeStates = [];
|
||
foreach ($this->mirror->connection()->table('ops_fys_shop_info')
|
||
->select(['Id', 'erpShopName', 'shopState'])->orderBy('Id')->get() as $row) {
|
||
$key = $this->shopKey($row->erpShopName);
|
||
if ($key !== '') {
|
||
$storeStates[$key] = self::STORE_STATUS_LABELS[(int) $row->shopState] ?? '';
|
||
}
|
||
}
|
||
|
||
$authorizations = [];
|
||
$now = now()->getTimestamp();
|
||
foreach ($this->mirror->connection()->table('ops_jst_shop_info')
|
||
->select(['Id', 'shop_name', 'session_expired'])->orderBy('Id')->get() as $row) {
|
||
$key = $this->shopKey($row->shop_name);
|
||
if ($key === '') {
|
||
continue;
|
||
}
|
||
$expired = trim((string) $row->session_expired);
|
||
$timestamp = $expired === '' ? false : strtotime($expired);
|
||
$authorizations[$key] = $timestamp === false
|
||
? '未授权'
|
||
: ($timestamp >= $now ? '已授权' : '已过期');
|
||
}
|
||
|
||
$flows = $this->latestFlowByStoreName();
|
||
$result = ['store' => [], 'authorization' => [], 'flow' => [], 'flow_type' => []];
|
||
foreach ($this->mirror->connection()->table($this->datasets->get(self::DATASET)->targetTable())
|
||
->select('storeName')->whereNotNull('storeName')->distinct()->pluck('storeName') as $name) {
|
||
$name = (string) $name;
|
||
$key = $this->shopKey($name);
|
||
$result['store'][$name] = $storeStates[$key] ?? '';
|
||
$result['authorization'][$name] = $authorizations[$key] ?? '';
|
||
$result['flow_type'][$name] = $flows[$key]['flow_type'] ?? '';
|
||
$result['flow'][$name] = $flows[$key]['approval_status'] ?? '';
|
||
}
|
||
|
||
// 状态取自店铺档案与流程镜像,变化很慢;全量算一次要几秒,缓存久一点。
|
||
Cache::put(self::STATUS_CACHE_KEY, $result, now()->addMinutes(30));
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 账号店铺 -> 最近一单店铺申请流程(开店/变更/关店/主体注销自动)的类型与审批状态。
|
||
*
|
||
* 按 ERP 店铺名关联 jiansun.dbo.fys_shopInfo_flow,同一店铺取申请时间最近的一单;
|
||
* 审批状态用中台库 OA 流程表(dbo.flow_flow)的字典文案。
|
||
*
|
||
* @return array<string,array{flow_type:string,approval_status:string}> 归一键 => 流程信息
|
||
*/
|
||
private function latestFlowByStoreName(): array
|
||
{
|
||
try {
|
||
$form = $this->datasets->get(self::FLOW_FORM_DATASET)->targetTable();
|
||
$oa = $this->datasets->get(self::FLOW_OA_DATASET)->targetTable();
|
||
} catch (\Throwable) {
|
||
// 店铺申请流程数据集尚未接入时,这两列留空即可,不影响其它列。
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
$rows = $this->mirror->connection()->table($form . ' as f')
|
||
->leftJoin($oa . ' as o', 'o.flowId', '=', 'f.flowId')
|
||
->select(['f.erpShopName as erp_shop_name', 'f.flowType as flow_type', 'o.flowStatus as flow_status'])
|
||
->orderByDesc('f.applyTime')->orderByDesc('f.Id')
|
||
->get();
|
||
} catch (\Throwable $exception) {
|
||
report($exception);
|
||
|
||
return [];
|
||
}
|
||
|
||
$flows = [];
|
||
foreach ($rows as $row) {
|
||
$key = $this->shopKey($row->erp_shop_name);
|
||
if ($key === '' || array_key_exists($key, $flows)) {
|
||
continue; // 已按申请时间倒序,首次出现的就是最近一单
|
||
}
|
||
$type = (int) $row->flow_type;
|
||
$flows[$key] = [
|
||
'flow_type' => ShopInfoFlowMirrorRepository::FLOW_TYPES[$type] ?? ('其它(' . $type . ')'),
|
||
'approval_status' => $row->flow_status === null
|
||
? ''
|
||
: (ShopInfoFlowMirrorRepository::APPROVAL_STATUSES[(int) $row->flow_status] ?? (string) $row->flow_status),
|
||
];
|
||
}
|
||
|
||
return $flows;
|
||
}
|
||
|
||
/**
|
||
* 筛选值统一成列表:单选("正常")和多选(["正常","已关闭"])都支持。
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
private function filterValues(mixed $value): array
|
||
{
|
||
$values = [];
|
||
foreach ((array) $value as $item) {
|
||
if (! is_scalar($item)) {
|
||
continue;
|
||
}
|
||
$item = trim((string) $item);
|
||
if ($item !== '') {
|
||
$values[$item] = true;
|
||
}
|
||
}
|
||
|
||
return array_keys($values);
|
||
}
|
||
|
||
/** @return list<string> 命中任一指定状态的账号店铺名 */
|
||
private function storeNamesWithStatuses(array $byStore, array $expected): array
|
||
{
|
||
$names = [];
|
||
foreach ($byStore as $name => $status) {
|
||
if (in_array($status, $expected, true)) {
|
||
$names[] = $name;
|
||
}
|
||
}
|
||
|
||
return $names;
|
||
}
|
||
|
||
/**
|
||
* 把状态列翻译成 SQL 排序:按给定文案顺序生成 CASE WHEN。
|
||
*
|
||
* @param array<string,string> $byStore
|
||
* @param list<string> $labels
|
||
* @return array{0:string,1:list<string>}
|
||
*/
|
||
private function statusOrderBy(array $byStore, array $labels, string $direction): array
|
||
{
|
||
$cases = [];
|
||
$bindings = [];
|
||
|
||
foreach ($labels as $index => $label) {
|
||
$names = $this->storeNamesWithStatuses($byStore, [$label]);
|
||
if ($names === []) {
|
||
continue;
|
||
}
|
||
$cases[] = 'WHEN storeName IN (' . implode(', ', array_fill(0, count($names), '?')) . ') THEN ' . $index;
|
||
array_push($bindings, ...$names);
|
||
}
|
||
|
||
if ($cases === []) {
|
||
return ['1', []];
|
||
}
|
||
|
||
return ['CASE ' . implode(' ', $cases) . ' ELSE ' . count($labels) . ' END ' . $direction, $bindings];
|
||
}
|
||
|
||
/** 店铺名归一键:下划线、半/全角空格统一成短横线,便于跨表关联。 */
|
||
private function shopKey(mixed $name): string
|
||
{
|
||
return str_replace(['_', ' ', ' '], ['-', '', ''], trim((string) $name));
|
||
}
|
||
|
||
/** @return list<string> 自动跟随上游补进来的字段(镜像列名) */
|
||
public function followedColumns(): array
|
||
{
|
||
return app(FollowedUpstreamColumns::class)->columns(self::DATASET);
|
||
}
|
||
|
||
/**
|
||
* 给页面字段列表补上自动跟随进来的列(字段名与标签都用上游列名)。
|
||
*
|
||
* @param list<array<string,mixed>> $fields
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
public function withFollowedFields(array $fields): array
|
||
{
|
||
return app(FollowedUpstreamColumns::class)->appendFields(self::DATASET, $fields);
|
||
}
|
||
|
||
public function containsAccount(string $id): bool
|
||
{
|
||
$dataset = $this->datasets->get(self::DATASET);
|
||
if (! $this->mirror->ready($dataset)) {
|
||
throw new RuntimeException('store_account_mirror_not_ready');
|
||
}
|
||
return $this->mirror->connection()->table($dataset->targetTable())->where('id', $id)->exists();
|
||
}
|
||
|
||
public function containsAccounts(array $ids): bool
|
||
{
|
||
$ids = array_values(array_unique($ids));
|
||
if ($ids === []) {
|
||
return false;
|
||
}
|
||
$dataset = $this->datasets->get(self::DATASET);
|
||
if (! $this->mirror->ready($dataset)) {
|
||
throw new RuntimeException('store_account_mirror_not_ready');
|
||
}
|
||
return $this->mirror->connection()->table($dataset->targetTable())
|
||
->whereIn('id', $ids)->distinct()->count('id') === count($ids);
|
||
}
|
||
|
||
/**
|
||
* 店铺维度报表的归属字段覆盖表,以店铺名称(storeName)为键。
|
||
*
|
||
* @return array<string,array{operation_owner:string,operation_director:string,profit_center:string}>
|
||
*/
|
||
public function ownershipByStoreName(): array
|
||
{
|
||
$dataset = $this->datasets->get(self::DATASET);
|
||
if (! $this->mirror->ready($dataset)) {
|
||
throw new RuntimeException('store_account_mirror_not_ready');
|
||
}
|
||
|
||
$map = [];
|
||
foreach ($this->mirror->connection()->table($dataset->targetTable())
|
||
->select('storeName', 'operationOwner', 'operationDirector', 'profitCenter')
|
||
->get() as $row) {
|
||
$name = trim((string) $row->storeName);
|
||
if ($name === '') {
|
||
continue;
|
||
}
|
||
$map[$name] = [
|
||
'operation_owner' => trim((string) $row->operationOwner),
|
||
'operation_director' => trim((string) $row->operationDirector),
|
||
'profit_center' => trim((string) $row->profitCenter),
|
||
];
|
||
}
|
||
|
||
return $map;
|
||
}
|
||
|
||
private function accountType(mixed $value): ?string
|
||
{
|
||
$type = trim((string) $value);
|
||
|
||
return match (strtoupper($type)) {
|
||
'MAIN' => '主账号',
|
||
'SUB' => '子账号',
|
||
'', '/' => null,
|
||
default => $type,
|
||
};
|
||
}
|
||
}
|