CaiWuXiTong/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php
renshen 65112a2bff 店铺账号密码:状态映射缓存键升到 v4,强制刷新流程状态
换成 jiansun 实时 OA 流程表后,旧的 v3 缓存(30 分钟)里还留着 7 月之后
审批状态为空的结果,页面会继续显示横杠;升版缓存键让所有环境重新计算。
2026-10-09 10:32:00 +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 = '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,
};
}
}