订单管理.

This commit is contained in:
18631081161 2026-01-06 20:56:09 +08:00
parent 74de21b28f
commit 07f594523c
15 changed files with 1662 additions and 11 deletions

View File

@ -0,0 +1,181 @@
/**
* Property-based tests for order delete functionality
* **Feature: order-delete, Property 2: Deleted orders are excluded from list queries**
* **Validates: Requirements 1.3**
*/
import { describe, it, expect } from 'vitest'
import * as fc from 'fast-check'
/**
* Order status constants
* 1: 待支付 (pending)
* 2: 已支付 (paid)
* 3: 已取消 (cancelled)
* 4: 已退款 (refunded)
*/
const ORDER_STATUS = {
PENDING: 1,
PAID: 2,
CANCELLED: 3,
REFUNDED: 4
} as const
/**
* Simulates the canDeleteOrder function from the frontend
* Orders with status 2 (paid) cannot be deleted
*/
function canDeleteOrder(status: number): boolean {
return status !== ORDER_STATUS.PAID
}
/**
* Simulates filtering orders that are not deleted
* This represents the backend behavior that the frontend relies on
*/
function filterNonDeletedOrders<T extends { isDeleted: boolean }>(orders: T[]): T[] {
return orders.filter(order => !order.isDeleted)
}
/**
* Generates a mock order for testing
*/
const orderArbitrary = fc.record({
orderId: fc.integer({ min: 1, max: 1000000 }),
orderNo: fc.string({ minLength: 10, maxLength: 20 }),
status: fc.constantFrom(ORDER_STATUS.PENDING, ORDER_STATUS.PAID, ORDER_STATUS.CANCELLED, ORDER_STATUS.REFUNDED),
isDeleted: fc.boolean(),
amount: fc.float({ min: Math.fround(0.01), max: Math.fround(10000), noNaN: true }),
})
describe('Order Delete Property Tests', () => {
/**
* Property 2: Deleted orders are excluded from list queries
* *For any* order query, the result SHALL NOT include orders where IsDeleted is true.
* **Validates: Requirements 1.3**
*/
describe('Property 2: Deleted orders are excluded from list queries', () => {
it('should never include deleted orders in filtered results', () => {
fc.assert(
fc.property(
fc.array(orderArbitrary, { minLength: 0, maxLength: 50 }),
(orders) => {
const filteredOrders = filterNonDeletedOrders(orders)
// Property: No order in the result should have isDeleted = true
return filteredOrders.every(order => order.isDeleted === false)
}
),
{ numRuns: 100 }
)
})
it('should include all non-deleted orders in filtered results', () => {
fc.assert(
fc.property(
fc.array(orderArbitrary, { minLength: 0, maxLength: 50 }),
(orders) => {
const filteredOrders = filterNonDeletedOrders(orders)
const nonDeletedOrders = orders.filter(o => !o.isDeleted)
// Property: The count of filtered orders should equal the count of non-deleted orders
return filteredOrders.length === nonDeletedOrders.length
}
),
{ numRuns: 100 }
)
})
it('should preserve order data integrity after filtering', () => {
fc.assert(
fc.property(
fc.array(orderArbitrary, { minLength: 1, maxLength: 50 }),
(orders) => {
const filteredOrders = filterNonDeletedOrders(orders)
// Property: Each filtered order should exist in the original list with same data
return filteredOrders.every(filtered =>
orders.some(original =>
original.orderId === filtered.orderId &&
original.orderNo === filtered.orderNo &&
original.status === filtered.status &&
original.amount === filtered.amount
)
)
}
),
{ numRuns: 100 }
)
})
it('should return empty array when all orders are deleted', () => {
fc.assert(
fc.property(
fc.array(
fc.record({
orderId: fc.integer({ min: 1, max: 1000000 }),
orderNo: fc.string({ minLength: 10, maxLength: 20 }),
status: fc.constantFrom(ORDER_STATUS.PENDING, ORDER_STATUS.PAID, ORDER_STATUS.CANCELLED, ORDER_STATUS.REFUNDED),
isDeleted: fc.constant(true), // All orders are deleted
amount: fc.float({ min: Math.fround(0.01), max: Math.fround(10000), noNaN: true }),
}),
{ minLength: 1, maxLength: 20 }
),
(allDeletedOrders) => {
const filteredOrders = filterNonDeletedOrders(allDeletedOrders)
// Property: When all orders are deleted, result should be empty
return filteredOrders.length === 0
}
),
{ numRuns: 100 }
)
})
})
/**
* Additional property: Order deletion eligibility based on status
* This validates the frontend's canDeleteOrder function
* **Validates: Requirements 2.1, 2.2**
*/
describe('Order deletion eligibility', () => {
it('should only allow deletion of non-paid orders', () => {
fc.assert(
fc.property(
fc.constantFrom(ORDER_STATUS.PENDING, ORDER_STATUS.PAID, ORDER_STATUS.CANCELLED, ORDER_STATUS.REFUNDED),
(status) => {
const canDelete = canDeleteOrder(status)
// Property: canDelete should be true if and only if status is not PAID
return canDelete === (status !== ORDER_STATUS.PAID)
}
),
{ numRuns: 100 }
)
})
it('should never allow deletion of paid orders', () => {
fc.assert(
fc.property(
fc.constant(ORDER_STATUS.PAID),
(status) => {
return canDeleteOrder(status) === false
}
),
{ numRuns: 100 }
)
})
it('should always allow deletion of pending, cancelled, and refunded orders', () => {
fc.assert(
fc.property(
fc.constantFrom(ORDER_STATUS.PENDING, ORDER_STATUS.CANCELLED, ORDER_STATUS.REFUNDED),
(status) => {
return canDeleteOrder(status) === true
}
),
{ numRuns: 100 }
)
})
})
})

