CaiWuXiTong/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php
renshen d69a7d2ca7 店铺账号密码:把流程类型和审批状态两列都拉过来
上一版只带了「流程状态」(关店流程的审批状态)。改成按 ERP 店铺名取该店铺
最近一单申请流程(开店/变更/关店/主体注销自动),同时给出流程类型与审批状态,
列位置仍在「店铺状态」后面;两列都可搜索多选筛选、可排序。

线上:966 个账号店铺里 396 家最近一单是关店流程;状态文案与流程统计页一致。
状态映射缓存从 10 分钟放宽到 30 分钟(全量算一次要几秒,数据变化很慢)。
2026-10-08 15:12:41 +08:00

438 lines
20 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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 = 'js_data_middle_platform.dbo.flow_flow';
/** 流程类型 / 审批状态文案;顺序同时用于下拉和排序。 */
private const FLOW_TYPE_LABELS = ['店铺开店', '店铺变更', '店铺关店', '主体注销自动流程'];
private const FLOW_STATUS_LABELS = ['流程中', '待审批', '完成', '拒绝', '撤回', '暂存', '待签署', '已签署'];
/** 账号店铺名 -> 状态 的缓存键(两张档案表要全表读,缓存 10 分钟)。 */
private const STATUS_CACHE_KEY = 'store-accounts.statuses:v3';
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,
};
}
}