diff --git a/supplied-h5/src/api/supplied-service.js b/supplied-h5/src/api/supplied-service.js index c6d911d..c228856 100644 --- a/supplied-h5/src/api/supplied-service.js +++ b/supplied-h5/src/api/supplied-service.js @@ -38,3 +38,19 @@ export const postFunction = async (name, body = {}) => { return payload.data } + +export const uploadImageFunction = async (file, folder = 'after-sale') => { + const contentBase64 = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result || '')) + reader.onerror = reject + reader.readAsDataURL(file) + }) + + return postFunction('upload', { + fileName: file.name, + contentType: file.type, + contentBase64, + folder + }) +} diff --git a/supplied-h5/src/router/index.js b/supplied-h5/src/router/index.js index 17ffb26..837d982 100644 --- a/supplied-h5/src/router/index.js +++ b/supplied-h5/src/router/index.js @@ -14,7 +14,12 @@ import JoyfulSelectionAddressList from '@/view/joyful-selection/address/index.vu import JoyfulSelectionAddressEdit from '@/view/joyful-selection/address/edit/index.vue' import JoyfulSelectionOrders from '@/view/joyful-selection/profile/orders/index.vue' import JoyfulSelectionOrderDetail from '@/view/joyful-selection/profile/order-detail/index.vue' -import JoyfulSelectionAfterSale from '@/view/joyful-selection/profile/after-sale/index.vue' +import JoyfulSelectionAfterSale from '@/view/joyful-selection/after-sale/index/index.vue' +import JoyfulSelectionAfterSaleDetail from '@/view/joyful-selection/after-sale/detail/index.vue' +import JoyfulSelectionAfterSaleSelect from '@/view/joyful-selection/after-sale/select/index.vue' +import JoyfulSelectionAfterSaleReturn from '@/view/joyful-selection/after-sale/return/index.vue' +import JoyfulSelectionAfterSaleExchange from '@/view/joyful-selection/after-sale/exchange/index.vue' +import JoyfulSelectionMerchant from '@/view/joyful-selection/merchant/index.vue' import JoyfulSelectionProfile from '@/view/joyful-selection/profile/index.vue' import JoyfulSelectionPoints from '@/view/joyful-selection/profile/points/index.vue' import HomeLiving from '@/view/home-living/index.vue' @@ -45,6 +50,11 @@ export default new Router({ { path: 'orders', component: JoyfulSelectionOrders }, { path: 'order-detail', component: JoyfulSelectionOrderDetail }, { path: 'after-sale', component: JoyfulSelectionAfterSale }, + { path: 'after-sale/detail', component: JoyfulSelectionAfterSaleDetail }, + { path: 'after-sale/select', component: JoyfulSelectionAfterSaleSelect }, + { path: 'after-sale/return', component: JoyfulSelectionAfterSaleReturn }, + { path: 'after-sale/exchange', component: JoyfulSelectionAfterSaleExchange }, + { path: 'merchant', component: JoyfulSelectionMerchant }, { path: 'profile', component: JoyfulSelectionProfile }, { path: 'points', component: JoyfulSelectionPoints } ] diff --git a/supplied-h5/src/view/joyful-selection/address/edit/index.vue b/supplied-h5/src/view/joyful-selection/address/edit/index.vue index 4137f86..a2225ca 100644 --- a/supplied-h5/src/view/joyful-selection/address/edit/index.vue +++ b/supplied-h5/src/view/joyful-selection/address/edit/index.vue @@ -90,7 +90,8 @@ export default { ...nextAddress, addressId: nextAddress.id, supplierId: this.supplierId, - userId: this.userId + userId: this.userId, + isDefault: ['checkout', 'after-sale'].includes(this.$route.query.from) }) saveChosenAddressId(nextAddress.id) Toast('保存成功') diff --git a/supplied-h5/src/view/joyful-selection/address/index.vue b/supplied-h5/src/view/joyful-selection/address/index.vue index 195f04d..2799b02 100644 --- a/supplied-h5/src/view/joyful-selection/address/index.vue +++ b/supplied-h5/src/view/joyful-selection/address/index.vue @@ -81,7 +81,7 @@ export default { this.chosenAddressId = item.id saveChosenAddressId(item.id) - if (this.$route.query.from === 'checkout') { + if (['checkout', 'after-sale'].includes(this.$route.query.from)) { const address = this.addresses.find(addressItem => addressItem.id === item.id) if (address) { try { @@ -101,10 +101,10 @@ export default { } }, onAdd() { - this.$router.push('/joyful-selection/address/edit') + this.$router.push({ path: '/joyful-selection/address/edit', query: { from: this.$route.query.from } }) }, onEdit(item) { - this.$router.push({ path: '/joyful-selection/address/edit', query: { id: item.id } }) + this.$router.push({ path: '/joyful-selection/address/edit', query: { id: item.id, from: this.$route.query.from } }) } } } diff --git a/supplied-h5/src/view/joyful-selection/after-sale/detail/index.vue b/supplied-h5/src/view/joyful-selection/after-sale/detail/index.vue new file mode 100644 index 0000000..bbf8d85 --- /dev/null +++ b/supplied-h5/src/view/joyful-selection/after-sale/detail/index.vue @@ -0,0 +1,766 @@ + + + + + diff --git a/supplied-h5/src/view/joyful-selection/after-sale/exchange/index.vue b/supplied-h5/src/view/joyful-selection/after-sale/exchange/index.vue new file mode 100644 index 0000000..5636c67 --- /dev/null +++ b/supplied-h5/src/view/joyful-selection/after-sale/exchange/index.vue @@ -0,0 +1,819 @@ + + + + + diff --git a/supplied-h5/src/view/joyful-selection/profile/after-sale/index.vue b/supplied-h5/src/view/joyful-selection/after-sale/index/index.vue similarity index 69% rename from supplied-h5/src/view/joyful-selection/profile/after-sale/index.vue rename to supplied-h5/src/view/joyful-selection/after-sale/index/index.vue index fdcd89a..4995ffb 100644 --- a/supplied-h5/src/view/joyful-selection/profile/after-sale/index.vue +++ b/supplied-h5/src/view/joyful-selection/after-sale/index/index.vue @@ -3,10 +3,20 @@ - + + + + - + @@ -46,15 +57,38 @@ import { Toast } from 'vant' import { postFunction } from '@/api/supplied-service' import { useUserStore } from '@/store/user' +const SKU_COLOR_MAP = { + cream: '奶油白', + blue: '雾霾蓝', + orange: '暖橙色' +} + +const SKU_SIZE_MAP = { + standard: '标准款', + plus: '升级款' +} + export default { name: 'JoyfulSelectionAfterSale', data: () => ({ + activeTab: 'processing', afterSaleItems: [], loading: false, refreshing: false }), computed: { - ...mapState(useUserStore, ['supplierId', 'userId']) + ...mapState(useUserStore, ['supplierId', 'userId']), + currentAfterSaleItems() { + return this.afterSaleItems.filter(item => { + const serviceState = Number(item.serviceState) + const isHistory = [2, 5, 6].includes(serviceState) + + return this.activeTab === 'history' ? isHistory : !isHistory + }) + }, + emptyDescription() { + return this.activeTab === 'history' ? '暂无历史售后' : '暂无处理中售后' + } }, created() { this.loadAfterSales() @@ -64,6 +98,22 @@ export default { previewProducts(products = []) { return products.slice(0, 3) }, + skuText(product = {}) { + const sku = String(product.sku || product.skuText || '').trim() + const parts = sku.split('-').slice(-2).filter(Boolean) + const color = SKU_COLOR_MAP[parts[0]] || parts[0] + const size = SKU_SIZE_MAP[parts[1]] || parts[1] + + return color || size ? [color, size].filter(Boolean).join(' | ') : '-' + }, + goDetail(item) { + this.$router.push({ + path: '/joyful-selection/after-sale/detail', + query: { + serviceOrderId: item.serviceOrderId + } + }) + }, async loadAfterSales(showLoading = true) { if (showLoading) { this.loading = true @@ -122,7 +172,11 @@ export default { .after-sale-list { min-height: calc(100vh - 46px); box-sizing: border-box; - padding: 12px 10px 18px; + padding-bottom: 18px; +} + +.after-sale-tab-panel { + padding: 12px 10px 0; } .after-sale-card { @@ -132,6 +186,16 @@ export default { background: #fff; } +.skeleton-card { + padding: 14px 12px; + border-radius: 8px; + background: #fff; +} + +.skeleton-card ::v-deep .van-skeleton + .van-skeleton { + margin-top: 18px; +} + .after-sale-card__header { display: flex; align-items: center; diff --git a/supplied-h5/src/view/joyful-selection/after-sale/return/index.vue b/supplied-h5/src/view/joyful-selection/after-sale/return/index.vue new file mode 100644 index 0000000..7e6544e --- /dev/null +++ b/supplied-h5/src/view/joyful-selection/after-sale/return/index.vue @@ -0,0 +1,642 @@ + + + + + diff --git a/supplied-h5/src/view/joyful-selection/after-sale/select/index.vue b/supplied-h5/src/view/joyful-selection/after-sale/select/index.vue new file mode 100644 index 0000000..1b40767 --- /dev/null +++ b/supplied-h5/src/view/joyful-selection/after-sale/select/index.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/supplied-h5/src/view/joyful-selection/merchant/index.vue b/supplied-h5/src/view/joyful-selection/merchant/index.vue new file mode 100644 index 0000000..e5b9d89 --- /dev/null +++ b/supplied-h5/src/view/joyful-selection/merchant/index.vue @@ -0,0 +1,513 @@ + + + + + diff --git a/supplied-h5/src/view/joyful-selection/order/payment-confirm/index.vue b/supplied-h5/src/view/joyful-selection/order/payment-confirm/index.vue deleted file mode 100644 index 87248a4..0000000 --- a/supplied-h5/src/view/joyful-selection/order/payment-confirm/index.vue +++ /dev/null @@ -1,201 +0,0 @@ - - - - - diff --git a/supplied-h5/src/view/joyful-selection/product-data.js b/supplied-h5/src/view/joyful-selection/product-data.js deleted file mode 100644 index 64e08ff..0000000 --- a/supplied-h5/src/view/joyful-selection/product-data.js +++ /dev/null @@ -1,135 +0,0 @@ -import bluetoothEarbudsImage from '@/assets/joyful-selection/products/bluetooth-earbuds.png' -import miniOvenImage from '@/assets/joyful-selection/products/mini-oven.png' -import smartSpeakerImage from '@/assets/joyful-selection/products/smart-speaker.png' -import giftBoxImage from '@/assets/joyful-selection/products/gift-box.png' - -const productImages = { - bluetoothEarbuds: bluetoothEarbudsImage, - miniOven: miniOvenImage, - smartSpeaker: smartSpeakerImage, - giftBox: giftBoxImage -} - -const createProduct = ({ id, name, desc, detail, price, sales, image, categoryId, category, tag = '赠品' }) => ({ - id, - name, - desc, - detail: detail || desc, - price, - originPrice: (Number(price) * 1.25).toFixed(2), - sales, - image, - categoryId, - category, - tag -}) - -export const joyfulCategories = [ - { id: 'recommend', name: '推荐' }, - { id: 'homeLiving', name: '居家生活' }, - { id: 'digitalAppliance', name: '数码家电' }, - { id: 'beautyCare', name: '美妆个护' }, - { id: 'foodDrink', name: '食品饮料' }, - { id: 'motherBaby', name: '母婴玩具' }, - { id: 'fashionBag', name: '服饰鞋包' }, - { id: 'sportsOutdoor', name: '运动户外' } -] - -const categoryNameMap = joyfulCategories.reduce((map, category) => { - map[category.id] = category.name - return map -}, {}) - -const rawProducts = [ - ['cream-oven', '奶油白家用迷你烤箱', '小巧不占地,早餐烘焙好帮手', '129.00', '1.2万件', productImages.miniOven, 'homeLiving'], - ['cotton-bedding', '100%纯棉舒适四件套', '柔软亲肤,安心睡眠', '179.00', '6453件', productImages.miniOven, 'homeLiving'], - ['aroma-gift', '橙意香薰礼盒套装', '淡雅留香,装点居家空间', '59.90', '8321件', productImages.giftBox, 'homeLiving'], - ['desktop-vase', '简约北欧陶瓷花瓶', '清新配色,点亮桌面角落', '39.90', '3180件', productImages.giftBox, 'homeLiving'], - ['earbuds-air2', '小米蓝牙耳机 Air2 SE', '轻盈半入耳,通勤运动都好听', '69.90', '1.2万件', productImages.bluetoothEarbuds, 'digitalAppliance'], - ['smart-speaker-pro', '黑曜智能音箱 Pro', '语音唤醒,沉浸听感', '99.00', '2.1万件', productImages.smartSpeaker, 'digitalAppliance'], - ['electric-toothbrush', '智能声波电动牙刷', '高频洁净,呵护口腔', '129.00', '2.1万件', productImages.bluetoothEarbuds, 'digitalAppliance'], - ['dual-charger', '双口快充充电器 30W', '小巧便携,多设备快充', '69.00', '5632件', productImages.smartSpeaker, 'digitalAppliance'], - ['skin-care-box', '植萃保湿护肤礼盒', '水润修护,清爽不粘腻', '99.90', '4820件', productImages.giftBox, 'beautyCare'], - ['aroma-wash', '香氛洗护旅行套装', '温和清洁,清新留香', '59.90', '7932件', productImages.giftBox, 'beautyCare'], - ['makeup-mirror', '便携补光化妆镜', '高清镜面,随时补妆', '49.90', '3651件', productImages.smartSpeaker, 'beautyCare'], - ['hand-cream', '滋润护手霜三支装', '细腻滋养,轻柔香气', '39.90', '5280件', productImages.giftBox, 'beautyCare'], - ['orange-gift', '橙意惊喜零食礼盒', '精致包装,送礼更有心意', '59.90', '6324件', productImages.giftBox, 'foodDrink'], - ['coffee-cup', '咖啡随行杯组合', '锁温防漏,醇香随行', '49.90', '3520件', productImages.bluetoothEarbuds, 'foodDrink'], - ['tea-snack', '下午茶点心礼盒', '多种风味,一盒满足', '69.90', '4891件', productImages.giftBox, 'foodDrink'], - ['fruit-drink', '轻享果饮礼盒', '清爽酸甜,聚会常备', '79.90', '2976件', productImages.giftBox, 'foodDrink'], - ['baby-bottle', '安心防摔儿童水杯', '轻巧易握,出门好携带', '39.90', '4250件', productImages.bluetoothEarbuds, 'motherBaby'], - ['toy-blocks', '益智拼搭积木套装', '色彩柔和,陪伴成长', '89.00', '2186件', productImages.giftBox, 'motherBaby'], - ['kids-night-light', '柔光儿童陪伴夜灯', '温和光线,安心入睡', '69.90', '3548件', productImages.smartSpeaker, 'motherBaby'], - ['baby-towel', '纯棉柔软浴巾两件装', '亲肤吸水,日常必备', '59.90', '2761件', productImages.miniOven, 'motherBaby'], - ['laptop-backpack', '大容量双肩电脑背包', '分区收纳,通勤出行轻松装', '149.00', '1.6万件', productImages.giftBox, 'fashionBag'], - ['mini-bag', '轻巧通勤斜挎小包', '简洁百搭,日常实用', '79.90', '5321件', productImages.giftBox, 'fashionBag'], - ['silk-scarf', '柔软撞色丝巾礼盒', '细腻亲肤,点亮穿搭', '69.90', '2680件', productImages.giftBox, 'fashionBag'], - ['canvas-tote', '简约帆布托特包', '大容量开口,随手能装', '49.90', '6412件', productImages.giftBox, 'fashionBag'], - ['sport-bottle', '运动便携保温水杯', '长效锁温,户外随行', '49.90', '7342件', productImages.bluetoothEarbuds, 'sportsOutdoor'], - ['neck-massager', '筋膜放松按摩仪 Mini', '轻巧便携,舒缓酸胀', '99.00', '3921件', productImages.smartSpeaker, 'sportsOutdoor'], - ['camping-light', '户外便携露营灯', '多档照明,夜晚更安心', '79.00', '2568件', productImages.smartSpeaker, 'sportsOutdoor'], - ['fitness-band', '弹力健身拉力带套装', '居家训练,轻松塑形', '29.90', '4396件', productImages.miniOven, 'sportsOutdoor'] -] - -export const joyfulProductList = rawProducts.map(([id, name, desc, price, sales, image, categoryId]) => - createProduct({ - id, - name, - desc, - price, - sales, - image, - categoryId, - category: categoryNameMap[categoryId] - }) -) - -export const joyfulProducts = [ - { - id: 'bluetooth-earbuds', - name: '小米蓝牙耳机 Air2 SE', - desc: '轻盈半入耳,通勤运动都好听', - detail: '舒适半入耳设计,配合便携充电盒,日常通勤、运动和在线会议都能保持清晰稳定的聆听体验。', - price: '69.90', - originPrice: '99.90', - category: '数码家电', - tag: '积分兑换', - image: bluetoothEarbudsImage - }, - { - id: 'mini-oven', - name: '奶油白家用迷你烤箱', - desc: '小巧不占地,早餐烘焙好帮手', - detail: '紧凑台式机身搭配可视化炉门,适合烤吐司、蛋挞和小份点心,让厨房日常更轻松。', - price: '129.00', - originPrice: '169.00', - category: '居家生活', - tag: '新品上架', - image: miniOvenImage - }, - { - id: 'smart-speaker', - name: '黑曜智能音箱 Pro', - desc: '语音唤醒,沉浸听感', - detail: '圆柱一体式声学结构,支持语音互动和日常音乐播放,适合卧室、书房与办公桌面使用。', - price: '99.00', - originPrice: '139.00', - category: '数码家电', - tag: '热门推荐', - image: smartSpeakerImage - }, - { - id: 'gift-box', - name: '橙意惊喜礼盒', - desc: '精致包装,送礼更有心意', - detail: '亮橙礼盒搭配丝带包装,适合节日福利、积分兑换礼和日常心意礼赠场景。', - price: '59.90', - originPrice: '89.90', - category: '食品饮料', - tag: '品质精选', - image: giftBoxImage - }, - ...joyfulProductList -] - -export const getJoyfulProduct = id => joyfulProducts.find(item => item.id === id) || joyfulProducts[0] diff --git a/supplied-h5/src/view/joyful-selection/profile/order-detail/index.vue b/supplied-h5/src/view/joyful-selection/profile/order-detail/index.vue index 7bfd057..a42e92d 100644 --- a/supplied-h5/src/view/joyful-selection/profile/order-detail/index.vue +++ b/supplied-h5/src/view/joyful-selection/profile/order-detail/index.vue @@ -74,7 +74,7 @@