534
admin/package-lock.json generated
View File

@ -24,14 +24,17 @@
"@typescript-eslint/eslint-plugin": "^8.33.0",
"@typescript-eslint/parser": "^8.33.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vitest/coverage-v8": "^4.0.16",
"eslint": "^9.28.0",
"eslint-plugin-vue": "^10.1.0",
"fast-check": "^4.5.3",
"prettier": "^3.5.3",
"sass": "^1.97.1",
"typescript": "~5.8.3",
"unplugin-auto-import": "^20.3.0",
"unplugin-vue-components": "^30.0.0",
"vite": "^6.3.5",
"vitest": "^4.0.16",
"vue-tsc": "^2.2.10"
}
},
@ -81,6 +84,16 @@
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
@ -1485,6 +1498,31 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1786,6 +1824,159 @@
"vue": "^3.2.25"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.16.tgz",
"integrity": "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.0.16",
"ast-v8-to-istanbul": "^0.3.8",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^5.0.6",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.1",
"obug": "^2.1.1",
"std-env": "^3.10.0",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.0.16",
"vitest": "4.0.16"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz",
"integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.0.16",
"@vitest/utils": "4.0.16",
"chai": "^6.2.1",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz",
"integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.0.16",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz",
"integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz",
"integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.0.16",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz",
"integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.16",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz",
"integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz",
"integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.16",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@volar/language-core": {
"version": "2.4.15",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
@ -2143,6 +2334,38 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "0.3.10",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz",
"integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^9.0.1"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
@ -2236,6 +2459,16 @@
"node": ">=6"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -2504,6 +2737,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@ -2839,6 +3079,16 @@
"node": ">=0.10.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
@ -2846,6 +3096,29 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-check": {
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.5.3.tgz",
"integrity": "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^7.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -3150,6 +3423,13 @@
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
@ -3247,6 +3527,60 @@
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-source-maps": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.23",
"debug": "^4.1.1",
"istanbul-lib-coverage": "^3.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
@ -3387,6 +3721,34 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz",
"integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@babel/types": "^7.28.5",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -3578,6 +3940,17 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -3817,6 +4190,23 @@
"node": ">=6"
}
},
"node_modules/pure-rand": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
"integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@ -3971,6 +4361,13 @@
"node": ">=8"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -3989,6 +4386,20 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@ -4040,6 +4451,23 @@
"node": ">=8"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -4057,6 +4485,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -4376,6 +4814,85 @@
}
}
},
"node_modules/vitest": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz",
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",
"@vitest/pretty-format": "4.0.16",
"@vitest/runner": "4.0.16",
"@vitest/snapshot": "4.0.16",
"@vitest/spy": "4.0.16",
"@vitest/utils": "4.0.16",
"es-module-lexer": "^1.7.0",
"expect-type": "^1.2.2",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.16",
"@vitest/browser-preview": "4.0.16",
"@vitest/browser-webdriverio": "4.0.16",
"@vitest/ui": "4.0.16",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
@ -4503,6 +5020,23 @@
"node": ">= 8"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",

View File

@ -8,7 +8,9 @@
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --fix",
"format": "prettier --write src/"
"format": "prettier --write src/",
"test": "vitest --run",
"test:watch": "vitest"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
@ -27,14 +29,17 @@
"@typescript-eslint/eslint-plugin": "^8.33.0",
"@typescript-eslint/parser": "^8.33.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vitest/coverage-v8": "^4.0.16",
"eslint": "^9.28.0",
"eslint-plugin-vue": "^10.1.0",
"fast-check": "^4.5.3",
"prettier": "^3.5.3",
"sass": "^1.97.1",
"typescript": "~5.8.3",
"unplugin-auto-import": "^20.3.0",
"unplugin-vue-components": "^30.0.0",
"vite": "^6.3.5",
"vitest": "^4.0.16",
"vue-tsc": "^2.2.10"
}
}

View File

@ -11,7 +11,8 @@ import type {
OrderQueryParams,
RefundRequest,
RefundResponse,
OrderStatistics
OrderStatistics,
BatchDeleteResponse
} from '@/types/order.d'
/**
@ -49,3 +50,23 @@ export function refundOrder(id: number, data: RefundRequest): Promise<RefundResp
export function getOrderStatistics(): Promise<OrderStatistics> {
return request.get('/admin/orders/statistics')
}
/**
*
* Requirements: 1.2
* @param id ID
* @returns Promise<void>
*/
export function deleteOrder(id: number): Promise<void> {
return request.delete(`/admin/orders/${id}`)
}
/**
*
* Requirements: 3.1
* @param ids ID列表
* @returns
*/
export function batchDeleteOrders(ids: number[]): Promise<BatchDeleteResponse> {
return request.delete('/admin/orders/batch', { data: { orderIds: ids } })
}

View File

