Files
xiaoqu_mcp_service/xiaoqu-franchisee/doc/customer-product-list-dev.md
T
xiaoqu b6d4bf6126 导入三个参考项目: xiaoqu-franchisee / xiaoqu-publicbeanandmap / elasticsearchpublic
作为 xiaoqu-agent-service(MCP 服务) 的开发参考。新系统与旧系统零代码耦合,
仅照搬其行级数据权限规则与数据源结构。设计方案见 xiaoqu-franchisee/doc/agent-mcp-service-design.md
2026-09-01 23:00:47 +08:00

36 KiB
Raw Blame History

开发文档:客户产品清单查询页面

需求:在「客户管理」菜单下新增「客户产品清单」页面,对 customer_product_list 表做查询展示。 环境:dev 192.168.1.181:3306(root/kaixinjiuhao);prd 上线需替换 parent_id / authority_id 后重跑。 涉及库:xiaoqu_comples_d(菜单/字段/权限);业务库 xiaoqu_franchisee_d 表 customer_product_list 已存在,无需建表。 基础设施:Bean / Mapper / Service 也已存在(CustomerProductList / CustomerProductListMapper / CustomerProductListService 及其 Impl,已被 PickupOrder / DeliveryOrder / WithdrawOrder / WarehouseHardStock 等 Controller 使用),本期只新增 Controller + DTO。


1. 数据库变更

1.1 业务表 — xiaoqu_franchisee_d(dataSource2 / m2)

customer_product_list 已存在,结构如下(不动):

