本文说明 DGJ2.0 总分店体系从 OPS 组织关系同步、总部主账号创建、子账号管理、门店数据授权、登录 Session 构建、总部与分店上下文切换,到采购、销售、应收、应付和主营利润汇总的完整链路。

这不是单纯的“一个账号能看多个门店”。系统实际上同时维护三套上下文:

  1. 总部身份:当前登录的是哪个总部主账号或子账号。
  2. 授权范围:这个账号允许访问哪些服务站,以及默认进入哪个服务站。
  3. 业务站点身份:进入分店后,普通采购、销售、库存接口看到的 sid、uid、roleid 是哪一个站点主账号。

经营报表又分成两类:总部聚合接口直接对多个授权站点计算汇总;传统单站报表则通过 Redis 保存“当前报表选择的分店”,再让公共 Controller 解析出实际 sid。理解问题时必须先判断请求属于哪一类。

证据范围:本文以当前仓库静态代码为依据。接口域名、生产 MQ 交换机、报表中心数据延迟、实际数据库索引和线上菜单配置不能仅靠仓库证明,均在“待环境确认”章节单独标记。

1. 业务目标

总分店能力要解决以下业务问题:

  • 连锁组织在 OPS 维护总部、上级总部和所属服务站关系。
  • OPS 变更组织关系后,DGJ 自动创建或更新总部主账号和门店授权。
  • 总部主账号可以创建最多约 99 个业务子账号。
  • 主账号可以给每个子账号配置可访问门店和默认门店。
  • 总部账号既可以停留在总部工作台,也可以切换成某个分店的业务身份。
  • 切换分店后复用原有站点采购、销售、库存、客户和报表能力。
  • 总部可以一次查看所有授权分店的采购、销售和财务经营汇总。
  • 当 OPS 停用总部、移除门店或切换上级时,已登录账号的 Session 能刷新或被强制退出。

2. 适用场景与边界

2.1 适用场景

场景业务动作主要入口
新建总部OPS 创建虚拟站点/总部节点OpsCenterNotify::headOfficeAccountSync
调整组织OPS 修改总部名称、手机号、上级或所属门店AccountService::updateAccount
新建子账号总部管理员录入姓名、手机号和密码headOffice/account/createSubAccount
分配门店总部管理员勾选可访问门店和默认门店headOffice/account/saveRights
启停子账号总部管理员切换子账号状态headOffice/account/toggleStatus
切换分店总部账号在顶部导航选择门店headOffice/account/switchBranch
查看总部汇总按时间查询全部授权分店headOffice/reports/get*Detail
查看单站报表在传统报表页选择具体服务站headOffice/reports/setBranch + 普通报表接口
权限变更生效请求生命周期中刷新总部 SessionAccountService::updateSession

2.2 不属于本模块的能力

  • 普通服务站员工账号、角色权限和设备授权,详见《35_账号登录_设备_PC授权与安全》。
  • 单站采购、销售、库存的业务规则,分别详见采购、销售和库存专题。
  • OPS 自身如何编辑组织树,不在 DGJ 仓库中;本文只说明 DGJ 消费到的同步消息。
  • 报表中心/Hologres 内部数据加工逻辑不在当前仓库中;本文只能说明请求 SQL、参数和 DGJ 端聚合公式。

3. 角色、身份与容易混淆的概念

3.1 角色

角色识别方式能力
OPS 组织中心MQ 事件 opscenter_sitemgr_virtual创建、更新总部主账号和组织关系
总部主账号t_sys_head_office_account.is_admin = 1管理子账号、授权门店、切换分店、查看汇总
总部子账号同表 is_admin = 0只能使用被授权门店,不能管理其他账号
分店主账号t_sys_admin 中站点主账号提供切换后使用的 uid/sid/roleid 业务身份
报表中心CENTER_API 和 ReportProvider提供采购明细查询和主营利润数据
DGJ 业务库销售、发票、收付款和联系人表提供应收、应付和销售汇总的业务事实

3.2 核心概念

概念含义不要误认为
code一个总部组织的业务编码;主账号与子账号共享当前分店 sid
username具体登录账号;主账号通常等于 code组织编码一定唯一于所有账号表
parent_code当前总部的上级总部编码普通服务站父级 sid
rightsJSON 门店授权快照数据库外键关系表
sid = 0运行时人工插入的“总部工作台”选项真实服务站记录
defaultStore登录后默认进入的分店,0 表示停留总部当前每个报表选择的分店
HASH_HO_REPORTS_SETTINGS各报表菜单最后选择的分店登录 Session 的 defaultStore
总部聚合报表一次聚合全部授权 sid单个分店的普通明细报表

4. 系统边界

flowchart LR
    OPS["OPS 组织中心"] -->|"opscenter_sitemgr_virtual"| MQ["消息队列"]
    MQ --> N["OpsCenterNotify"]
    N --> AS["AccountService"]
    AS --> HOA[("t_sys_head_office_account")]
    HOA --> LOGIN["Web / PC 登录"]
    LOGIN --> SESSION["jxcsys Session"]
    SESSION --> DESKTOP["总部工作台"]
    SESSION --> SWITCH["分店切换"]
    SWITCH --> ADMIN[("t_sys_admin")]
    SWITCH --> BIZ["普通站点业务接口"]
    SESSION --> REPORT["总部经营报表"]
    REPORT --> DB[("DGJ 业务库")]
    REPORT --> CENTER["报表中心 / Hologres"]
    REPORT --> REDIS[("Redis 报表分店设置")]

5. 代码入口地图

5.1 Controller

文件方法作用
application/controllers/headOffice/Account.phplist查询同一总部编码下的主账号和子账号
同上getUsername生成建议的下一子账号用户名
同上createSubAccount创建总部子账号
同上getStoreList查询指定总部账号的门店授权 JSON
同上saveRights保存子账号门店权限和默认门店
同上toggleStatus启用或停用账号
同上updatePwd管理员修改账号密码;当前存在参数契约缺陷
同上switchBranch切换到总部或具体分店并重写 Session
application/controllers/headOffice/Reports.phpgetPurchaseDetail总部采购汇总
同上getSaleDetail总部销售汇总
同上getFinanceDetail总部应收、应付、利润汇总
同上setBranch保存传统单站报表选择的分店
application/controllers/tasks/OpsCenterNotify.phpheadOfficeAccountSync消费 OPS 总分店组织同步事件

5.2 Service、Model 与基础设施

文件核心职责
application/Services/HeadOffice/AccountService.php账号、授权、组织关系、Session、分店切换
application/Services/HeadOffice/ReportService.php总部采购、销售、财务汇总及报表分店偏好
application/models/sys/HeadOfficeAccountModel.php总部账号表查询和批量更新
application/Services/User/DgjwebSer.phpWeb 登录时识别总部账号并构造上下文
application/Services/User/DgjpcSer.phpPC 登录时识别总部账号、默认门店和设备控制
application/core/BaseController.php根据路由和 Redis 解析传统报表实际 sid
application/service/sysitem/MenusService.php总部/分店上下文下的菜单生成与过滤
application/Services/Report/PurchaseReportSer.php生成采购明细报表 SQL
application/Services/Report/AccountReceiveSer.php查询客户应收初始余额和业务单位
application/Services/Report/AccountPaySer.php查询供应商应付初始余额和业务单位
application/Services/Report/ProfitReportSer.php格式化报表中心主营利润数据

6. 数据模型

6.1 核心表与缓存

物理对象常量/键用途关键字段
t_sys_head_office_accountSYS_HEAD_OFFICE_ACCOUNT总部主账号和子账号id,code,username,userpwd,status,rights,mobile,true_name,is_admin,parent_code,is_delete
t_sys_adminSYS_ADMIN普通分店主账号uid,sid,name,username,province,eparchy,city,roleid,status
t_bs_storeBS_STORE服务站基础资料sid,name,status 等
销售出库表SCM_SA_INVOICE*应收与销售事实sid,billDate,amount,qty,price,deduction
采购入库/发票表采购相关模型应付与采购事实sid,billDate,amount,qty,price
收付款明细表SCM_PAYMENT_INFO已收、优惠、已付sid,amount,diffAmount,billDate 等
客户/供应商资料BS_CONTACT历史初始欠款sid,debt_amount 等
Redis HashHASH_HO_REPORTS_SETTINGS总部各菜单选中的分店field=code,value=菜单到 sid 的 JSON