@ -186,3 +186,18 @@ export interface DailyIncomeStat {
/** 收入金额 */
income: number
}
/**
*
* Requirements: 3.1, 3.4
*/
export interface BatchDeleteResponse {
/** 成功删除数量 */
successCount: number
/** 跳过数量 */
skippedCount: number
/** 跳过的订单ID列表 */
skippedOrderIds: number[]
/** 消息 */
message: string
}

View File

@ -1,16 +1,16 @@
<script setup lang="ts">
/**
* 订单列表页面
* Requirements: 5.1
* Requirements: 5.1, 1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 3.1, 3.2, 3.4
*/
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { View, RefreshRight } from '@element-plus/icons-vue'
import { View, RefreshRight, Delete } from '@element-plus/icons-vue'
import SearchForm from '@/components/SearchForm/index.vue'
import Pagination from '@/components/Pagination/index.vue'
import StatusTag from '@/components/StatusTag/index.vue'
import { getOrderList, getOrderDetail, refundOrder } from '@/api/order'
import type { OrderListItem, OrderDetail, OrderQueryParams } from '@/types/order.d'
import { getOrderList, getOrderDetail, refundOrder, deleteOrder, batchDeleteOrders } from '@/api/order'
import type { OrderListItem, OrderDetail, OrderQueryParams, BatchDeleteResponse } from '@/types/order.d'
//
const loading = ref(false)
@ -68,6 +68,23 @@ const refundForm = reactive({
reason: ''
})
//
const selectedOrders = ref<OrderListItem[]>([])
//
const deleteLoading = ref(false)
//
const canBatchDelete = computed(() => {
return selectedOrders.value.length > 0
})
//
const canDeleteOrder = (status: number): boolean => {
// 2
return status !== 2
}
//
const fetchOrderList = async () => {
loading.value = true
@ -195,6 +212,95 @@ const handleConfirmRefund = async () => {
}
}
//
const handleSelectionChange = (selection: OrderListItem[]) => {
selectedOrders.value = selection
}
//
// Requirements: 1.1, 1.2, 1.4
const handleDelete = async (row: OrderListItem) => {
try {
await ElMessageBox.confirm(
`确定要删除订单「${row.orderNo}」吗?此操作不可撤销。`,
'删除确认',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
}
)
deleteLoading.value = true
await deleteOrder(row.orderId)
ElMessage.success('删除成功')
fetchOrderList()
} catch (error: any) {
if (error !== 'cancel') {
const errorMsg = error?.response?.data?.message || error?.message || '删除失败'
ElMessage.error(errorMsg)
console.error('删除订单失败:', error)
}
} finally {
deleteLoading.value = false
}
}
//
// Requirements: 3.1, 3.2, 3.3, 3.4
const handleBatchDelete = async () => {
if (selectedOrders.value.length === 0) {
ElMessage.warning('请先选择要删除的订单')
return
}
const selectedCount = selectedOrders.value.length
const paidCount = selectedOrders.value.filter(o => o.status === 2).length
let confirmMessage = `确定要删除选中的 ${selectedCount} 个订单吗?`
if (paidCount > 0) {
confirmMessage += `\n\n注意其中 ${paidCount} 个已支付订单将被跳过。`
}
try {
await ElMessageBox.confirm(
confirmMessage,
'批量删除确认',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
}
)
deleteLoading.value = true
const orderIds = selectedOrders.value.map(o => o.orderId)
const result: BatchDeleteResponse = await batchDeleteOrders(orderIds)
//
if (result.successCount > 0 && result.skippedCount === 0) {
ElMessage.success(`成功删除 ${result.successCount} 个订单`)
} else if (result.successCount > 0 && result.skippedCount > 0) {
ElMessage.warning(`成功删除 ${result.successCount} 个订单,跳过 ${result.skippedCount} 个已支付订单`)
} else if (result.successCount === 0 && result.skippedCount > 0) {
ElMessage.warning(`所有选中订单均为已支付状态,无法删除`)
} else {
ElMessage.info(result.message || '操作完成')
}
selectedOrders.value = []
fetchOrderList()
} catch (error: any) {
if (error !== 'cancel') {
const errorMsg = error?.response?.data?.message || error?.message || '批量删除失败'
ElMessage.error(errorMsg)
console.error('批量删除订单失败:', error)
}
} finally {
deleteLoading.value = false
}
}
//
const formatTime = (time: string | null) => {
if (!time) return '-'
@ -304,13 +410,35 @@ onMounted(() => {
<!-- 数据表格 -->
<el-card shadow="never">
<!-- 工具栏 -->
<div class="table-toolbar">
<el-button
type="danger"
:icon="Delete"
:disabled="!canBatchDelete"
:loading="deleteLoading"
@click="handleBatchDelete"
>
批量删除
</el-button>
<span v-if="selectedOrders.length > 0" class="selection-info">
已选择 {{ selectedOrders.length }}
</span>
</div>
<el-table
v-loading="loading"
:data="orderList"
stripe
border
style="width: 100%"
@selection-change="handleSelectionChange"
>
<el-table-column
type="selection"
width="55"
fixed="left"
/>
<el-table-column
prop="orderNo"
label="订单号"
@ -404,7 +532,7 @@ onMounted(() => {
</el-table-column>
<el-table-column
label="操作"
width="150"
width="200"
fixed="right"
align="center"
>
@ -426,6 +554,30 @@ onMounted(() => {
>
退款
</el-button>
<el-tooltip
v-if="!canDeleteOrder(row.status)"
content="已支付订单不可删除"
placement="top"
>
<el-button
type="danger"
link
:icon="Delete"
disabled
>
删除
</el-button>
</el-tooltip>
<el-button
v-else
type="danger"
link
:icon="Delete"
:loading="deleteLoading"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
@ -573,6 +725,18 @@ onMounted(() => {
<style scoped lang="scss">
.order-list-page {
.table-toolbar {
display: flex;
align-items: center;
margin-bottom: 16px;
gap: 12px;
.selection-info {
color: #909399;
font-size: 14px;
}
}
.user-info {
.nickname {
font-weight: 500;

18
admin/vitest.config.ts Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config'
import { resolve } from 'path'
export default defineConfig({
test: {
environment: 'node',
globals: true,
include: ['__tests__/**/*.test.ts', '__tests__/**/*.property.test.ts'],
coverage: {
reporter: ['text', 'json', 'html'],
},
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
})

View File

@ -73,6 +73,32 @@ public class AdminOrderController : ControllerBase
return ApiResponse<AdminOrderStatisticsDto>.Success(result);
}
/// <summary>
/// 删除订单(软删除)
/// </summary>
/// <param name="id">订单ID</param>
/// <returns>操作结果</returns>
[HttpDelete("{id}")]
public async Task<ApiResponse> DeleteOrder(long id)
{
var adminId = GetCurrentAdminId();
await _adminOrderService.DeleteOrderAsync(id, adminId);
return ApiResponse.Success("删除成功");
}
/// <summary>
/// 批量删除订单(软删除)
/// </summary>
/// <param name="request">批量删除请求</param>
/// <returns>批量删除结果</returns>
[HttpDelete("batch")]
public async Task<ApiResponse<BatchDeleteOrderResult>> BatchDeleteOrders([FromBody] BatchDeleteOrderRequest request)
{
var adminId = GetCurrentAdminId();
var result = await _adminOrderService.BatchDeleteOrdersAsync(request.OrderIds, adminId);
return ApiResponse<BatchDeleteOrderResult>.Success(result);
}
/// <summary>
/// 获取当前管理员ID
/// </summary>

View File

@ -61,3 +61,14 @@ public class AdminOrderRefundRequest
/// </summary>
public string? Reason { get; set; }
}
/// <summary>
/// 批量删除订单请求
/// </summary>
public class BatchDeleteOrderRequest
{
/// <summary>
/// 订单ID列表
/// </summary>
public List<long> OrderIds { get; set; } = new();
}

View File

@ -330,3 +330,29 @@ public class DailyIncomeStatDto
/// </summary>
public decimal Income { get; set; }
}
/// <summary>
/// 批量删除订单结果
/// </summary>
public class BatchDeleteOrderResult
{
/// <summary>
/// 成功删除数量
/// </summary>
public int SuccessCount { get; set; }
/// <summary>
/// 跳过数量(不可删除的订单)
/// </summary>
public int SkippedCount { get; set; }
/// <summary>
/// 跳过的订单ID列表
/// </summary>
public List<long> SkippedOrderIds { get; set; } = new();
/// <summary>
/// 结果消息
/// </summary>
public string Message { get; set; } = string.Empty;
}

View File

@ -36,4 +36,27 @@ public interface IAdminOrderService
/// </summary>
/// <returns>统计数据</returns>
Task<AdminOrderStatisticsDto> GetOrderStatisticsAsync();
/// <summary>
/// 删除单个订单(软删除)
/// </summary>
/// <param name="orderId">订单ID</param>
/// <param name="adminId">操作管理员ID</param>
/// <returns>删除结果</returns>
Task DeleteOrderAsync(long orderId, long adminId);
/// <summary>
/// 批量删除订单(软删除)
/// </summary>
/// <param name="orderIds">订单ID列表</param>
/// <param name="adminId">操作管理员ID</param>
/// <returns>批量删除结果</returns>
Task<BatchDeleteOrderResult> BatchDeleteOrdersAsync(List<long> orderIds, long adminId);
/// <summary>
/// 检查订单是否可删除
/// </summary>
/// <param name="orderStatus">订单状态</param>
/// <returns>是否可删除</returns>
bool CanDeleteOrder(int orderStatus);
}

View File

@ -71,6 +71,9 @@ public class AdminOrderService : IAdminOrderService
var orders = await _orderRepository.GetAllAsync();
var query = orders.AsQueryable();
// Filter out deleted orders (soft delete)
query = query.Where(o => !o.IsDeleted);
// Filter by order type
if (request.OrderType.HasValue)
{
@ -248,8 +251,10 @@ public class AdminOrderService : IAdminOrderService
var weekStart = today.AddDays(-(int)today.DayOfWeek);
var monthStart = new DateTime(now.Year, now.Month, 1);
// Get all orders
var allOrders = await _orderRepository.GetAllAsync();
// Get all orders (excluding deleted orders)
var allOrders = (await _orderRepository.GetAllAsync())
.Where(o => !o.IsDeleted)
.ToList();
// Total orders
var totalOrders = allOrders.Count;
@ -330,6 +335,102 @@ public class AdminOrderService : IAdminOrderService
};
}
#region Delete Methods
/// <inheritdoc />
public bool CanDeleteOrder(int orderStatus)
{
// Only paid orders (status = 2) cannot be deleted
// Pending (1), Cancelled (3), and Refunded (4) can be deleted
return orderStatus != (int)OrderStatus.Paid;
}
/// <inheritdoc />
public async Task DeleteOrderAsync(long orderId, long adminId)
{
var order = await _orderRepository.GetByIdAsync(orderId);
if (order == null)
{
throw new BusinessException(ErrorCodes.OrderNotFound, "订单不存在");
}
if (order.IsDeleted)
{
throw new BusinessException(ErrorCodes.OrderNotFound, "订单已被删除");
}
if (!CanDeleteOrder(order.Status))
{
throw new BusinessException(ErrorCodes.OrderCannotDelete, "已支付订单不可删除");
}
// Soft delete
order.IsDeleted = true;
order.DeleteTime = DateTime.Now;
order.UpdateTime = DateTime.Now;
await _orderRepository.UpdateAsync(order);
_logger.LogInformation("订单已删除: AdminId={AdminId}, OrderId={OrderId}, OrderNo={OrderNo}",
adminId, orderId, order.OrderNo);
}
/// <inheritdoc />
public async Task<BatchDeleteOrderResult> BatchDeleteOrdersAsync(List<long> orderIds, long adminId)
{
var result = new BatchDeleteOrderResult();
if (orderIds == null || orderIds.Count == 0)
{
result.Message = "没有选择要删除的订单";
return result;
}
var orders = await _orderRepository.GetListAsync(o => orderIds.Contains(o.Id) && !o.IsDeleted);
var now = DateTime.Now;
foreach (var order in orders)
{
if (CanDeleteOrder(order.Status))
{
order.IsDeleted = true;
order.DeleteTime = now;
order.UpdateTime = now;
await _orderRepository.UpdateAsync(order);
result.SuccessCount++;
}
else
{
result.SkippedCount++;
result.SkippedOrderIds.Add(order.Id);
}
}
// Build result message
if (result.SuccessCount > 0 && result.SkippedCount > 0)
{
result.Message = $"成功删除 {result.SuccessCount} 个订单,跳过 {result.SkippedCount} 个已支付订单";
}
else if (result.SuccessCount > 0)
{
result.Message = $"成功删除 {result.SuccessCount} 个订单";
}
else if (result.SkippedCount > 0)
{
result.Message = $"所有选中订单均为已支付状态,无法删除";
}
else
{
result.Message = "没有找到可删除的订单";
}
_logger.LogInformation("批量删除订单: AdminId={AdminId}, SuccessCount={SuccessCount}, SkippedCount={SkippedCount}",
adminId, result.SuccessCount, result.SkippedCount);
return result;
}
#endregion
#region Private Helper Methods
private static AdminOrderListDto MapToOrderListDto(Order order, User? user)

View File

@ -57,6 +57,7 @@ public static class ErrorCodes
public const int MenuHasChildren = 40029;
public const int CannotDeleteSelf = 40030;
public const int PermissionNotFound = 40031;
public const int OrderCannotDelete = 40032;
#endregion
#region 50000-59999

View File

@ -6,7 +6,7 @@ namespace XiangYi.Core.Entities.Biz;
/// 订单表
/// </summary>
[Table(Name = "Order")]
public class Order : BaseEntity
public class Order : SoftDeleteEntity
{
/// <summary>
/// 订单号

View File

@ -0,0 +1,525 @@
using FsCheck;
using FsCheck.Xunit;
using NSubstitute;
using Xunit;
using XiangYi.Application.Services;
using XiangYi.Core.Entities.Biz;
using XiangYi.Core.Enums;
using XiangYi.Core.Interfaces;
using Microsoft.Extensions.Logging;
namespace XiangYi.Application.Tests.Services;
/// <summary>
/// AdminOrderService属性测试 - 订单删除功能
/// </summary>
public class AdminOrderDeletePropertyTests
{
/// <summary>
/// **Feature: order-delete, Property 3: Order deletion eligibility is determined by status**
/// **Validates: Requirements 2.1, 2.2, 2.3**
///
/// *For any* order, the order is deletable if and only if its status is NOT "已支付" (status = 2).
/// Orders with status 1 (待支付), 3 (已取消), or 4 (已退款) SHALL be deletable.
/// </summary>
[Property(MaxTest = 100)]
public Property CanDeleteOrder_PaidStatus_ShouldReturnFalse()
{
// Paid status (2) should NOT be deletable
return Prop.ForAll(
Arb.Default.PositiveInt(),
_ =>
{
var paidStatus = (int)OrderStatus.Paid; // 2
var service = new AdminOrderService(null!, null!, null!, null!, null!);
var canDelete = service.CanDeleteOrder(paidStatus);
return !canDelete;
});
}
/// <summary>
/// **Feature: order-delete, Property 3: Order deletion eligibility is determined by status**
/// **Validates: Requirements 2.1, 2.2, 2.3**
///
/// Pending orders (status = 1) should be deletable
/// </summary>
[Property(MaxTest = 100)]
public Property CanDeleteOrder_PendingStatus_ShouldReturnTrue()
{
return Prop.ForAll(
Arb.Default.PositiveInt(),
_ =>
{
var pendingStatus = (int)OrderStatus.Pending; // 1
var service = new AdminOrderService(null!, null!, null!, null!, null!);
var canDelete = service.CanDeleteOrder(pendingStatus);
return canDelete;
});
}
/// <summary>
/// **Feature: order-delete, Property 3: Order deletion eligibility is determined by status**
/// **Validates: Requirements 2.1, 2.2, 2.3**
///
/// Cancelled orders (status = 3) should be deletable
/// </summary>
[Property(MaxTest = 100)]
public Property CanDeleteOrder_CancelledStatus_ShouldReturnTrue()
{
return Prop.ForAll(
Arb.Default.PositiveInt(),
_ =>
{
var cancelledStatus = (int)OrderStatus.Cancelled; // 3
var service = new AdminOrderService(null!, null!, null!, null!, null!);
var canDelete = service.CanDeleteOrder(cancelledStatus);
return canDelete;
});
}
/// <summary>
/// **Feature: order-delete, Property 3: Order deletion eligibility is determined by status**
/// **Validates: Requirements 2.1, 2.2, 2.3**
///
/// Refunded orders (status = 4) should be deletable
/// </summary>
[Property(MaxTest = 100)]
public Property CanDeleteOrder_RefundedStatus_ShouldReturnTrue()
{
return Prop.ForAll(
Arb.Default.PositiveInt(),
_ =>
{
var refundedStatus = (int)OrderStatus.Refunded; // 4
var service = new AdminOrderService(null!, null!, null!, null!, null!);
var canDelete = service.CanDeleteOrder(refundedStatus);
return canDelete;
});
}
/// <summary>
/// **Feature: order-delete, Property 3: Order deletion eligibility is determined by status**
/// **Validates: Requirements 2.1, 2.2, 2.3**
///
/// *For any* valid order status, only paid status (2) should return false for CanDeleteOrder
/// </summary>
[Property(MaxTest = 100)]
public Property CanDeleteOrder_AllValidStatuses_OnlyPaidShouldBeFalse()
{
var validStatusArb = Gen.Elements(1, 2, 3, 4).ToArbitrary();
return Prop.ForAll(
validStatusArb,
status =>
{
var service = new AdminOrderService(null!, null!, null!, null!, null!);
var canDelete = service.CanDeleteOrder(status);
// Only status 2 (Paid) should return false
var expectedCanDelete = status != (int)OrderStatus.Paid;
return canDelete == expectedCanDelete;
});
}
/// <summary>
/// **Feature: order-delete, Property 3: Order deletion eligibility is determined by status**
/// **Validates: Requirements 2.1, 2.2, 2.3**
///
/// *For any* random status value, the result should be consistent with the rule:
/// deletable if and only if status != 2
/// </summary>
[Property(MaxTest = 100)]
public Property CanDeleteOrder_AnyStatus_ShouldFollowRule()
{
var statusArb = Gen.Choose(-10, 10).ToArbitrary();
return Prop.ForAll(
statusArb,
status =>
{
var service = new AdminOrderService(null!, null!, null!, null!, null!);
var canDelete = service.CanDeleteOrder(status);
// Rule: deletable if and only if status != 2 (Paid)
var expectedCanDelete = status != (int)OrderStatus.Paid;
return canDelete == expectedCanDelete;
});
}
}
/// <summary>
/// AdminOrderService属性测试 - 软删除行为
/// </summary>
public class AdminOrderSoftDeletePropertyTests
{
/// <summary>
/// **Feature: order-delete, Property 1: Soft delete marks order as deleted**
/// **Validates: Requirements 1.2**
///
/// *For any* order that is eligible for deletion (status is pending, cancelled, or refunded),
/// when the delete operation is performed, the order's IsDeleted field SHALL be set to true
/// and DeleteTime SHALL be set to the current timestamp.
/// </summary>
[Property(MaxTest = 100)]
public Property DeleteOrder_EligibleOrder_ShouldSetIsDeletedAndDeleteTime()
{
// Generate eligible statuses (1=Pending, 3=Cancelled, 4=Refunded)
var eligibleStatusArb = Gen.Elements(
(int)OrderStatus.Pending,
(int)OrderStatus.Cancelled,
(int)OrderStatus.Refunded
).ToArbitrary();
var orderIdArb = Gen.Choose(1, int.MaxValue).Select(x => (long)x).ToArbitrary();
var adminIdArb = Gen.Choose(1, int.MaxValue).Select(x => (long)x).ToArbitrary();
return Prop.ForAll(
eligibleStatusArb,
orderIdArb,
adminIdArb,
(status, orderId, adminId) =>
{
// Arrange
var order = new Order
{
Id = orderId,
OrderNo = $"TEST{orderId}",
Status = status,
IsDeleted = false,
DeleteTime = null
};
var orderRepository = Substitute.For<IRepository<Order>>();
orderRepository.GetByIdAsync(orderId).Returns(Task.FromResult<Order?>(order));
orderRepository.UpdateAsync(Arg.Any<Order>()).Returns(Task.FromResult(1));
var userRepository = Substitute.For<IRepository<User>>();
var memberRepository = Substitute.For<IRepository<Member>>();
var weChatService = Substitute.For<Infrastructure.WeChat.IWeChatService>();
var logger = Substitute.For<ILogger<AdminOrderService>>();
var service = new AdminOrderService(
orderRepository, userRepository, memberRepository, weChatService, logger);
// Act
var beforeDelete = DateTime.Now;
service.DeleteOrderAsync(orderId, adminId).Wait();
var afterDelete = DateTime.Now;
// Assert
return order.IsDeleted == true &&
order.DeleteTime != null &&
order.DeleteTime >= beforeDelete &&
order.DeleteTime <= afterDelete;
});
}
/// <summary>
/// **Feature: order-delete, Property 1: Soft delete marks order as deleted**
/// **Validates: Requirements 1.2**
///
/// Soft delete should preserve the original order data (only IsDeleted and DeleteTime change)
/// </summary>
[Property(MaxTest = 100)]
public Property DeleteOrder_ShouldPreserveOriginalData()
{
var eligibleStatusArb = Gen.Elements(
(int)OrderStatus.Pending,
(int)OrderStatus.Cancelled,
(int)OrderStatus.Refunded
).ToArbitrary();
var orderIdArb = Gen.Choose(1, int.MaxValue).Select(x => (long)x).ToArbitrary();
var amountArb = Gen.Choose(1, 10000).Select(x => (decimal)x).ToArbitrary();
return Prop.ForAll(
eligibleStatusArb,
orderIdArb,
amountArb,
(status, orderId, amount) =>
{
// Arrange
var userId = orderId + 1000; // Derive userId from orderId
var originalOrderNo = $"TEST{orderId}";
var originalProductName = "Test Product";
var originalOrderType = 1;
var order = new Order
{
Id = orderId,
OrderNo = originalOrderNo,
UserId = userId,
Status = status,
Amount = amount,
PayAmount = amount,
ProductName = originalProductName,
OrderType = originalOrderType,
IsDeleted = false,
DeleteTime = null
};
var orderRepository = Substitute.For<IRepository<Order>>();
orderRepository.GetByIdAsync(orderId).Returns(Task.FromResult<Order?>(order));
orderRepository.UpdateAsync(Arg.Any<Order>()).Returns(Task.FromResult(1));
var userRepository = Substitute.For<IRepository<User>>();
var memberRepository = Substitute.For<IRepository<Member>>();
var weChatService = Substitute.For<Infrastructure.WeChat.IWeChatService>();
var logger = Substitute.For<ILogger<AdminOrderService>>();
var service = new AdminOrderService(
orderRepository, userRepository, memberRepository, weChatService, logger);
// Act
service.DeleteOrderAsync(orderId, 1).Wait();
// Assert - Original data should be preserved
return order.OrderNo == originalOrderNo &&
order.UserId == userId &&
order.Status == status &&
order.Amount == amount &&
order.ProductName == originalProductName &&
order.OrderType == originalOrderType &&
order.IsDeleted == true; // Only IsDeleted should change
});
}
}
/// <summary>
/// AdminOrderService属性测试 - 批量删除过滤
/// </summary>
public class AdminOrderBatchDeletePropertyTests
{
/// <summary>
/// **Feature: order-delete, Property 4: Batch deletion correctly filters and reports results**
/// **Validates: Requirements 3.3, 3.4**
///
/// *For any* batch of order IDs submitted for deletion, the operation SHALL delete only
/// eligible orders (non-paid status), skip ineligible orders, and return accurate counts
/// of successful deletions and skipped orders.
/// </summary>
[Property(MaxTest = 100)]
public Property BatchDelete_ShouldCorrectlyFilterAndReportResults()
{
// Generate a mix of order statuses
var orderCountArb = Gen.Choose(1, 10).ToArbitrary();
return Prop.ForAll(
orderCountArb,
orderCount =>
{
// Arrange - Create orders with mixed statuses
var orders = new List<Order>();
var orderIds = new List<long>();
var expectedSuccessCount = 0;
var expectedSkippedCount = 0;
var expectedSkippedIds = new List<long>();
for (int i = 1; i <= orderCount; i++)
{
var orderId = (long)i;
// Alternate between statuses: 1, 2, 3, 4, 1, 2, 3, 4...
var status = ((i - 1) % 4) + 1;
var order = new Order
{
Id = orderId,
OrderNo = $"TEST{orderId}",
Status = status,
IsDeleted = false,
DeleteTime = null
};
orders.Add(order);
orderIds.Add(orderId);
if (status == (int)OrderStatus.Paid)
{
expectedSkippedCount++;
expectedSkippedIds.Add(orderId);
}
else
{
expectedSuccessCount++;
}
}
var orderRepository = Substitute.For<IRepository<Order>>();
orderRepository.GetListAsync(Arg.Any<System.Linq.Expressions.Expression<Func<Order, bool>>>())
.Returns(Task.FromResult<List<Order>>(orders));
orderRepository.UpdateAsync(Arg.Any<Order>()).Returns(Task.FromResult(1));
var userRepository = Substitute.For<IRepository<User>>();
var memberRepository = Substitute.For<IRepository<Member>>();
var weChatService = Substitute.For<Infrastructure.WeChat.IWeChatService>();
var logger = Substitute.For<ILogger<AdminOrderService>>();
var service = new AdminOrderService(
orderRepository, userRepository, memberRepository, weChatService, logger);
// Act
var result = service.BatchDeleteOrdersAsync(orderIds, 1).Result;
// Assert
return result.SuccessCount == expectedSuccessCount &&
result.SkippedCount == expectedSkippedCount &&
result.SkippedOrderIds.Count == expectedSkippedIds.Count;
});
}
/// <summary>
/// **Feature: order-delete, Property 4: Batch deletion correctly filters and reports results**
/// **Validates: Requirements 3.3, 3.4**
///
/// When all orders are eligible (non-paid), all should be deleted successfully
/// </summary>
[Property(MaxTest = 100)]
public Property BatchDelete_AllEligible_ShouldDeleteAll()
{
var orderCountArb = Gen.Choose(1, 10).ToArbitrary();
var eligibleStatusArb = Gen.Elements(
(int)OrderStatus.Pending,
(int)OrderStatus.Cancelled,
(int)OrderStatus.Refunded
).ToArbitrary();
return Prop.ForAll(
orderCountArb,
eligibleStatusArb,
(orderCount, status) =>
{
// Arrange - Create orders with only eligible statuses
var orders = new List<Order>();
var orderIds = new List<long>();
for (int i = 1; i <= orderCount; i++)
{
var orderId = (long)i;
var order = new Order
{
Id = orderId,
OrderNo = $"TEST{orderId}",
Status = status,
IsDeleted = false,
DeleteTime = null
};
orders.Add(order);
orderIds.Add(orderId);
}
var orderRepository = Substitute.For<IRepository<Order>>();
orderRepository.GetListAsync(Arg.Any<System.Linq.Expressions.Expression<Func<Order, bool>>>())
.Returns(Task.FromResult<List<Order>>(orders));
orderRepository.UpdateAsync(Arg.Any<Order>()).Returns(Task.FromResult(1));
var userRepository = Substitute.For<IRepository<User>>();
var memberRepository = Substitute.For<IRepository<Member>>();
var weChatService = Substitute.For<Infrastructure.WeChat.IWeChatService>();
var logger = Substitute.For<ILogger<AdminOrderService>>();
var service = new AdminOrderService(
orderRepository, userRepository, memberRepository, weChatService, logger);
// Act
var result = service.BatchDeleteOrdersAsync(orderIds, 1).Result;
// Assert
return result.SuccessCount == orderCount &&
result.SkippedCount == 0 &&
result.SkippedOrderIds.Count == 0;
});
}
/// <summary>
/// **Feature: order-delete, Property 4: Batch deletion correctly filters and reports results**
/// **Validates: Requirements 3.3, 3.4**
///
/// When all orders are paid (ineligible), none should be deleted
/// </summary>
[Property(MaxTest = 100)]
public Property BatchDelete_AllPaid_ShouldSkipAll()
{
var orderCountArb = Gen.Choose(1, 10).ToArbitrary();
return Prop.ForAll(
orderCountArb,
orderCount =>
{
// Arrange - Create orders with only paid status
var orders = new List<Order>();
var orderIds = new List<long>();
for (int i = 1; i <= orderCount; i++)
{
var orderId = (long)i;
var order = new Order
{
Id = orderId,
OrderNo = $"TEST{orderId}",
Status = (int)OrderStatus.Paid,
IsDeleted = false,
DeleteTime = null
};
orders.Add(order);
orderIds.Add(orderId);
}
var orderRepository = Substitute.For<IRepository<Order>>();
orderRepository.GetListAsync(Arg.Any<System.Linq.Expressions.Expression<Func<Order, bool>>>())
.Returns(Task.FromResult<List<Order>>(orders));
orderRepository.UpdateAsync(Arg.Any<Order>()).Returns(Task.FromResult(1));
var userRepository = Substitute.For<IRepository<User>>();
var memberRepository = Substitute.For<IRepository<Member>>();
var weChatService = Substitute.For<Infrastructure.WeChat.IWeChatService>();
var logger = Substitute.For<ILogger<AdminOrderService>>();
var service = new AdminOrderService(
orderRepository, userRepository, memberRepository, weChatService, logger);
// Act
var result = service.BatchDeleteOrdersAsync(orderIds, 1).Result;
// Assert
return result.SuccessCount == 0 &&
result.SkippedCount == orderCount &&
result.SkippedOrderIds.Count == orderCount;
});
}
/// <summary>
/// **Feature: order-delete, Property 4: Batch deletion correctly filters and reports results**
/// **Validates: Requirements 3.3, 3.4**
///
/// Empty order list should return zero counts
/// </summary>
[Property(MaxTest = 100)]
public Property BatchDelete_EmptyList_ShouldReturnZeroCounts()
{
return Prop.ForAll(
Arb.Default.PositiveInt(),
_ =>
{
// Arrange
var orderIds = new List<long>();
var orderRepository = Substitute.For<IRepository<Order>>();
var userRepository = Substitute.For<IRepository<User>>();
var memberRepository = Substitute.For<IRepository<Member>>();
var weChatService = Substitute.For<Infrastructure.WeChat.IWeChatService>();
var logger = Substitute.For<ILogger<AdminOrderService>>();
var service = new AdminOrderService(
orderRepository, userRepository, memberRepository, weChatService, logger);
// Act
var result = service.BatchDeleteOrdersAsync(orderIds, 1).Result;
// Assert
return result.SuccessCount == 0 &&
result.SkippedCount == 0 &&
result.SkippedOrderIds.Count == 0;
});
}
}