test(invoice): 优化开票编辑状态验证逻辑并添加测试
- 修改发票编辑权限验证逻辑,支持免审批模式下的全状态编辑
- 更新错误提示信息,明确显示当前状态和允许编辑的状态
- 新增发票编辑状态单元测试类,覆盖各种审核状态场景
- 验证免审批配置下所有发票状态都允许编辑的功能
- 确保合同金额限制验证在免审批模式下仍正常工作
- 添加操作日志时间调整脚本用于数据迁移处理
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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)) |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | """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') |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | { |
| | | "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 |
| | | } |
| | | } |
| | | } |
| | |
| | | // ========== å¼ç¥¨ 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, "æ´æ°å¼ç¥¨å®¡æ ¸ç¶æå¤±è´¥ï¼åå ï¼å¼ç¥¨ä¸æ¯å®¡æ ¸ä¸ç¶æ"); |
| | |
| | | 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); |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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")); |
| | | } |
| | | } |