6.2 总部账号字段

字段说明规则
id主键API 管理操作使用
code总部组织编码主账号和所有子账号相同
username登录名主账号通常为 code,子账号为编码加序号
userpwd密码摘要当前实现兼容/使用 MD5,存在安全风险
status登录状态1 启用,0 停用
rights门店授权 JSON保存门店名称、授权和默认标识
mobile手机号OPS 同步或管理员创建时写入
true_name账号姓名/总部名称主账号来自 OPS,子账号由管理员录入
is_admin是否总部主账号1 主账号,0 子账号
parent_code上级总部编码OPS 组织关系变更时传播
is_delete逻辑删除Model 查询默认只取 0

6.3 rights JSON 结构

数据库中保存的结构示例:

[
  {
    "sid": 10001,
    "name": "示例一店",
    "is_default": true,
    "has_authority": true
  },
  {
    "sid": 10002,
    "name": "示例二店",
    "is_default": false,
    "has_authority": false
  }
]

运行时 getAvailableStores() 会过滤 has_authority = false 的门店,并在首位插入一个数据库不存在的总部项:

{
  "sid": 0,
  "name": "总部显示名称",
  "is_default": false,
  "has_authority": true
}

因此需要牢记:

  • 数据库 rights 与 Session rights 不是完全相同的数组。
  • Session.rights[0] 通常是人工插入的总部项。
  • 代码中使用 rights[1] 作为第一个真实门店时,依赖上述数组顺序。
  • rights 是整段 JSON 更新,没有行级并发控制。

6.4 关系图

