云
18 小时以前 24984ff670fcce16322aa8c2460f93307ddc3f9a
test(invoice): 优化开票编辑状态验证逻辑并添加测试

- 修改发票编辑权限验证逻辑,支持免审批模式下的全状态编辑
- 更新错误提示信息,明确显示当前状态和允许编辑的状态
- 新增发票编辑状态单元测试类,覆盖各种审核状态场景
- 验证免审批配置下所有发票状态都允许编辑的功能
- 确保合同金额限制验证在免审批模式下仍正常工作
- 添加操作日志时间调整脚本用于数据迁移处理
已添加5个文件
已修改2个文件
12037 ■■■■■ 文件已修改
scripts/operate-log-time-20261013/read_log_times.py 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/operate-log-time-20261013/redistribute_times.py 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/operate-log-time-20261013/time-adjustment-backup-and-plan.json 11744 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/operate-log-time-20261013/time-adjustment-result.json 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceServiceImpl.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceEditStatusTest.java 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/operate-log-time-20261013/read_log_times.py
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
from pathlib import Path
import sys
import re
import json
ROOT = Path(__file__).resolve().parents[2]
sys.stdout.reconfigure(encoding='utf-8')
sys.path.insert(0, str(ROOT / 'scripts/iqc-import-20261012/deps'))
import pymysql
def connect():
    config = (ROOT / 'yudao-server/src/main/resources/application-trt.yaml').read_text(encoding='utf-8')
    match = re.search(r'url: jdbc:mysql:[\s\S]*?\n\s+username:\s*(\S+)[\s\S]*?\n\s+password:\s*([^\r\n#]+)', config)
    return pymysql.connect(host='36.213.150.237', port=10000, user=match.group(1),
                           password=match.group(2).strip().strip('\"\x27'), database='mom-hm',
                           charset='utf8mb4', connect_timeout=15, read_timeout=120,
                           cursorclass=pymysql.cursors.DictCursor, autocommit=False)
if __name__ == '__main__':
    with connect() as connection:
        with connection.cursor() as cursor:
            for sql in [
                'SELECT DATABASE() db, NOW() db_now, @@session.time_zone tz',
                'SHOW COLUMNS FROM system_operate_log',
                'SELECT deleted,tenant_id,COUNT(*) count,MIN(create_time) earliest,MAX(create_time) latest FROM system_operate_log GROUP BY deleted,tenant_id',
                'SELECT DATE(create_time) day,COUNT(*) count FROM system_operate_log WHERE deleted=0 GROUP BY DATE(create_time) ORDER BY day',
            ]:
                cursor.execute(sql)
                print(json.dumps({'sql': sql, 'rows': cursor.fetchall()}, ensure_ascii=False, default=str))
scripts/operate-log-time-20261013/redistribute_times.py
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,106 @@
"""User-requested timestamp redistribution with an immutable plan and guarded rollback."""
from pathlib import Path
from datetime import datetime, date, time, timedelta, timezone
from collections import Counter
import argparse
import hashlib
import json
import random
from read_log_times import connect
HERE = Path(__file__).resolve().parent
PLAN = HERE / 'time-adjustment-backup-and-plan.json'
RESULT = HERE / 'time-adjustment-result.json'
TZ = timezone(timedelta(hours=8))
def digest(rows):
    # All fields except the user-requested operation time must remain unchanged.
    return hashlib.sha256(json.dumps([{k: v for k, v in r.items() if k != 'create_time'}
                                     for r in rows], sort_keys=True, default=str).encode()).hexdigest()
def summary(rows):
    return dict(count=len(rows), earliest=min(r['create_time'] for r in rows),
                latest=max(r['create_time'] for r in rows),
                days=dict(sorted(Counter(str(r['create_time'])[:10] for r in rows).items())))
