# 门店档案模块设计文档

> 本文档为门店档案功能的完整需求设计，涵盖数据库设计、实体类、枚举、API 接口、OSS 文件上传方案。
> 门店档案用于管理门店的基础信息、固定资产、合同信息、结算开票信息及合同附件。

---

## 一、功能概述

### 1.1 业务背景

现有 `t_shop` 表仅包含门店基础字段（名称、地址、联系人等），无法满足业务上对门店合同、租金、结算、附件等档案信息的管理需求。本模块在现有门店体系基础上扩展，新增门店档案主表、固定资产子表、附件子表，实现门店全生命周期档案管理。

### 1.2 与现有模块的关系

| 现有模块 | 关系说明 |
|---------|---------|
| `t_shop`（门店表） | 门店档案通过 `shop_id` 关联，一个门店对应一条档案记录 |
| `t_device`（设备表） | 固定资产中的"自助售卖机"与设备表独立管理，档案中的设备信息侧重商务属性（机器编号、状态），不替代设备表的技术属性 |
| OssUploadController | 新增 OSS 上传凭证接口，前端获取 STS Token 后直传 OSS，后端仅保存 URL |

### 1.3 模块划分

```
门店档案
├── 一、门店基础信息（档案主表字段）
├── 二、固定资产设备信息（固定资产子表，1:N）
├── 三、合同核心信息（档案主表字段）
├── 四、甲方收款结算 & 开票信息（档案主表字段）
├── 五、合同附件归档（附件子表，1:N）
└── 六、系统溯源字段（自动回填）
```

---

## 二、数据库设计

### 2.1 门店档案主表 `t_shop_archive`