订单编号{{ currentSubOrder.orderNumberId || currentSubOrder.orderId || '-' }}

下单时间{{ currentSubOrder.createTime || currentOrder.createTime || '-' }}

支付方式{{ payMethodText }}

-

支付时间{{ currentOrder.payTime || '-' }}

+

支付时间{{ payTimeText }}

商品金额¥{{ currentSubOrder.amount }}

运费¥{{ freightText }}

实付款¥{{ paidAmountText }}

@@ -177,6 +177,9 @@ export default { freightText() { return Number(this.currentSubOrder?.freightCost || 0).toFixed(2) }, + payTimeText() { + return this.currentSubOrder?.payTime || this.currentOrder?.payTime || '-' + }, paidAmountText() { return Number(this.currentSubOrder?.amount || 0).toFixed(2) }, @@ -282,7 +285,7 @@ export default { } if (action.key === 'after-sale') { - this.submitAfterSale(action) + this.goAfterSaleSelect() return } @@ -303,6 +306,15 @@ export default { }) this.$router.push('/joyful-selection/payment') }, + goAfterSaleSelect() { + this.$router.push({ + path: '/joyful-selection/after-sale/select', + query: { + mainOrderId: this.currentOrder?.mainOrderId || this.currentOrder?.orderId, + orderId: this.currentSubOrder?.orderId + } + }) + }, async cancelMainOrder() { try { await Dialog.confirm({ diff --git a/supplied-h5/src/view/joyful-selection/profile/orders/index.vue b/supplied-h5/src/view/joyful-selection/profile/orders/index.vue index 3c75468..1ce9f93 100644 --- a/supplied-h5/src/view/joyful-selection/profile/orders/index.vue +++ b/supplied-h5/src/view/joyful-selection/profile/orders/index.vue @@ -343,7 +343,7 @@ export default { } if ([ORDER_EVENT_TYPE.RETURN_ORDER, ORDER_EVENT_TYPE.EXCHANGE_ORDER].includes(action.orderType)) { - this.submitAfterSale(order, action) + this.goAfterSaleSelect(order) return } @@ -366,6 +366,15 @@ export default { }) this.$router.push('/joyful-selection/payment') }, + goAfterSaleSelect(order) { + this.$router.push({ + path: '/joyful-selection/after-sale/select', + query: { + mainOrderId: order.mainOrderId, + orderId: order.orderId + } + }) + }, async cancelMainOrder(order) { try { await Dialog.confirm({ diff --git a/supplied-service/.env.example b/supplied-service/.env.example index 9658623..f782ee2 100644 --- a/supplied-service/.env.example +++ b/supplied-service/.env.example @@ -8,8 +8,6 @@ UPLOAD_TOKEN="replace-me" # Main points mall bridge API, for example http://localhost/open. POINTS_MALL_OPEN_API_BASE="http://localhost/open" - -# Supplier open API crypto keys used by /supplierOpenApi/pointOrder/queryOrderInfo. SUPPLIER_OPEN_SM4_KEY_HEX="00112233445566778899aabbccddeeff" SUPPLIER_OPEN_SM4_IV_HEX="0102030405060708090a0b0c0d0e0f10" @@ -18,3 +16,9 @@ REDIS_HOST="127.0.0.1" REDIS_PORT="6379" REDIS_PASSWORD="" REDIS_DB="9" + +MINIO_ACCESS_KEY="replace-me" +MINIO_SECRET_KEY="replace-me" +MINIO_ENDPOINT="http://localhost:9000" +MINIO_BUCKET="njupt" +MINIO_REGION="us-east-1" \ No newline at end of file diff --git a/supplied-service/prisma/schema.prisma b/supplied-service/prisma/schema.prisma index cf39589..b9bd4cd 100644 --- a/supplied-service/prisma/schema.prisma +++ b/supplied-service/prisma/schema.prisma @@ -92,6 +92,7 @@ model SupplierOrderMain { remark String? @db.Text createTime DateTime? updateTime DateTime? + payTime DateTime? deliveryTime DateTime? orderFinishTime DateTime? completeTime DateTime? @@ -131,6 +132,7 @@ model SupplierOrderSub { remark String? @db.Text createTime DateTime? updateTime DateTime? + payTime DateTime? deliveryTime DateTime? orderFinishTime DateTime? completeTime DateTime? @@ -309,6 +311,13 @@ model SupplierAfterSale { logisticsState Int @default(0) amount Decimal @default(0) @db.Decimal(10, 2) reason String? @db.Text + reasonType String? + problemDescription String? @db.Text + pickupMethod String? + pickupName String? + pickupMobile String? + pickupAddress String? @db.Text + images Json? auditRemark String? @db.Text userLogisticsNo String? userLogisticsCompany String? diff --git a/supplied-service/prisma/sql/order-pay-time.sql b/supplied-service/prisma/sql/order-pay-time.sql new file mode 100644 index 0000000..6270b2f --- /dev/null +++ b/supplied-service/prisma/sql/order-pay-time.sql @@ -0,0 +1,2 @@ +alter table supplier_order_main add column payTime datetime null; +alter table supplier_order_sub add column payTime datetime null; diff --git a/supplied-service/prisma/sql/supplier-after-sale-apply-fields.sql b/supplied-service/prisma/sql/supplier-after-sale-apply-fields.sql new file mode 100644 index 0000000..a9c99be --- /dev/null +++ b/supplied-service/prisma/sql/supplier-after-sale-apply-fields.sql @@ -0,0 +1,7 @@ +alter table supplier_after_sale add column reasonType varchar(191) null; +alter table supplier_after_sale add column problemDescription text null; +alter table supplier_after_sale add column pickupMethod varchar(191) null; +alter table supplier_after_sale add column pickupName varchar(191) null; +alter table supplier_after_sale add column pickupMobile varchar(191) null; +alter table supplier_after_sale add column pickupAddress text null; +alter table supplier_after_sale add column images json null; diff --git a/supplied-service/src/controllers/basic.controller.ts b/supplied-service/src/controllers/basic.controller.ts index 50d3e25..4407ee9 100644 --- a/supplied-service/src/controllers/basic.controller.ts +++ b/supplied-service/src/controllers/basic.controller.ts @@ -2,6 +2,8 @@ import type { Context } from '../utils/handler.js' import { guardMethod } from '../utils/method.js' import { withErrorBoundary } from '../utils/handler.js' import { basicService } from '../services/basic.service.js' +import { minioUploadService, type PreviewInput, type UploadInput } from '../services/minio-upload.service.js' +import { readJson } from '../utils/request.js' import { toHttpResponse } from './controller-response.js' export const loginController = withErrorBoundary(async (request: Request, _context: Context) => { @@ -22,7 +24,14 @@ export const uploadController = withErrorBoundary(async (request: Request, _cont const methodResponse = guardMethod(request, ['POST']) if (methodResponse) return methodResponse - return toHttpResponse(basicService.upload()) + return toHttpResponse(await minioUploadService.upload(await readJson(request))) +}) + +export const uploadPreviewController = withErrorBoundary(async (request: Request, _context: Context) => { + const methodResponse = guardMethod(request, ['POST']) + if (methodResponse) return methodResponse + + return toHttpResponse(minioUploadService.preview(await readJson(request))) }) export const userInfoController = withErrorBoundary(async (request: Request, _context: Context) => { diff --git a/supplied-service/src/controllers/merchant.controller.ts b/supplied-service/src/controllers/merchant.controller.ts new file mode 100644 index 0000000..e1f898c --- /dev/null +++ b/supplied-service/src/controllers/merchant.controller.ts @@ -0,0 +1,69 @@ +import type { Context } from '../utils/handler.js' +import { guardMethod } from '../utils/method.js' +import { readJson } from '../utils/request.js' +import { withErrorBoundary } from '../utils/handler.js' +import { + merchantService, + type MerchantAfterSaleListInput, + type MerchantOrderListInput, + type MerchantShipInput +} from '../services/merchant.service.js' +import type { AfterSaleAuditInput, AfterSaleLogisticsInput } from '../services/after-sale.service.js' +import { toHttpResponse } from './controller-response.js' + +type MerchantAfterSaleActionInput = AfterSaleLogisticsInput & { + logisticsNo?: string + logisticsCompany?: string +} + +export const merchantOrderListController = withErrorBoundary(async (request: Request, _context: Context) => { + const methodResponse = guardMethod(request, ['POST']) + if (methodResponse) return methodResponse + + return toHttpResponse(await merchantService.listOrders(await readJson(request))) +}) + +export const merchantOrderShipController = withErrorBoundary(async (request: Request, _context: Context) => { + const methodResponse = guardMethod(request, ['POST']) + if (methodResponse) return methodResponse + + return toHttpResponse(await merchantService.shipOrder(await readJson(request))) +}) + +export const merchantAfterSaleListController = withErrorBoundary(async (request: Request, _context: Context) => { + const methodResponse = guardMethod(request, ['POST']) + if (methodResponse) return methodResponse + + return toHttpResponse(await merchantService.listAfterSales(await readJson(request))) +}) + +export const merchantAfterSaleAuditController = withErrorBoundary(async (request: Request, _context: Context) => { + const methodResponse = guardMethod(request, ['POST']) + if (methodResponse) return methodResponse + + return toHttpResponse(await merchantService.auditAfterSale(await readJson(request))) +}) + +export const merchantAfterSaleReceiveController = withErrorBoundary(async (request: Request, _context: Context) => { + const methodResponse = guardMethod(request, ['POST']) + if (methodResponse) return methodResponse + + const body = await readJson(request) + return toHttpResponse(await merchantService.receiveAfterSale( + String(body.serviceOrderId || '').trim(), + String(body.supplierId || '').trim() + )) +}) + +export const merchantAfterSaleShipController = withErrorBoundary(async (request: Request, _context: Context) => { + const methodResponse = guardMethod(request, ['POST']) + if (methodResponse) return methodResponse + + const body = await readJson(request) + return toHttpResponse(await merchantService.shipExchange( + String(body.serviceOrderId || '').trim(), + String(body.supplierId || '').trim(), + String(body.logisticsNo || body.merchantLogisticsNo || '').trim(), + String(body.logisticsCompany || body.merchantLogisticsCompany || '').trim() + )) +}) diff --git a/supplied-service/src/controllers/order.controller.ts b/supplied-service/src/controllers/order.controller.ts index a2522a6..c4d660e 100644 --- a/supplied-service/src/controllers/order.controller.ts +++ b/supplied-service/src/controllers/order.controller.ts @@ -6,6 +6,7 @@ import { afterSaleService, type AfterSaleApplyInput, type AfterSaleAuditInput, + type AfterSaleDetailInput, type AfterSaleListInput, type AfterSaleLogisticsInput } from '../services/after-sale.service.js' @@ -99,6 +100,13 @@ export const afterSaleListController = withErrorBoundary(async (request: Request return toHttpResponse(await afterSaleService.list(await readJson(request))) }) +export const afterSaleDetailController = withErrorBoundary(async (request: Request, _context: Context) => { + const methodResponse = guardMethod(request, ['POST']) + if (methodResponse) return methodResponse + + return toHttpResponse(await afterSaleService.detail(await readJson(request))) +}) + export const afterSaleAuditController = withErrorBoundary(async (request: Request, _context: Context) => { const methodResponse = guardMethod(request, ['POST']) if (methodResponse) return methodResponse diff --git a/supplied-service/src/dao/order.dao.ts b/supplied-service/src/dao/order.dao.ts index b490027..e4a633f 100644 --- a/supplied-service/src/dao/order.dao.ts +++ b/supplied-service/src/dao/order.dao.ts @@ -137,6 +137,40 @@ export const orderDao = { }) }, + findMerchantSubOrders(supplierId: string, orderState?: number) { + return prisma.supplierOrderSub.findMany({ + where: { + supplierId, + deleted: 0, + ...(Number.isInteger(orderState) ? { orderState } : {}) + }, + include: { + products: { + where: { deleted: 0 }, + orderBy: { createAt: 'asc' } + } + }, + orderBy: [ + { createTime: 'desc' }, + { createAt: 'desc' } + ] + }) + }, + + findMerchantAfterSales(supplierId: string, serviceStates?: number[]) { + return prisma.supplierAfterSale.findMany({ + where: { + supplierId, + deleted: 0, + ...(serviceStates?.length ? { serviceState: { in: serviceStates } } : {}) + }, + orderBy: [ + { createTime: 'desc' }, + { id: 'desc' } + ] + }) + }, + findUserAddresses(supplierId: string, userId: string) { return prisma.supplierUserAddress.findMany({ where: { supplierId, userId, deleted: 0 }, diff --git a/supplied-service/src/functions/after-sale-detail.ts b/supplied-service/src/functions/after-sale-detail.ts new file mode 100644 index 0000000..fb8cac1 --- /dev/null +++ b/supplied-service/src/functions/after-sale-detail.ts @@ -0,0 +1 @@ +export { afterSaleDetailController as default } from '../controllers/order.controller.js' diff --git a/supplied-service/src/functions/merchant-after-sale-audit.ts b/supplied-service/src/functions/merchant-after-sale-audit.ts new file mode 100644 index 0000000..e8287a9 --- /dev/null +++ b/supplied-service/src/functions/merchant-after-sale-audit.ts @@ -0,0 +1 @@ +export { merchantAfterSaleAuditController as default } from '../controllers/merchant.controller.js' diff --git a/supplied-service/src/functions/merchant-after-sale-list.ts b/supplied-service/src/functions/merchant-after-sale-list.ts new file mode 100644 index 0000000..8abb740 --- /dev/null +++ b/supplied-service/src/functions/merchant-after-sale-list.ts @@ -0,0 +1 @@ +export { merchantAfterSaleListController as default } from '../controllers/merchant.controller.js' diff --git a/supplied-service/src/functions/merchant-after-sale-receive.ts b/supplied-service/src/functions/merchant-after-sale-receive.ts new file mode 100644 index 0000000..991335a --- /dev/null +++ b/supplied-service/src/functions/merchant-after-sale-receive.ts @@ -0,0 +1 @@ +export { merchantAfterSaleReceiveController as default } from '../controllers/merchant.controller.js' diff --git a/supplied-service/src/functions/merchant-after-sale-ship.ts b/supplied-service/src/functions/merchant-after-sale-ship.ts new file mode 100644 index 0000000..69f4ead --- /dev/null +++ b/supplied-service/src/functions/merchant-after-sale-ship.ts @@ -0,0 +1 @@ +export { merchantAfterSaleShipController as default } from '../controllers/merchant.controller.js' diff --git a/supplied-service/src/functions/merchant-order-list.ts b/supplied-service/src/functions/merchant-order-list.ts new file mode 100644 index 0000000..83a61ff --- /dev/null +++ b/supplied-service/src/functions/merchant-order-list.ts @@ -0,0 +1 @@ +export { merchantOrderListController as default } from '../controllers/merchant.controller.js' diff --git a/supplied-service/src/functions/merchant-order-ship.ts b/supplied-service/src/functions/merchant-order-ship.ts new file mode 100644 index 0000000..6611935 --- /dev/null +++ b/supplied-service/src/functions/merchant-order-ship.ts @@ -0,0 +1 @@ +export { merchantOrderShipController as default } from '../controllers/merchant.controller.js' diff --git a/supplied-service/src/functions/upload-preview.ts b/supplied-service/src/functions/upload-preview.ts new file mode 100644 index 0000000..a5903d7 --- /dev/null +++ b/supplied-service/src/functions/upload-preview.ts @@ -0,0 +1 @@ +export { uploadPreviewController as default } from '../controllers/basic.controller.js' diff --git a/supplied-service/src/server.ts b/supplied-service/src/server.ts index 7106ff0..25a6dbe 100644 --- a/supplied-service/src/server.ts +++ b/supplied-service/src/server.ts @@ -14,11 +14,13 @@ import { cartAddController, loginController, uploadController, + uploadPreviewController, userInfoController } from './controllers/basic.controller.js' import { afterSaleApplyController, afterSaleAuditController, + afterSaleDetailController, afterSaleListController, afterSaleLogisticsController, orderCancelMainController, @@ -33,6 +35,14 @@ import { orderShipController, supplierOrderInfoController } from './controllers/order.controller.js' +import { + merchantAfterSaleAuditController, + merchantAfterSaleListController, + merchantAfterSaleReceiveController, + merchantAfterSaleShipController, + merchantOrderListController, + merchantOrderShipController +} from './controllers/merchant.controller.js' import { productDetailController, productListController } from './controllers/product.controller.js' import { supplierDebugTokensController, @@ -52,10 +62,17 @@ const handlers: Record = { 'address-save': addressSaveController, 'after-sale-apply': afterSaleApplyController, 'after-sale-audit': afterSaleAuditController, + 'after-sale-detail': afterSaleDetailController, 'after-sale-list': afterSaleListController, 'after-sale-logistics': afterSaleLogisticsController, 'cart-add': cartAddController, login: loginController, + 'merchant-after-sale-audit': merchantAfterSaleAuditController, + 'merchant-after-sale-list': merchantAfterSaleListController, + 'merchant-after-sale-receive': merchantAfterSaleReceiveController, + 'merchant-after-sale-ship': merchantAfterSaleShipController, + 'merchant-order-list': merchantOrderListController, + 'merchant-order-ship': merchantOrderShipController, 'order-cancel-main': orderCancelMainController, 'order-cancel-paid-main': orderCancelPaidMainController, 'order-cancel-sub': orderCancelSubController, @@ -74,6 +91,7 @@ const handlers: Record = { 'supplier-points': supplierPointsController, 'supplier-token': supplierTokenController, upload: uploadController, + 'upload-preview': uploadPreviewController, 'user-info': userInfoController } @@ -116,7 +134,7 @@ const dispatch = (name: string) => async (ctx: Koa.Context) => { await writeResponse(ctx, response) } -app.use(bodyParser()) +app.use(bodyParser({ jsonLimit: '10mb' })) app.use(async (ctx, next) => { Object.entries(corsHeaders).forEach(([key, value]) => ctx.set(key, value)) if (ctx.method === 'OPTIONS') { diff --git a/supplied-service/src/services/after-sale.service.ts b/supplied-service/src/services/after-sale.service.ts index 946d106..d272b8c 100644 --- a/supplied-service/src/services/after-sale.service.ts +++ b/supplied-service/src/services/after-sale.service.ts @@ -5,6 +5,7 @@ import { ORDER_STATE } from '../constants/order.js' import { orderDao } from '../dao/order.dao.js' +import { productDao } from '../dao/product.dao.js' import { logger } from '../utils/logger.js' import { serviceFail, serviceOk } from '../types/service-result.js' @@ -17,6 +18,16 @@ export type AfterSaleApplyInput = { serviceType?: number amount?: number reason?: string + reasonType?: string + problemDescription?: string + pickupMethod?: string + pickupName?: string + pickupMobile?: string + pickupAddress?: string + images?: unknown[] + exchangeProductSku?: string + exchangeProductName?: string + exchangeProductPrice?: number | string } export type AfterSaleListInput = { @@ -24,6 +35,12 @@ export type AfterSaleListInput = { userId?: string } +export type AfterSaleDetailInput = { + supplierId?: string + userId?: string + serviceOrderId?: string +} + export type AfterSaleAuditInput = { supplierId?: string serviceOrderId?: string @@ -111,6 +128,61 @@ const createServiceOrderId = (subOrderId: string, serviceType: number) => { return `AS${serviceType}_${subOrderId}_${Date.now()}` } +const afterSaleDetailPayload = (record: NonNullable>>, subOrder?: Awaited>[number] | null) => { + const requestBody = record.requestBody && typeof record.requestBody === 'object' ? record.requestBody as AfterSaleApplyInput : {} + const products = subOrder?.products.map(product => ({ + id: product.id, + sku: product.sku, + name: product.name, + number: product.number, + price: toYuanText(product.price), + image: product.productImgUrl || firstImage(product.picInfo) + })) || [] + + return { + serviceOrderId: record.serviceOrderId, + supplierId: record.supplierId, + userId: record.userId, + mainOrderId: record.mainOrderId, + subOrderId: record.subOrderId, + orderId: record.subOrderId, + orderNumberId: subOrder?.orderNumberId || '', + serviceType: record.serviceType, + serviceTypeText: serviceTypeText(record.serviceType), + serviceState: record.serviceState, + serviceStateText: serviceStateText(record.serviceState), + auditStatus: record.auditStatus, + logisticsState: record.logisticsState, + logisticsStateText: logisticsStateText(record.logisticsState), + amount: toYuanText(record.amount), + reason: record.reason || '', + reasonType: record.reasonType || '', + problemDescription: record.problemDescription || '', + pickupMethod: record.pickupMethod || '', + pickupName: record.pickupName || '', + pickupMobile: record.pickupMobile || '', + pickupAddress: record.pickupAddress || '', + exchangeProduct: requestBody.exchangeProductSku ? { + sku: requestBody.exchangeProductSku, + name: requestBody.exchangeProductName || '', + price: requestBody.exchangeProductPrice || '' + } : null, + images: Array.isArray(record.images) ? record.images : [], + auditRemark: record.auditRemark || '', + userLogisticsNo: record.userLogisticsNo || '', + userLogisticsCompany: record.userLogisticsCompany || '', + merchantLogisticsNo: record.merchantLogisticsNo || '', + merchantLogisticsCompany: record.merchantLogisticsCompany || '', + applyTime: toDateTimeText(record.applyTime || record.createTime), + auditTime: toDateTimeText(record.auditTime), + userShipTime: toDateTimeText(record.userShipTime), + merchantReceiveTime: toDateTimeText(record.merchantReceiveTime), + merchantShipTime: toDateTimeText(record.merchantShipTime), + finishTime: toDateTimeText(record.finishTime), + products + } +} + export const afterSaleService = { async apply(input: AfterSaleApplyInput) { const supplierId = String(input.supplierId || '').trim() @@ -118,6 +190,14 @@ export const afterSaleService = { const subOrderId = String(input.subOrderId || input.orderId || '').trim() const serviceType = Number(input.serviceType) const reason = String(input.reason || '').trim() + const reasonType = String(input.reasonType || '').trim() + const problemDescription = String(input.problemDescription || '').trim() + const pickupMethod = String(input.pickupMethod || '').trim() + const pickupName = String(input.pickupName || '').trim() + const pickupMobile = String(input.pickupMobile || '').trim() + const pickupAddress = String(input.pickupAddress || '').trim() + const images = Array.isArray(input.images) ? input.images.slice(0, 3) : [] + const exchangeProductSku = String(input.exchangeProductSku || '').trim() logger.info('after sale apply', { supplierId, userId, subOrderId, serviceType }) if (!supplierId || !userId || !subOrderId || !serviceType) { @@ -137,6 +217,22 @@ export const afterSaleService = { return serviceFail('当前订单状态不能申请退款/售后', 400, 'ORDER_NOT_ALLOW_AFTER_SALE') } + if (serviceType === ORDER_EVENT_TYPE.EXCHANGE_ORDER) { + if (!exchangeProductSku) { + return serviceFail('换货商品不能为空', 400, 'EXCHANGE_PRODUCT_REQUIRED') + } + + const originalProduct = subOrder.products[0] + const exchangeProduct = await productDao.findEnabledProduct('', exchangeProductSku) + if (!originalProduct || !exchangeProduct) { + return serviceFail('换货商品不存在或已下架', 404, 'EXCHANGE_PRODUCT_NOT_FOUND') + } + + if (toYuanText(exchangeProduct.price) !== toYuanText(originalProduct.price)) { + return serviceFail('换货商品价格必须与原商品价格一致', 400, 'EXCHANGE_PRODUCT_PRICE_NOT_MATCH') + } + } + const activeAfterSale = await orderDao.findAfterSale({ supplierId, userId, @@ -166,6 +262,13 @@ export const afterSaleService = { logisticsState: AFTER_SALE_LOGISTICS_STATE.NONE, amount, reason: reason || null, + reasonType: reasonType || null, + problemDescription: problemDescription || null, + pickupMethod: pickupMethod || null, + pickupName: pickupName || null, + pickupMobile: pickupMobile || null, + pickupAddress: pickupAddress || null, + images, applyTime: now, requestBody: input } @@ -196,7 +299,20 @@ export const afterSaleService = { serviceState: AFTER_SALE_SERVICE_STATE.PENDING_AUDIT, serviceStateText: serviceStateText(AFTER_SALE_SERVICE_STATE.PENDING_AUDIT), logisticsState: AFTER_SALE_LOGISTICS_STATE.NONE, - logisticsStateText: logisticsStateText(AFTER_SALE_LOGISTICS_STATE.NONE) + logisticsStateText: logisticsStateText(AFTER_SALE_LOGISTICS_STATE.NONE), + reason, + reasonType, + problemDescription, + pickupMethod, + pickupName, + pickupMobile, + pickupAddress, + exchangeProduct: exchangeProductSku ? { + sku: exchangeProductSku, + name: input.exchangeProductName || '', + price: input.exchangeProductPrice || '' + } : null, + images }) }, @@ -222,50 +338,38 @@ export const afterSaleService = { const subOrderMap = new Map(subOrders.map(item => [item.orderId, item])) const afterSaleItems = records.map(record => { const subOrder = record.subOrderId ? subOrderMap.get(record.subOrderId) : null - const products = subOrder?.products.map(product => ({ - id: product.id, - sku: product.sku, - name: product.name, - number: product.number, - price: toYuanText(product.price), - image: product.productImgUrl || firstImage(product.picInfo) - })) || [] - - return { - serviceOrderId: record.serviceOrderId, - supplierId: record.supplierId, - userId: record.userId, - mainOrderId: record.mainOrderId, - subOrderId: record.subOrderId, - orderId: record.subOrderId, - orderNumberId: subOrder?.orderNumberId || '', - serviceType: record.serviceType, - serviceTypeText: serviceTypeText(record.serviceType), - serviceState: record.serviceState, - serviceStateText: serviceStateText(record.serviceState), - auditStatus: record.auditStatus, - logisticsState: record.logisticsState, - logisticsStateText: logisticsStateText(record.logisticsState), - amount: toYuanText(record.amount), - reason: record.reason || '', - auditRemark: record.auditRemark || '', - userLogisticsNo: record.userLogisticsNo || '', - userLogisticsCompany: record.userLogisticsCompany || '', - merchantLogisticsNo: record.merchantLogisticsNo || '', - merchantLogisticsCompany: record.merchantLogisticsCompany || '', - applyTime: toDateTimeText(record.applyTime || record.createTime), - auditTime: toDateTimeText(record.auditTime), - userShipTime: toDateTimeText(record.userShipTime), - merchantReceiveTime: toDateTimeText(record.merchantReceiveTime), - merchantShipTime: toDateTimeText(record.merchantShipTime), - finishTime: toDateTimeText(record.finishTime), - products - } + return afterSaleDetailPayload(record, subOrder) }) return serviceOk({ afterSaleItems }) }, + async detail(input: AfterSaleDetailInput) { + const supplierId = String(input.supplierId || '').trim() + const userId = String(input.userId || '').trim() + const serviceOrderId = String(input.serviceOrderId || '').trim() + logger.info('after sale detail query', { supplierId, userId, serviceOrderId }) + + if (!supplierId || !userId || !serviceOrderId) { + return serviceFail('供应商、用户或售后单不能为空') + } + + const record = await orderDao.findAfterSale({ + supplierId, + userId, + serviceOrderId, + deleted: 0 + }) + if (!record) { + return serviceFail('售后申请不存在', 404, 'AFTER_SALE_NOT_FOUND') + } + + const subOrders = record.subOrderId + ? await orderDao.findOpenApiSubOrders(supplierId, [record.subOrderId]) + : [] + return serviceOk({ afterSale: afterSaleDetailPayload(record, subOrders[0]) }) + }, + async audit(input: AfterSaleAuditInput) { const supplierId = String(input.supplierId || '').trim() const serviceOrderId = String(input.serviceOrderId || '').trim() diff --git a/supplied-service/src/services/merchant.service.ts b/supplied-service/src/services/merchant.service.ts new file mode 100644 index 0000000..981114c --- /dev/null +++ b/supplied-service/src/services/merchant.service.ts @@ -0,0 +1,204 @@ +import { + AFTER_SALE_LOGISTICS_STATE, + AFTER_SALE_SERVICE_STATE, + ORDER_EVENT_TYPE, + ORDER_STATE +} from '../constants/order.js' +import { orderDao } from '../dao/order.dao.js' +import { afterSaleService, type AfterSaleAuditInput, type AfterSaleLogisticsInput } from './after-sale.service.js' +import { orderActionService } from './order-action.service.js' +import { logger } from '../utils/logger.js' +import { serviceFail, serviceOk } from '../types/service-result.js' + +export type MerchantOrderListInput = { + supplierId?: string + orderState?: number +} + +export type MerchantShipInput = { + supplierId?: string + userId?: string + orderId?: string + logisticsOrderId?: string + crrgBsnNm?: string +} + +export type MerchantAfterSaleListInput = { + supplierId?: string + scene?: string +} + +const serviceStateText = (serviceState: number) => { + if (serviceState === AFTER_SALE_SERVICE_STATE.PENDING_AUDIT) return '商家待审核' + if (serviceState === AFTER_SALE_SERVICE_STATE.APPROVED) return '审核通过' + if (serviceState === AFTER_SALE_SERVICE_STATE.REJECTED) return '审核拒绝' + if (serviceState === AFTER_SALE_SERVICE_STATE.USER_SHIPPED) return '用户已寄回' + if (serviceState === AFTER_SALE_SERVICE_STATE.RECEIVED) return '商家已收货' + if (serviceState === AFTER_SALE_SERVICE_STATE.FINISHED) return '售后完成' + if (serviceState === AFTER_SALE_SERVICE_STATE.CANCELLED) return '已撤销' + return '处理中' +} + +const serviceTypeText = (serviceType: number) => { + if (serviceType === ORDER_EVENT_TYPE.RETURN_ORDER) return '退货' + if (serviceType === ORDER_EVENT_TYPE.EXCHANGE_ORDER) return '换货' + return '售后' +} + +const toYuanText = (value: { toFixed: (digits: number) => string } | number | string | null | undefined) => { + if (value && typeof value === 'object' && 'toFixed' in value) { + return value.toFixed(2) + } + + return Number(value || 0).toFixed(2) +} + +const toDateTimeText = (date?: Date | null) => { + if (!date) return '' + const pad = (value: number) => String(value).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +} + +const firstImage = (picInfo: unknown) => { + if (Array.isArray(picInfo)) { + return String(picInfo[0] || '') + } + + return '' +} + +const afterSaleStatesByScene = (scene: string) => { + if (scene === 'audit') return [AFTER_SALE_SERVICE_STATE.PENDING_AUDIT] + if (scene === 'process') { + return [ + AFTER_SALE_SERVICE_STATE.APPROVED, + AFTER_SALE_SERVICE_STATE.USER_SHIPPED, + AFTER_SALE_SERVICE_STATE.RECEIVED + ] + } + + return undefined +} + +export const merchantService = { + async listOrders(input: MerchantOrderListInput) { + const supplierId = String(input.supplierId || '').trim() + const orderState = Number(input.orderState ?? ORDER_STATE.FINISH_PAY) + logger.info('merchant order list query', { supplierId, orderState }) + + if (!supplierId) { + return serviceFail('供应商不能为空') + } + + const orders = await orderDao.findMerchantSubOrders(supplierId, orderState) + const orderItems = orders.map(order => ({ + orderId: order.orderId, + mainOrderId: order.mainOrderId, + userId: order.userId || '', + supplierId: order.supplierId, + orderState: order.orderState, + orderNumberId: order.orderNumberId || '', + createTime: toDateTimeText(order.createTime || order.createAt), + amount: toYuanText(order.totalPrice), + logisticsOrderId: order.logisticsOrderId || '', + crrgBsnNm: order.crrgBsnNm || '', + products: order.products.map(product => ({ + id: product.id, + sku: product.sku, + name: product.name, + number: product.number, + price: toYuanText(product.price), + image: product.productImgUrl || firstImage(product.picInfo) + })) + })) + + return serviceOk({ orderItems }) + }, + + shipOrder(input: MerchantShipInput) { + return orderActionService.shipOrder({ + supplierId: String(input.supplierId || '').trim(), + userId: String(input.userId || '').trim(), + orderId: String(input.orderId || '').trim(), + deliveryStatus: 1, + logisticsOrderId: String(input.logisticsOrderId || '').trim(), + crrgBsnNm: String(input.crrgBsnNm || '').trim() + }) + }, + + async listAfterSales(input: MerchantAfterSaleListInput) { + const supplierId = String(input.supplierId || '').trim() + const scene = String(input.scene || '').trim() + logger.info('merchant after sale list query', { supplierId, scene }) + + if (!supplierId) { + return serviceFail('供应商不能为空') + } + + const records = await orderDao.findMerchantAfterSales(supplierId, afterSaleStatesByScene(scene)) + const subOrderIds = records.map(item => item.subOrderId).filter(Boolean) as string[] + const subOrders = subOrderIds.length ? await orderDao.findOpenApiSubOrders(supplierId, subOrderIds) : [] + const subOrderMap = new Map(subOrders.map(item => [item.orderId, item])) + const afterSaleItems = records.map(record => { + const subOrder = record.subOrderId ? subOrderMap.get(record.subOrderId) : null + + return { + serviceOrderId: record.serviceOrderId, + supplierId: record.supplierId, + userId: record.userId || '', + mainOrderId: record.mainOrderId || '', + subOrderId: record.subOrderId || '', + serviceType: record.serviceType, + serviceTypeText: serviceTypeText(record.serviceType), + serviceState: record.serviceState, + serviceStateText: serviceStateText(record.serviceState), + logisticsState: record.logisticsState, + reason: record.reason || '', + problemDescription: record.problemDescription || '', + pickupMethod: record.pickupMethod || '', + pickupName: record.pickupName || '', + pickupMobile: record.pickupMobile || '', + pickupAddress: record.pickupAddress || '', + userLogisticsNo: record.userLogisticsNo || '', + userLogisticsCompany: record.userLogisticsCompany || '', + applyTime: toDateTimeText(record.applyTime || record.createTime), + products: subOrder?.products.map(product => ({ + id: product.id, + sku: product.sku, + name: product.name, + number: product.number, + price: toYuanText(product.price), + image: product.productImgUrl || firstImage(product.picInfo) + })) || [] + } + }) + + return serviceOk({ afterSaleItems }) + }, + + auditAfterSale(input: AfterSaleAuditInput) { + return afterSaleService.audit(input) + }, + + updateAfterSaleLogistics(input: AfterSaleLogisticsInput) { + return afterSaleService.updateLogistics(input) + }, + + receiveAfterSale(serviceOrderId: string, supplierId: string) { + return afterSaleService.updateLogistics({ + supplierId, + serviceOrderId, + logisticsState: AFTER_SALE_LOGISTICS_STATE.MERCHANT_RECEIVED + }) + }, + + shipExchange(serviceOrderId: string, supplierId: string, logisticsNo: string, logisticsCompany: string) { + return afterSaleService.updateLogistics({ + supplierId, + serviceOrderId, + logisticsState: AFTER_SALE_LOGISTICS_STATE.MERCHANT_SHIPPED, + merchantLogisticsNo: logisticsNo, + merchantLogisticsCompany: logisticsCompany + }) + } +} diff --git a/supplied-service/src/services/minio-upload.service.ts b/supplied-service/src/services/minio-upload.service.ts new file mode 100644 index 0000000..bbae7da --- /dev/null +++ b/supplied-service/src/services/minio-upload.service.ts @@ -0,0 +1,178 @@ +import crypto from 'node:crypto' +import { serviceFail, serviceOk } from '../types/service-result.js' + +export type UploadInput = { + fileName?: string + contentType?: string + contentBase64?: string + folder?: string +} + +export type PreviewInput = { + objectName?: string + expires?: number +} + +const minioConfig = { + accessKey: process.env.MINIO_ACCESS_KEY || '', + secretKey: process.env.MINIO_SECRET_KEY || '', + endPoint: process.env.MINIO_ENDPOINT || 'http://localhost:9000', + bucket: process.env.MINIO_BUCKET || 'njupt', + region: process.env.MINIO_REGION || 'us-east-1' +} + +const amzDate = (date: Date) => date.toISOString().replace(/[:-]|\.\d{3}/g, '') +const shortDate = (date: Date) => amzDate(date).slice(0, 8) +const sha256 = (value: crypto.BinaryLike) => crypto.createHash('sha256').update(value).digest('hex') +const hmac = (key: crypto.BinaryLike | crypto.KeyObject, value: string) => crypto.createHmac('sha256', key).update(value).digest() +const encodePath = (value: string) => value.split('/').map(item => encodeURIComponent(item)).join('/') + +const ensureConfig = () => Boolean(minioConfig.accessKey && minioConfig.secretKey && minioConfig.endPoint && minioConfig.bucket) + +const signingKey = (date: string) => { + const dateKey = hmac(`AWS4${minioConfig.secretKey}`, date) + const regionKey = hmac(dateKey, minioConfig.region) + const serviceKey = hmac(regionKey, 's3') + return hmac(serviceKey, 'aws4_request') +} + +const objectUrl = (objectName: string) => { + const endpoint = new URL(minioConfig.endPoint) + endpoint.pathname = `${endpoint.pathname.replace(/\/$/, '')}/${minioConfig.bucket}/${encodePath(objectName)}` + return endpoint +} + +const extName = (fileName: string) => { + const matched = fileName.match(/\.[a-zA-Z0-9]+$/) + return matched ? matched[0].toLowerCase() : '' +} + +const safeFolder = (folder?: string) => String(folder || 'after-sale').replace(/[^a-zA-Z0-9/_-]/g, '').replace(/^\/+|\/+$/g, '') || 'after-sale' + +const presign = (method: 'GET', objectName: string, expires = 3600) => { + const now = new Date() + const date = shortDate(now) + const fullDate = amzDate(now) + const credentialScope = `${date}/${minioConfig.region}/s3/aws4_request` + const endpoint = objectUrl(objectName) + const params = new URLSearchParams({ + 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', + 'X-Amz-Credential': `${minioConfig.accessKey}/${credentialScope}`, + 'X-Amz-Date': fullDate, + 'X-Amz-Expires': String(Math.min(Math.max(Number(expires) || 3600, 1), 604800)), + 'X-Amz-SignedHeaders': 'host' + }) + const canonicalRequest = [ + method, + endpoint.pathname, + params.toString(), + `host:${endpoint.host}\n`, + 'host', + 'UNSIGNED-PAYLOAD' + ].join('\n') + const stringToSign = [ + 'AWS4-HMAC-SHA256', + fullDate, + credentialScope, + sha256(canonicalRequest) + ].join('\n') + const signature = crypto.createHmac('sha256', signingKey(date)).update(stringToSign).digest('hex') + params.set('X-Amz-Signature', signature) + endpoint.search = params.toString() + return endpoint.toString() +} + +const signedPutHeaders = (url: URL, body: Buffer, contentType: string) => { + const now = new Date() + const date = shortDate(now) + const fullDate = amzDate(now) + const payloadHash = sha256(body) + const credentialScope = `${date}/${minioConfig.region}/s3/aws4_request` + const canonicalHeaders = [ + `content-type:${contentType}`, + `host:${url.host}`, + `x-amz-content-sha256:${payloadHash}`, + `x-amz-date:${fullDate}` + ].join('\n') + '\n' + const signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date' + const canonicalRequest = [ + 'PUT', + url.pathname, + '', + canonicalHeaders, + signedHeaders, + payloadHash + ].join('\n') + const stringToSign = [ + 'AWS4-HMAC-SHA256', + fullDate, + credentialScope, + sha256(canonicalRequest) + ].join('\n') + const signature = crypto.createHmac('sha256', signingKey(date)).update(stringToSign).digest('hex') + + return { + 'Content-Type': contentType, + 'X-Amz-Content-Sha256': payloadHash, + 'X-Amz-Date': fullDate, + Authorization: `AWS4-HMAC-SHA256 Credential=${minioConfig.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}` + } +} + +export const minioUploadService = { + async upload(input: UploadInput) { + if (!ensureConfig()) { + return serviceFail('MinIO配置不完整', 500, 'MINIO_CONFIG_MISSING') + } + + const fileName = String(input.fileName || '').trim() + const contentType = String(input.contentType || 'application/octet-stream').trim() + const contentBase64 = String(input.contentBase64 || '').replace(/^data:[^;]+;base64,/, '') + if (!fileName || !contentBase64) { + return serviceFail('文件名或图片内容不能为空') + } + if (!contentType.startsWith('image/')) { + return serviceFail('只支持上传图片', 400, 'ONLY_IMAGE_ALLOWED') + } + + const body = Buffer.from(contentBase64, 'base64') + const objectName = `${safeFolder(input.folder)}/${Date.now()}_${crypto.randomUUID()}${extName(fileName)}` + const url = objectUrl(objectName) + const response = await fetch(url, { + method: 'PUT', + headers: signedPutHeaders(url, body, contentType), + body + }) + if (!response.ok) { + return serviceFail(`图片上传失败:${response.status}`, 500, 'MINIO_UPLOAD_FAILED') + } + + const previewUrl = presign('GET', objectName) + return serviceOk({ + objectName, + fileName, + contentType, + size: body.length, + url: previewUrl, + previewUrl + }) + }, + + preview(input: PreviewInput) { + if (!ensureConfig()) { + return serviceFail('MinIO配置不完整', 500, 'MINIO_CONFIG_MISSING') + } + + const objectName = String(input.objectName || '').trim() + if (!objectName) { + return serviceFail('图片对象名不能为空') + } + + const previewUrl = presign('GET', objectName, Number(input.expires || 3600)) + return serviceOk({ + objectName, + url: previewUrl, + previewUrl + }) + } +} diff --git a/supplied-service/src/services/order-action.service.ts b/supplied-service/src/services/order-action.service.ts index 08d74df..47bea94 100644 --- a/supplied-service/src/services/order-action.service.ts +++ b/supplied-service/src/services/order-action.service.ts @@ -197,6 +197,7 @@ export const orderActionService = { return serviceFail('当前订单状态不可支付,请刷新订单状态', 400, 'ORDER_NOT_PAYABLE') } + const payTime = new Date() await orderDao.transaction(async tx => { await tx.supplierOrderMain.update({ where: { orderId }, @@ -206,12 +207,13 @@ export const orderActionService = { totalPrice, wPayOrAPay, pointsPrice, - updateTime: new Date() + payTime, + updateTime: payTime } }) await tx.supplierOrderSub.updateMany({ where: { mainOrderId: orderId, deleted: 0 }, - data: { orderState: ORDER_STATE.FINISH_PAY, updateTime: new Date() } + data: { orderState: ORDER_STATE.FINISH_PAY, payTime, updateTime: payTime } }) }) diff --git a/supplied-service/src/services/order-list.service.ts b/supplied-service/src/services/order-list.service.ts index 1bdf0db..2ec9070 100644 --- a/supplied-service/src/services/order-list.service.ts +++ b/supplied-service/src/services/order-list.service.ts @@ -109,6 +109,7 @@ export const orderListService = { receiverAddress: addressView?.address || '', remark: subOrder.remark || mainOrder.remark || '', createTime: toDateTimeText(subOrder.createTime || subOrder.createAt), + payTime: toDateTimeText(subOrder.payTime || mainOrder.payTime), status: status.text, statusClass: status.statusClass, orderState: subOrder.orderState, @@ -138,6 +139,7 @@ export const orderListService = { receiverAddress: addressView?.address || '', remark: mainOrder.remark || '', createTime: toDateTimeText(mainOrder.createTime || mainOrder.createAt), + payTime: toDateTimeText(mainOrder.payTime), status: mainStatus.text, statusClass: mainStatus.statusClass, orderState: mainOrder.orderState,