erDiagram
    HEAD_OFFICE_ACCOUNT {
        bigint id PK
        string code
        string username
        int status
        text rights
        int is_admin
        string parent_code
    }
    SYS_ADMIN {
        bigint uid PK
        bigint sid
        string username
        int roleid
        int status
    }
    STORE {
        bigint sid PK
        string name
    }
    HEAD_OFFICE_ACCOUNT }o--o{ STORE : "rights JSON 引用 sid"
    STORE ||--o{ SYS_ADMIN : "站点账号"
    HEAD_OFFICE_ACCOUNT }o--o| HEAD_OFFICE_ACCOUNT : "parent_code 组织关系"
这张图表达逻辑关系,不代表数据库存在外键。rights.sid 位于 JSON 中,数据库无法直接保证引用完整性。

7. 状态、枚举与权限规则

7.1 账号枚举

来源:application/KzData/Enums/HeadOfficeAccountEnums.php。

枚举值说明
STATUS_ENABLE1账号启用
STATUS_STOP0账号停用
IS_DELETE_TRUE1已逻辑删除
IS_DELETE_FALSE0未删除
IS_ADMIN_TRUE1总部主账号
IS_ADMIN_FALSE0总部子账号
MAX_SUB_ACCOUNT_COUNT99子账号配置上限
OPS_OPERATOR_CREATE1OPS 创建操作
OPS_OPERATOR_UPDATE2OPS 更新操作

OPS enable 与本地状态映射:

OPS enable本地 status
11,启用
00,停用

7.2 报表菜单枚举

来源:application/KzData/Enums/HeadOfficeReportsEnums.php。

menuIdRedis 菜单键报表
1station-purchase-detail分店采购明细
2station-sale-detail分店销售明细
3station-rec-detail分店应收明细
4station-payable-detail分店应付明细
5station-profit-detail分店利润表

部分基础资料接口会被映射到“销售明细”当前分店,例如:

  • basedata/assist
  • basedata/invlocation
  • basedata/employee
  • settings/goods_batch_kz
  • basedata/inventory
  • basedata/inventory/car_search
  • scm/inv_sa/just_intime_inv

这是因为传统销售报表加载商品、品牌、员工、库存等辅助数据时,也必须使用报表当前选中的分店。

7.3 账号状态机

stateDiagram-v2
    [*] --> Enabled: "OPS 创建或管理员创建"
    Enabled --> Disabled: "管理员 toggleStatus 或 OPS enable=0"
    Disabled --> Enabled: "管理员 toggleStatus 或 OPS enable=1"
    Enabled --> SessionDestroyed: "权限刷新发现账号停用"
    Disabled --> LoginRejected: "登录或切换门店"
    SessionDestroyed --> [*]
    LoginRejected --> [*]

系统没有独立的“锁定、过期、注销”总部账号状态;删除由 is_delete 逻辑字段控制,但当前主要流程集中在启停。

8. API 总览

Controller 遵循 CodeIgniter 默认路由,以下路径均以 /index.php/ 为基准。请求通常是表单或 GET/POST 混合参数,响应由 splashJson() 统一包装。

接口方法身份要求主要读取主要写入
headOffice/account/listGET/POST总部登录总部账号表无
headOffice/account/getUsernamePOST总部主账号子账号数量无
headOffice/account/createSubAccountPOST总部主账号Session 权限、账号表新子账号
headOffice/account/getStoreListPOST总部主账号指定账号 rights无
headOffice/account/saveRightsPOST总部主账号原 rights更新整段 rights
headOffice/account/toggleStatusPOST总部主账号账号状态反转状态
headOffice/account/updatePwdPOST总部主账号账号密码摘要
headOffice/account/switchBranchGET/POST总部账号账号权限、分店主账号Session
headOffice/reports/getPurchaseDetailGET/POST总部账号授权门店、报表中心无
headOffice/reports/getSaleDetailGET/POST总部账号授权门店、销售事实无
headOffice/reports/getFinanceDetailGET/POST总部账号发票、收付款、联系人、利润中心无
headOffice/reports/setBranchGET总部账号Session 授权Redis Hash

9. 账号接口契约

9.1 查询账号列表

POST /index.php/headOffice/account/list
Cookie: 当前登录 Session

代表性响应:

{
  "status": "success",
  "msg": "查询成功",
  "data": {
    "items": [
      {
        "share": true,
        "admin": true,
        "userId": 101,
        "isCom": 1,
        "role": 0,
        "userName": "示例总部编码",
        "realName": "示例总部",
        "shareType": 1,
        "mobile": "已脱敏手机号"
      }
    ],
    "shareTotal": 3,
    "toalsize": 4,
    "totalUserNum": 99
  }
}

字段注意事项:

  • shareTotal = count(accounts) - 1,默认认为集合中恰好有一个主账号。
  • toalsize 是当前代码里的拼写,前端可能已依赖,不能直接更名。
  • 列表方法本身没有调用 checkHOPermission();页面入口和基类登录校验是外层边界。

9.2 获取建议用户名

POST /index.php/headOffice/account/getUsername
Cookie: 总部主账号 Session

生成规则:

username = code + 两位补零的(countSubAccounts + 1)

例如已有 3 个子账号时,建议用户名类似 总部编码04。

风险:代码只在 count > 99 时阻止创建,而不是 count >= 99。第 100 次生成可能超出“两位序号”约束,也可能与历史删除/空洞账号发生冲突;最终创建仍会检查用户名是否已存在。

9.3 创建子账号

POST /index.php/headOffice/account/createSubAccount
Content-Type: application/x-www-form-urlencoded
Cookie: 总部主账号 Session

userName=示例总部01
password=<12到20位数字和字母组合>
pswConfirm=<同一密码>
realName=张三
userMobile=<手机号>

Controller 校验:

字段AccountValidateService 二次规则
userName必填,长度 6 到 8全局用户名不能已存在
password必填,6 到 20check_password 实际要求 12 到 20 且包含数字和字母
pswConfirm必填,6 到 20同上,并与密码 MD5 后比较
realName必填,中文写 true_name
userMobile必填,手机号写 mobile

写入规则:

  1. 从当前 Session 读取总部 code、parentCode 和可用门店。
  2. 对 Session rights 执行 array_shift(),去掉 sid=0 总部项。
  3. 继承剩余门店权限快照。
  4. status=1、is_admin 使用表默认值 0。
  5. 密码以 MD5 摘要写入。

成功响应只返回新账号主键:

{
  "status": "success",
  "msg": "保存成功",
  "data": {"id": 102}
}

9.4 查询并保存门店权限

查询请求:

POST /index.php/headOffice/account/getStoreList
Content-Type: application/x-www-form-urlencoded

username=示例总部01

保存请求:

POST /index.php/headOffice/account/saveRights
Content-Type: application/x-www-form-urlencoded

id=102
enableStoreIds=10001,10003
defaultStore=10001

保存算法:

flowchart TD
    A["读取目标账号 rights JSON"] --> B["拆分 enableStoreIds"]
    B --> C["遍历 rights 中每个门店"]
    C --> D{"sid 是否在授权集合"}
    D -->|"是"| E["has_authority = true"]
    D -->|"否"| F["has_authority = false"]
    E --> G{"sid 是否等于 defaultStore"}
    F --> G
    G -->|"是"| H["is_default = true"]
    G -->|"否"| I["is_default = false"]
    H --> J["整段 JSON 覆盖写回"]
    I --> J

当前实现没有显式校验:

  • 目标账号是否属于当前登录总部的 code。
  • defaultStore 是否同时存在于 enableStoreIds。
  • enableStoreIds 中的门店是否属于当前管理员可管理范围。
  • 同时编辑时是否发生最后写入覆盖前一人的修改。

这些都应视为高风险权限边界,而不是前端保证即可。

9.5 启停账号

POST /index.php/headOffice/account/toggleStatus
Content-Type: application/x-www-form-urlencoded

id=102

Service 直接执行:

newStatus = int(!oldStatus)

接口是“反转”而不是“设置成目标值”,重复请求不幂等。若第一次响应丢失后前端重试,账号可能被切回原状态。

9.6 修改密码

期望请求:

POST /index.php/headOffice/account/updatePwd
Content-Type: application/x-www-form-urlencoded

id=102
newPassword=<新密码>

当前代码存在确定的 Controller/Service 参数契约错误:

  • Controller 调用 updatePwd($id, $newPassword)。
  • Service 声明为 updatePwd(array $request)。

在严格运行环境中会产生参数类型或参数数量错误。另一个个人资料入口 Right::update_password() 才是传入完整数组。修改此处时必须同时回归两个入口。

此外,管理员改密场景只做 min:6|max:20 校验,没有复用创建账号的数字加字母规则,最终仍写 MD5。

9.7 切换总部或分店

GET /index.php/headOffice/account/switchBranch?sid=10001
Cookie: 总部账号 Session
  • sid=0:回到总部上下文,返回数据中没有普通站点 sid/uid。
  • sid>0:进入具体分店,必须通过账号权限和分店主账号状态校验。
  • 成功后 Controller 直接覆盖 jxcsys Session,并重定向到 desktop/index。
  • 该接口不是纯 JSON API,调用方必须处理 302/页面跳转。

10. OPS 组织同步

10.1 消息入口

事件常量:

MqEventEnums::OPSCENTER_SITEMGR_VIRTUAL = opscenter_sitemgr_virtual

消费者:application/controllers/tasks/OpsCenterNotify.php。

代表性业务载荷结构:

{
  "operatorType": 1,
  "code": "示例总部编码",
  "name": "示例总部名称",
  "pCode": "示例上级编码",
  "phone": "已脱敏手机号",
  "enable": 1,
  "detailList": [
    {"code": 10001, "name": "示例一店"},
    {"code": 10002, "name": "示例二店"}
  ]
}

字段由 AccountService::createAccount/updateAccount 使用;实际 MQ 外层信封和生产字段完整契约仍需结合 OPS 服务确认。

10.2 创建总部主账号

sequenceDiagram
    participant OPS as OPS组织中心
    participant MQ as MQ
    participant N as OpsCenterNotify
    participant S as AccountService
    participant H as 总部账号表
    participant A as 普通账号表
    OPS->>MQ: opscenter_sitemgr_virtual(operatorType=1)
    MQ->>N: 投递消息
    N->>S: createAccount(payload)
    S->>H: 按 code/username 查重
    S->>A: 检查普通账号用户名冲突
    S->>S: detailList 转 rights JSON
    S->>H: 插入主账号 is_admin=1
    H-->>S: 写入结果
    S-->>N: 成功或抛异常

创建规则:

  • code = username = OPS code。
  • true_name = name。
  • parent_code = pCode。
  • status = 1。
  • is_admin = 1。
  • 每个 detailList 门店初始均 has_authority=true、is_default=false。
  • 初始密码为 MD5(code + HEAD_OFFICE_PART_PWD),属于可预测规则,必须依赖首次改密或外围安全控制。

10.3 更新总部组织

更新可能同时影响:

  • 当前总部主账号名称、手机号、状态、上级和门店列表。
  • 当前总部所有子账号的状态、上级和门店授权结构。
  • 原上级总部的门店授权。
  • 新上级总部的门店授权。
  • 原、新上级总部下的子账号授权。
flowchart TD
    A["收到 OPS 更新"] --> B["按 code 查总部主账号"]
    B --> C["映射 enable 为本地 status"]
    C --> D["比较新旧 detailList"]
    D --> E["得到 add / intersect / remove"]
    E --> F["重建当前主账号 rights"]
    E --> G["重建所有子账号 rights"]
    E --> H{"parent_code 是否变化"}
    H -->|"否"| I["更新现有上级 rights"]
    H -->|"是"| J["新上级增加门店"]
    J --> K["旧上级移除门店"]
    F --> L["数据库事务提交"]
    G --> L
    I --> L
    K --> L

prepareStations() 的意图是:

  • 新增门店加入 JSON。
  • 保留门店更新名称等信息。
  • 删除门店从 JSON 中移除。
  • 对子账号尽量保留原 has_authority/is_default 状态。

事务覆盖本地总部账号表的多次更新,但不能覆盖 MQ 生产端、OPS 数据和已经发出的其他系统消息。

10.4 组织更新当前风险

在 updateAccount() 的子账号循环中,临时变量 $item 没有在每次循环开始时重置:

foreach ($subAccounts as $subAccount) {
    $isChangeStatus && $item['status'] = $status;
    // ...
    $item['rights'] = ...;
}

如果某个子账号设置了 parent_code/status,后续子账号可能继承上一轮残留字段。修复时应在循环第一行显式 $item = [];,并回归“状态未变但上级变化”“部分子账号上级已一致”等组合。

11. 登录与 Session 构建

11.1 账号识别

Web 和 PC 登录首先查询普通 t_sys_admin。普通账号未命中时,再从 t_sys_head_office_account 查询总部账号,并标记为非普通账号。

flowchart TD
    A["提交用户名密码"] --> B{"普通站点账号是否存在"}
    B -->|"是"| C["按普通账号登录"]
    B -->|"否"| D{"总部账号是否存在且未删除"}
    D -->|"否"| E["用户名或密码错误"]
    D -->|"是"| F{"status 是否启用"}
    F -->|"否"| G["账号停用"]
    F -->|"是"| H["校验密码"]
    H --> I{"子账号是否配置默认门店"}
    I -->|"是"| J["加载分店主账号上下文"]
    I -->|"否"| K["停留总部工作台"]
    J --> L["构建 jxcsys + headOfficeAccount"]
    K --> L

11.2 Session 结构

总部上下文核心结构:

{
  "headOfficeAccount": {
    "code": "总部编码",
    "username": "当前登录账号",
    "trueName": "管理员或子账号姓名",
    "isAdmin": 1,
    "rights": [
      {"sid": 0, "name": "总部", "has_authority": true},
      {"sid": 10001, "name": "一店", "has_authority": true}
    ],
    "defaultStore": 0,
    "parentCode": "上级总部编码"
  }
}

进入分店后,同一个 Session 还会增加普通业务上下文:

{
  "uid": 501,
  "sid": 10001,
  "name": "示例一店",
  "username": "分店主账号",
  "province": "省级编码",
  "eparchy": "地市编码",
  "city": "城市编码",
  "roleid": 0,
  "login": "kzmall",
  "areaCode": "省级编码",
  "headOfficeAccount": {
    "username": "原总部登录账号",
    "defaultStore": 10001
  }
}

所以日志排查时:

  • 顶层 username 可能是分店主账号。
  • 真正登录的总部账号在 headOfficeAccount.username。
  • 顶层 sid 是当前业务分店;没有顶层 sid 表示总部视角。

11.3 PC 登录的默认门店

总部子账号登录 PC 时:

  1. 从数据库 rights 选择 is_default=true 的门店。
  2. 查询该门店主账号。
  3. 若存在,继续执行 PC 设备、版本和授权校验。
  4. 以门店身份进入系统,同时保留总部身份。
  5. 没有默认门店则停留总部上下文。

总部主账号不会通过这段子账号默认门店逻辑自动进入分店。

12. 分店切换完整链路

sequenceDiagram
    participant U as 总部用户
    participant NAV as 顶部导航
    participant C as Account Controller
    participant S as AccountService
    participant H as 总部账号表
    participant A as 分店主账号表
    participant SS as Session
    U->>NAV: 选择 sid
    NAV->>C: GET switchBranch?sid=...
    C->>S: switchBranch(sid)
    S->>H: 重新读取当前总部账号
    S->>S: 校验账号状态和 rights
    alt sid = 0
        S->>S: 构建纯总部上下文
    else sid > 0
        S->>A: 查询启用的分店主账号
        S->>S: 组装 uid/sid/roleid/areaCode
    end
    S-->>C: 新 jxcsys 数据
    C->>SS: 覆盖 Session
    C-->>NAV: 重定向 desktop/index

12.1 切换到分店的校验顺序

顺序校验失败信息
1当前总部账号仍存在用户不存在
2总部账号 status=1账号已停用
3rights 中存在目标 sid暂无权限管理分店
4目标门店 has_authority=true暂无权限管理分店
5分店主账号存在分店已被禁用
6分店主账号状态可用账号被锁定

12.2 切回总部

sid=0 时不会保留顶层分店 sid/uid,而是返回只含 headOfficeAccount 的新数组。Desktop 检测到“有总部身份、没有顶层 sid、defaultStore=0”后渲染总部工作台。

13. 权限传播与 Session 刷新

13.1 权限的四层边界

flowchart TD
    A["OPS 组织 detailList"] --> B["总部主账号 rights"]
    B --> C["子账号 has_authority / is_default"]
    C --> D["登录 Session 可用门店"]
    D --> E["切换后的普通站点权限和菜单"]
层决定什么数据源
组织层总部理论上管理哪些门店OPS detailList
账号层某子账号能访问哪些门店总部账号表 rights
Session 层当前请求看到哪些门店headOfficeAccount.rights
站点层进入分店后能看到哪些菜单和数据分店 t_sys_admin、菜单配置和业务权限

总部授权不能绕过分店自身状态与菜单配置。它只决定“能否进入分店”,进入后仍使用该分店主账号的 uid/roleid 和站点菜单。

13.2 updateSession() 生效逻辑

flowchart TD
    A["按 username 重读总部账号"] --> B{"账号是否启用"}
    B -->|"否"| C["销毁 Session"]
    B -->|"是"| D["重建可用门店和总部信息"]
    D --> E{"姓名/rights/parentCode 是否变化"}
    E -->|"否"| F["保持当前 Session"]
    E -->|"是"| G{"当前 sid 是否仍在新权限"}
    G -->|"否"| H["销毁 Session"]
    G -->|"是"| I["更新 headOfficeAccount"]

该方法是否在每一个请求生命周期稳定调用,需要结合基类/中间件实际运行路径确认。若未触发,数据库权限已变更而旧 Session 仍可能短期保留。

14. 报表体系总览

flowchart LR
    S["总部 Session 授权门店"] --> P["采购汇总"]
    S --> SA["销售汇总"]
    S --> F["财务汇总"]
    P --> H["PurchaseReportSer 生成 SQL"]
    H --> HC["Hologres 查询接口"]
    SA --> RM["ReportModel 销售明细"]
    RM --> DB[("DGJ 分库业务表")]
    F --> AR["应收"]
    F --> AP["应付"]
    F --> PR["主营利润"]
    AR --> DB
    AP --> DB
    PR --> DC["ReportProvider 数据中心"]

14.1 授权 sid 来源

ReportService::getSids() 从 Session headOfficeAccount.rights 提取所有非零 sid:

sids = array_filter(array_column(sessionRights, 'sid'))

它没有再次判断 has_authority,依赖 Session rights 已经过 getAvailableStores() 过滤。正常登录路径满足这一假设,但手工构造、旧 Session 或其他调用路径会放大越权风险。

15. 总部采购汇总

15.1 请求

POST /index.php/headOffice/reports/getPurchaseDetail
Content-Type: application/x-www-form-urlencoded

beginDate=2026-07-01
endDate=2026-07-31

Controller 只要求开始、结束日期。调用方不能传任意 sids;Service 从 Session 授权范围生成。

15.2 数据链路

sequenceDiagram
    participant C as Reports Controller
    participant S as ReportService
    participant PS as PurchaseReportSer
    participant P as PurchaseProvider
    participant H as Hologres接口
    C->>S: getPurchaseDetail(beginDate,endDate)
    loop 每个授权 sid
        S->>PS: getPurchaseDetailReportSql(JXCSID=sid)
        PS-->>S: countSql + whereSql
        S->>P: 查询 countSql
        P->>H: /devcenter/report/hologres/query
        H-->>P: 总行数
        S->>P: 每 1000 行循环查询 whereSql
        P->>H: 分页 SQL
        H-->>P: 采购明细
        S->>S: 计算 amount/inAmount/price
    end
    S-->>C: 分店明细和总部合计

15.3 计算口径

每行入库金额:

inAmount += price * inqty - deduction * inqty / qty

同时累计:

amount += row.amount
price  += row.price

最终总部合计为各授权分店结果相加。

返回字段含义注意
amountService 累加明细 amount具体业务口径来自采购报表 SQL
inAmount按入库数量分摊优惠后的入库金额qty=0 会有除零风险
price逐行价格简单相加不是加权平均采购价
total*所有门店简单合计依赖每个门店查询都成功

采购查询按门店串行执行,门店数量多或数据量大时,可能产生明显延迟。单个门店请求失败会抛异常,当前没有“部分门店成功”的返回契约。

16. 总部销售汇总

16.1 请求

POST /index.php/headOffice/reports/getSaleDetail
Content-Type: application/x-www-form-urlencoded

beginDate=2026-07-01
endDate=2026-07-31

16.2 查询条件

Service 组装核心条件:

billType = SALE
sid in (Session 授权门店)
billDate >= beginDate
billDate <= endDate
order by billDate, id

查询通过 ReportModel::getSaDetailReportBySids() 访问销售明细。

16.3 数量和金额方向

统计数量 qty = 数据库 qty > 0 ? -abs(qty) : abs(qty)
amount = qty * price

代码注释说明“销售在数据库中是负数,统计时应为正数”。因此:

  • 正常销售负数量会转成正统计数量。
  • 正数量会转成负数,通常表达退货或冲减方向。

优惠与应收:

若 disAmount == 0:
    discount = deduction
否则:
    discount = amount - disAmount

recAmount = amount - discount
flowchart TD
    A["读取全部授权门店销售明细"] --> B["按数量正负转换销售/退货方向"]
    B --> C["amount = qty × price"]
    C --> D{"disAmount 是否为 0"}
    D -->|"是"| E["discount = deduction"]
    D -->|"否"| F["discount = amount - disAmount"]
    E --> G["recAmount = amount - discount"]
    F --> G
    G --> H["按 sid 聚合并补零门店"]
    H --> I["计算总部总计"]

16.4 返回结构

{
  "detail": [
    {
      "sid": 10001,
      "name": "示例一店",
      "amount": "10000.00",
      "disAmount": "300.00",
      "recAmount": "9700.00"
    }
  ],
  "total": {
    "totalAmount": "10000.00",
    "totalDisAmount": "300.00",
    "totalRecAmount": "9700.00"
  }
}

没有销售数据的授权门店也会返回一条金额为 0 的记录,便于总部按完整门店列表展示。

17. 总部财务汇总

财务接口一次返回应收、应付和主营利润三组指标。

POST /index.php/headOffice/reports/getFinanceDetail
Content-Type: application/x-www-form-urlencoded

beginDate=2026-07-01
endDate=2026-07-31
flowchart TD
    A["getFinanceDetail"] --> B["Session 授权 sids"]
    B --> C["getReceiveDetail 应收"]
    B --> D["getAccountsPayableDetail 应付"]
    B --> E["getCoreProfitDetail 主营利润"]
    C --> F["按 sid 合并"]
    D --> F
    E --> F
    F --> G["分店 detail + 总部 total"]

17.1 应收口径

数据源:

数据来源
客户历史欠款AccountReceiveSer::getContactsBySids 返回 debt_amount
期初前销售额SaInvoiceModel::sumInvoiceAmount('', beginDate)
期初前收款和优惠PaymentInfoModel::sumReceivedPaymentAmount('', beginDate)
本期销售额sumInvoiceAmount(beginDate, endDate)
本期收款和优惠sumReceivedPaymentAmount(beginDate, endDate)

公式:

期初应收 = 期初前销售额 + 客户历史欠款 - 期初前已收款 - 期初前收款优惠

本期销售额 = 本期销售发票金额
本期优惠额 = 本期收款优惠 diffAmount

期末应收 = 期初应收 + 本期销售额 - 本期已收款 - 本期优惠额

接口返回的应收部分只有:

  • sellAmount:本期销售额。
  • diffAmount:本期收款优惠。
  • recBalance:期末应收余额。

期初余额和本期收款作为中间计算值,没有直接返回。

17.2 应付口径

数据源:

数据来源
供应商历史欠款AccountPaySer::getContactsBySids 返回 debt_amount
期初前采购额PuInvoiceModel::sumInvoiceAmountBySids('', beginDate)
期初前付款PaymentInfoModel::sumPayAmountBySids('', beginDate)
本期采购额sumInvoiceAmountBySids(beginDate, endDate)
本期付款sumPayAmountBySids(beginDate, endDate)

公式:

期初应付 = 期初前采购额 + 供应商历史欠款 - 期初前已付款
期末应付 = 期初应付 + 本期采购额 - 本期已付款

返回:

  • purchaseAmount:本期采购额。
  • payableBalance:期末应付余额。

当前表达式:

bcsub($periodBalance + $invoiceAmount[$sid]['amount'] ?? 0, ...)

+ 与 ?? 的优先级容易导致空键行为不符合作者意图。建议改为先取默认值,再显式相加:

currentPurchase = invoiceAmount[sid].amount ?? 0
payableBalance = periodBalance + currentPurchase - currentPay

17.3 主营利润口径

每个授权门店通过 ReportProvider::getCoreBizFinReport() 异步请求数据中心,再借助反射调用 ProfitReportSer::formatByMonth()。

主营收入 coreIn = Σ sale_fee
主营成本 coreCost = Σ cost_fee
主营利润 coreProfit = coreIn - coreCost
主营利润率 = coreProfit / coreIn × 100%

总部利润率:

totalCoreProfitRate = 总部总利润 / 总部总收入 × 100%

它是按总收入加权后的比率,不是各门店利润率的算术平均。

sequenceDiagram
    participant S as ReportService
    participant RP as ReportProvider
    participant DC as 数据中心
    participant FS as ProfitReportSer
    loop 每个授权 sid
        S->>RP: getCoreBizFinReport(sid,dateRange)
        RP->>DC: 异步请求
        DC-->>RP: 月度 coreBiz 数据
    end
    S->>RP: asyncWait()
    RP-->>S: 所有回调完成
    S->>FS: formatByMonth(反射调用)
    S->>S: 收入、成本、利润、利润率

反射调用受 ProfitReportSer 私有方法签名影响,属于较强耦合。该方法改名或参数变化不会被普通接口契约提前发现。

18. 传统单站报表的分店选择

总部工作台中的应收、应付、销售等传统报表不是所有请求都直接调用 getFinanceDetail()。页面先选择一个分店,然后把这个选择保存到 Redis,后续普通接口由 BaseController::getStoreId() 自动解析。

18.1 保存请求

GET /index.php/headOffice/reports/setBranch?storeId=10001&menuId=3
Cookie: 总部账号 Session

Redis 逻辑结构:

key   = RedisKeys::HASH_HO_REPORTS_SETTINGS
field = headOfficeAccount.code
value = {
  "station-sale-detail": {"sid": 10001},
  "station-rec-detail": {"sid": 10002},
  "station-payable-detail": {"sid": 10001}
}
sequenceDiagram
    participant U as 总部用户
    participant V as 单站报表页面
    participant R as Reports::setBranch
    participant K as Redis
    participant B as BaseController
    participant API as 普通业务接口
    U->>V: 选择服务站
    V->>R: storeId + menuId
    R->>K: HSET code -> menu.sid
    V->>API: 查询明细/辅助数据
    API->>B: getStoreId()
    B->>K: HGET code
    K-->>B: 当前菜单 sid
    B-->>API: 实际 JXCSID

18.2 默认分店

Redis 没有当前菜单设置时,代码回退到:

headOfficeAccount.rights[1].sid

这依赖:

  1. rights[0] 是人工插入的总部项。
  2. 至少有一个授权分店。
  3. 数组索引被连续重排。

若账号没有任何真实分店,可能出现未定义索引。

18.3 当前授权风险

setBranch() 校验了 menuId 只能为 1 到 5,但 Service 没有显式校验:

  • storeId 是否属于当前 Session 授权门店。
  • storeId 是否为启用的真实服务站。
  • 当前账号是否能访问该报表菜单。

而 BaseController::getStoreId() 会信任 Redis 中的 sid。因此必须在 Service 写入前增加授权集合校验,并在读取时做防御性校验,不能只依赖下拉框选项。

19. 数据流与最终业务事实

19.1 账号链路

动作输入持久化事实运行时事实
OPS 创建总部组织编码、门店列表主账号行、rights JSON下次登录可见
创建子账号用户名、密码、姓名、手机号子账号行、继承的 rights返回新 id
保存权限账号 id、门店集合、默认门店覆盖目标账号 rights旧 Session 需刷新
启停账号账号 idstatus 反转刷新后退出或允许登录
切换分店sid不写业务表Session 顶层站点身份改变
单站报表选店storeId/menuIdRedis 菜单偏好后续普通接口使用该 sid

19.2 经营报表链路

报表门店范围数据源最终事实
总部采购Session 全部非零 sidHologres 采购明细每店采购、入库金额及总计
总部销售Session 全部非零 sidDGJ 销售明细每店销售、优惠、应收及总计
总部应收Session 全部非零 sid销售发票、收款、客户欠款每店期末应收
总部应付Session 全部非零 sid采购发票、付款、供应商欠款每店期末应付
总部利润Session 全部非零 sid数据中心主营业务报表每店及总部利润和利润率
单站传统报表Redis 当前菜单 sid原有单站接口当前选择门店明细

20. 事务、幂等与一致性

场景事务/幂等现状影响
OPS 创建主账号先查重再插入,无显式幂等键事务并发重复消息依赖唯一索引;索引需环境确认
OPS 更新组织本地多账号更新在数据库事务内可保证本表多行更新一起提交
创建子账号查重、计数、插入分离并发创建可能生成相同用户名
保存 rights整段 JSON 覆盖并发编辑可能丢失更新
toggleStatus反转操作,不幂等重试可能恢复原状态
切换分店覆盖 Session多标签页共享 Session 时会互相影响
setBranchRedis HGET 后修改 JSON 再 HSET并发切换不同菜单可能发生覆盖
总部汇总读多个库/服务,无快照事务查询期间发生业务变更时,各分店时间点可能不同

21. 异常流程

21.1 症状与第一判断

症状第一检查常见根因
总部账号无法登录总部账号行和 statusOPS 未同步、用户名冲突、停用、密码不一致
子账号看不到门店账号 rightshas_authority=false、组织已移除、旧 Session
默认门店未生效rights.is_default 和分店主账号默认门店未授权、主账号禁用、多个默认项
切换分店提示无权限Session 与数据库 rights门店被移除、Session 未刷新、传错 sid
切换后菜单不对顶层 sid/uid/roleid使用的是分店主账号菜单,不是总部菜单
单站报表显示另一家店Redis 菜单设置多页面切换、field 共用总部 code、写入覆盖
总部报表缺少某门店Session 非零 sid 列表门店未授权、Session 旧、rights 数据异常
采购报表慢门店数和 Hologres 分页每店串行查询、数据量大、中心接口慢
财务数字对不上日期边界和期初公式历史欠款、收款优惠、同步延迟、口径不同
主营利润部分门店为空数据中心回调上游无数据、超时、格式变化

21.2 排查决策图

flowchart TD
    A["总部或分店数据异常"] --> B{"能否正常登录"}
    B -->|"否"| C["查总部账号 status/password/is_delete"]
    B -->|"是"| D{"问题发生在切换前还是切换后"}
    D -->|"切换前"| E["比对数据库 rights 与 Session rights"]
    D -->|"切换后"| F["确认顶层 sid/uid 和分店主账号状态"]
    E --> G{"是汇总报表还是单站报表"}
    F --> G
    G -->|"汇总"| H["检查 getSids 与各数据源"]
    G -->|"单站"| I["检查 Redis 菜单 sid 与 getStoreId"]
    H --> J["按采购/销售/应收/应付/利润逐层核对"]
    I --> K["核对 storeId 是否在授权集合"]

22. 只读排查 SQL

表名和字段以当前代码常量为依据。生产执行前先确认分库、只读账号和脱敏要求,禁止直接修改。

22.1 查询总部主账号和子账号

SELECT id, code, username, status, mobile, true_name,
       is_admin, parent_code, is_delete, rights
FROM t_sys_head_office_account
WHERE code = :head_office_code
  AND is_delete = 0
ORDER BY is_admin DESC, id ASC;

22.2 按用户名检查冲突

SELECT id, code, username, status, is_admin, is_delete
FROM t_sys_head_office_account
WHERE username = :username;

SELECT uid, sid, username, roleid, status
FROM t_sys_admin
WHERE username = :username;

22.3 查询目标分店主账号

SELECT uid, sid, name, username, province, eparchy, city, roleid, status
FROM t_sys_admin
WHERE sid = :sid
ORDER BY roleid ASC, uid ASC;

需要结合 AdminModel::getNormalMainStationBySid() 的真实过滤条件确认哪一行被选为主账号。

22.4 检查授权 JSON 中的门店

MySQL 版本支持 JSON_TABLE 时可使用:

SELECT h.username,
       j.sid,
       j.name,
       j.has_authority,
       j.is_default
FROM t_sys_head_office_account h,
JSON_TABLE(
  h.rights,
  '$[*]' COLUMNS (
    sid BIGINT PATH '$.sid',
    name VARCHAR(255) PATH '$.name',
    has_authority BOOLEAN PATH '$.has_authority',
    is_default BOOLEAN PATH '$.is_default'
  )
) AS j
WHERE h.username = :username
  AND h.is_delete = 0;

旧 MySQL 不支持时,只读查询 rights 后在本地 JSON 工具中解析,不要使用字符串 LIKE 作为最终权限判断。

22.5 财务口径核对顺序

应收问题建议按以下顺序分别查询对应 Model 的 SQL:

1. 客户 debt_amount
2. beginDate 之前的销售发票金额
3. beginDate 之前的收款金额和 diffAmount
4. 本期销售发票金额
5. 本期收款金额和 diffAmount
6. 按公式手工重算 recBalance

应付问题:

1. 供应商 debt_amount
2. beginDate 之前的采购发票金额
3. beginDate 之前的付款金额
4. 本期采购发票金额
5. 本期付款金额
6. 按公式手工重算 payableBalance

23. Redis 排查

只查看键结构,不在生产直接修改:

redis-cli --raw HGET '<环境前缀>:hash_ho_reports_settings' '<总部code>'

实际键带有 RedisKeys::PREFIX,必须从当前环境配置确认完整前缀。输出 JSON 后重点检查:

  • 菜单键是否与 REPORT_MENUS_MAP 一致。
  • sid 是否仍在当前账号授权范围。
  • 不同页面是否覆盖了同一个菜单键。
  • 总部组织 code 变化后是否遗留旧 field。

24. 日志和代码定位命令

rg -n "class Account|createSubAccount|saveRights|switchBranch|updateSession" \
  application/controllers/headOffice application/Services/HeadOffice

rg -n "headOfficeAccountSync|OPSCENTER_SITEMGR_VIRTUAL|opscenter_sitemgr_virtual" \
  application/controllers/tasks application/KzData/Enums

rg -n "getPurchaseDetail|getSaleDetail|getFinanceDetail|getCoreProfitDetail" \
  application/controllers/headOffice application/Services/HeadOffice

rg -n "HASH_HO_REPORTS_SETTINGS|REPORT_MENUS_MAP|ROUTE_TO_REPORT_MENU|getStoreId" \
  application/core application/KzData application/views/report

rg -n "getNormalUserByUsername|headOfficeAccount|defaultStore|getAvailableStores" \
  application/Services/User application/controllers/Desktop.php application/controllers/v2/Desktop.php

rg -n "sumInvoiceAmount|sumReceivedPaymentAmount|sumPayAmountBySids|getSaDetailReportBySids" \
  application/models application/Services/Report

日志检索业务键优先级:

username > headOffice code > sid > 请求路径 > beginDate/endDate > MQ event

不得把原始密码、完整手机号、Session Cookie 或消息凭证粘贴到共享排查记录。

25. 当前代码风险清单

级别风险证据后果
高管理员改密 Controller 与 Service 参数不一致Account::updatePwd vs AccountService::updatePwd(array)接口直接报错
高saveRights 未校验目标账号属于当前总部按请求 id 直接查账号越权修改其他总部账号权限
高setBranch 未校验 storeId 在授权范围直接写 Redis可能越权读取单站数据
高toggleStatus 非幂等反转旧状态重试导致状态反复
高密码使用 MD5且主账号初始密码可预测md5(...)凭证安全风险
中子账号上限判断使用 > 99getUsername/createSubAccount第 100 个账号边界异常
中子账号更新循环 $item 未重置updateAccount字段在不同子账号间串值
中getSids 信任 Session 已过滤不看 has_authority旧/异常 Session 放大权限
中rights 整段 JSON 覆盖saveRights/prepareStations并发丢更新、缺少约束
中报表默认使用 rights[1]BaseController::getStoreId无门店或数组异常时报错
中应付公式存在运算符优先级歧义getAccountsPayableDetail缺少门店数据时口径异常
中采购分摊可能除以零deduction * inqty / qty异常行导致报表失败
中总部采购按门店串行分页getPurchaseDetail大组织响应慢或超时
中利润格式化通过反射调用getCoreProfitDetail私有方法改动引起运行时错误
低toalsize 拼写错误已被前端使用accounts()/jqGrid修正字段会破坏兼容

26. 修改影响面

26.1 修改账号结构时

必须同时检查:

  • DgjwebSer 与 DgjpcSer 登录字段。
  • Desktop、v2/Desktop 页面注入。
  • AccountService::switchBranch/updateSession/getAvailableStores。
  • 顶部导航的门店下拉。
  • MenusService 对总部账号的特殊判断。
  • 报表 getSids() 和 BaseController::getStoreId()。
  • OPS 同步消息的创建、更新和上级传播。

26.2 修改报表口径时

必须同时检查:

  • 单站原始报表口径是否一致。
  • 总部汇总是否只是单站求和,还是有二次换算。
  • 销售退货正负方向。
  • 历史欠款 debt_amount 是否应计入期初。
  • 收款优惠是否已在发票金额中体现,避免重复扣减。
  • 总部利润率是否继续使用加权公式。
  • Hologres/数据中心日期边界与 DGJ 本地日期边界是否一致。

27. 回归测试清单

27.1 OPS 同步

  • 新总部创建主账号,字段和门店 JSON 正确。
  • 重复创建消息不会产生第二个主账号。
  • 修改名称、手机号、启停状态分别正确生效。
  • 新增、删除、重命名门店后主账号和子账号同步正确。
  • 主账号停用时所有子账号状态同步。
  • 上级总部切换时新旧上级及其子账号权限正确。
  • 本地事务失败时所有账号更新回滚。
  • 多个子账号更新时不存在 $item 字段串值。

27.2 子账号

  • 正常创建、用户名重复、密码不一致、手机号非法。
  • 11、12、20、21 位密码边界。
  • 仅数字、仅字母、数字加字母组合。
  • 第 98、99、100 个子账号边界和并发创建。
  • 授权一个、多个、全部、不授权任何门店。
  • 默认门店必须属于授权门店。
  • 两名管理员并发保存同一账号权限。
  • 启停接口重复请求不会产生意外反转;修复后按目标状态测试幂等。
  • 管理员改密和个人资料改密两个入口都成功。

27.3 登录与切换

  • 主账号登录停留总部。
  • 子账号无默认门店时停留总部。
  • 子账号有默认门店时 Web/PC 行为一致。
  • 切换授权门店成功,顶层 sid/uid/roleid 正确。
  • 切换未授权、停用、无主账号的门店失败。
  • 从分店切回 sid=0 后不残留分店业务身份。
  • OPS 移除当前门店后旧 Session 被销毁。
  • 多浏览器标签切换不同门店时行为符合产品预期。

27.4 采购汇总

  • 单门店、多门店、无授权门店。
  • 无数据门店返回零值。
  • 采购、退货、优惠分摊和部分入库。
  • qty=0 异常数据防护。
  • 超过 1000 行分页及整页边界。
  • 某一门店 Hologres 超时或返回格式异常。
  • 总部合计等于同口径各门店合计。

27.5 销售汇总

  • 正常销售负数量转正。
  • 销售退货正数量转负。
  • disAmount=0 使用 deduction。
  • disAmount!=0 使用 amount-disAmount。
  • 没有销售的门店补零。
  • 日期首尾当天数据均包含。
  • 总金额、优惠和应收三者关系正确。

27.6 财务汇总

  • 客户/供应商无历史欠款和有历史欠款。
  • 期初前存在发票、收款、付款和优惠。
  • 本期无业务、只有发票、只有收付款。
  • 跨月、跨年日期范围。
  • 负余额、退款和冲销场景。
  • 某门店数组键缺失时使用 0 而不报错。
  • 主营收入为 0 时利润率为 0。
  • 总部利润率按总收入加权,而不是门店比率平均。
  • 数据中心部分失败时有明确错误或降级,不悄悄返回 0。

27.7 Redis 单站报表

  • 五种 menuId 分别保存独立 sid。
  • 非法 menuId 被 Validator 拒绝。
  • 未授权 storeId 被 Service 拒绝。
  • Redis 无设置时回退第一个授权门店。
  • 没有授权门店时返回明确错误。
  • 两个页面并发切换不同菜单不丢失设置。
  • 门店被 OPS 移除后旧 Redis sid 不再生效。

28. 推荐修复顺序

  1. 修复 Account::updatePwd 调用契约,并统一密码强度和摘要升级策略。
  2. 给账号管理接口增加“目标账号必须属于当前总部 code”校验。
  3. 将 toggleStatus 改为传入目标状态的幂等接口。
  4. 在 setBranch/getStoreId 两端校验 sid 属于当前授权集合。
  5. 修复子账号数量 >=99 边界和并发用户名生成。
  6. 在 updateAccount 子账号循环中重置 $item。
  7. 把 rights JSON 长期迁移为规范化关系表,或至少增加版本号/乐观锁。
  8. 修复应付空值运算和采购 qty=0 防护。
  9. 为总部报表建立固定账套样本和自动口径对账。
  10. 为多门店查询增加超时、部分失败策略和可观测性。

29. 证据文件

结论权威代码
账号接口和主账号权限检查application/controllers/headOffice/Account.php
子账号、门店授权、切换和 Sessionapplication/Services/HeadOffice/AccountService.php
总部账号字段与查询范围application/models/sys/HeadOfficeAccountModel.php
状态和 OPS 映射application/KzData/Enums/HeadOfficeAccountEnums.php
报表接口application/controllers/headOffice/Reports.php
采购、销售、应收、应付、利润公式application/Services/HeadOffice/ReportService.php
报表菜单映射application/KzData/Enums/HeadOfficeReportsEnums.php
OPS 消费入口application/controllers/tasks/OpsCenterNotify.php
Web/PC 登录上下文application/Services/User/DgjwebSer.php、DgjpcSer.php
传统报表 sid 解析application/core/BaseController.php
前端账号请求statics/old/js/dist/addSubAccount.js、public/js/page/subAccountLimit.js
前端分店切换application/views/layouts/navigation.php
单站报表分店选择application/views/report/station_*_detail.php

30. 静态代码无法证明、需要环境确认的事实

  • t_sys_head_office_account.username 是否存在数据库唯一索引。
  • OPS 消息的完整信封、重试次数、死信策略和消费者 ACK/NACK 配置。
  • HEAD_OFFICE_PART_PWD 是否仍在生产使用,以及首次登录是否强制改密。
  • updateSession() 在所有 Web、PC 和 API 请求中的真实调用频率。
  • 报表中心/Hologres 的数据刷新周期、时区、日期闭区间和容灾策略。
  • ReportProvider 部分门店失败时是抛错、返回空还是重试。
  • 生产 Redis 完整键前缀、过期策略和集群一致性。
  • OPS 组织树中 code/pCode/detailList.code 的全局唯一性约束。
  • 单站采购、销售和财务报表与总部汇总是否已有产品确认的固定对账口径。

31. 一句话记住这条业务

总分店的核心不是“总部账号带一组 sid”,而是 OPS 组织快照写入总部账号 rights,登录时把授权门店转成 Session,切换分店时再借用该站主账号建立普通业务身份;总部汇总按 Session 授权范围跨站聚合,传统单站报表则通过 Redis 保存每个菜单的当前分店。任何权限或报表问题,都应沿这四层依次核对。

请求-日志-数据变更追踪卡

多入口请求链路

场景调用方与入口请求载荷/上下文Controller/ConsumerService/Provider汇合点最终业务事实
OPS 组织同步OpsCenter MQ总部账号、组织/门店清单、版本OpsCenterNotifyHeadOffice/AccountServicehead account IDrights 保存授权门店快照
总部登录/headOffice/Account/登录链总部账号、凭证、设备Account Controller/User ServiceHeadOffice AccountServicehead account + sessionSession 带授权 sid 集合
切换分店总部页面目标 sid、菜单/来源HeadOffice AccountDgjweb/Dgjpc User Servicehead account + target sid借用目标站主账号建立业务上下文
汇总/单站报表/headOffice/Reports 或普通 reports授权门店集合、日期、菜单当前站Reports ControllerHeadOffice/Report Servicesid set/menu key跨站聚合或单站查询

日志证据矩阵

| 链路段 | 日志来源 | 可检索锚点 | 成功信号 | 失败信号 | 与下一段关联方式 | | --- | --- | --- | --- | --- | --- | --- | | 组织同步 | OpsCenterNotify/AccountService | message ID、head account、组织版本、sid 集合 | rights 更新并 ACK | 门店漏同步、旧版本覆盖 | account ID 查 SYS_HEAD_OFFICE_ACCOUNT | | 登录 | Login/HeadOffice Account | request_id、account ID、session ID(hash) | Session 权限集等于 rights | 空 rights、旧组织缓存 | account+session 进入切店 | | 切店 | Account/User Service | account、target sid、menu | 目标在 rights 且业务用户建立 | 越权 sid、站主账号缺失 | session 当前 sid 查业务请求 | | 报表 | HeadOffice Report/普通 Report | request ID、sid set、menu Redis key、query ID | 结果只含授权站点 | 串站、遗漏、缓存当前站错误 | sid 集合与结果明细对照 |

环节数据变更台账

步骤代码位置事务读取事实写入表/缓存/MQ字段或数量变化回查证据
同步账号组织OpsCenterNotify -> AccountService消费事务账号、组织版本、门店快照SYS_HEAD_OFFICE_ACCOUNTrights old set -> new set,旧消息不回退message/version、sid 集合差
建立总部 SessionAccount/User Service登录事务account status、rightssession/cacheauthorizedSids = rights;登录时间更新session 脱敏快照
切换站点Dgjweb/Dgjpc Servicesession/cache 操作target sid ∈ rights、BS_STORE、站主账号session、HASH_HO_REPORTS_SETTINGScurrentSid/menuSid old -> targetrequest ID、session、Redis key
汇总查询HeadOffice/ReportService只读session sid set、DWD/report data无按 sid set filter/group,不写 OLTPquery ID、明细 sid distinct
单站查询普通 ReportSer只读menu 当前 sid无仅目标 sid 结果Redis menu key、SQL filter

子模块追踪:headoffice-sync OPS 总分店组织同步

环节入口/触发请求/业务键代码链路读取事实写入与字段变化日志证据异常与补偿
组织消费OPS 总部/分店/账号变更事件message ID、org/account ID、version、sid setapplication/controllers/tasks/OpsCenterNotify.php -> application/Services/HeadOffice/AccountService.php当前组织版本、账号和授权门店集合单消息本地事务 rights old set -> new set,旧版本零变化message ID + org/account + version + sid diff乱序不回退;部分失败按账号业务键重放
同步回查OPS 正确本地组织旧account/org ID、both versionsapplication/models/sys/HeadOfficeAccountModel.php本地账号、rights、外部快照和消费日志查询只读;确认后幂等补事件account/org + versions + affected rows不直接手改集合掩盖漏消息;外部事实需环境确认

子模块追踪:headoffice-login 总部账号登录与 Session

环节入口/触发请求/业务键代码链路读取事实写入与字段变化日志证据异常与补偿
总部登录Web/PC 总部账号登录account、client、session IDapplication/controllers/headOffice/Account.php -> application/Services/HeadOffice/AccountService.phpaccount status、authorizedSids、密码和终端规则登录本地事务/缓存 session none -> active,保存授权 sid 快照request ID + account + rights count + session hash无授权门店/锁定账号零业务写入;凭证脱敏
会话校验总部后续请求session、currentSid、rights versionapplication/Services/User/DgjwebSer.phpsession rights、账号最新 rights 和重登标记校验查询只读;rights 变更时 session 刷新/失效request ID + account/currentSid + versioncurrentSid 不在 rights 时拒绝并要求重选

子模块追踪:headoffice-switch 总部切换分店

环节入口/触发请求/业务键代码链路读取事实写入与字段变化日志证据异常与补偿
切换总部用户选择目标分店account、target sid、sessionapplication/controllers/v2/Desktop.php -> application/Services/User/DgjpcSer.phptarget ∈ authorizedSids、门店状态、站主账号和菜单session/cache currentSid/menuSid old -> target,业务 DB 不变request ID + account + old/target sid越权/停用门店零变化;切换不改变授权集合
切换回查页面仍显示原分店session、HASH_HO_REPORTS_SETTINGSapplication/KzData/Enums/HeadOfficeAccountEnums.phpsession currentSid、菜单 sid 和 Redis 设置查询只读;事务外清/刷新目标缓存request ID + account + cache key + sid只修会话/缓存,不改组织 rights

子模块追踪:headoffice-rights 权限传播与 Session 刷新

环节入口/触发请求/业务键代码链路读取事实写入与字段变化日志证据异常与补偿
rights 更新OPS 同步或管理员调整account、rights version、sid setapplication/Services/HeadOffice/AccountService.php原 rights、活跃 sessions 和资源权限账号本地事务 rights old -> new;commit 后 session rights 刷新/失效message/request ID + account + sid diff删除当前 sid 权限必须强制重选;旧事件不回退
最小验收更新后各分店菜单/接口account、sample sids、resource codeapplication/core/BaseController.phpsession、currentSid、组织 rights 和资源授权查询只读 不写request ID + account/sid + allow/deny菜单可见与运行时鉴权都测;缓存失败只补失效

子模块追踪:headoffice-purchase 总部采购汇总

环节入口/触发请求/业务键代码链路读取事实写入与字段变化日志证据异常与补偿
汇总查询总部采购报表account、authorized sid set、period/filtersapplication/controllers/headOffice/Reports.php -> application/Services/HeadOffice/ReportService.phpsession sid set、采购报表数据和离线水位查询只读 不写;SQL/filter 只能命中授权 sid 集合query ID + account + sid distinct + max time空/少数据先查 rights、日期和 DWD 水位
单站对照汇总与分店采购不一致target sid、po billNosapplication/Services/Report/PurchaseReportSer.php同条件单站结果、汇总分组和转站历史查询只读;各站合计等于总部汇总query ID + sid/billNo samples不用 currentSid 替代 authorized set;源正确只补报表层

子模块追踪:headoffice-sale 总部销售汇总

环节入口/触发请求/业务键代码链路读取事实写入与字段变化日志证据异常与补偿
汇总查询总部销售/利润聚合account、sid set、customer/date/statusapplication/Services/HeadOffice/ReportService.php -> application/Services/Report/ProfitReportSer.php授权门店、销售/出库/退货和成本口径查询只读 不写;按 sid 分组后汇总query ID + account + sid distinct + totals重复 join/跨站客户先按业务主键抽样
单站对照总部销售与分店页面差异sid、sale/invoice billNosapplication/Services/Report/SaleReportSer.php同过滤、软删、离线时间和权限查询只读;列表/导出/汇总口径一致query ID + billNos + raw/final rowscurrentSid 仅单站页面;总部必须显式 sid set

子模块追踪:headoffice-finance 总部财务与单站报表

环节入口/触发请求/业务键代码链路读取事实写入与字段变化日志证据异常与补偿
财务汇总总部应收/应付/利润查询account、sid set、cutoff/customer/supplierapplication/Services/Report/AccountReceiveSer.php、application/Services/Report/AccountPaySer.php授权站点、期初、期间资金和离线水位查询只读 不写;按授权集合过滤并聚合query ID + account + sid distinct + amounts金额异常先统一日期/单位和期初口径
单站模式切换后查看普通站点报表account、currentSid、report codeapplication/KzData/Enums/HeadOfficeReportsEnums.phpcurrentSid、menuSid、单站权限和缓存查询只读,仅目标 sid;不改变 rights setrequest ID/query ID + currentSid + cache key单站/总部模式切换需清查询条件缓存,不混用结果