```sql
CREATE TABLE IF NOT EXISTS `t_shop_archive` (
    `id` BIGINT NOT NULL COMMENT '主键ID（雪花算法）',

    -- ========== 关联 ==========
    `shop_id` BIGINT NOT NULL COMMENT '关联门店ID（t_shop.id）',

    -- ========== 一、门店基础信息 ==========
    `shop_name` VARCHAR(100) NOT NULL COMMENT '门店名称（冗余，便于查询）',
    `shop_address` VARCHAR(255) DEFAULT NULL COMMENT '门店地址（完整详细地址）',
    `venue_type` VARCHAR(20) DEFAULT NULL COMMENT '合作场地类型：CHARGING_STATION-充电站, OFFICE_BUILDING-写字楼',
    `cooperation_mode` VARCHAR(100) DEFAULT NULL COMMENT '合作模式（多选逗号分隔）：DRINKS-水饮副食, BOX_MEAL-盒饭',

    `party_b_salesman` VARCHAR(50) DEFAULT NULL COMMENT '乙方业务员姓名',
    `party_b_salesman_phone` VARCHAR(20) DEFAULT NULL COMMENT '乙方业务员手机号',

    `party_a_company` VARCHAR(200) DEFAULT NULL COMMENT '甲方公司名称（签约主体全称）',
    `party_a_manager` VARCHAR(50) DEFAULT NULL COMMENT '甲方场地管理人员姓名',
    `party_a_manager_phone` VARCHAR(20) DEFAULT NULL COMMENT '甲方场地管理人员电话',

    `business_status` VARCHAR(20) DEFAULT 'NORMAL' COMMENT '门店经营状态：NORMAL-正常营业, SUSPENDED-暂停营业, WITHDRAWN-已撤场',
    `remark` VARCHAR(500) DEFAULT NULL COMMENT '门店备注（特殊要求等）',

    -- ========== 三、合同核心信息 ==========
    `contract_no` VARCHAR(100) DEFAULT NULL COMMENT '合同编号（企业归档唯一编码）',
    `contract_sign_date` DATE DEFAULT NULL COMMENT '合同签订日期',
    `contract_start_date` DATE DEFAULT NULL COMMENT '合同起始日',
    `contract_end_date` DATE DEFAULT NULL COMMENT '合同到期日',
    `contract_status` VARCHAR(20) DEFAULT 'PENDING' COMMENT '合同状态：PENDING-待签约, ACTIVE-已生效, EXPIRING-到期待续约, TERMINATED-已解约终止',

    `rent_start_date` DATE DEFAULT NULL COMMENT '租金起始日',
    `monthly_rent` DECIMAL(10,2) DEFAULT NULL COMMENT '每月固定租金金额（元）',
    `rent_free_days` INT DEFAULT 0 COMMENT '免租期天数',
    `rent_pay_deadline_day` INT DEFAULT NULL COMMENT '租金缴纳截止日（每月几号）',

    `deposit_amount` DECIMAL(10,2) DEFAULT NULL COMMENT '保证金（合同约定总额，元）',
    `deposit_status` VARCHAR(20) DEFAULT 'UNPAID' COMMENT '保证金缴纳状态：UNPAID-未缴纳, PARTIAL-部分缴纳, FULL-全额缴纳',
    `deposit_paid_amount` DECIMAL(10,2) DEFAULT NULL COMMENT '保证金实缴金额（元）',
    `deposit_refund_condition` VARCHAR(500) DEFAULT NULL COMMENT '保证金退还条件',

    `billing_mode` VARCHAR(20) DEFAULT 'RENT_ONLY' COMMENT '合作计费模式：RENT_ONLY-纯租金, RENT_ELECTRIC-租金+电费, COMMISSION_ONLY-纯提成, RENT_COMMISSION-租金+提成, RENT_COMMISSION_ELECTRIC-租金+提成+电费',
    `electricity_price` DECIMAL(8,4) DEFAULT NULL COMMENT '电费结算单价（元/度）',
    `electricity_share_rule` VARCHAR(500) DEFAULT NULL COMMENT '电费公摊规则说明',

    `commission_base_type` VARCHAR(20) DEFAULT NULL COMMENT '提成基数计算口径：SALES-门店实收销售额, GROSS_PROFIT-门店毛利总额, NET_REVENUE-扣除成本后营收',
    `commission_base` DECIMAL(12,2) DEFAULT NULL COMMENT '提成基数（元）',
    `commission_rate` DECIMAL(5,2) DEFAULT NULL COMMENT '提成比例（0~100，单位%）',
    `commission_min_guarantee` DECIMAL(10,2) DEFAULT NULL COMMENT '最低保底提成金额（元）',
    `commission_period_months` INT DEFAULT 0 COMMENT '提成账期（延后N个月结算）',

    `payment_cycle` VARCHAR(20) DEFAULT 'MONTHLY' COMMENT '计费方式：MONTHLY-月付, QUARTERLY-季度付, SEMI_ANNUAL-半年付, ANNUAL-年付',

    `termination_date` DATE DEFAULT NULL COMMENT '解约日期',
    `termination_remark` VARCHAR(500) DEFAULT NULL COMMENT '解约备注',

    -- ========== 四、甲方收款结算 & 开票信息 ==========
    `payment_method` VARCHAR(20) DEFAULT NULL COMMENT '打款方式：CORPORATE-对公, PRIVATE-对私',
    `payee_account_name` VARCHAR(100) DEFAULT NULL COMMENT '甲方收款账户名称',
    `payee_bank_name` VARCHAR(100) DEFAULT NULL COMMENT '收款开户行',
    `payee_bank_account` VARCHAR(50) DEFAULT NULL COMMENT '银行账号',
    `taxpayer_id` VARCHAR(50) DEFAULT NULL COMMENT '纳税人识别号',
    `invoice_type` VARCHAR(20) DEFAULT NULL COMMENT '发票类型：SPECIAL-增值税专票, NORMAL-增值税普票',
    `invoice_tax_rate` DECIMAL(5,2) DEFAULT NULL COMMENT '开票税率（单位%）',

    -- ========== 六、系统溯源字段 ==========
    `creator_id` BIGINT DEFAULT NULL COMMENT '数据创建人ID',
    `creator_name` VARCHAR(50) DEFAULT NULL COMMENT '数据创建人姓名',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `editor_id` BIGINT DEFAULT NULL COMMENT '最后编辑人ID',
    `editor_name` VARCHAR(50) DEFAULT NULL COMMENT '最后编辑人姓名',
    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后编辑时间',

    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_shop_id` (`shop_id`),
    KEY `idx_contract_no` (`contract_no`),
    KEY `idx_contract_status` (`contract_status`),
    KEY `idx_business_status` (`business_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='门店档案主表';
```

### 2.2 固定资产设备表 `t_shop_fixed_asset`

```sql
CREATE TABLE IF NOT EXISTS `t_shop_fixed_asset` (
    `id` BIGINT NOT NULL COMMENT '主键ID（雪花算法）',

    `shop_archive_id` BIGINT NOT NULL COMMENT '关联门店档案ID（t_shop_archive.id）',
    `shop_id` BIGINT NOT NULL COMMENT '关联门店ID（冗余，便于查询）',

    `asset_type` VARCHAR(20) NOT NULL COMMENT '资产类型：VENDING_MACHINE-自助售卖机, MICROWAVE-微波炉',
    `asset_name` VARCHAR(100) DEFAULT NULL COMMENT '资产名称（如售卖机名称）',
    `asset_code` VARCHAR(100) DEFAULT NULL COMMENT '资产编号（机器编号/微波炉编号）',
    `asset_status` VARCHAR(20) DEFAULT 'NORMAL' COMMENT '设备当前状态：NORMAL-正常运行, REPAIR-故障维修, IDLE-闲置撤机',

    `remark` VARCHAR(200) DEFAULT NULL COMMENT '备注',

    `creator_id` BIGINT DEFAULT NULL COMMENT '创建人ID',
    `creator_name` VARCHAR(50) DEFAULT NULL COMMENT '创建人姓名',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `editor_id` BIGINT DEFAULT NULL COMMENT '最后编辑人ID',
    `editor_name` VARCHAR(50) DEFAULT NULL COMMENT '最后编辑人姓名',
    `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后编辑时间',

    PRIMARY KEY (`id`),
    KEY `idx_shop_archive_id` (`shop_archive_id`),
    KEY `idx_shop_id` (`shop_id`),
    KEY `idx_asset_type` (`asset_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='门店固定资产设备表';
```

### 2.3 合同附件表 `t_shop_contract_attachment`

```sql
CREATE TABLE IF NOT EXISTS `t_shop_contract_attachment` (
    `id` BIGINT NOT NULL COMMENT '主键ID（雪花算法）',

    `shop_archive_id` BIGINT NOT NULL COMMENT '关联门店档案ID（t_shop_archive.id）',
    `shop_id` BIGINT NOT NULL COMMENT '关联门店ID（冗余，便于查询）',

    `attachment_type` VARCHAR(30) NOT NULL COMMENT '附件类型：CONTRACT-合同附件, LEASE_AGREEMENT-场地租赁协议, ELECTRICITY_AGREEMENT-电费补充协议, DEPOSIT_RECEIPT-保证金收据, TERMINATION_AGREEMENT-解约/补充变更协议',
    `file_name` VARCHAR(200) NOT NULL COMMENT '原始文件名',
    `file_url` VARCHAR(500) NOT NULL COMMENT 'OSS文件URL',
    `file_size` BIGINT DEFAULT NULL COMMENT '文件大小（字节）',
    `file_extension` VARCHAR(20) DEFAULT NULL COMMENT '文件扩展名（pdf, jpg, png, docx等）',

    `sort_order` INT DEFAULT 0 COMMENT '排序序号',

    `creator_id` BIGINT DEFAULT NULL COMMENT '上传人ID',
    `creator_name` VARCHAR(50) DEFAULT NULL COMMENT '上传人姓名',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',

    PRIMARY KEY (`id`),
    KEY `idx_shop_archive_id` (`shop_archive_id`),
    KEY `idx_shop_id` (`shop_id`),
    KEY `idx_attachment_type` (`attachment_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='门店合同附件表';
```

---

## 三、枚举类设计

### 3.1 合作场地类型 `VenueType`

**位置**: `haha-common/src/main/java/com/haha/common/enums/VenueType.java`

```java
package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum VenueType {

    CHARGING_STATION("CHARGING_STATION", "充电站"),
    OFFICE_BUILDING("OFFICE_BUILDING", "写字楼");

    private final String code;
    private final String description;

    VenueType(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static VenueType fromCode(String code) {
        if (code == null) return null;
        for (VenueType t : values()) {
            if (t.code.equals(code)) return t;
        }
        return null;
    }

    public static String getDescription(String code) {
        VenueType t = fromCode(code);
        return t != null ? t.description : "未知";
    }
}
```

### 3.2 门店经营状态 `BusinessStatus`

**位置**: `haha-common/src/main/java/com/haha/common/enums/BusinessStatus.java`

```java
package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum BusinessStatus {

    NORMAL("NORMAL", "正常营业"),
    SUSPENDED("SUSPENDED", "暂停营业"),
    WITHDRAWN("WITHDRAWN", "已撤场");

    private final String code;
    private final String description;

    BusinessStatus(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static BusinessStatus fromCode(String code) {
        if (code == null) return null;
        for (BusinessStatus s : values()) {
            if (s.code.equals(code)) return s;
        }
        return null;
    }

    public static String getDescription(String code) {
        BusinessStatus s = fromCode(code);
        return s != null ? s.description : "未知";
    }
}
```

### 3.3 合同状态 `ContractStatus`

**位置**: `haha-common/src/main/java/com/haha/common/enums/ContractStatus.java`

```java
package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum ContractStatus {

    PENDING("PENDING", "待签约"),
    ACTIVE("ACTIVE", "已生效"),
    EXPIRING("EXPIRING", "到期待续约"),
    TERMINATED("TERMINATED", "已解约终止");

    private final String code;
    private final String description;

    ContractStatus(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static ContractStatus fromCode(String code) {
        if (code == null) return null;
        for (ContractStatus s : values()) {
            if (s.code.equals(code)) return s;
        }
        return null;
    }

    public static String getDescription(String code) {
        ContractStatus s = fromCode(code);
        return s != null ? s.description : "未知";
    }
}
```

### 3.4 保证金缴纳状态 `DepositStatus`

**位置**: `haha-common/src/main/java/com/haha/common/enums/DepositStatus.java`

```java
package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum DepositStatus {

    UNPAID("UNPAID", "未缴纳"),
    PARTIAL("PARTIAL", "部分缴纳"),
    FULL("FULL", "全额缴纳");

    private final String code;
    private final String description;

    DepositStatus(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static DepositStatus fromCode(String code) {
        if (code == null) return null;
        for (DepositStatus s : values()) {
            if (s.code.equals(code)) return s;
        }
        return null;
    }

    public static String getDescription(String code) {
        DepositStatus s = fromCode(code);
        return s != null ? s.description : "未知";
    }
}
```

### 3.5 计费模式 `BillingMode`

**位置**: `haha-common/src/main/java/com/haha/common/enums/BillingMode.java`

```java
package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum BillingMode {

    RENT_ONLY("RENT_ONLY", "纯租金"),
    RENT_ELECTRIC("RENT_ELECTRIC", "租金+电费"),
    COMMISSION_ONLY("COMMISSION_ONLY", "纯提成"),
    RENT_COMMISSION("RENT_COMMISSION", "租金+提成"),
    RENT_COMMISSION_ELECTRIC("RENT_COMMISSION_ELECTRIC", "租金+提成+电费");

    private final String code;
    private final String description;

    BillingMode(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static BillingMode fromCode(String code) {
        if (code == null) return null;
        for (BillingMode m : values()) {
            if (m.code.equals(code)) return m;
        }
        return null;
    }

    public static String getDescription(String code) {
        BillingMode m = fromCode(code);
        return m != null ? m.description : "未知";
    }
}
```

### 3.6 计费方式 `PaymentCycle`

**位置**: `haha-common/src/main/java/com/haha/common/enums/PaymentCycle.java`

```java
package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum PaymentCycle {

    MONTHLY("MONTHLY", "月付"),
    QUARTERLY("QUARTERLY", "季度付"),
    SEMI_ANNUAL("SEMI_ANNUAL", "半年付"),
    ANNUAL("ANNUAL", "年付");

    private final String code;
    private final String description;

    PaymentCycle(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static PaymentCycle fromCode(String code) {
        if (code == null) return null;
        for (PaymentCycle c : values()) {
            if (c.code.equals(code)) return c;
        }
        return null;
    }

    public static String getDescription(String code) {
        PaymentCycle c = fromCode(code);
        return c != null ? c.description : "未知";
    }
}
```

### 3.7 固定资产类型 `AssetType`

**位置**: `haha-common/src/main/java/com/haha/common/enums/AssetType.java`

```java
package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum AssetType {

    VENDING_MACHINE("VENDING_MACHINE", "自助售卖机"),
    MICROWAVE("MICROWAVE", "微波炉");

    private final String code;
    private final String description;

    AssetType(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static AssetType fromCode(String code) {
        if (code == null) return null;
        for (AssetType t : values()) {
            if (t.code.equals(code)) return t;
        }
        return null;
    }

    public static String getDescription(String code) {
        AssetType t = fromCode(code);
        return t != null ? t.description : "未知";
    }
}
```

### 3.8 资产设备状态 `AssetStatus`

**位置**: `haha-common/src/main/java/com/haha/common/enums/AssetStatus.java`

```java
package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum AssetStatus {

    NORMAL("NORMAL", "正常运行"),
    REPAIR("REPAIR", "故障维修"),
    IDLE("IDLE", "闲置撤机");

    private final String code;
    private final String description;

    AssetStatus(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static AssetStatus fromCode(String code) {
        if (code == null) return null;
        for (AssetStatus s : values()) {
            if (s.code.equals(code)) return s;
        }
        return null;
    }

    public static String getDescription(String code) {
        AssetStatus s = fromCode(code);
        return s != null ? s.description : "未知";
    }
}
```

### 3.9 附件类型 `AttachmentType`

**位置**: `haha-common/src/main/java/com/haha/common/enums/AttachmentType.java`

```java
package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum AttachmentType {

    CONTRACT("CONTRACT", "合同附件"),
    LEASE_AGREEMENT("LEASE_AGREEMENT", "场地租赁协议"),
    ELECTRICITY_AGREEMENT("ELECTRICITY_AGREEMENT", "电费补充协议"),
    DEPOSIT_RECEIPT("DEPOSIT_RECEIPT", "保证金收据"),
    TERMINATION_AGREEMENT("TERMINATION_AGREEMENT", "解约/补充变更协议");

    private final String code;
    private final String description;

    AttachmentType(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static AttachmentType fromCode(String code) {
        if (code == null) return null;
        for (AttachmentType t : values()) {
            if (t.code.equals(code)) return t;
        }
        return null;
    }

    public static String getDescription(String code) {
        AttachmentType t = fromCode(code);
        return t != null ? t.description : "未知";
    }
}
```

### 3.10 打款方式 `PaymentMethod`

**位置**: `haha-common/src/main/java/com/haha/common/enums/PaymentMethod.java`

```java
package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum PaymentMethod {

    CORPORATE("CORPORATE", "对公"),
    PRIVATE("PRIVATE", "对私");

    private final String code;
    private final String description;

    PaymentMethod(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static PaymentMethod fromCode(String code) {
        if (code == null) return null;
        for (PaymentMethod m : values()) {
            if (m.code.equals(code)) return m;
        }
        return null;
    }

    public static String getDescription(String code) {
        PaymentMethod m = fromCode(code);
        return m != null ? m.description : "未知";
    }
}
```

### 3.11 发票类型 `InvoiceType`

**位置**: `haha-common/src/main/java/com/haha/common/enums/InvoiceType.java`

```java
package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum InvoiceType {

    SPECIAL("SPECIAL", "增值税专票"),
    NORMAL("NORMAL", "增值税普票");

    private final String code;
    private final String description;

    InvoiceType(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static InvoiceType fromCode(String code) {
        if (code == null) return null;
        for (InvoiceType t : values()) {
            if (t.code.equals(code)) return t;
        }
        return null;
    }

    public static String getDescription(String code) {
        InvoiceType t = fromCode(code);
        return t != null ? t.description : "未知";
    }
}
```

### 3.12 提成基数计算口径 `CommissionBaseType`

**位置**: `haha-common/src/main/java/com/haha/common/enums/CommissionBaseType.java`

```java
package com.haha.common.enums;

import lombok.Getter;

@Getter
public enum CommissionBaseType {

    SALES("SALES", "门店实收销售额"),
    GROSS_PROFIT("GROSS_PROFIT", "门店毛利总额"),
    NET_REVENUE("NET_REVENUE", "扣除成本后营收");

    private final String code;
    private final String description;

    CommissionBaseType(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public static CommissionBaseType fromCode(String code) {
        if (code == null) return null;
        for (CommissionBaseType t : values()) {
            if (t.code.equals(code)) return t;
        }
        return null;
    }

    public static String getDescription(String code) {
        CommissionBaseType t = fromCode(code);
        return t != null ? t.description : "未知";
    }
}
```

---

## 四、实体类设计

### 4.1 ShopArchive（门店档案主表实体）

**位置**: `haha-entity/src/main/java/com/haha/entity/ShopArchive.java`

```java
package com.haha.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;

@Data
@TableName("t_shop_archive")
public class ShopArchive implements Serializable {
    private static final long serialVersionUID = 1L;

    @TableId(type = IdType.ASSIGN_ID)
    @JsonSerialize(using = ToStringSerializer.class)
    private Long id;

    // ========== 关联 ==========
    @JsonSerialize(using = ToStringSerializer.class)
    private Long shopId;

    // ========== 一、门店基础信息 ==========
    private String shopName;
    private String shopAddress;
    private String venueType;
    private String cooperationMode;
    private String partyBSalesman;
    private String partyBSalesmanPhone;
    private String partyACompany;
    private String partyAManager;
    private String partyAManagerPhone;
    private String businessStatus;
    private String remark;

    // ========== 三、合同核心信息 ==========
    private String contractNo;

    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "Asia/Shanghai")
    private LocalDate contractSignDate;

    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "Asia/Shanghai")
    private LocalDate contractStartDate;

    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "Asia/Shanghai")
    private LocalDate contractEndDate;

    private String contractStatus;

    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "Asia/Shanghai")
    private LocalDate rentStartDate;

    private BigDecimal monthlyRent;
    private Integer rentFreeDays;
    private Integer rentPayDeadlineDay;

    private BigDecimal depositAmount;
    private String depositStatus;
    private BigDecimal depositPaidAmount;
    private String depositRefundCondition;

    private String billingMode;
    private BigDecimal electricityPrice;
    private String electricityShareRule;

    private String commissionBaseType;
    private BigDecimal commissionBase;
    private BigDecimal commissionRate;
    private BigDecimal commissionMinGuarantee;
    private Integer commissionPeriodMonths;

    private String paymentCycle;

    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "Asia/Shanghai")
    private LocalDate terminationDate;

    private String terminationRemark;

    // ========== 四、甲方收款结算 & 开票信息 ==========
    private String paymentMethod;
    private String payeeAccountName;
    private String payeeBankName;
    private String payeeBankAccount;
    private String taxpayerId;
    private String invoiceType;
    private BigDecimal invoiceTaxRate;

    // ========== 六、系统溯源字段 ==========
    @JsonSerialize(using = ToStringSerializer.class)
    private Long creatorId;
    private String creatorName;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
    private LocalDateTime createTime;

    @JsonSerialize(using = ToStringSerializer.class)
    private Long editorId;
    private String editorName;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
    private LocalDateTime updateTime;

    // ========== 非数据库字段 ==========

    /** 关联的固定资产列表 */
    @TableField(exist = false)
    private List<ShopFixedAsset> fixedAssets;

    /** 关联的附件列表 */
    @TableField(exist = false)
    private List<ShopContractAttachment> attachments;

    /** 关联门店编码 */
    @TableField(exist = false)
    private String shopCode;

    /** 合同状态标签 */
    @TableField(exist = false)
    private String contractStatusLabel;

    /** 经营状态标签 */
    @TableField(exist = false)
    private String businessStatusLabel;
}
```

### 4.2 ShopFixedAsset（固定资产设备实体）

**位置**: `haha-entity/src/main/java/com/haha/entity/ShopFixedAsset.java`

```java
package com.haha.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;

@Data
@TableName("t_shop_fixed_asset")
public class ShopFixedAsset implements Serializable {
    private static final long serialVersionUID = 1L;

    @TableId(type = IdType.ASSIGN_ID)
    @JsonSerialize(using = ToStringSerializer.class)
    private Long id;

    @JsonSerialize(using = ToStringSerializer.class)
    private Long shopArchiveId;

    @JsonSerialize(using = ToStringSerializer.class)
    private Long shopId;

    private String assetType;
    private String assetName;
    private String assetCode;
    private String assetStatus;
    private String remark;

    @JsonSerialize(using = ToStringSerializer.class)
    private Long creatorId;
    private String creatorName;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
    private LocalDateTime createTime;

    @JsonSerialize(using = ToStringSerializer.class)
    private Long editorId;
    private String editorName;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
    private LocalDateTime updateTime;

    // ========== 非数据库字段 ==========

    /** 资产类型标签 */
    @TableField(exist = false)
    private String assetTypeLabel;

    /** 资产状态标签 */
    @TableField(exist = false)
    private String assetStatusLabel;
}
```

### 4.3 ShopContractAttachment（合同附件实体）

**位置**: `haha-entity/src/main/java/com/haha/entity/ShopContractAttachment.java`

```java
package com.haha.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;

@Data
@TableName("t_shop_contract_attachment")
public class ShopContractAttachment implements Serializable {
    private static final long serialVersionUID = 1L;

    @TableId(type = IdType.ASSIGN_ID)
    @JsonSerialize(using = ToStringSerializer.class)
    private Long id;

    @JsonSerialize(using = ToStringSerializer.class)
    private Long shopArchiveId;

    @JsonSerialize(using = ToStringSerializer.class)
    private Long shopId;

    private String attachmentType;
    private String fileName;
    private String fileUrl;
    private Long fileSize;
    private String fileExtension;
    private Integer sortOrder;

    @JsonSerialize(using = ToStringSerializer.class)
    private Long creatorId;
    private String creatorName;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
    private LocalDateTime createTime;

    // ========== 非数据库字段 ==========

    /** 附件类型标签 */
    @TableField(exist = false)
    private String attachmentTypeLabel;
}
```

---

## 五、DTO 类设计

### 5.1 ShopArchiveCreateDTO

**位置**: `haha-entity/src/main/java/com/haha/entity/dto/ShopArchiveCreateDTO.java`

```java
package com.haha.entity.dto;

import lombok.Data;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

@Data
public class ShopArchiveCreateDTO {

    @NotNull(message = "门店ID不能为空")
    private Long shopId;

    // 基础信息
    @NotBlank(message = "门店名称不能为空")
    private String shopName;
    private String shopAddress;
    private String venueType;
    private String cooperationMode;
    private String partyBSalesman;
    private String partyBSalesmanPhone;
    private String partyACompany;
    private String partyAManager;
    private String partyAManagerPhone;
    private String businessStatus;
    private String remark;

    // 合同信息
    private String contractNo;
    private LocalDate contractSignDate;
    private LocalDate contractStartDate;
    private LocalDate contractEndDate;
    private String contractStatus;
    private LocalDate rentStartDate;
    private BigDecimal monthlyRent;
    private Integer rentFreeDays;
    private Integer rentPayDeadlineDay;
    private BigDecimal depositAmount;
    private String depositStatus;
    private BigDecimal depositPaidAmount;
    private String depositRefundCondition;
    private String billingMode;
    private BigDecimal electricityPrice;
    private String electricityShareRule;
    private String commissionBaseType;
    private BigDecimal commissionBase;
    private BigDecimal commissionRate;
    private BigDecimal commissionMinGuarantee;
    private Integer commissionPeriodMonths;
    private String paymentCycle;
    private LocalDate terminationDate;
    private String terminationRemark;

    // 结算开票信息
    private String paymentMethod;
    private String payeeAccountName;
    private String payeeBankName;
    private String payeeBankAccount;
    private String taxpayerId;
    private String invoiceType;
    private BigDecimal invoiceTaxRate;

    // 固定资产列表
    @Valid
    private List<FixedAssetDTO> fixedAssets;

    // 附件列表
    @Valid
    private List<AttachmentDTO> attachments;
}
```

### 5.2 ShopArchiveUpdateDTO

**位置**: `haha-entity/src/main/java/com/haha/entity/dto/ShopArchiveUpdateDTO.java`

与 `ShopArchiveCreateDTO` 字段相同，额外增加：

```java
@NotNull(message = "档案ID不能为空")
private Long id;
```

### 5.3 ShopArchiveQueryDTO

**位置**: `haha-entity/src/main/java/com/haha/entity/dto/ShopArchiveQueryDTO.java`

```java
package com.haha.entity.dto;

import com.haha.common.dto.BasePageQueryDTO;
import lombok.Data;
import lombok.EqualsAndHashCode;

@Data
@EqualsAndHashCode(callSuper = true)
public class ShopArchiveQueryDTO extends BasePageQueryDTO {

    private Long shopId;
    private String shopName;
    private String contractNo;
    private String contractStatus;
    private String businessStatus;
    private String billingMode;
    private String venueType;
}
```

### 5.4 FixedAssetDTO（固定资产子项DTO）

**位置**: `haha-entity/src/main/java/com/haha/entity/dto/FixedAssetDTO.java`

```java
package com.haha.entity.dto;

import lombok.Data;
import jakarta.validation.constraints.NotBlank;

@Data
public class FixedAssetDTO {

    private Long id;  // 更新时传，新增时不传

    @NotBlank(message = "资产类型不能为空")
    private String assetType;

    private String assetName;
    private String assetCode;
    private String assetStatus;
    private String remark;
}
```

### 5.5 AttachmentDTO（附件子项DTO）

**位置**: `haha-entity/src/main/java/com/haha/entity/dto/AttachmentDTO.java`

```java
package com.haha.entity.dto;

import lombok.Data;
import jakarta.validation.constraints.NotBlank;

@Data
public class AttachmentDTO {

    private Long id;  // 更新时传，新增时不传

    @NotBlank(message = "附件类型不能为空")
    private String attachmentType;

    @NotBlank(message = "文件名不能为空")
    private String fileName;

    @NotBlank(message = "文件URL不能为空")
    private String fileUrl;

    private Long fileSize;
    private String fileExtension;
    private Integer sortOrder;
}
```

---

## 六、Mapper 接口设计

### 6.1 ShopArchiveMapper

**位置**: `haha-mapper/src/main/java/com/haha/mapper/ShopArchiveMapper.java`

```java
package com.haha.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.haha.entity.ShopArchive;
import org.apache.ibatis.annotations.Param;

public interface ShopArchiveMapper extends BaseMapper<ShopArchive> {

    /**
     * 根据门店ID查询档案（含关联数据）
     */
    ShopArchive selectByShopId(@Param("shopId") Long shopId);
}
```

### 6.2 ShopFixedAssetMapper

**位置**: `haha-mapper/src/main/java/com/haha/mapper/ShopFixedAssetMapper.java`

```java
package com.haha.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.haha.entity.ShopFixedAsset;

public interface ShopFixedAssetMapper extends BaseMapper<ShopFixedAsset> {
}
```

### 6.3 ShopContractAttachmentMapper

**位置**: `haha-mapper/src/main/java/com/haha/mapper/ShopContractAttachmentMapper.java`

```java
package com.haha.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.haha.entity.ShopContractAttachment;

public interface ShopContractAttachmentMapper extends BaseMapper<ShopContractAttachment> {
}
```

### 6.4 ShopArchiveMapper.xml

**位置**: `haha-mapper/src/main/resources/mapper/ShopArchiveMapper.xml`

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.haha.mapper.ShopArchiveMapper">

    <resultMap id="ShopArchiveResultMap" type="com.haha.entity.ShopArchive">
        <id column="id" property="id"/>
        <result column="shop_id" property="shopId"/>
        <result column="shop_name" property="shopName"/>
        <result column="shop_address" property="shopAddress"/>
        <result column="venue_type" property="venueType"/>
        <result column="cooperation_mode" property="cooperationMode"/>
        <result column="party_b_salesman" property="partyBSalesman"/>
        <result column="party_b_salesman_phone" property="partyBSalesmanPhone"/>
        <result column="party_a_company" property="partyACompany"/>
        <result column="party_a_manager" property="partyAManager"/>
        <result column="party_a_manager_phone" property="partyAManagerPhone"/>
        <result column="business_status" property="businessStatus"/>
        <result column="remark" property="remark"/>
        <result column="contract_no" property="contractNo"/>
        <result column="contract_sign_date" property="contractSignDate"/>
        <result column="contract_start_date" property="contractStartDate"/>
        <result column="contract_end_date" property="contractEndDate"/>
        <result column="contract_status" property="contractStatus"/>
        <result column="rent_start_date" property="rentStartDate"/>
        <result column="monthly_rent" property="monthlyRent"/>
        <result column="rent_free_days" property="rentFreeDays"/>
        <result column="rent_pay_deadline_day" property="rentPayDeadlineDay"/>
        <result column="deposit_amount" property="depositAmount"/>
        <result column="deposit_status" property="depositStatus"/>
        <result column="deposit_paid_amount" property="depositPaidAmount"/>
        <result column="deposit_refund_condition" property="depositRefundCondition"/>
        <result column="billing_mode" property="billingMode"/>
        <result column="electricity_price" property="electricityPrice"/>
        <result column="electricity_share_rule" property="electricityShareRule"/>
        <result column="commission_base_type" property="commissionBaseType"/>
        <result column="commission_base" property="commissionBase"/>
        <result column="commission_rate" property="commissionRate"/>
        <result column="commission_min_guarantee" property="commissionMinGuarantee"/>
        <result column="commission_period_months" property="commissionPeriodMonths"/>
        <result column="payment_cycle" property="paymentCycle"/>
        <result column="termination_date" property="terminationDate"/>
        <result column="termination_remark" property="terminationRemark"/>
        <result column="payment_method" property="paymentMethod"/>
        <result column="payee_account_name" property="payeeAccountName"/>
        <result column="payee_bank_name" property="payeeBankName"/>
        <result column="payee_bank_account" property="payeeBankAccount"/>
        <result column="taxpayer_id" property="taxpayerId"/>
        <result column="invoice_type" property="invoiceType"/>
        <result column="invoice_tax_rate" property="invoiceTaxRate"/>
        <result column="creator_id" property="creatorId"/>
        <result column="creator_name" property="creatorName"/>
        <result column="create_time" property="createTime"/>
        <result column="editor_id" property="editorId"/>
        <result column="editor_name" property="editorName"/>
        <result column="update_time" property="updateTime"/>
    </resultMap>

    <select id="selectByShopId" resultMap="ShopArchiveResultMap">
        SELECT * FROM t_shop_archive
        WHERE shop_id = #{shopId}
        AND deleted = 0
        LIMIT 1
    </select>

</mapper>
```

---

## 七、Service 层设计

### 7.1 ShopArchiveService 接口

**位置**: `haha-service/src/main/java/com/haha/service/ShopArchiveService.java`

```java
package com.haha.service;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.haha.entity.ShopArchive;
import com.haha.entity.dto.ShopArchiveCreateDTO;
import com.haha.entity.dto.ShopArchiveQueryDTO;
import com.haha.entity.dto.ShopArchiveUpdateDTO;

import java.util.List;

public interface ShopArchiveService extends IService<ShopArchive> {

    /**
     * 分页查询门店档案列表
     */
    IPage<ShopArchive> getPage(ShopArchiveQueryDTO queryDTO);

    /**
     * 查询门店档案详情（含固定资产、附件）
     */
    ShopArchive getDetailById(Long id);

    /**
     * 根据门店ID查询档案
     */
    ShopArchive getByShopId(Long shopId);

    /**
     * 创建门店档案
     */
    ShopArchive create(ShopArchiveCreateDTO dto);

    /**
     * 更新门店档案
     */
    ShopArchive update(ShopArchiveUpdateDTO dto);

    /**
     * 删除门店档案（逻辑删除）
     */
    void delete(Long id);

    /**
     * 获取所有枚举选项（供前端下拉使用）
     */
    List<?> getEnumOptions(String enumType);
}
```

### 7.2 ShopArchiveServiceImpl 核心逻辑

**位置**: `haha-service/src/main/java/com/haha/service/impl/ShopArchiveServiceImpl.java`

核心业务逻辑要点：

1. **创建档案**：
   - 校验 `shopId` 对应的门店是否存在
   - 校验同一门店不可重复创建档案（`uk_shop_id` 唯一约束）
   - 自动回填 `creatorId`、`creatorName`（从 Sa-Token 获取当前登录管理员）
   - 事务内保存主表 + 批量保存固定资产 + 批量保存附件

2. **更新档案**：
   - 自动回填 `editorId`、`editorName`
   - 固定资产采用"先删后增"策略（根据 `shopArchiveId` 删除旧记录，重新插入）
   - 附件采用"差异对比"策略（根据 ID 判断新增/更新/删除）
   - 提成比例校验：0~100 区间

3. **删除档案**：
   - 逻辑删除（添加 `deleted` 字段，或在应用层标记状态）
   - 同步删除关联的固定资产和附件记录

4. **条件显示逻辑**（前端根据以下规则控制字段显隐）：
   - `billingMode` 包含电费相关（`RENT_ELECTRIC` / `RENT_COMMISSION_ELECTRIC`）时，显示电费单价和公摊规则
   - `billingMode` 包含提成相关（`COMMISSION_ONLY` / `RENT_COMMISSION` / `RENT_COMMISSION_ELECTRIC`）时，显示提成基数、比例、保底金额、账期
   - `paymentMethod` = `CORPORATE` 时，开户行必填

---

## 八、Controller 层设计

**位置**: `haha-admin/src/main/java/com/haha/admin/controller/ShopArchiveController.java`

```java
@Slf4j
@RestController
@RequestMapping("/shop-archive")
@RequiredArgsConstructor
public class ShopArchiveController {

    private final ShopArchiveService shopArchiveService;

    /**
     * 分页查询门店档案列表
     */
    @RequirePermission("shop:archive:read")
    @GetMapping("/list")
    public Result<PageResult<ShopArchive>> list(ShopArchiveQueryDTO queryDTO) {
        queryDTO.validate();
        return Result.success("查询成功", PageResult.of(shopArchiveService.getPage(queryDTO)));
    }

    /**
     * 查询门店档案详情
     */
    @RequirePermission("shop:archive:read")
    @GetMapping("/{id}")
    public Result<ShopArchive> getById(@PathVariable Long id) {
        ShopArchive archive = shopArchiveService.getDetailById(id);
        if (archive == null) {
            return Result.error(404, "门店档案不存在");
        }
        return Result.success("查询成功", archive);
    }

    /**
     * 根据门店ID查询档案
     */
    @RequirePermission("shop:archive:read")
    @GetMapping("/by-shop/{shopId}")
    public Result<ShopArchive> getByShopId(@PathVariable Long shopId) {
        ShopArchive archive = shopArchiveService.getByShopId(shopId);
        if (archive == null) {
            return Result.error(404, "该门店尚未创建档案");
        }
        return Result.success("查询成功", archive);
    }

    /**
     * 创建门店档案
     */
    @RequirePermission("shop:archive:create")
    @Log(module = "门店档案管理", operation = OperationType.CREATE, summary = "创建门店档案")
    @PostMapping
    public Result<ShopArchive> create(@RequestBody @Valid ShopArchiveCreateDTO dto) {
        ShopArchive archive = shopArchiveService.create(dto);
        return Result.success("创建成功", archive);
    }

    /**
     * 更新门店档案
     */
    @RequirePermission("shop:archive:update")
    @Log(module = "门店档案管理", operation = OperationType.UPDATE, summary = "更新门店档案")
    @PutMapping("/{id}")
    public Result<ShopArchive> update(@PathVariable Long id, @RequestBody @Valid ShopArchiveUpdateDTO dto) {
        dto.setId(id);
        ShopArchive archive = shopArchiveService.update(dto);
        return Result.success("更新成功", archive);
    }

    /**
     * 删除门店档案
     */
    @RequirePermission("shop:archive:delete")
    @Log(module = "门店档案管理", operation = OperationType.DELETE, summary = "删除门店档案")
    @DeleteMapping("/{id}")
    public Result<Void> delete(@PathVariable Long id) {
        shopArchiveService.delete(id);
        return Result.success("删除成功", null);
    }

    /**
     * 获取枚举选项（供前端下拉框使用）
     * @param enumType 枚举类型：venueType / businessStatus / contractStatus / depositStatus / billingMode / paymentCycle / assetType / assetStatus / attachmentType / paymentMethod / invoiceType / commissionBaseType
     */
    @RequirePermission("shop:archive:read")
    @GetMapping("/enum-options")
    public Result<List<StatusLabel>> getEnumOptions(@RequestParam String enumType) {
        return Result.success("查询成功", shopArchiveService.getEnumOptions(enumType));
    }
}
```

### 8.1 API 接口汇总

| 操作 | Method | 路径 | 权限 | 说明 |
|------|--------|------|------|------|
| 分页列表 | GET | `/admin/shop-archive/list` | `shop:archive:read` | 支持门店名称、合同编号、状态筛选 |
| 详情 | GET | `/admin/shop-archive/{id}` | `shop:archive:read` | 含固定资产、附件 |
| 按门店查询 | GET | `/admin/shop-archive/by-shop/{shopId}` | `shop:archive:read` | 根据门店ID查档案 |
| 创建 | POST | `/admin/shop-archive` | `shop:archive:create` | 创建完整档案 |
| 更新 | PUT | `/admin/shop-archive/{id}` | `shop:archive:update` | 更新档案信息 |
| 删除 | DELETE | `/admin/shop-archive/{id}` | `shop:archive:delete` | 逻辑删除 |
| 枚举选项 | GET | `/admin/shop-archive/enum-options?enumType=xxx` | `shop:archive:read` | 获取下拉选项 |
| **获取上传凭证** | GET | `/admin/oss/upload-credential?dir=xxx` | `shop:archive:create` | 返回 STS Token，前端直传 OSS |
| **删除 OSS 文件** | DELETE | `/admin/oss/file?objectKey=xxx` | `shop:archive:delete` | 根据 objectKey 删除 OSS 文件 |

---

## 九、阿里云 OSS 文件上传方案（前端直传）

### 9.1 方案说明

采用**前端直传 OSS** 模式，文件不经过后端服务器中转：

```
──────────┐     ①请求上传凭证      ┌──────────┐
│  前端     │ ──────────────────────→ │  后端     │
│          │ ←────────────────────── │          │
│          │     ②返回STS Token       │          │
│          │                          │          │
│          │     ③直传文件到OSS        │          │
│          │ ──────────────────────→ 阿里云 OSS  │
│          │                          │          │
│          │     ④上传成功，回调后端    │          │
│          │ ──────────────────────→ │  后端     │
│          │     保存 fileUrl         │  保存URL  │
└──────────┘                          └──────────┘
```

**优势**：
- 后端不处理文件流，减轻服务器带宽和存储压力
- 前端直传速度更快，用户体验更好
- 通过 STS 临时凭证控制权限，安全性有保障

### 9.2 依赖引入

在 `haha-service/pom.xml` 中添加：

```xml
<!-- 阿里云 STS SDK（用于生成临时上传凭证） -->
<dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>aliyun-java-sdk-sts</artifactId>
    <version>3.1.2</version>
</dependency>
<dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>aliyun-java-sdk-core</artifactId>
    <version>4.6.4</version>
</dependency>
```

### 9.3 配置项

在 `application.yml` 中添加：

```yaml
# 阿里云 OSS 配置
aliyun:
  oss:
    endpoint: oss-cn-hangzhou.aliyuncs.com                    # OSS Endpoint
    access-key-id: ${OSS_ACCESS_KEY_ID}                        # 主账号AK（仅用于签发STS）
    access-key-secret: ${OSS_ACCESS_KEY_SECRET}                # 主账号SK（仅用于签发STS）
    bucket-name: haha-shop-archive                             # Bucket 名称
    domain-prefix: https://haha-shop-archive.oss-cn-hangzhou.aliyuncs.com  # 访问域名前缀
    sts:
      role-arn: acs:ram::xxx:role/haha-oss-upload-role         # RAM角色ARN
      role-session-name: haha-shop-archive-upload              # 会话名称
      duration-seconds: 900                                    # 凭证有效期（秒），默认15分钟
```

### 9.4 OSS 配置类

**位置**: `haha-common/src/main/java/com/haha/common/config/OssConfig.java`

```java
package com.haha.common.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Data
@Configuration(proxyBeanMethods = false)
@ConfigurationProperties(prefix = "aliyun.oss")
public class OssConfig {

    private String endpoint;
    private String accessKeyId;
    private String accessKeySecret;
    private String bucketName;
    private String domainPrefix;
    private StsConfig sts = new StsConfig();

    @Data
    public static class StsConfig {
        private String roleArn;
        private String roleSessionName;
        private int durationSeconds = 900;
    }
}
```

### 9.5 OssService 接口与实现

**接口位置**: `haha-service/src/main/java/com/haha/service/OssService.java`

```java
package com.haha.service;

import java.util.Map;

public interface OssService {

    /**
     * 获取前端直传 OSS 的临时凭证（STS Token）
     *
     * @param dir 上传目录前缀（如 "shop-archive/contract"）
     * @return 包含 accessKeyId、accessKeySecret、securityToken、
     *         expiration、host、dir 的凭证信息
     */
    Map<String, String> getUploadCredential(String dir);

    /**
     * 根据 objectKey 删除 OSS 文件
     *
     * @param objectKey OSS 对象键（不含域名）
     */
    void deleteByObjectKey(String objectKey);
}
```

**实现位置**: `haha-service/src/main/java/com/haha/service/impl/OssServiceImpl.java`

核心逻辑：
- `getUploadCredential()`：调用阿里云 STS AssumeRole 接口，生成临时凭证
  - 返回 `accessKeyId`、`accessKeySecret`、`securityToken`、`expiration`（过期时间）
  - 返回 `host`（bucket 上传地址，如 `https://haha-shop-archive.oss-cn-hangzhou.aliyuncs.com`）
  - 返回 `dir`（上传目录，如 `shop-archive/contract/20260709/`）
  - 前端拿到凭证后使用阿里云 OSS SDK（`ali-oss` npm 包）直传
- 凭证有效期 15 分钟，过期后前端需重新请求
- 前端上传成功后，将 `fileUrl`（`domainPrefix + objectKey`）提交给后端保存

### 9.6 上传凭证 Controller

**位置**: `haha-admin/src/main/java/com/haha/admin/controller/OssUploadController.java`

```java
@Slf4j
@RestController
@RequestMapping("/oss")
@RequiredArgsConstructor
public class OssUploadController {

    private final OssService ossService;

    /**
     * 获取 OSS 直传凭证
     *
     * @param dir 上传目录（如 contract、lease、electricity、deposit、termination）
     * @return STS 临时凭证 + 上传地址
     */
    @RequirePermission("shop:archive:create")
    @GetMapping("/upload-credential")
    public Result<Map<String, String>> getUploadCredential(
            @RequestParam(value = "dir", defaultValue = "shop-archive") String dir) {
        Map<String, String> credential = ossService.getUploadCredential(dir);
        return Result.success("获取凭证成功", credential);
    }

    /**
     * 删除 OSS 文件（根据 objectKey）
     */
    @RequirePermission("shop:archive:delete")
    @DeleteMapping("/file")
    public Result<Void> deleteFile(@RequestParam String objectKey) {
        ossService.deleteByObjectKey(objectKey);
        return Result.success("删除成功", null);
    }
}
```

### 9.7 前端上传方案

#### 9.7.1 安装依赖

```bash
npm install ali-oss
```

#### 9.7.2 上传工具类

**位置**: `haha-admin-web/src/utils/ossUpload.ts`

```typescript
import OSS from 'ali-oss';
import { http } from '@/utils/http';

interface OssCredential {
  accessKeyId: string;
  accessKeySecret: string;
  securityToken: string;
  expiration: string;
  host: string;
  dir: string;
}

interface UploadResult {
  url: string;       // 完整访问URL（domainPrefix + objectKey）
  objectKey: string; // OSS对象键
  fileName: string;
  fileSize: number;
  fileExtension: string;
}

let cachedCredential: OssCredential | null = null;
let credentialExpireTime = 0;

/**
 * 获取 OSS 上传凭证（带缓存，过期前60秒自动刷新）
 */
async function getOssCredential(dir: string): Promise<OssCredential> {
  const now = Date.now();
  if (cachedCredential && credentialExpireTime - now > 60000) {
    return cachedCredential;
  }

  const res: any = await http.request('get', '/oss/upload-credential', {
    params: { dir }
  });

  if (res.code !== 200) {
    throw new Error(res.message || '获取上传凭证失败');
  }

  cachedCredential = res.data;
  credentialExpireTime = new Date(res.data.expiration).getTime();
  return cachedCredential;
}

/**
 * 前端直传文件到 OSS
 *
 * @param file     待上传文件
 * @param dir      上传目录（如 "shop-archive/contract"）
 * @param onProgress 上传进度回调 (percent: number) => void
 * @returns        上传结果（URL、objectKey等）
 */
export async function uploadToOss(
  file: File,
  dir: string = 'shop-archive',
  onProgress?: (percent: number) => void
): Promise<UploadResult> {
  const credential = await getOssCredential(dir);

  const client = new OSS({
    region: credential.host.match(/oss-(.+?)\./)?.[1] || 'cn-hangzhou',
    accessKeyId: credential.accessKeyId,
    accessKeySecret: credential.accessKeySecret,
    stsToken: credential.securityToken,
    bucket: credential.host.match(/\/\/(.+?)\./)?.[1] || '',
    endpoint: credential.host,
  });

  // 生成 objectKey：dir/yyyyMMdd/uuid.ext
  const ext = file.name.includes('.') ? file.name.substring(file.name.lastIndexOf('.')) : '';
  const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '');
  const uuid = Math.random().toString(36).substring(2, 15) + Date.now().toString(36);
  const objectKey = `${credential.dir}${dateStr}/${uuid}${ext}`;

  const result = await client.put(objectKey, file, {
    progress: (p: number) => onProgress?.(Math.round(p * 100)),
  });

  // 拼接完整访问URL
  const domainPrefix = credential.host.replace('://', '://');
  const url = `${domainPrefix}/${objectKey}`;

  return {
    url,
    objectKey,
    fileName: file.name,
    fileSize: file.size,
    fileExtension: ext.replace('.', ''),
  };
}
```

#### 9.7.3 前端 API 层

**位置**: `haha-admin-web/src/api/shopArchive.ts`

```typescript
import { http } from '@/utils/http';
import { uploadToOss } from '@/utils/ossUpload';

// 获取 OSS 上传凭证
export function getOssUploadCredential(dir: string) {
  return http.request('get', '/oss/upload-credential', { params: { dir } });
}

// 前端直传 OSS（封装）
export { uploadToOss };

// 删除 OSS 文件
export function deleteOssFile(objectKey: string) {
  return http.request('delete', '/oss/file', { params: { objectKey } });
}

// 门店档案 CRUD
export function getArchiveList(params: any) {
  return http.request('get', '/shop-archive/list', { params });
}

export function getArchiveDetail(id: string | number) {
  return http.request('get', `/shop-archive/${id}`);
}

export function createArchive(data: any) {
  return http.request('post', '/shop-archive', { data });
}

export function updateArchive(id: string | number, data: any) {
  return http.request('put', `/shop-archive/${id}`, { data });
}

export function deleteArchive(id: string | number) {
  return http.request('delete', `/shop-archive/${id}`);
}

export function getEnumOptions(enumType: string) {
  return http.request('get', '/shop-archive/enum-options', { params: { enumType } });
}
```

#### 9.7.4 前端附件上传组件使用示例

```vue
<template>
  <el-upload
    :http-request="handleUpload"
    :show-file-list="true"
    :before-upload="beforeUpload"
    multiple
  >
    <el-button type="primary">上传合同附件</el-button>
  </el-upload>
</template>

<script setup lang="tsx">
import { uploadToOss } from '@/api/shopArchive';

const handleUpload = async (options: any) => {
  const file = options.file;
  try {
    const result = await uploadToOss(file, 'shop-archive/contract', (percent) => {
      options.onProgress({ percent });
    });

    // 将上传结果加入附件列表
    attachmentList.value.push({
      attachmentType: 'CONTRACT',
      fileName: result.fileName,
      fileUrl: result.url,
      fileSize: result.fileSize,
      fileExtension: result.fileExtension,
    });

    options.onSuccess(result);
  } catch (err) {
    options.onError(err);
  }
};

const beforeUpload = (file: File) => {
  const maxSize = 10 * 1024 * 1024; // 10MB
  if (file.size > maxSize) {
    ElMessage.error('文件大小不能超过 10MB');
    return false;
  }
  const allowedTypes = ['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx'];
  const ext = file.name.split('.').pop()?.toLowerCase();
  if (!ext || !allowedTypes.includes(ext)) {
    ElMessage.error('仅支持 PDF、图片、Word 格式');
    return false;
  }
  return true;
};
</script>
```

### 9.8 附件分组上传

前端附件区域按 5 种类型分组，每种类型独立上传入口：

| 附件类型 | 枚举值 | 上传目录 | 说明 |
|---------|--------|---------|------|
| 合同附件 | `CONTRACT` | `shop-archive/contract` | 支持多文件 |
| 场地租赁协议 | `LEASE_AGREEMENT` | `shop-archive/lease` | 支持多文件 |
| 电费补充协议 | `ELECTRICITY_AGREEMENT` | `shop-archive/electricity` | 支持多文件 |
| 保证金收据 | `DEPOSIT_RECEIPT` | `shop-archive/deposit` | 支持多文件 |
| 解约/补充变更协议 | `TERMINATION_AGREEMENT` | `shop-archive/termination` | 支持多文件 |

---

## 十、菜单与权限配置

### 10.1 权限点

| 权限标识 | 说明 |
|---------|------|
| `shop:archive:read` | 查看门店档案 |
| `shop:archive:create` | 创建门店档案 |
| `shop:archive:update` | 编辑门店档案 |
| `shop:archive:delete` | 删除门店档案 |

### 10.2 菜单结构建议

在现有"运营管理 > 门店管理"下新增子菜单：

```
运营管理
├── 门店管理（现有）
├── 门店档案管理（新增）
│   ├── 查看
│   ├── 创建
│   ├── 编辑
│   └── 删除
├── 设备管理（现有）
├── ...
```

---

## 十一、SQL 迁移脚本

**位置**: `docs/database/create_shop_archive.sql`

包含三张表的完整建表语句（见第二章），以及初始菜单/权限数据插入。

---

## 十二、前端页面设计要点

### 12.1 档案列表页

- 表格列：门店名称、合同编号、合同状态（标签）、经营状态（标签）、计费模式、每月租金、创建时间、操作
- 筛选条件：门店名称（模糊）、合同编号、合同状态（下拉）、经营状态（下拉）、计费模式（下拉）
- 操作列：查看、编辑、删除

### 12.2 档案详情/编辑页

采用分步 Tab 或折叠面板形式，按六大模块组织：

```
┌─────────────────────────────────────────────┐
│  Tab1: 门店基础信息                           │
│  Tab2: 固定资产设备                           │
│  Tab3: 合同核心信息                           │
│  Tab4: 收款结算 & 开票                        │
│  Tab5: 合同附件                               │
└─────────────────────────────────────────────┘
```

**字段联动规则**：
- 选择"计费模式"后，动态显示/隐藏电费和提成相关字段
- 选择"打款方式=对公"时，显示开户行字段
- 合同状态为"已解约终止"时，显示解约日期和解约备注
- 固定资产支持动态增删行（表格内编辑）
- 附件按类型分组展示，每组支持多文件上传

### 12.3 路由配置

在 `haha-admin-web/src/router/modules/operation.ts` 中新增：

```typescript
{
  path: "/operation/shop-archive",
  name: "ShopArchiveList",
  component: () => import("@/views/shop-archive/index.vue"),
  meta: {
    icon: "ri:file-text-line",
    title: "门店档案管理"
  }
}
```

---

## 十三、开发任务清单

### Phase 1：基础搭建

- [ ] 执行建表 SQL（`t_shop_archive`、`t_shop_fixed_asset`、`t_shop_contract_attachment`）
- [ ] 新增 12 个枚举类（VenueType、BusinessStatus、ContractStatus、DepositStatus、BillingMode、PaymentCycle、AssetType、AssetStatus、AttachmentType、PaymentMethod、InvoiceType、CommissionBaseType）
- [ ] 新增 3 个 Entity 类（ShopArchive、ShopFixedAsset、ShopContractAttachment）
- [ ] 新增 3 个 Mapper 接口 + XML
- [ ] 新增 DTO 类（ShopArchiveCreateDTO、ShopArchiveUpdateDTO、ShopArchiveQueryDTO、FixedAssetDTO、AttachmentDTO）

### Phase 2：业务逻辑

- [ ] 新增 OssConfig 配置类（haha-common）
- [ ] 新增 OssService 接口与实现（STS 临时凭证签发 + 文件删除）
- [ ] 新增 OssUploadController（获取凭证 + 删除文件）
- [ ] 新增 ShopArchiveService 接口与实现
- [ ] 新增 ShopArchiveController（7 个 API 接口）
- [ ] 配置 OSS 相关 application.yml 配置项（AK/SK/STS Role）
- [ ] 插入菜单和权限数据

### Phase 3：前端开发

- [ ] 安装 `ali-oss` npm 依赖
- [ ] 新增 `ossUpload.ts` 上传工具类（凭证缓存 + 直传逻辑）
- [ ] 新增 API 文件 `shopArchive.ts`
- [ ] 新增门店档案列表页
- [ ] 新增门店档案详情/编辑页（含 Tab 分组、字段联动、附件分组上传）
- [ ] 路由配置与菜单对接

### Phase 4：测试与优化

- [ ] 接口联调测试
- [ ] OSS STS 凭证签发与直传测试
- [ ] 附件上传/预览/下载/删除测试
- [ ] 权限校验测试
- [ ] 字段联动逻辑测试