-- 仅作记录,不要再次执行
CREATE TABLE `customer_product_list` (
  `id`                    bigint(8)    NOT NULL AUTO_INCREMENT,
  `create_id`             bigint(8)    DEFAULT '0' COMMENT '创建id',
  `create_time`           datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `modify_id`             bigint(8)    DEFAULT '0' COMMENT '修改id',
  `modify_time`           datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  `soft_delete`           int(4)       DEFAULT '0' COMMENT '数据状态',
  `agent_id`              bigint(8)    DEFAULT '0' COMMENT '代理ID',
  `property_id`           bigint(8)    DEFAULT '0' COMMENT '物业ID',
  `district_id`           bigint(8)    DEFAULT '0' COMMENT '站点ID',
  `agent_name`            varchar(64)  DEFAULT ''  COMMENT '公司名称',
  `property_name`         varchar(64)  DEFAULT ''  COMMENT '物业名称',
  `district_name`         varchar(64)  DEFAULT ''  COMMENT '项目名称',
  `materiel_definition_id`bigint(8)    DEFAULT '0' COMMENT '物品ID',
  `material_no`           varchar(32)  DEFAULT ''  COMMENT '物品编号',
  `materiel_full_name`    varchar(256) DEFAULT ''  COMMENT '物品名称',
  `materiel_index_id`     bigint(8)    DEFAULT '0' COMMENT '索引ID',
  `materiel_sort`         varchar(64)  DEFAULT ''  COMMENT '物品分类',
  `specifications`        varchar(256) DEFAULT ''  COMMENT '物品规格型号',
  `current_inventory`     int(4)       DEFAULT '0' COMMENT '当前数量',
  `on_the_way`            int(4)       DEFAULT '0' COMMENT '在途数量',
  `sn_code`               varchar(64)  DEFAULT ''  COMMENT '物品SN',
  `product_sort`          varchar(64)  DEFAULT ''  COMMENT '产品分类',
  `is_back`               int(4)       DEFAULT '0' COMMENT '是否备用机 0:否 1:是',
  `state`                 int(4)       DEFAULT '0' COMMENT '产品状态(-1:在途;0:可用;1:待检;2:返修;3:返厂;4:在线;5:未使用;6:取回)',
  `order_no`              varchar(64)  DEFAULT NULL COMMENT '关联工单',
  `memo`                  varchar(64)  DEFAULT NULL COMMENT '备注',
  `address`               varchar(256) DEFAULT ''  COMMENT '地址',
  `building_id`           bigint(8)    DEFAULT '0' COMMENT '楼id',
  `floor`                 varchar(20)  DEFAULT ''  COMMENT '楼层',
  `toilet_id`             bigint(8)    DEFAULT '0' COMMENT '厕所id',
  `position_id`           bigint(8)    DEFAULT '0' COMMENT '厕位id',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户清单';

dev 数据量:约 1275 条;state 分布 -1(996) / 0(15) / 2(1) / 4(11) / 5(252),状态 1/3/6 暂无数据。

1.2 菜单 + 字段 + 权限(权限库)

挂到「客户管理」(web_id=60) 之下,order_by=6(前面 0-5 已被占用)。

环境对应的权限 schema:

  • dev → xiaoqu_comples_d
  • test → test
  • prod → xiaoqu_comples_p

切环境只需改第一行 USE,其余语句通用(与 messageNotice / out-certificate 同款做法)。

授权策略:按 PM 决议,本页只授给 authority_id=1(系统管理员),不复用兄弟菜单的授权范围。dev/test/prod 上 authority_id=1 都对应「系统管理员」,所以三个环境 SQL 完全一致。

USE xiaoqu_comples_d;   -- dev;test 改 USE test; prod 改 USE xiaoqu_comples_p;

START TRANSACTION;

-- ─────────────────────────────────────────────────────────────
-- ① webpage:菜单本体
-- ─────────────────────────────────────────────────────────────
INSERT INTO webpage
  (title, param, icon, description, order_by, parent_id, `type`, `status`, `path`, is_table_show)
VALUES
  ('客户产品清单', '', '', '', 6, 60, 0, 1, 'customerProductList', 1);

SET @new_web_id := LAST_INSERT_ID();
SELECT @new_web_id AS new_webpage_id;

-- ─────────────────────────────────────────────────────────────
-- ② webpage_authority:菜单授权(仅系统管理员=1,与 web_id=228 站点信息统计查询一致)
-- ─────────────────────────────────────────────────────────────
INSERT INTO webpage_authority
  (web_id, authority_id, title, `path`, parent_id, check_status, `type`, is_table_show, order_by)
VALUES
  (@new_web_id, 1, '客户产品清单', 'customerProductList', 60, 1, 0, 1, 6);

-- ─────────────────────────────────────────────────────────────
-- ③ page_field:页面字段定义
--   class_name 全限定类路径必须与后端 DTO 一致
-- ─────────────────────────────────────────────────────────────
INSERT INTO page_field
  (web_id, cn_name, en_name, class_name, field_type, order_by,
   is_select, nav_select, is_table_show, param)
VALUES
  -- 列表展示字段(按业务重要性排序)
  (@new_web_id, '公司名称',     'agentName',         'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'string',  1,  1, 0, 1, ''),
  (@new_web_id, '物业名称',     'propertyName',      'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'string',  2,  1, 0, 1, ''),
  (@new_web_id, '项目名称',     'districtName',      'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'string',  3,  1, 0, 1, ''),
  (@new_web_id, '物品编号',     'materialNo',        'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'string',  4,  1, 0, 1, ''),
  (@new_web_id, '物品名称',     'materielFullName',  'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'string',  5,  1, 1, 1, ''),
  (@new_web_id, '物品分类',     'materielSort',      'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'string',  6,  1, 1, 1, ''),
  (@new_web_id, '产品分类',     'productSort',       'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'string',  7,  1, 1, 1, ''),
  (@new_web_id, '规格型号',     'specifications',    'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'string',  8,  1, 0, 1, ''),
  (@new_web_id, 'SN',           'snCode',            'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'string',  9,  1, 1, 1, ''),
  (@new_web_id, '产品状态',     'stateName',         'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'string', 10,  1, 0, 1, ''),
  (@new_web_id, '是否备用机',   'isBackName',        'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'string', 11,  1, 0, 1, ''),
  (@new_web_id, '当前数量',     'currentInventory',  'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'string', 12,  1, 0, 1, ''),
  (@new_web_id, '在途数量',     'onTheWay',          'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'string', 13,  1, 0, 1, ''),
  (@new_web_id, '关联工单',     'orderNo',           'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'string', 14,  1, 0, 1, ''),
  (@new_web_id, '楼层',         'floor',             'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'string', 15,  1, 0, 1, ''),
  (@new_web_id, '地址',         'address',           'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'string', 16,  1, 0, 1, ''),
  (@new_web_id, '备注',         'memo',              'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'string', 17,  1, 0, 1, ''),
  (@new_web_id, '创建时间',     'createTime',        'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'date',   18,  1, 0, 1, ''),

  -- 搜索区隐藏字段(用于下拉/状态枚举传 ID/枚举值)
  (@new_web_id, '公司名称',     'agentId',           'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'inputSelect', 20, 1, 1, 0, ''),
  (@new_web_id, '物业名称',     'propertyId',        'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'inputSelect', 21, 1, 1, 0, ''),
  (@new_web_id, '项目名称',     'districtId',        'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'inputSelect', 22, 1, 1, 0, ''),
  (@new_web_id, '产品状态',     'state',             'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'inputSelect', 23, 1, 1, 0, ''),

  -- 公共审计字段(不展示,仅元数据完整性)
  (@new_web_id, 'id',           'id',                'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'string',     32, 1, 0, 0, ''),
  (@new_web_id, '创建id',       'createId',          'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'inputSelect',33, 1, 0, 0, ''),
  (@new_web_id, '修改id',       'modifyId',          'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'inputSelect',34, 1, 0, 0, ''),
  (@new_web_id, '修改时间',     'modifyTime',        'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'date',       35, 1, 0, 0, ''),
  (@new_web_id, '数据状态',     'softDelete',        'me.iiv.iivframework.controller.customerProduct.output.CustomerProductListOutput', 'string',     36, 1, 0, 0, '');

-- ─────────────────────────────────────────────────────────────
-- ④ field_webpage_authority:字段级权限(仅 authority_id=1)
-- ─────────────────────────────────────────────────────────────
INSERT INTO field_webpage_authority
  (web_id, authority_id, cn_name, en_name, is_select, nav_select,
   field_type, field_id, order_by, check_status, param, is_table_show)
SELECT
  pf.web_id, 1 AS authority_id, pf.cn_name, pf.en_name, pf.is_select, pf.nav_select,
  pf.field_type, pf.id, pf.order_by, 1, pf.param, pf.is_table_show
FROM page_field pf
WHERE pf.web_id = @new_web_id;

-- ─────────────────────────────────────────────────────────────
-- ⑤ button + button_webpage_authority:导出按钮
-- ─────────────────────────────────────────────────────────────
INSERT INTO `button`
  (web_id, cn_name, en_name, icon, class_name, function_path,
   `type`, `status`, order_by, theme, memo, app_en_name)
VALUES
  (@new_web_id, '导出', 'export', '', '', '',
   1, 1, 1, 'primary', '客户产品清单导出', '');

SET @new_export_button_id := LAST_INSERT_ID();

INSERT INTO `button_webpage_authority`
  (web_id, authority_id, cn_name, en_name, button_id,
   order_by, check_status, param, icon, `type`, class_name, theme, memo)
VALUES
  (@new_web_id, 1, '导出', 'export', @new_export_button_id,
   1, 1, '', '', 1, '', 'primary', '');

-- ─────────────────────────────────────────────────────────────
-- ⑥ 校验
-- ─────────────────────────────────────────────────────────────
SELECT id, parent_id, title, path, order_by FROM webpage WHERE id = @new_web_id;
SELECT id, authority_id, title FROM webpage_authority WHERE web_id = @new_web_id;
SELECT COUNT(*) AS field_count FROM page_field WHERE web_id = @new_web_id;
SELECT authority_id, COUNT(*) AS field_auth_count
  FROM field_webpage_authority WHERE web_id = @new_web_id GROUP BY authority_id;
SELECT id, en_name, `type` FROM `button` WHERE web_id = @new_web_id;
SELECT id, en_name FROM `button_webpage_authority` WHERE web_id = @new_web_id;

COMMIT;
-- 失败请改 ROLLBACK

已执行情况(2026-05-14):

  • dev (xiaoqu_comples_d):webpage.id=387,webpage_authority=1 条(authority 1),27 字段,field_webpage_authority=27 条(27×1),button.id=790(export)
  • test (test):webpage.id=389,webpage_authority=1 条(authority 1),27 字段,field_webpage_authority=27 条(27×1),button.id=791(export)

1.3 字段配置说明

字段 (en_name) order_by nav_select 搜索区 is_table_show 列表 field_type 控件 说明
agentName 1 ✗ ✓ string 列表「公司名称」
propertyName 2 ✗ ✓ string 列表「物业名称」
districtName 3 ✗ ✓ string 列表「项目名称」
materialNo 4 ✗ ✓ string 物品编号
materielFullName 5 ✓ ✓ string 搜索「物品名称」+ 列表(模糊)
materielSort 6 ✓ ✓ string 搜索「物品分类」+ 列表(模糊)
productSort 7 ✓ ✓ string 搜索「产品分类」+ 列表(模糊)
specifications 8 ✗ ✓ string 规格型号
snCode 9 ✓ ✓ string 搜索 SN + 列表(模糊)
stateName 10 ✗ ✓ string 产品状态文案(后端转换)
isBackName 11 ✗ ✓ string 是否备用机文案(后端转换)
currentInventory 12 ✗ ✓ string 当前数量
onTheWay 13 ✗ ✓ string 在途数量
orderNo 14 ✗ ✓ string 关联工单
floor 15 ✗ ✓ string 楼层
address 16 ✗ ✓ string 地址
memo 17 ✗ ✓ string 备注
createTime 18 ✗ ✓ date 创建时间
agentId 20 ✓ ✗ inputSelect 搜索「公司名称」下拉(传 ID,cn_name 同名"公司名称")
propertyId 21 ✓ ✗ inputSelect 搜索「物业名称」下拉(联动 agentId)
districtId 22 ✓ ✗ inputSelect 搜索「项目名称」下拉(联动 propertyId)
state 23 ✓ ✗ inputSelect 搜索「产品状态」下拉
id / createId / modifyId / modifyTime / softDelete 32-36 ✗ ✗ — DTO 公共字段

共 18 个列表列(PM 可砍)+ 4 个搜索条件(公司/物业/项目下拉联动 + 物品名称/分类/SN/状态文本 + 产品状态枚举),列稍多但物品级表本来字段多。


2. 后端改动 — xiaoqu-franchisee

2.1 包结构与文件

xiaoqu-publicbeanandmap/...        # 已存在,不动
├── beans/b2/CustomerProductList.java                      ✅
├── mapper/m2/CustomerProductListMapper.java               ✅
└── service/s2/
    ├── CustomerProductListService.java                    ✅
    └── Impl/CustomerProductListServiceImpl.java           ✅

xiaoqu-franchisee/src/main/java/me/iiv/iivframework/controller/customerProduct/
├── CustomerProductListController.java                     🆕 新增 @RequestMapping("/customerProductList")
├── input/CustomerProductListQueryInput.java               🆕 新增
└── output/CustomerProductListOutput.java                  🆕 新增(class_name 必须与 SQL 中 page_field.class_name 一致)

xiaoqu-franchisee/src/main/java/me/iiv/iivframework/controller/export/
└── CustomerProductListExport.java                         🆕 新增导出 DTO(@Excel 注解,参考 WarehouseOutExport)

2.2 API

Method URL 说明
POST /customerProductList/list 分页列表(含搜索条件)
POST /customerProductList/export 导出 Excel(不分页,按搜索条件全量导出)

Input

@Data
public class CustomerProductListQueryInput {
    private Long    agentId;
    private Long    propertyId;
    private Long    districtId;
    private String  materielFullName;   // 模糊
    private String  materielSort;       // 模糊
    private String  productSort;        // 模糊
    private String  snCode;             // 模糊
    private Integer state;              // 精确

    private Integer page = 1;
    private Integer limit = 10;
    private Integer rows;               // 兼容前端 rows 字段
    private String  sidx = "id";
    private String  sord = "desc";
}

Output

@Data
public class CustomerProductListOutput {
    private Long    id;
    private Long    createId;
    private String  createTime;
    private Long    modifyId;
    private String  modifyTime;
    private Integer softDelete;

    private Long   agentId;
    private Long   propertyId;
    private Long   districtId;
    private String agentName;
    private String propertyName;
    private String districtName;

    private Long   materielDefinitionId;
    private String materialNo;
    private String materielFullName;
    private Long   materielIndexId;
    private String materielSort;
    private String specifications;
    private Integer currentInventory;
    private Integer onTheWay;
    private String snCode;
    private String productSort;
    private Integer isBack;
    private String isBackName;          // 后端转:0=否 1=是
    private Integer state;
    private String stateName;           // 后端转:-1 在途 / 0 可用 / 1 待检 / 2 返修 / 3 返厂 / 4 在线 / 5 未使用 / 6 取回
    private String orderNo;
    private String memo;
    private String address;
    private Long   buildingId;
    private String floor;
    private Long   toiletId;
    private Long   positionId;
}

2.3 查询逻辑

@Controller
@RequestMapping(value = "/customerProductList")
@Api(value = "客户产品清单")
public class CustomerProductListController {

    @Autowired private CustomerProductListService customerProductListService;

    private static final Map<Integer, String> STATE_NAMES = new HashMap<Integer, String>() {{
        put(-1, "在途"); put(0, "可用"); put(1, "待检"); put(2, "返修");
        put(3, "返厂"); put(4, "在线"); put(5, "未使用"); put(6, "取回");
    }};

    @ApiOperation(value = "客户产品清单查询列表")
    @ResponseBody
    @RequestMapping(value = "/list", method = RequestMethod.POST)
    public BaseOutput<PageOutput<CustomerProductListOutput>> list(
            HttpServletRequest request,
            @RequestBody CustomerProductListQueryInput input) {

        int pageNum  = input.getPage() == null ? 1 : input.getPage();
        int pageSize = input.getRows()  != null ? input.getRows()  :
                       (input.getLimit() != null ? input.getLimit() : 10);

        Page<CustomerProductList> p = new Page<>(pageNum, pageSize);
        LambdaQueryWrapper<CustomerProductList> qw = Wrappers.<CustomerProductList>lambdaQuery()
            .eq(CustomerProductList::getSoftDelete, 0)
            .eq(input.getAgentId()    != null, CustomerProductList::getAgentId,    input.getAgentId())
            .eq(input.getPropertyId() != null, CustomerProductList::getPropertyId, input.getPropertyId())
            .eq(input.getDistrictId() != null, CustomerProductList::getDistrictId, input.getDistrictId())
            .eq(input.getState()      != null, CustomerProductList::getState,      input.getState())
            .like(StringUtils.isNotBlank(input.getMaterielFullName()), CustomerProductList::getMaterielFullName, input.getMaterielFullName())
            .like(StringUtils.isNotBlank(input.getMaterielSort()),     CustomerProductList::getMaterielSort,     input.getMaterielSort())
            .like(StringUtils.isNotBlank(input.getProductSort()),      CustomerProductList::getProductSort,      input.getProductSort())
            .like(StringUtils.isNotBlank(input.getSnCode()),           CustomerProductList::getSnCode,           input.getSnCode());

        // 简单排序:仅允许 id / create_time 两列防注入
        String sidx = "id".equalsIgnoreCase(input.getSidx()) ? "id" :
                      "create_time".equalsIgnoreCase(input.getSidx()) ? "create_time" : "id";
        boolean asc = "asc".equalsIgnoreCase(input.getSord());
        qw.orderBy(true, asc, sidx);

        Page<CustomerProductList> page = customerProductListService.page(p, qw);
        List<CustomerProductListOutput> rows = page.getRecords().stream().map(e -> {
            CustomerProductListOutput out = new CustomerProductListOutput();
            BeanUtils.copyProperties(e, out);
            out.setCreateTime(e.getCreateTime() == null ? null : DateUtil.format(e.getCreateTime(), "yyyy-MM-dd HH:mm:ss"));
            out.setModifyTime(e.getModifyTime() == null ? null : DateUtil.format(e.getModifyTime(), "yyyy-MM-dd HH:mm:ss"));
            out.setStateName(STATE_NAMES.getOrDefault(e.getState(), ""));
            out.setIsBackName(e.getIsBack() != null && e.getIsBack() == 1 ? "是" : "否");
            return out;
        }).collect(Collectors.toList());

        PageOutput<CustomerProductListOutput> resp = new PageOutput<>();
        resp.setRows(rows);
        resp.setRecords(page.getTotal());
        return ResultResponseBeanUtil.success(resp);
    }

    @ApiOperation(value = "客户产品清单导出")
    @PostMapping("/export")
    public void export(HttpServletRequest request, HttpServletResponse response,
                       @RequestBody CustomerProductListQueryInput input) throws IOException {
        LambdaQueryWrapper<CustomerProductList> qw = Wrappers.<CustomerProductList>lambdaQuery()
            .eq(CustomerProductList::getSoftDelete, 0)
            .eq(input.getAgentId()    != null, CustomerProductList::getAgentId,    input.getAgentId())
            .eq(input.getPropertyId() != null, CustomerProductList::getPropertyId, input.getPropertyId())
            .eq(input.getDistrictId() != null, CustomerProductList::getDistrictId, input.getDistrictId())
            .eq(input.getState()      != null, CustomerProductList::getState,      input.getState())
            .like(StringUtils.isNotBlank(input.getMaterielFullName()), CustomerProductList::getMaterielFullName, input.getMaterielFullName())
            .like(StringUtils.isNotBlank(input.getMaterielSort()),     CustomerProductList::getMaterielSort,     input.getMaterielSort())
            .like(StringUtils.isNotBlank(input.getProductSort()),      CustomerProductList::getProductSort,      input.getProductSort())
            .like(StringUtils.isNotBlank(input.getSnCode()),           CustomerProductList::getSnCode,           input.getSnCode())
            .orderByDesc(CustomerProductList::getId);

        List<CustomerProductList> list = customerProductListService.list(qw);
        List<CustomerProductListExport> exportList = list.stream().map(e -> {
            CustomerProductListExport row = new CustomerProductListExport();
            BeanUtils.copyProperties(e, row);
            row.setStateName(STATE_NAMES.getOrDefault(e.getState(), ""));
            row.setIsBackName(e.getIsBack() != null && e.getIsBack() == 1 ? "是" : "否");
            return row;
        }).collect(Collectors.toList());

        ExcelUtil<CustomerProductListExport> util = new ExcelUtil<>(CustomerProductListExport.class);
        util.exportExcel(response, exportList, "客户产品清单");
    }
}

2.3.1 导出 DTO CustomerProductListExport

参考 WarehouseOutExport,列顺序与列表展示一致(18 列):

@ApiModel(value = "客户产品清单导出对象")
@Data
public class CustomerProductListExport {
    @Excel(name = "公司名称")       private String  agentName;
    @Excel(name = "物业名称")       private String  propertyName;
    @Excel(name = "项目名称")       private String  districtName;
    @Excel(name = "物品编号")       private String  materialNo;
    @Excel(name = "物品名称")       private String  materielFullName;
    @Excel(name = "物品分类")       private String  materielSort;
    @Excel(name = "产品分类")       private String  productSort;
    @Excel(name = "规格型号")       private String  specifications;
    @Excel(name = "SN")             private String  snCode;
    @Excel(name = "产品状态")       private String  stateName;
    @Excel(name = "是否备用机")     private String  isBackName;
    @Excel(name = "当前数量")       private Integer currentInventory;
    @Excel(name = "在途数量")       private Integer onTheWay;
    @Excel(name = "关联工单")       private String  orderNo;
    @Excel(name = "楼层")           private String  floor;
    @Excel(name = "地址")           private String  address;
    @Excel(name = "备注")           private String  memo;
    @Excel(name = "创建时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
    private Date createTime;
}

3. 前端改动 — vue-manage-system

3.1 路由

src/router/module/propertyManageRouter.js(或者放到「客户管理」对应路由文件)追加:

{
    path: '/customerProductList',
    name: 'customerProductList',
    component: () => import('@/components/page/propertyManage/customerProductList.vue'),
    meta: { title: '客户产品清单' }
}

路由文件名以现仓库实际为准(看 客户管理 现有子页面 propertyManage / districtForAgentManage 是放哪个 router 文件里)。

3.2 API

src/api/api.js 增加块:

customerProductList: {
    list:   '/customerProductList/list',
    export: '/customerProductList/export'
}

src/api/propertyService.js(或新建 customerProductListService.js):

export const customerProductListService = {
    list(obj) {
        common.setWebId(obj);
        return request.post($api.customerProductList.list, obj);
    },
    export(obj) {
        common.setWebId(obj);
        return request.post($api.customerProductList.export, obj, { responseType: 'blob' }).then((res) => {
            request.download('客户产品清单', res);
        });
    }
};

3.3 页面

src/components/page/propertyManage/customerProductList.vue,骨架直接参考 deviceManage/deviceManageSearch.vue(设备列表页,搜索区下拉级联接口模式完全一致):

  • 搜索区下拉级联(沿用设备列表的 3 个下拉接口):
    dropDownService.selectAllAgent()                                // 公司
    dropDownService.selectPropertyByAgentId({ agentId })            // 物业(依赖公司)
    dropDownService.selectDistrictByPropertyId({ propertyId })      // 项目(依赖物业)
    
    watcher:agentId 变 → 清空 propertyId / districtId 并刷新 properties 列表;propertyId 变 → 清空 districtId 并刷新 districts 列表。
  • 产品状态下拉,前端硬编码 7 个状态(不含 6 取回,业务无此场景):
    stateList: [
      { key: -1, value: '在途' },
      { key:  0, value: '可用' },
      { key:  1, value: '待检' },
      { key:  2, value: '返修' },
      { key:  3, value: '返厂' },
      { key:  4, value: '在线' },
      { key:  5, value: '未使用' }
    ]
    
  • 列表用 standard-table + standTableMixin
  • 搜索区放「清空 / 搜索」 + 顶部 tableButtons 放「导出」(由按钮权限驱动,buttonsClickFun 里写 export() → 调 customerProductListService.export(this.screenForm))

模板骨架:

<template>
  <div class="cust-product-list">
    <div class="head" ref="topScreen">
      <el-form :model="screenForm" inline label-width="100px" ref="screenForm">
        <el-form-item label="公司名称">
          <el-select v-model="screenForm.agentId" filterable clearable placeholder="请选择">
            <el-option v-for="i in agents"     :key="i.key" :label="i.value" :value="i.key" />
          </el-select>
        </el-form-item>
        <el-form-item label="物业名称">
          <el-select v-model="screenForm.propertyId" filterable clearable placeholder="请选择">
            <el-option v-for="i in properties" :key="i.key" :label="i.value" :value="i.key" />
          </el-select>
        </el-form-item>
        <el-form-item label="项目名称">
          <el-select v-model="screenForm.districtId" filterable clearable placeholder="请选择">
            <el-option v-for="i in districts"  :key="i.key" :label="i.value" :value="i.key" />
          </el-select>
        </el-form-item>
        <el-form-item label="物品名称">
          <el-input v-model="screenForm.materielFullName" clearable />
        </el-form-item>
        <el-form-item label="物品分类">
          <el-input v-model="screenForm.materielSort" clearable />
        </el-form-item>
        <el-form-item label="产品分类">
          <el-input v-model="screenForm.productSort" clearable />
        </el-form-item>
        <el-form-item label="SN">
          <el-input v-model="screenForm.snCode" clearable />
        </el-form-item>
        <el-form-item label="产品状态">
          <el-select v-model="screenForm.state" clearable placeholder="请选择">
            <el-option v-for="i in stateList" :key="i.key" :label="i.value" :value="i.key" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="warning" @click="clearScreen">清空</el-button>
          <el-button type="primary" @click="restSearch">搜索</el-button>
        </el-form-item>
      </el-form>
    </div>
    <div class="main-content">
      <standard-table
        :tableData="tableData"
        :tableHeader="tableHeader"
        :tableHeight="tableHeight"
        :showPagination="true"
        :showScreenDetail="false"
        :pageData="pageData"
        showIndex
        @pageChange="pageChange"
        @sortChange="sortChange"
        ref="mainTable"
      />
    </div>
  </div>
</template>

完整方法骨架(参考 deviceManageSearch.vue 660-700 行):

import { dropDownService } from '@/api/dropDownService';

watch: {
  'screenForm.agentId'(val) {
    this.screenForm.propertyId = '';
    this.screenForm.districtId = '';
    this.propertys = [];
    this.districts = [];
    if (val) this.fetchPropertys();
  },
  'screenForm.propertyId'(val) {
    this.screenForm.districtId = '';
    this.districts = [];
    if (val) this.fetchDistricts();
  }
},

methods: {
  fetchAgents() {
    dropDownService.selectAllAgent().then(r => { if (r.code == 0) this.agents = r.data; });
  },
  fetchPropertys() {
    dropDownService.selectPropertyByAgentId({ agentId: this.screenForm.agentId })
      .then(r => { if (r.code == 0) this.propertys = r.data; });
  },
  fetchDistricts() {
    dropDownService.selectDistrictByPropertyId({ propertyId: this.screenForm.propertyId })
      .then(r => { if (r.code == 0) this.districts = r.data; });
  }
}

模板里 el-select 项的 :options 绑定到上述 agents / propertys / districts。


4. 上线执行顺序

  1. DEV 库:执行 §1.2(菜单 + 字段 + 权限,事务)— 业务库 §1.1 已经存在不动
  2. 后端:编码 → mvn -pl xiaoqu-franchisee package → 部署 Tomcat
  3. 前端:编码 → yarn build-dev → 部署
  4. 冒烟:
    • 管理员账号登录,左侧菜单出现「客户管理 > 客户产品清单」
    • 进入页面:列表接口返回 1275 条(dev 已有数据)
    • 后台「权限组管理 → 配置页面内容权限」能看到这个页面的所有字段
    • 各搜索条件挨个验:公司下拉、物业下拉、项目下拉、物品名称模糊、SN 模糊、产品状态枚举
    • 列表的 state / isBack 文案是否正确(看 stateName / isBackName)
  5. PRD 上线:在 xiaoqu_complex(注意是 complex 不带 s)重跑 §1.2 SQL; ⚠️ 上线前先查 PRD 库正确的「客户管理」parent_id 和 authority_id:
    SELECT id FROM webpage WHERE title='客户管理' AND parent_id=0;
    -- 用结果替换 §1.2 里的 60
    SELECT id, name FROM authority WHERE name='系统管理员';
    -- 用结果替换 §1.2 里的 1
    

5. 风险与注意

项 风险 处理
parent_id=60 硬编码 PRD 不一定一致 上线前 SELECT 取真实 id
authority_id=1 硬编码 PRD 权限组结构不同 上线前 SELECT 取真实 id(PRD 系统管理员对应 id)
page_field.class_name 全限定类名 后端 DTO 路径必须严格一致 DTO 必须在 me.iiv.iivframework.controller.customerProduct.output 包,不要随意换名
列数过多(18 列)横向滚动 用户体验差 等 PM 评审后砍冗余列;可优先砍 building/toilet/position id 等基础字段,留业务字段
搜索区下拉级联接口 接口已确认(设备列表页同款) selectAllAgent / selectPropertyByAgentId / selectDistrictByPropertyId 三个 dropDown 接口可用
导出大数据量 1275 → 几十万级别可能 OOM 当前按全量查询;后续如行数 >10w 改流式导出(ExcelUtil 默认是 list 全加载到内存)
state 文案散落两处(后端 + 前端搜索下拉) 不一致风险 前端搜索区下拉的 stateList 用注释标明对应后端 STATE_NAMES 同源,改其一记得改另一处
sidx 注入 LambdaQueryWrapper 防注入 已白名单只允许 id / create_time
customer_product_list 表数据量增长 1275→几十万级别可能慢查询 后续看监控加 (agent_id, soft_delete) / (sn_code) 等组合索引
备注/SN 文本可能含特殊字符 列表渲染 el-table 自动转义,不用处理

6. PM 确认结论(已敲定,2026-05-14)

# 决议
1 列表列:18 列全保留。
2 搜索区下拉:复用「设备列表页 deviceManageSearch.vue」现成的 3 个接口:dropDownService.selectAllAgent / selectPropertyByAgentId / selectDistrictByPropertyId,含级联 watch。
3 产品状态枚举:列 7 个(-1 在途 / 0 可用 / 1 待检 / 2 返修 / 3 返厂 / 4 在线 / 5 未使用),不含 6 取回。
4 导出 Excel:要。顶部 tableButton「导出」,按当前搜索条件全量导出(参考 WarehouseOutController.allExport 模式 + @Excel 注解 DTO)。
5 行内"查看详情"按钮:不做。纯列表查询。
6 权限:仅 authority_id=1(系统管理员),同 web_id=228 站点信息统计查询。