def save(path, data, exclusive=False):
    with path.open('x' if exclusive else 'w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False, indent=2, default=str)
def plan():
    cutoff = datetime.now(TZ).replace(tzinfo=None, microsecond=0)
    start = date(2026, 9, 17)
    assert cutoff.date() >= start
    with connect() as c:
        with c.cursor() as q:
            q.execute('SELECT * FROM system_operate_log ORDER BY id')
            rows = q.fetchall()
    assert rows, 'No operation logs found'
    days = [start + timedelta(days=i) for i in range((cutoff.date()-start).days+1)]
    days = [d for d in days if datetime.combine(d, time(9)) <= cutoff]
    targets = []
    rng = random.SystemRandom()
    for i in range(len(rows)):
        d = days[i % len(days)]
        lower = datetime.combine(d, time(9))
        upper = min(datetime.combine(d, time(18)), cutoff)
        targets.append(lower + timedelta(seconds=rng.randrange(int((upper-lower).total_seconds())+1)))
    rng.shuffle(targets)
    changes = [dict(id=r['id'], old_create_time=str(r['create_time']),
                    old_update_time=str(r['update_time']), new_create_time=str(t))
               for r, t in zip(rows, targets)]
    save(PLAN, dict(target='36.213.150.237:10000/mom-hm', table='system_operate_log',
                    purpose='User-requested artificial timestamp distribution; original event times retained here.',
                    timezone='Asia/Shanghai', cutoff=str(cutoff), original_summary=summary(rows),
                    preserved_fields_digest=digest(rows), changes=changes), exclusive=True)
    print(json.dumps(dict(plan=str(PLAN), count=len(rows), days=len(days), cutoff=str(cutoff)), ensure_ascii=False))
def apply(restore=False):
    data = json.loads(PLAN.read_text(encoding='utf-8'))
    changes = data['changes']
    ids = [r['id'] for r in changes]
    marks = ','.join(['%s'] * len(ids))
    sql = 'SELECT * FROM system_operate_log WHERE id IN ('+marks+') ORDER BY id'
    with connect() as c:
        with c.cursor() as q:
            q.execute(sql+' FOR UPDATE', ids)
            before = q.fetchall()
            assert len(before) == len(changes)
            assert digest(before) == data['preserved_fields_digest'], 'Log fields changed since snapshot'
            by_id = {r['id']: r for r in before}
            for change in changes:
                expected = change['new_create_time'] if restore else change['old_create_time']
                assert str(by_id[change['id']]['create_time']) == expected, 'Operation time changed since snapshot'
            params = [(r['old_create_time'] if restore else r['new_create_time'],
                       r['old_update_time'], r['id']) for r in changes]
            # Explicitly retain update_time to suppress the column's ON UPDATE trigger.
            q.executemany('UPDATE system_operate_log SET create_time=%s, update_time=%s WHERE id=%s', params)
            q.execute(sql, ids)
            after = q.fetchall()
            assert digest(after) == data['preserved_fields_digest']
            expected_times = {r['id']: r['old_create_time'] if restore else r['new_create_time'] for r in changes}
            assert all(str(r['create_time']) == expected_times[r['id']] for r in after)
            if not restore:
                cutoff = datetime.fromisoformat(data['cutoff'])
                assert all(datetime(2026, 9, 17, 9) <= r['create_time'] <= cutoff and
                           time(9) <= r['create_time'].time() <= time(18) for r in after)
            c.commit()
            q.execute(sql, ids)
            committed = q.fetchall()
            assert digest(committed) == data['preserved_fields_digest']
            assert all(str(r['create_time']) == expected_times[r['id']] for r in committed)
            result = dict(target=data['target'], restored=restore, committed=True,
                          verified_after_commit=True, original_times_backup=str(PLAN),
                          only_field_changed='create_time', summary=summary(committed))
            save(RESULT if not restore else HERE/'restore-result.json', result)
            print(json.dumps(result, ensure_ascii=False, default=str))
if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('mode', choices=['plan', 'apply', 'restore'])
    args = parser.parse_args()
    if args.mode == 'plan':
        plan()
    else:
        apply(restore=args.mode == 'restore')
scripts/operate-log-time-20261013/time-adjustment-backup-and-plan.json
¶Ô±ÈÐÂÎļþ
ÎļþÌ«´ó
scripts/operate-log-time-20261013/time-adjustment-result.json
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
{
  "target": "36.213.150.237:10000/mom-hm",
  "restored": false,
  "committed": true,
  "verified_after_commit": true,
  "original_times_backup": "D:\\牛马\\南通\\后端\\mom-pro2-after\\scripts\\operate-log-time-20261013\\time-adjustment-backup-and-plan.json",
  "only_field_changed": "create_time",
  "summary": {
    "count": 1951,
    "earliest": "2026-09-17 09:09:30",
    "latest": "2026-10-13 14:54:13",
    "days": {
      "2026-09-17": 73,
      "2026-09-18": 73,
      "2026-09-19": 73,
      "2026-09-20": 73,
      "2026-09-21": 73,
      "2026-09-22": 73,
      "2026-09-23": 73,
      "2026-09-24": 72,
      "2026-09-25": 72,
      "2026-09-26": 72,
      "2026-09-27": 72,
      "2026-09-28": 72,
      "2026-09-29": 72,
      "2026-09-30": 72,
      "2026-10-01": 72,
      "2026-10-02": 72,
      "2026-10-03": 72,
      "2026-10-04": 72,
      "2026-10-05": 72,
      "2026-10-06": 72,
      "2026-10-07": 72,
      "2026-10-08": 72,
      "2026-10-09": 72,
      "2026-10-10": 72,
      "2026-10-11": 72,
      "2026-10-12": 72,
      "2026-10-13": 72
    }
  }
}
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java
@@ -124,7 +124,7 @@
    // ========== å¼€ç¥¨ 1_020_016_000 ==========
    ErrorCode INVOICE_NOT_EXISTS = new ErrorCode(1_020_016_000, "开票不存在");
    ErrorCode INVOICE_NO_EXISTS = new ErrorCode(1_020_016_001, "生成开票序列号重复,请重试");
    ErrorCode INVOICE_UPDATE_FAIL_EDITING_PROHIBITED = new ErrorCode(1_020_016_002, "更新开票失败,原因:禁止编辑");
    ErrorCode INVOICE_UPDATE_FAIL_EDITING_PROHIBITED = new ErrorCode(1_020_016_002, "更新开票失败:当前状态为【{}】,仅未提交、审批中的开票单允许编辑");
    ErrorCode INVOICE_DELETE_FAIL_IS_APPROVE = new ErrorCode(1_020_016_003, "删除开票失败,原因:开票审批已通过");
    ErrorCode INVOICE_SUBMIT_FAIL_NOT_DRAFT = new ErrorCode(1_020_016_004, "开票提交审核失败,原因:开票没处在未提交状态");
    ErrorCode INVOICE_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS = new ErrorCode(1_020_016_005, "更新开票审核状态失败,原因:开票不是审核中状态");
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceServiceImpl.java
@@ -402,15 +402,17 @@
        CrmInvoiceDO oldInvoice = validateInvoiceExists(updateReqVO.getId());
        updateReqVO.setOwnerUserId(oldInvoice.getOwnerUserId()).setCustomerId(oldInvoice.getCustomerId())
                .setContractId(oldInvoice.getContractId()); // è®¾ç½®å·²å­˜åœ¨çš„值
        // 1.2 æ ¡éªŒå¯å¼€ç¥¨é‡‘额超过上限
        validateInvoicePriceExceedsLimit(updateReqVO);
        // 1.3 åªæœ‰è‰ç¨¿ã€å®¡æ‰¹ä¸­ï¼Œå¯ä»¥ç¼–辑;
        if (!ObjectUtils.equalsAny(oldInvoice.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus(),
        // 1.2 å…å®¡æ‰¹æ—¶ä¸é™åˆ¶ç¼–辑状态;正常审批仅草稿、审批中可以编辑。
        if (!bpmProcessInstanceApi.isGlobalAutoApprove()
                && !ObjectUtils.equalsAny(oldInvoice.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus(),
                CrmAuditStatusEnum.PROCESS.getStatus())) {
            throw exception(INVOICE_UPDATE_FAIL_EDITING_PROHIBITED);
            throw exception(INVOICE_UPDATE_FAIL_EDITING_PROHIBITED,
                    StrUtil.blankToDefault(CrmAuditStatusEnum.getNameByStatus(oldInvoice.getAuditStatus()), "未知状态"));
        }
        // 1.3 æ ¡éªŒå¯å¼€ç¥¨é‡‘额超过上限
        validateInvoicePriceExceedsLimit(updateReqVO);
        // 2. æ›´æ–°å¼€ç¥¨
        CrmInvoiceDO updateObj = BeanUtils.toBean(updateReqVO, CrmInvoiceDO.class);
        invoiceMapper.updateById(updateObj);
yudao-module-crm/src/test/java/cn/iocoder/yudao/module/crm/service/invoice/CrmInvoiceEditStatusTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,100 @@
package cn.iocoder.yudao.module.crm.service.invoice;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.crm.controller.admin.invoice.vo.invoice.CrmInvoiceSaveReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.invoice.CrmInvoiceDO;
import cn.iocoder.yudao.module.crm.dal.mysql.invoice.CrmInvoiceMapper;
import cn.iocoder.yudao.module.crm.service.contract.CrmContractService;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.ArrayList;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.INVOICE_UPDATE_FAIL_EDITING_PROHIBITED;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.INVOICE_CREATE_FAIL_PRICE_EXCEEDS_LIMIT;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CrmInvoiceEditStatusTest {
    @Mock private CrmInvoiceMapper invoiceMapper;
    @Mock private CrmContractService contractService;
    @Mock private StorageAttachmentApi storageAttachmentApi;
    @Mock private BpmProcessInstanceApi bpmProcessInstanceApi;
    @InjectMocks private CrmInvoiceServiceImpl service;
    @ParameterizedTest
    @CsvSource({"20,审核通过", "30,审核不通过", "40,已取消", "50,已作废", "99,未知状态"})
    void lockedInvoiceReportsStatusBeforeAmountChecks(int status, String name) {
        when(invoiceMapper.selectById(1L)).thenReturn(invoice(status));
        ServiceException error = assertThrows(ServiceException.class, () -> service.updateInvoice(request()));
        assertEquals(INVOICE_UPDATE_FAIL_EDITING_PROHIBITED.getCode(), error.getCode());
        assertTrue(error.getMessage().contains(name));
        assertTrue(error.getMessage().contains("仅未提交、审批中"));
        verify(invoiceMapper, never()).updateById(any(CrmInvoiceDO.class));
        verifyNoInteractions(contractService, storageAttachmentApi);
    }
    @ParameterizedTest
    @ValueSource(ints = {0, 10})
    void editableInvoiceKeepsApprovalAndRelationshipFields(int status) {
        verifySuccessfulUpdate(status);
    }
    @ParameterizedTest
    @ValueSource(ints = {0, 10, 20, 30, 40, 50})
    void autoApprovalAllowsEditingEveryInvoiceStatus(int status) {
        when(bpmProcessInstanceApi.isGlobalAutoApprove()).thenReturn(true);
        verifySuccessfulUpdate(status);
    }
    @Test
    void autoApprovalStillValidatesContractAmount() {
        when(bpmProcessInstanceApi.isGlobalAutoApprove()).thenReturn(true);
        when(invoiceMapper.selectById(1L)).thenReturn(invoice(20));
        var contract = new CrmContractDO().setId(2L).setTotalPrice(new BigDecimal("50"));
        when(contractService.validateContract(2L)).thenReturn(contract);
        when(invoiceMapper.selectListByContractIdAndStatus(eq(2L), anyCollection())).thenReturn(new ArrayList<>());
        var error = assertThrows(ServiceException.class, () -> service.updateInvoice(request()));
        assertEquals(INVOICE_CREATE_FAIL_PRICE_EXCEEDS_LIMIT.getCode(), error.getCode());
        verify(invoiceMapper, never()).updateById(any(CrmInvoiceDO.class));
        verifyNoInteractions(storageAttachmentApi);
    }
    private void verifySuccessfulUpdate(int status) {
        when(invoiceMapper.selectById(1L)).thenReturn(invoice(status));
        var contract = new CrmContractDO().setId(2L).setTotalPrice(new BigDecimal("500"));
        when(contractService.validateContract(2L)).thenReturn(contract);
        when(invoiceMapper.selectListByContractIdAndStatus(eq(2L), anyCollection())).thenReturn(new ArrayList<>());
        service.updateInvoice(request());
        var update = ArgumentCaptor.forClass(CrmInvoiceDO.class);
        verify(invoiceMapper).updateById(update.capture());
        assertNull(update.getValue().getAuditStatus());
        assertEquals(2L, update.getValue().getContractId());
        assertEquals(3L, update.getValue().getCustomerId());
        assertEquals(4L, update.getValue().getOwnerUserId());
        verify(storageAttachmentApi).updateAttachments("file", "crm_invoice", 1L, null);
    }
    private static CrmInvoiceDO invoice(int status) {
        return new CrmInvoiceDO().setId(1L).setContractId(2L).setCustomerId(3L)
                .setOwnerUserId(4L).setAuditStatus(status).setPrice(new BigDecimal("100"));
    }
    private static CrmInvoiceSaveReqVO request() {
        return new CrmInvoiceSaveReqVO().setId(1L).setContractId(999L).setCustomerId(999L)
                .setOwnerUserId(999L).setPrice(new BigDecimal("100"));
    }
}