资讯动态

纺织行业进销存系统架构实战:多单位换算、缸号管理与AI落地

发布时间:2026/10/27 3:34:17 来源:尧图企业网站定制
1. 纺织行业进销存为什么不能直接套用通用模板做过几个行业的进销存之后我越来越确信一件事进销存系统的难点从来不在“增删改查”而在行业特有的计量逻辑和业务约束。纺织行业就是典型中的典型。你拿一套电商通用的SKU库存模型直接套过来上线第一天仓库就会炸锅。纺织行业的货品管理有三个绕不开的特性。第一是多计量单位并存一匹布、一卷布、一米布、一公斤布这四个单位之间不是简单的固定倍率关系而是跟克重、门幅、实际卷长都相关。同一个批次的布称重和量长度算出来的数量往往对不上因为受湿度、张力影响。第二是批次与色号强绑定同一个品号的面料不同缸号染缸批次之间存在色差仓库发货时必须按缸号配套出库否则客户收到货发现颜色对不上就是退货。第三是委外加工环节多织造、染色、定型、印花经常是外发加工原料出去、成品回来中间的损耗和余料管理极其琐碎。所以这套系统的架构设计核心不是把Vue3和NestJS拼起来跑通而是先想清楚数据模型怎么承载这些行业约束再考虑前后端怎么分工。我见过太多团队一上来就搭脚手架、配路由、写CRUD结果做到库存扣减那一步发现单位换算根本没法在现有表结构里表达只能推倒重来。这篇文章我会按真实的项目推进顺序来讲先拆业务模型和数据库设计再讲NestJS后端的分层与关键接口然后是Vue3前端的交互设计最后聊AI能力具体落在哪些环节、怎么落地。中间会穿插我在实际开发中踩过的坑和取舍逻辑适合正在做类似系统、或者想理解“业务驱动架构”到底怎么落地的开发者。2. 先把数据模型想透纺织进销存的表结构设计2.1 品号、缸号、卷号三级标识怎么拆通用进销存通常只有“商品-SKU”两级纺织行业至少要三级。我的设计是这样的品号ProductCode代表一类面料比如“32支精梳棉汗布”描述的是材质和工艺不涉及颜色和批次。缸号DyeLot同一品号下按染色批次划分是色差控制的最小单位。客户下单时如果指定色号出库就必须锁定缸号。卷号RollNo每一卷布的唯一标识记录实际卷长、实际重量、门幅、克重。这是库存的最小物理单位。为什么要拆到卷号因为纺织仓库的盘点不是数“有几件”而是量“有几米”。一卷布的实际长度和标签长度可能有偏差出库时按卷号扣减才能保证账实相符。如果只到缸号级别盘点时永远对不上。对应的核心表结构大致是这样-- 品号主表 CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_code VARCHAR(32) NOT NULL UNIQUE, product_name VARCHAR(128) NOT NULL, category_id BIGINT, base_unit VARCHAR(16) DEFAULT KG, -- 基准单位统一用重量 spec VARCHAR(255), -- 规格描述 created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 缸号表 CREATE TABLE dye_lot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, lot_no VARCHAR(32) NOT NULL, color_code VARCHAR(32), color_name VARCHAR(64), supplier_id BIGINT, production_date DATE, UNIQUE KEY uk_product_lot (product_id, lot_no) ); -- 卷号表库存最小单位 CREATE TABLE roll ( id BIGINT PRIMARY KEY AUTO_INCREMENT, dye_lot_id BIGINT NOT NULL, roll_no VARCHAR(32) NOT NULL UNIQUE, actual_length DECIMAL(10,2), -- 实际长度(米) actual_weight DECIMAL(10,3), -- 实际重量(公斤) width DECIMAL(6,2), -- 门幅(厘米) gram_weight DECIMAL(6,2), -- 克重(g/m²) status TINYINT DEFAULT 1, -- 1在库 2锁定 3出库 warehouse_id BIGINT, location VARCHAR(32) );这里有个关键决策基准单位统一用重量KG。因为纺织品的长度会随湿度和张力变化而重量相对稳定。所有库存汇总、成本核算都以重量为准长度作为辅助参考。出库时如果客户按米下单系统按该卷的实际克重和门幅反算重量扣减。这个换算逻辑必须写在后端服务层不能放前端否则不同终端算出来的结果会不一致。2.2 单位换算为什么必须做成独立服务单位换算在纺织行业不是简单的乘除。一米布的重量 门幅(米) × 克重(g/m²) ÷ 1000。但实际业务中客户可能按“公斤”报价、按“米”下单、按“卷”提货三个单位来回切。如果换算逻辑散落在各个Controller里改一次公式要改十几个地方。我的做法是抽一个独立的UnitConversionService所有涉及数量转换的地方都调它// unit-conversion.service.ts Injectable() export class UnitConversionService { // 根据卷的门幅和克重把长度换算成重量 lengthToWeight(lengthM: number, widthCm: number, gramWeight: number): number { const widthM widthCm / 100; const weightKg (lengthM * widthM * gramWeight) / 1000; return Math.round(weightKg * 1000) / 1000; // 保留3位小数 } // 反向换算 weightToLength(weightKg: number, widthCm: number, gramWeight: number): number { const widthM widthCm / 100; const lengthM (weightKg * 1000) / (widthM * gramWeight); return Math.round(lengthM * 100) / 100; } // 批量换算用于订单行 convertBatch(items: ConversionItem[], targetUnit: string): ConversionResult[] { // ... 按目标单位统一转换 } }注意换算结果一定要做四舍五入并明确精度。我吃过亏前端显示保留两位、后端存三位、财务对账用四位三方对不上。后来统一规定重量保留3位小数长度保留2位小数金额保留2位小数全系统一致。2.3 委外加工的库存流转怎么建模委外加工是纺织进销存最容易被低估的模块。原料坯布发出去成品染色布收回来中间还有损耗。如果只用一个“库存”字段根本表达不了“货在谁手上”。我的方案是引入**库存位置StockLocation**概念把库存分成几类自有仓库、委外加工商在途、委外加工商在库、客户寄放。每次委外发料生成一张OutsourcingOrder记录发出卷号、加工商、预计回收日期。回收时按实际回收卷号核销损耗部分走单独的损耗单。// 委外发料的核心逻辑 async issueMaterial(orderId: number, rollIds: number[]) { const order await this.outsourcingRepo.findOne(orderId); await this.dataSource.transaction(async (manager) { for (const rollId of rollIds) { // 1. 锁定卷号防止重复发料 const roll await manager.findOne(Roll, { where: { id: rollId, status: 1 }, lock: { mode: pessimistic_write } }); if (!roll) throw new BusinessException(卷号不可用或已被锁定); // 2. 变更库存位置 roll.status 2; // 锁定 roll.location OUTSOURCING_${order.supplierId}; await manager.save(roll); // 3. 写流转记录 await manager.insert(StockMovement, { rollId, fromLocation: WAREHOUSE, toLocation: OUTSOURCING_${order.supplierId}, bizType: OUTSOURCE_ISSUE, bizId: orderId }); } }); }这里用悲观锁而不是乐观锁是因为发料操作并发不高但绝对不能出错。一卷布发出去两次盘点时就是一笔糊涂账。用pessimistic_write锁住行虽然牺牲一点性能但换来数据一致性值得。3. NestJS后端分层、事务与AI能力的接入点3.1 模块划分与依赖方向NestJS的模块化很适合这种业务复杂的系统但前提是划分清楚。我的划分原则是按业务能力分模块不按技术层分。具体是ProductModule品号、缸号、卷号的管理InventoryModule库存查询、扣减、盘点、调拨PurchaseModule采购订单、收货、退货SalesModule销售订单、发货、对账OutsourcingModule委外发料、回收、损耗AiModuleAI相关的所有能力封装CommonModule单位换算、编号生成、异常处理等依赖方向严格单向业务模块可以依赖CommonModule和AiModule但AiModule不能反向依赖业务模块。AI能力通过接口注入避免耦合。// ai.module.ts Module({ providers: [ AiForecastService, // 销量预测 AiOcrService, // 单据识别 AiAnomalyService, // 异常检测 { provide: AI_PROVIDER, useClass: OpenAiProvider } ], exports: [AiForecastService, AiOcrService, AiAnomalyService] }) export class AiModule {}3.2 库存扣减的事务边界怎么定库存扣减是进销存的心脏也是最容易出并发问题的地方。我的原则是一次业务操作只开一个事务事务内不做任何外部调用。以销售出库为例完整流程是校验订单状态 → 锁定卷号 → 扣减库存 → 生成出库单 → 写流转记录 → 更新订单状态。这六步必须在一个事务里任何一步失败全部回滚。async shipOrder(orderId: number, rollIds: number[], operatorId: number) { return await this.dataSource.transaction(async (manager) { const order await manager.findOne(SalesOrder, { where: { id: orderId }, lock: { mode: pessimistic_write } }); if (order.status ! OrderStatus.CONFIRMED) { throw new BusinessException(订单状态不允许出库); } const rolls await manager.findByIds(Roll, rollIds, { lock: { mode: pessimistic_write } }); // 校验卷号归属和状态 for (const roll of rolls) { if (roll.status ! 1) throw new BusinessException(卷号${roll.rollNo}不可出库); } // 扣减并生成出库单 const shipment manager.create(Shipment, { orderId, operatorId, ... }); await manager.save(shipment); for (const roll of rolls) { roll.status 3; await manager.save(roll); await manager.insert(StockMovement, { rollId: roll.id, bizType: SALES_OUT, bizId: shipment.id }); } order.status OrderStatus.SHIPPED; await manager.save(order); return shipment; }); }实操心得事务里绝对不要调AI接口或发消息队列。我早期把“出库后触发AI异常检测”写在事务里结果AI接口超时导致整个出库回滚仓库那边货都装车了系统还没扣库存。后来改成事务提交后通过事件触发用OnEvent异步处理。3.3 AI能力在哪些环节真正有用AI不是拿来炫技的得落在具体痛点上。我在这个系统里实际用了三个AI能力都是解决真实问题的第一个是采购建议。纺织行业的采购周期长坯布从下单到到货经常要30-45天。传统做法是采购员凭经验拍脑袋经常要么断料要么压库存。我用历史出库数据训练了一个简单的时序预测模型按品号预测未来4周的用量结合当前库存和安全库存自动生成采购建议。这里不需要大模型用Prophet或者简单的移动平均就够了关键是数据要干净。第二个是单据OCR识别。纺织行业的供应商很多还是纸质送货单仓库收货时要手工录入卷号、长度、重量效率低还容易错。我接了一个OCR服务拍照上传送货单自动提取表格数据人工核对后入库。识别准确率大概85%剩下的15%人工修正整体效率提升明显。第三个是库存异常检测。系统每天凌晨跑一次批处理对比理论库存和实际盘点数据找出偏差超过阈值的品号推送给仓管。这个用规则引擎就能做不一定非要AI但加上简单的聚类分析能发现一些隐藏的规律比如某个仓库的某个区域总是盘亏可能是摆放或记录习惯问题。// ai-forecast.service.ts Injectable() export class AiForecastService { async generatePurchaseSuggestion(productId: number, weeks 4) { // 1. 拉取历史出库数据 const history await this.movementRepo.find({ where: { productId, bizType: SALES_OUT }, order: { createdAt: DESC }, take: 180 // 近半年 }); // 2. 按周聚合 const weeklyData this.aggregateByWeek(history); // 3. 调用预测模型这里用移动平均季节因子简化示例 const forecast this.movingAverageForecast(weeklyData, weeks); // 4. 结合当前库存和安全库存 const currentStock await this.inventoryService.getStockByProduct(productId); const safetyStock await this.getSafetyStock(productId); const suggestion forecast.map((qty, i) ({ week: i 1, forecastQty: qty, suggestedPurchase: Math.max(0, qty safetyStock - currentStock - forecast.slice(0, i).reduce((a, b) a b, 0)) })); return suggestion; } }注意AI预测结果一定要标注“建议”而非“指令”让采购员有最终决定权。我见过系统直接按AI建议自动下单的结果模型没考虑到一次促销活动导致大量积压。AI是辅助不是替代。4. Vue3前端复杂表格与多单位交互怎么设计4.1 为什么选Vue3而不是继续用Vue2这个项目启动时团队讨论过要不要用Vue2毕竟生态成熟、坑少。最后选Vue3主要三个原因一是Composition API对复杂业务逻辑的复用太友好了进销存里大量“查询-筛选-分页-导出”的逻辑可以抽成composable二是性能纺织行业的单据动辄几百行Vue3的响应式系统在大列表下明显更流畅三是TypeScript支持更彻底配合NestJS后端能共享类型定义。共享类型定义这一点特别值。我在后端用class-validator定义DTO前端直接import这些类型接口字段改了前端编译就报错省掉大量联调时间。// shared/types/inventory.ts 前后端共享 export class RollQueryDto { IsOptional() productCode?: string; IsOptional() dyeLotNo?: string; IsOptional() IsEnum(RollStatus) status?: RollStatus; IsOptional() IsInt() Min(1) page: number 1; IsOptional() IsInt() Min(1) Max(100) pageSize: number 20; } export interface RollVO { id: number; rollNo: string; productName: string; dyeLotNo: string; colorName: string; actualLength: number; actualWeight: number; width: number; gramWeight: number; status: RollStatus; location: string; }4.2 卷号选择器一个组件解决80%的交互复杂度整个系统里最复杂的交互是“选卷号”。销售出库、采购退货、委外发料、库存调拨全都要选卷号。如果每个页面各写一套维护成本爆炸。我把它抽成了一个独立的RollSelector组件支持多选、按品号/缸号筛选、显示实时库存、自动计算选中总量。这个组件的核心逻辑用computed做派生状态script setup langts import { ref, computed, watch } from vue; import type { RollVO } from /shared/types/inventory; const props defineProps{ modelValue: number[]; filterProductCode?: string; multiple?: boolean; }(); const emit defineEmits{ update:modelValue: [value: number[]]; selection-change: [rolls: RollVO[]]; }(); const rollList refRollVO[]([]); const selectedIds refnumber[](props.modelValue); // 选中卷号的汇总信息实时计算 const summary computed(() { const selected rollList.value.filter(r selectedIds.value.includes(r.id)); return { count: selected.length, totalLength: selected.reduce((sum, r) sum r.actualLength, 0), totalWeight: selected.reduce((sum, r) sum r.actualWeight, 0) }; }); // 选中变化时同步给父组件 watch(selectedIds, (val) { emit(update:modelValue, val); const selected rollList.value.filter(r val.includes(r.id)); emit(selection-change, selected); }, { deep: true }); /script实操心得卷号列表一定要做虚拟滚动。一个缸号下可能有几百卷全量渲染直接卡死。我用的是vue-virtual-scroller只渲染可视区域的20行滚动丝滑。另外筛选条件要支持“品号模糊搜索缸号精确匹配”的组合仓管实际用的时候经常只记得品号前缀。4.3 多单位显示让用户自己选但默认值要聪明前面说了纺织行业多单位并存。前端不能强制用户用某个单位但默认值要符合场景。我的做法是采购收货页面默认显示重量因为供应商按公斤结算销售出库页面默认显示长度因为客户按米下单库存查询页面同时显示长度和重量让用户自己判断所有数量输入框旁边有个单位切换下拉切换时自动换算换算在前端做还是后端做我的选择是前端做展示换算后端做业务换算。前端切换单位只是改显示不改变实际提交的值提交时统一转成基准单位重量发给后端。这样既保证用户体验又保证数据一致性。// composables/useUnitDisplay.ts export function useUnitDisplay(roll: RefRollVO) { const displayUnit refKG | M(KG); const displayValue computed(() { if (!roll.value) return 0; if (displayUnit.value KG) return roll.value.actualWeight; return roll.value.actualLength; }); const toggleUnit () { displayUnit.value displayUnit.value KG ? M : KG; }; return { displayUnit, displayValue, toggleUnit }; }4.4 单据页面的状态管理Pinia怎么用才不乱进销存系统里单据编辑是个状态密集的场景表头信息、明细行、合计、校验状态、保存状态全都要管。我用Pinia按“单据类型”建store而不是按“数据实体”建。// stores/salesOrder.ts export const useSalesOrderStore defineStore(salesOrder, () { const header refSalesOrderHeader(createEmptyHeader()); const lines refSalesOrderLine[]([]); const selectedRolls refRollVO[]([]); const saving ref(false); const dirty ref(false); const totalAmount computed(() lines.value.reduce((sum, l) sum l.qty * l.price, 0) ); const canSubmit computed(() header.value.customerId 0 lines.value.length 0 lines.value.every(l l.qty 0 l.price 0) ); async function submit() { if (!canSubmit.value) return; saving.value true; try { await api.post(/sales/orders, { ...header.value, lines: lines.value, rollIds: selectedRolls.value.map(r r.id) }); dirty.value false; } finally { saving.value false; } } return { header, lines, selectedRolls, saving, dirty, totalAmount, canSubmit, submit }; });注意dirty标记很重要。用户编辑到一半切走要弹窗提醒。我一开始没做结果仓管误操作丢过一整张单子被投诉了好几次。5. 前后端联调与部署中那些文档不会写的事5.1 接口约定字段命名和空值处理前后端联调最大的摩擦往往不是逻辑而是字段命名和空值。我的约定是所有接口字段用小驼峰productCode数据库用下划线product_codeNestJS的ClassSerializerInterceptor自动转换空值统一用null不用undefined不用空字符串。前端判断用 null一次搞定日期时间统一用ISO 8601字符串2026-01-15T08:30:0008:00前端用dayjs格式化金额和数量统一用number类型不用字符串。精度问题在后端用decimal.js处理前端展示时toFixed这些约定写在项目根目录的API_CONVENTION.md里新人入职第一件事就是读它。别小看这个能省掉大量“这个字段为什么是字符串”的扯皮。5.2 大列表查询的性能陷阱纺织行业的库存查询经常要扫几万行卷号。我踩过的坑坑一N1查询。查卷号列表时每卷都要查品号、缸号、仓库名称TypeORM默认懒加载会发几百条SQL。解决方法是显式leftJoinAndSelect一次查完。const qb this.rollRepo.createQueryBuilder(roll) .leftJoinAndSelect(roll.dyeLot, lot) .leftJoinAndSelect(lot.product, product) .leftJoinAndSelect(roll.warehouse, warehouse) .where(roll.status :status, { status: 1 }); if (query.productCode) { qb.andWhere(product.productCode LIKE :code, { code: ${query.productCode}% }); }坑二深分页。LIMIT 20 OFFSET 10000在数据量大时极慢因为数据库要扫描前10000行。我的方案是游标分页用id lastId代替OFFSET。前端不显示总页数只显示“加载更多”。仓管实际使用中很少翻到第100页这个取舍是值得的。坑三模糊搜索没索引。LIKE %关键词%用不上索引。品号搜索改成前缀匹配LIKE 关键词%配合索引速度快一个数量级。如果确实需要全文搜索上Elasticsearch但那是数据量上百万之后的事。5.3 部署架构为什么没上微服务这个系统最终是单体部署的NestJS打包成一个Node进程Vue3打包成静态文件由Nginx托管MySQL和Redis各一个实例。有人问为什么不上微服务我的理由很实在团队就5个人微服务的运维成本扛不住业务模块之间耦合度高拆开反而增加分布式事务的复杂度当前数据量几十万卷号、日均几百单单体完全扛得住真要扩展先把NestJS的CPU密集操作比如AI预测拆成独立worker比拆微服务简单得多部署用Docker Compose三个容器app、mysql、redis。Nginx在宿主机上做反向代理和静态文件服务。日志用pino输出JSON配合pm2做进程守护。这套方案不酷但稳。# docker-compose.yml version: 3.8 services: app: build: . ports: - 3000:3000 environment: - DB_HOSTmysql - REDIS_HOSTredis depends_on: - mysql - redis mysql: image: mysql:8.0 volumes: - ./data/mysql:/var/lib/mysql environment: - MYSQL_ROOT_PASSWORD${DB_PASSWORD} redis: image: redis:7-alpine volumes: - ./data/redis:/data实操心得数据库一定要配自动备份。我见过太多团队上线时信心满满结果硬盘故障丢了一个月数据。用mysqldump加crontab每天凌晨备份备份文件同步到另一台机器。这个操作花不了半小时但能救命。6. 上线后暴露的问题和我的应对6.1 单位换算的精度争议系统上线第二周财务和仓库吵起来了。仓库按卷号出库每卷的实际重量保留3位小数但财务对账时按订单的米数反算重量用的是理论克重两边差了0.5%。虽然金额不大但财务要求账实一致。我的处理方式是出库单同时记录实际重量和理论重量财务对账用实际重量成本核算用理论重量两个口径分开。这个改动涉及表结构变更好在系统刚上线数据量小停机半小时就迁移完了。教训是涉及多口径的数据一开始就要预留字段别等吵架了再改。6.2 AI预测的冷启动问题AI采购建议上线第一个月基本没法用因为历史数据不够。新系统的历史出库数据是空的模型预测出来的结果全是噪声。我的应对是前三个月用规则引擎兜底按“近30天日均出库 × 采购周期 × 1.2安全系数”算建议同时积累数据。三个月后数据够了再切换到模型预测。这个过渡方案很土但实用。6.3 用户习惯的阻力最大的阻力不是技术是人。仓管用了十年的Excel突然让他们用系统各种不配合。我的做法是先做“只读”功能让系统展示库存查询和报表但不强制他们录入。等他们发现系统查库存比翻Excel快自然就愿意用了。然后再逐步开放录入功能配合培训和奖励。技术问题好解决人的问题得慢慢来。这套系统到现在跑了快一年日均处理几百张单据库存准确率从原来的85%提升到99%以上。回头看架构设计上最正确的决定不是选了Vue3还是NestJS而是先把纺织行业的业务模型想透了再动手写代码。技术栈会过时但对业务的理解会一直有用。如果你也在做行业垂直的进销存我的建议是花两周时间跟仓管、采购、财务坐下来聊把他们的Excel和纸质单据都看一遍比看十篇架构文章都有用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