订单管理.
This commit is contained in:
parent
74de21b28f
commit
07f594523c
181
admin/__tests__/properties/order.property.test.ts
Normal file
181
admin/__tests__/properties/order.property.test.ts
Normal 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
534
admin/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
}
|
||||
|
|
|
|||
15
admin/src/types/order.d.ts
vendored
15
admin/src/types/order.d.ts
vendored
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
18
admin/vitest.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ namespace XiangYi.Core.Entities.Biz;
|
|||
/// 订单表
|
||||
/// </summary>
|
||||
[Table(Name = "Order")]
|
||||
public class Order : BaseEntity
|
||||
public class Order : SoftDeleteEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单号
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user