CaiWuXiTong/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php
renshen c88c0f165e 店铺账号密码:新增「流程状态」列(取店铺关店流程)
按 ERP 店铺名关联 jiansun.dbo.fys_shopInfo_flow 的关店流程(flowType=10),
同一店铺取最近一单,状态用 OA 流程表的审批状态文案(流程中/完成/拒绝/撤回…)。
列加在「店铺状态」后面,同时做成可搜索多选筛选,并支持按流程状态排序。

线上:966 个账号店铺里 486 个有关店流程(流程中 352 / 完成 46 / 撤回 14 / 拒绝 2 /
其余审批状态暂缺),列值缓存 10 分钟。
2026-10-08 14:51:44 +08:00

425 lines
18 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 = ['已授权', '已过期', '未授权'];
/** 店铺关店流程(jst_shopInfo_flow.flowType=10)所在的数据集。 */
private const CLOSE_FLOW_FORM_DATASET = 'jiansun.dbo.fys_shopInfo_flow';
private const CLOSE_FLOW_OA_DATASET = 'js_data_middle_platform.dbo.flow_flow';
private const CLOSE_FLOW_TYPE = 10;
/** 流程状态文案;顺序同时用于下拉和排序。 */
private const FLOW_STATUS_LABELS = ['流程中', '待审批', '完成', '拒绝', '撤回', '暂存', '待签署', '已签署'];
/** 账号店铺名 -> 状态 的缓存键(两张档案表要全表读,缓存 10 分钟)。 */
private const STATUS_CACHE_KEY = 'store-accounts.statuses:v2';
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_status' => $this->filterValues($filters['flow_status'] ?? ''),
];
$statuses = ($statusFilters['store_status'] !== []
|| $statusFilters['authorization_status'] !== []
|| $statusFilters['flow_status'] !== [])
? $this->statusesByStoreName()
: null;
if ($statuses !== null) {
foreach ([
'store_status' => 'store',
'authorization_status' => 'authorization',
'flow_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 (($filters['_sort'] ?? '') === 'flow_status') {
$statuses ??= $this->statusesByStoreName();
[$sql, $bindings] = $this->statusOrderBy($statuses['flow'], self::FLOW_STATUS_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_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 ? '已授权' : '已过期');
}
$closeFlows = $this->closeFlowStatusByName();
$result = ['store' => [], 'authorization' => [], 'flow' => []];
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'][$name] = $closeFlows[$key] ?? '';
}
Cache::put(self::STATUS_CACHE_KEY, $result, now()->addMinutes(10));
return $result;
}
/**
* 账号店铺 -> 关店流程状态:按 ERP 店铺名关联 jiansun.dbo.fys_shopInfo_flow 的
* 关店流程(flowType=10),同一店铺取最近一单,状态用 OA 流程表的审批状态文案。
*
* @return array<string,string> 归一键 => 流程状态
*/
private function closeFlowStatusByName(): array
{
try {
$form = $this->datasets->get(self::CLOSE_FLOW_FORM_DATASET)->targetTable();
$oa = $this->datasets->get(self::CLOSE_FLOW_OA_DATASET)->targetTable();
} catch (\Throwable) {
// 关店流程数据集尚未接入时,这一列留空即可,不影响其它列。
return [];
}
try {
$rows = $this->mirror->connection()->table($form . ' as f')
->leftJoin($oa . ' as o', 'o.flowId', '=', 'f.flowId')
->where('f.flowType', self::CLOSE_FLOW_TYPE)
->select(['f.erpShopName as erp_shop_name', 'o.flowStatus as flow_status'])
->orderByDesc('f.applyTime')->orderByDesc('f.Id')
->get();
} catch (\Throwable $exception) {
report($exception);
return [];
}
$statuses = [];
foreach ($rows as $row) {
$key = $this->shopKey($row->erp_shop_name);
if ($key === '' || array_key_exists($key, $statuses)) {
continue; // 已按申请时间倒序,首次出现的就是最近一单
}
$statuses[$key] = $row->flow_status === null
? ''
: (ShopInfoFlowMirrorRepository::APPROVAL_STATUSES[(int) $row->flow_status] ?? (string) $row->flow_status);
}
return $statuses;
}
/**
* 筛选值统一成列表:单选("正常")和多选(["正常","已关闭"])都支持。
*
* @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,
};
}
}