本文说明 DGJ2.0 总分店体系从 OPS 组织关系同步、总部主账号创建、子账号管理、门店数据授权、登录 Session 构建、总部与分店上下文切换,到采购、销售、应收、应付和主营利润汇总的完整链路。
这不是单纯的“一个账号能看多个门店”。系统实际上同时维护三套上下文:
- 总部身份:当前登录的是哪个总部主账号或子账号。
- 授权范围:这个账号允许访问哪些服务站,以及默认进入哪个服务站。
- 业务站点身份:进入分店后,普通采购、销售、库存接口看到的
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 + 普通报表接口 |
| 权限变更生效 | 请求生命周期中刷新总部 Session | AccountService::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 |
rights | JSON 门店授权快照 | 数据库外键关系表 |
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.php | list | 查询同一总部编码下的主账号和子账号 |
| 同上 | getUsername | 生成建议的下一子账号用户名 |
| 同上 | createSubAccount | 创建总部子账号 |
| 同上 | getStoreList | 查询指定总部账号的门店授权 JSON |
| 同上 | saveRights | 保存子账号门店权限和默认门店 |
| 同上 | toggleStatus | 启用或停用账号 |
| 同上 | updatePwd | 管理员修改账号密码;当前存在参数契约缺陷 |
| 同上 | switchBranch | 切换到总部或具体分店并重写 Session |
application/controllers/headOffice/Reports.php | getPurchaseDetail | 总部采购汇总 |
| 同上 | getSaleDetail | 总部销售汇总 |
| 同上 | getFinanceDetail | 总部应收、应付、利润汇总 |
| 同上 | setBranch | 保存传统单站报表选择的分店 |
application/controllers/tasks/OpsCenterNotify.php | headOfficeAccountSync | 消费 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.php | Web 登录时识别总部账号并构造上下文 |
application/Services/User/DgjpcSer.php | PC 登录时识别总部账号、默认门店和设备控制 |
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_account | SYS_HEAD_OFFICE_ACCOUNT | 总部主账号和子账号 | id,code,username,userpwd,status,rights,mobile,true_name,is_admin,parent_code,is_delete |
t_sys_admin | SYS_ADMIN | 普通分店主账号 | uid,sid,name,username,province,eparchy,city,roleid,status |
t_bs_store | BS_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 Hash | HASH_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与 Sessionrights不是完全相同的数组。 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_ENABLE | 1 | 账号启用 |
STATUS_STOP | 0 | 账号停用 |
IS_DELETE_TRUE | 1 | 已逻辑删除 |
IS_DELETE_FALSE | 0 | 未删除 |
IS_ADMIN_TRUE | 1 | 总部主账号 |
IS_ADMIN_FALSE | 0 | 总部子账号 |
MAX_SUB_ACCOUNT_COUNT | 99 | 子账号配置上限 |
OPS_OPERATOR_CREATE | 1 | OPS 创建操作 |
OPS_OPERATOR_UPDATE | 2 | OPS 更新操作 |
OPS enable 与本地状态映射:
OPS enable | 本地 status |
|---|---|
| 1 | 1,启用 |
| 0 | 0,停用 |
7.2 报表菜单枚举
来源:application/KzData/Enums/HeadOfficeReportsEnums.php。
menuId | Redis 菜单键 | 报表 |
|---|---|---|
| 1 | station-purchase-detail | 分店采购明细 |
| 2 | station-sale-detail | 分店销售明细 |
| 3 | station-rec-detail | 分店应收明细 |
| 4 | station-payable-detail | 分店应付明细 |
| 5 | station-profit-detail | 分店利润表 |
部分基础资料接口会被映射到“销售明细”当前分店,例如:
basedata/assistbasedata/invlocationbasedata/employeesettings/goods_batch_kzbasedata/inventorybasedata/inventory/car_searchscm/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/list | GET/POST | 总部登录 | 总部账号表 | 无 |
headOffice/account/getUsername | POST | 总部主账号 | 子账号数量 | 无 |
headOffice/account/createSubAccount | POST | 总部主账号 | Session 权限、账号表 | 新子账号 |
headOffice/account/getStoreList | POST | 总部主账号 | 指定账号 rights | 无 |
headOffice/account/saveRights | POST | 总部主账号 | 原 rights | 更新整段 rights |
headOffice/account/toggleStatus | POST | 总部主账号 | 账号状态 | 反转状态 |
headOffice/account/updatePwd | POST | 总部主账号 | 账号 | 密码摘要 |
headOffice/account/switchBranch | GET/POST | 总部账号 | 账号权限、分店主账号 | Session |
headOffice/reports/getPurchaseDetail | GET/POST | 总部账号 | 授权门店、报表中心 | 无 |
headOffice/reports/getSaleDetail | GET/POST | 总部账号 | 授权门店、销售事实 | 无 |
headOffice/reports/getFinanceDetail | GET/POST | 总部账号 | 发票、收付款、联系人、利润中心 | 无 |
headOffice/reports/setBranch | GET | 总部账号 | 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 校验:
| 字段 | AccountValidate | Service 二次规则 |
|---|---|---|
userName | 必填,长度 6 到 8 | 全局用户名不能已存在 |
password | 必填,6 到 20 | check_password 实际要求 12 到 20 且包含数字和字母 |
pswConfirm | 必填,6 到 20 | 同上,并与密码 MD5 后比较 |
realName | 必填,中文 | 写 true_name |
userMobile | 必填,手机号 | 写 mobile |
写入规则:
- 从当前 Session 读取总部
code、parentCode和可用门店。 - 对 Session
rights执行array_shift(),去掉sid=0总部项。 - 继承剩余门店权限快照。
status=1、is_admin使用表默认值 0。- 密码以 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 直接覆盖
jxcsysSession,并重定向到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 时:
- 从数据库
rights选择is_default=true的门店。 - 查询该门店主账号。
- 若存在,继续执行 PC 设备、版本和授权校验。
- 以门店身份进入系统,同时保留总部身份。
- 没有默认门店则停留总部上下文。
总部主账号不会通过这段子账号默认门店逻辑自动进入分店。
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 | 账号已停用 |
| 3 | rights 中存在目标 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
最终总部合计为各授权分店结果相加。
| 返回字段 | 含义 | 注意 |
|---|---|---|
amount | Service 累加明细 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
这依赖:
rights[0]是人工插入的总部项。- 至少有一个授权分店。
- 数组索引被连续重排。
若账号没有任何真实分店,可能出现未定义索引。
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 需刷新 |
| 启停账号 | 账号 id | status 反转 | 刷新后退出或允许登录 |
| 切换分店 | sid | 不写业务表 | Session 顶层站点身份改变 |
| 单站报表选店 | storeId/menuId | Redis 菜单偏好 | 后续普通接口使用该 sid |
19.2 经营报表链路
| 报表 | 门店范围 | 数据源 | 最终事实 |
|---|---|---|---|
| 总部采购 | Session 全部非零 sid | Hologres 采购明细 | 每店采购、入库金额及总计 |
| 总部销售 | Session 全部非零 sid | DGJ 销售明细 | 每店销售、优惠、应收及总计 |
| 总部应收 | Session 全部非零 sid | 销售发票、收款、客户欠款 | 每店期末应收 |
| 总部应付 | Session 全部非零 sid | 采购发票、付款、供应商欠款 | 每店期末应付 |
| 总部利润 | Session 全部非零 sid | 数据中心主营业务报表 | 每店及总部利润和利润率 |
| 单站传统报表 | Redis 当前菜单 sid | 原有单站接口 | 当前选择门店明细 |
20. 事务、幂等与一致性
| 场景 | 事务/幂等现状 | 影响 |
|---|---|---|
| OPS 创建主账号 | 先查重再插入,无显式幂等键事务 | 并发重复消息依赖唯一索引;索引需环境确认 |
| OPS 更新组织 | 本地多账号更新在数据库事务内 | 可保证本表多行更新一起提交 |
| 创建子账号 | 查重、计数、插入分离 | 并发创建可能生成相同用户名 |
保存 rights | 整段 JSON 覆盖 | 并发编辑可能丢失更新 |
toggleStatus | 反转操作,不幂等 | 重试可能恢复原状态 |
| 切换分店 | 覆盖 Session | 多标签页共享 Session 时会互相影响 |
setBranch | Redis HGET 后修改 JSON 再 HSET | 并发切换不同菜单可能发生覆盖 |
| 总部汇总 | 读多个库/服务,无快照事务 | 查询期间发生业务变更时,各分店时间点可能不同 |
21. 异常流程
21.1 症状与第一判断
| 症状 | 第一检查 | 常见根因 |
|---|---|---|
| 总部账号无法登录 | 总部账号行和 status | OPS 未同步、用户名冲突、停用、密码不一致 |
| 子账号看不到门店 | 账号 rights | has_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(...) | 凭证安全风险 |
| 中 | 子账号上限判断使用 > 99 | getUsername/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. 推荐修复顺序
- 修复
Account::updatePwd调用契约,并统一密码强度和摘要升级策略。 - 给账号管理接口增加“目标账号必须属于当前总部
code”校验。 - 将
toggleStatus改为传入目标状态的幂等接口。 - 在
setBranch/getStoreId两端校验sid属于当前授权集合。 - 修复子账号数量
>=99边界和并发用户名生成。 - 在
updateAccount子账号循环中重置$item。 - 把
rightsJSON 长期迁移为规范化关系表,或至少增加版本号/乐观锁。 - 修复应付空值运算和采购
qty=0防护。 - 为总部报表建立固定账套样本和自动口径对账。
- 为多门店查询增加超时、部分失败策略和可观测性。
29. 证据文件
| 结论 | 权威代码 |
|---|---|
| 账号接口和主账号权限检查 | application/controllers/headOffice/Account.php |
| 子账号、门店授权、切换和 Session | application/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/Consumer | Service/Provider | 汇合点 | 最终业务事实 |
|---|---|---|---|---|---|---|
| OPS 组织同步 | OpsCenter MQ | 总部账号、组织/门店清单、版本 | OpsCenterNotify | HeadOffice/AccountService | head account ID | rights 保存授权门店快照 |
| 总部登录 | /headOffice/Account/登录链 | 总部账号、凭证、设备 | Account Controller/User Service | HeadOffice AccountService | head account + session | Session 带授权 sid 集合 |
| 切换分店 | 总部页面 | 目标 sid、菜单/来源 | HeadOffice Account | Dgjweb/Dgjpc User Service | head account + target sid | 借用目标站主账号建立业务上下文 |
| 汇总/单站报表 | /headOffice/Reports 或普通 reports | 授权门店集合、日期、菜单当前站 | Reports Controller | HeadOffice/Report Service | sid 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_ACCOUNT | rights old set -> new set,旧消息不回退 | message/version、sid 集合差 |
| 建立总部 Session | Account/User Service | 登录事务 | account status、rights | session/cache | authorizedSids = rights;登录时间更新 | session 脱敏快照 |
| 切换站点 | Dgjweb/Dgjpc Service | session/cache 操作 | target sid ∈ rights、BS_STORE、站主账号 | session、HASH_HO_REPORTS_SETTINGS | currentSid/menuSid old -> target | request ID、session、Redis key |
| 汇总查询 | HeadOffice/ReportService | 只读 | session sid set、DWD/report data | 无 | 按 sid set filter/group,不写 OLTP | query ID、明细 sid distinct |
| 单站查询 | 普通 ReportSer | 只读 | menu 当前 sid | 无 | 仅目标 sid 结果 | Redis menu key、SQL filter |
子模块追踪:headoffice-sync OPS 总分店组织同步
| 环节 | 入口/触发 | 请求/业务键 | 代码链路 | 读取事实 | 写入与字段变化 | 日志证据 | 异常与补偿 |
|---|---|---|---|---|---|---|---|
| 组织消费 | OPS 总部/分店/账号变更事件 | message ID、org/account ID、version、sid set | application/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 versions | application/models/sys/HeadOfficeAccountModel.php | 本地账号、rights、外部快照和消费日志 | 查询只读;确认后幂等补事件 | account/org + versions + affected rows | 不直接手改集合掩盖漏消息;外部事实需环境确认 |
子模块追踪:headoffice-login 总部账号登录与 Session
| 环节 | 入口/触发 | 请求/业务键 | 代码链路 | 读取事实 | 写入与字段变化 | 日志证据 | 异常与补偿 |
|---|---|---|---|---|---|---|---|
| 总部登录 | Web/PC 总部账号登录 | account、client、session ID | application/controllers/headOffice/Account.php -> application/Services/HeadOffice/AccountService.php | account status、authorizedSids、密码和终端规则 | 登录本地事务/缓存 session none -> active,保存授权 sid 快照 | request ID + account + rights count + session hash | 无授权门店/锁定账号零业务写入;凭证脱敏 |
| 会话校验 | 总部后续请求 | session、currentSid、rights version | application/Services/User/DgjwebSer.php | session rights、账号最新 rights 和重登标记 | 校验查询只读;rights 变更时 session 刷新/失效 | request ID + account/currentSid + version | currentSid 不在 rights 时拒绝并要求重选 |
子模块追踪:headoffice-switch 总部切换分店
| 环节 | 入口/触发 | 请求/业务键 | 代码链路 | 读取事实 | 写入与字段变化 | 日志证据 | 异常与补偿 |
|---|---|---|---|---|---|---|---|
| 切换 | 总部用户选择目标分店 | account、target sid、session | application/controllers/v2/Desktop.php -> application/Services/User/DgjpcSer.php | target ∈ authorizedSids、门店状态、站主账号和菜单 | session/cache currentSid/menuSid old -> target,业务 DB 不变 | request ID + account + old/target sid | 越权/停用门店零变化;切换不改变授权集合 |
| 切换回查 | 页面仍显示原分店 | session、HASH_HO_REPORTS_SETTINGS | application/KzData/Enums/HeadOfficeAccountEnums.php | session currentSid、菜单 sid 和 Redis 设置 | 查询只读;事务外清/刷新目标缓存 | request ID + account + cache key + sid | 只修会话/缓存,不改组织 rights |
子模块追踪:headoffice-rights 权限传播与 Session 刷新
| 环节 | 入口/触发 | 请求/业务键 | 代码链路 | 读取事实 | 写入与字段变化 | 日志证据 | 异常与补偿 |
|---|---|---|---|---|---|---|---|
| rights 更新 | OPS 同步或管理员调整 | account、rights version、sid set | application/Services/HeadOffice/AccountService.php | 原 rights、活跃 sessions 和资源权限 | 账号本地事务 rights old -> new;commit 后 session rights 刷新/失效 | message/request ID + account + sid diff | 删除当前 sid 权限必须强制重选;旧事件不回退 |
| 最小验收 | 更新后各分店菜单/接口 | account、sample sids、resource code | application/core/BaseController.php | session、currentSid、组织 rights 和资源授权 | 查询只读 不写 | request ID + account/sid + allow/deny | 菜单可见与运行时鉴权都测;缓存失败只补失效 |
子模块追踪:headoffice-purchase 总部采购汇总
| 环节 | 入口/触发 | 请求/业务键 | 代码链路 | 读取事实 | 写入与字段变化 | 日志证据 | 异常与补偿 |
|---|---|---|---|---|---|---|---|
| 汇总查询 | 总部采购报表 | account、authorized sid set、period/filters | application/controllers/headOffice/Reports.php -> application/Services/HeadOffice/ReportService.php | session sid set、采购报表数据和离线水位 | 查询只读 不写;SQL/filter 只能命中授权 sid 集合 | query ID + account + sid distinct + max time | 空/少数据先查 rights、日期和 DWD 水位 |
| 单站对照 | 汇总与分店采购不一致 | target sid、po billNos | application/Services/Report/PurchaseReportSer.php | 同条件单站结果、汇总分组和转站历史 | 查询只读;各站合计等于总部汇总 | query ID + sid/billNo samples | 不用 currentSid 替代 authorized set;源正确只补报表层 |
子模块追踪:headoffice-sale 总部销售汇总
| 环节 | 入口/触发 | 请求/业务键 | 代码链路 | 读取事实 | 写入与字段变化 | 日志证据 | 异常与补偿 |
|---|---|---|---|---|---|---|---|
| 汇总查询 | 总部销售/利润聚合 | account、sid set、customer/date/status | application/Services/HeadOffice/ReportService.php -> application/Services/Report/ProfitReportSer.php | 授权门店、销售/出库/退货和成本口径 | 查询只读 不写;按 sid 分组后汇总 | query ID + account + sid distinct + totals | 重复 join/跨站客户先按业务主键抽样 |
| 单站对照 | 总部销售与分店页面差异 | sid、sale/invoice billNos | application/Services/Report/SaleReportSer.php | 同过滤、软删、离线时间和权限 | 查询只读;列表/导出/汇总口径一致 | query ID + billNos + raw/final rows | currentSid 仅单站页面;总部必须显式 sid set |
子模块追踪:headoffice-finance 总部财务与单站报表
| 环节 | 入口/触发 | 请求/业务键 | 代码链路 | 读取事实 | 写入与字段变化 | 日志证据 | 异常与补偿 |
|---|---|---|---|---|---|---|---|
| 财务汇总 | 总部应收/应付/利润查询 | account、sid set、cutoff/customer/supplier | application/Services/Report/AccountReceiveSer.php、application/Services/Report/AccountPaySer.php | 授权站点、期初、期间资金和离线水位 | 查询只读 不写;按授权集合过滤并聚合 | query ID + account + sid distinct + amounts | 金额异常先统一日期/单位和期初口径 |
| 单站模式 | 切换后查看普通站点报表 | account、currentSid、report code | application/KzData/Enums/HeadOfficeReportsEnums.php | currentSid、menuSid、单站权限和缓存 | 查询只读,仅目标 sid;不改变 rights set | request ID/query ID + currentSid + cache key | 单站/总部模式切换需清查询条件缓存,不混用结果 |