Эх сурвалжийг харах

feat(小程序): 对齐主路径开单并接入微信绑定登录

主路径单据可在小程序开单;登录改为授权手机号匹配员工,支持静默登录与岗位切换。

Co-authored-by: Cursor <cursoragent@cursor.com>
jiangtao 5 өдөр өмнө
parent
commit
96f373f049

+ 1 - 1
project.config.json

@@ -20,6 +20,6 @@
     "ignore": [],
     "include": []
   },
-  "appid": "touristappid",
+  "appid": "wx2aa33e1b0f13463e",
   "editorSetting": {}
 }

+ 75 - 7
src/api/modules/auth.ts

@@ -20,9 +20,37 @@ export interface LoginCaptchaVO {
   img?: string
 }
 
+export interface MiniRoleVO {
+  id: string
+  name: string
+  permissions?: string[]
+}
+
 export interface LoginResultVO {
   accessToken: string
   ssoToken?: string | null
+  userId?: string
+  phone?: string
+  currentRoleId?: string
+  roles?: MiniRoleVO[]
+}
+
+function persistLoginTokens(data: Record<string, any>): LoginResultVO {
+  const accessToken: string = data.accessToken ?? data.token ?? data.access_token
+  const ssoToken: string | null = data.ssoToken ?? data.sso_token ?? null
+  if (!accessToken) {
+    throw new Error('登录失败:未获取到令牌')
+  }
+  setAccessToken(accessToken)
+  if (ssoToken) setSsoToken(ssoToken)
+  return {
+    accessToken,
+    ssoToken,
+    userId: data.userId,
+    phone: data.phone,
+    currentRoleId: data.currentRoleId,
+    roles: Array.isArray(data.roles) ? data.roles : []
+  }
 }
 
 /** 获取 RSA 公钥(免登录) */
@@ -69,17 +97,57 @@ export async function login(
 
   const res = await post<any>('/login', body, { skipAuth: true })
   const data = res.data || {}
-  const accessToken: string = data.accessToken ?? data.token ?? data.access_token
-  const ssoToken: string | null = data.ssoToken ?? data.sso_token ?? null
-
-  if (!accessToken) {
+  try {
+    return persistLoginTokens(data)
+  } catch {
     throw new Error(res.msg || '登录失败:未获取到令牌')
   }
+}
 
-  setAccessToken(accessToken)
-  if (ssoToken) setSsoToken(ssoToken)
+/** 微信授权手机号:首次绑定 ERP 用户并签发 token(不自动开户) */
+export async function wxMiniLogin(jsCode: string, phoneCode: string): Promise<LoginResultVO> {
+  const res = await post<any>(
+    '/login/wx-mini',
+    { jsCode, phoneCode },
+    { skipAuth: true }
+  )
+  return persistLoginTokens(res.data || {})
+}
+
+/** 已绑定则凭 wx.login code 静默登录 */
+export async function wxMiniSilent(jsCode: string): Promise<LoginResultVO> {
+  const res = await post<any>(
+    '/login/wx-mini/silent',
+    { jsCode },
+    { skipAuth: true, showErrorToast: false }
+  )
+  return persistLoginTokens(res.data || {})
+}
 
-  return { accessToken, ssoToken }
+export async function fetchMiniSession(): Promise<LoginResultVO> {
+  const res = await get<any>('/mini/auth/v1/session')
+  const data = res.data || {}
+  return {
+    accessToken: '',
+    ssoToken: null,
+    userId: data.userId,
+    phone: data.phone,
+    currentRoleId: data.currentRoleId,
+    roles: Array.isArray(data.roles) ? data.roles : []
+  }
+}
+
+export async function switchMiniRole(roleId: string): Promise<LoginResultVO> {
+  const res = await post<any>('/mini/auth/v1/switch-role', { roleId })
+  const data = res.data || {}
+  return {
+    accessToken: '',
+    ssoToken: null,
+    userId: data.userId,
+    phone: data.phone,
+    currentRoleId: data.currentRoleId,
+    roles: Array.isArray(data.roles) ? data.roles : []
+  }
 }
 
 /** 当前登录用户信息(字段以 jeesharp 返回为准,宽松透传)

+ 20 - 0
src/api/modules/preview.ts

@@ -0,0 +1,20 @@
+/**
+ * 主路径预览接口:结算选单、物流带出、日利率试算
+ */
+import { get } from '@/utils/request'
+
+export function fromSalesOut(billNo: string) {
+  return get<any>('/logistics/fee/v1/from-sales', { billNo }, { showLoading: true })
+}
+
+export function fromSettlementBill(linkBillType: string, linkBillNo: string) {
+  return get<any>('/finance/settlement/v1/from-bill', { linkBillType, linkBillNo }, { showLoading: true })
+}
+
+export function calcPurchaseInterest(billId: string | number, asOfDate?: string) {
+  return get<any>(
+    '/finance/interest-rate/v1/calc',
+    { billId, ...(asOfDate ? { asOfDate } : {}) },
+    { showLoading: true }
+  )
+}

+ 4 - 0
src/constants/routes.ts

@@ -24,3 +24,7 @@ export function moduleEditPage(pkg: string): string {
 /** 商品选择器(独立搜索分页页,选中后 uni.$emit 回传) */
 export const MATERIAL_PICKER_ROUTE = '/packageMaterial/pages/materialPicker'
 export const MATERIAL_PICKER_EVENT = 'erp:material-selected'
+
+/** 业务单据选择器(主包,供结算/物流/付款/生产选已定稿单) */
+export const BILL_PICKER_ROUTE = '/pages/picker/bill'
+export const BILL_PICKER_EVENT = 'erp:bill-selected'

+ 138 - 34
src/modules/bill/BillDetailView.vue

@@ -9,8 +9,8 @@
         <text class="status-head__no">{{ info.billNo || module?.title }}</text>
         <ErpStatusTag
           v-if="info.status !== undefined"
-          :text="BILL_STATUS_TEXT[info.status] ?? String(info.status)"
-          :type="BILL_STATUS_TYPE[info.status] ?? 'info'"
+          :text="statusLabel"
+          :type="BILL_STATUS_TYPE[Number(info.status)] ?? 'info'"
         />
       </view>
 
@@ -34,7 +34,7 @@
         <view v-for="(item, idx) in itemRows" :key="idx" class="item-card">
           <view class="item-card__head">
             <text class="item-card__name">{{ item.materialName || item.name || `行 ${idx + 1}` }}</text>
-            <text class="item-card__qty">×{{ item.operNumber ?? item.number ?? '' }}</text>
+            <text class="item-card__qty">{{ itemWeight(item) }}</text>
           </view>
           <view v-for="(kv, i) in itemFields(item)" :key="i" class="item-card__row">
             <text class="item-card__label">{{ kv.label }}</text>
@@ -43,16 +43,40 @@
         </view>
       </view>
 
-      <!-- 计息预览(日利率 calc) -->
+      <!-- 计息试算(采购日利率) -->
+      <view v-if="module?.key === 'interestRate'" class="section">
+        <view class="section__title">计息试算</view>
+        <view class="section__body">
+          <view class="calc-row" @click="pickCalcBill">
+            <text class="calc-row__label">采购入库单</text>
+            <text class="calc-row__value">{{ calcBillNo || '选择已定稿采购入库' }}</text>
+          </view>
+          <view class="calc-row">
+            <text class="calc-row__label">截止日期</text>
+            <picker mode="date" :value="calcAsOf" @change="calcAsOf = ($event as any).detail.value">
+              <text>{{ calcAsOf }}</text>
+            </picker>
+          </view>
+          <view class="calc-row" @click="runInterestCalc">
+            <text class="link">开始试算</text>
+          </view>
+        </view>
+      </view>
+
       <view v-if="calcResult" class="section">
-        <view class="section__title">计息预览</view>
+        <view class="section__title">计息结果</view>
         <view class="section__body">
-          <ErpDetailCell
-            v-for="(v, k) in calcResult"
-            :key="String(k)"
-            :label="String(k)"
-            :value="typeof v === 'object' ? JSON.stringify(v) : String(v)"
-          />
+          <ErpDetailCell label="本金" :value="formatMoney(calcResult.principal)" />
+          <ErpDetailCell label="已付" :value="formatMoney(calcResult.paid)" />
+          <ErpDetailCell label="余额" :value="formatMoney(calcResult.balance)" strong />
+          <ErpDetailCell label="利息" :value="formatMoney(calcResult.interest)" strong />
+        </view>
+      </view>
+
+      <view v-if="info.loss != null && info.loss !== '' || info.lossWeight != null" class="section">
+        <view class="section__title">损耗</view>
+        <view class="section__body">
+          <ErpDetailCell label="损耗" :value="String(info.loss ?? info.lossWeight)" strong />
         </view>
       </view>
 
@@ -78,19 +102,21 @@
  * 通用详情模板(props 驱动):自动渲染 info 响应(标量字段 KV + 数组字段明细卡)
  * 底部操作条按注册表 actions + 单据 status 显隐,统一走 execAction
  */
-import { ref, computed, watch } from 'vue'
+import { ref, computed, watch, onUnmounted } from 'vue'
 import {
   getModule,
   availableActions,
-  ACTION_TEXT,
-  BILL_STATUS_TEXT,
+  actionLabel,
+  statusTextOf,
   BILL_STATUS_TYPE,
   type ModuleConfig
 } from '@/modules/registry'
 import { getCrudApi } from '@/api/crud'
 import type { CrudAction } from '@/api/crud'
-import { getEntitySchema } from './formSchemas'
-import { formatMoney, formatNumber, formatDateTime } from '@/utils/format'
+import { getEntitySchema, canEditBill, canDeleteBill, DEPOT_TYPE_TEXT, MATERIAL_TYPE_TEXT } from './formSchemas'
+import { formatMoney, formatNumber, formatDateTime, today } from '@/utils/format'
+import { calcPurchaseInterest } from '@/api/modules/preview'
+import { BILL_PICKER_ROUTE } from '@/constants/routes'
 import ErpDetailCell from '@/components/common/ErpDetailCell.vue'
 import ErpStatusTag from '@/components/common/ErpStatusTag.vue'
 import ErpEmpty from '@/components/common/ErpEmpty.vue'
@@ -102,7 +128,17 @@ const module = ref<ModuleConfig | null>(null)
 const loading = ref(true)
 const info = ref<any>(null)
 const calcResult = ref<any>(null)
+const calcBillId = ref('')
+const calcBillNo = ref('')
+const calcAsOf = ref(today())
 const dictStore = useDictStore()
+const calcEvent = 'erp:bill-selected:interest'
+
+const statusLabel = computed(() => {
+  if (!module.value || info.value?.status === undefined) return ''
+  const text = statusTextOf(module.value, Number(info.value.status))
+  return String(info.value.locked) === '1' ? `${text}·已锁` : text
+})
 
 /** 字段中文映射(覆盖常见 DTO 字段;未命中的 key 原样展示) */
 const FIELD_LABELS: Record<string, string> = {
@@ -111,7 +147,8 @@ const FIELD_LABELS: Record<string, string> = {
   totalPrice: '总金额', discount: '折扣率', discountMoney: '优惠金额', discountLastMoney: '优惠后金额',
   otherMoney: '其它费用', changeAmount: '变动金额', needPay: '本次收款', debt: '欠款',
   operNumber: '数量', unitPrice: '单价', allPrice: '金额', taxRate: '税率', taxMoney: '税额',
-  materialUnit: '单位', depotName: '仓库', fromDepotName: '调出仓库', toDepotName: '调入仓库',
+  fromDepotName: '调出仓库', toDepotName: '调入仓库', anotherDepotName: '调入仓库',
+  lineType: '行类型', orderNo: '令号',
   name: '名称', contacts: '联系人', phoneNum: '电话', telephone: '手机', email: '邮箱',
   address: '地址', taxNum: '税号', bankName: '开户行', accountNumber: '账号',
   enabled: '状态', sort: '排序', description: '描述', beginNeedPay: '期初应付',
@@ -125,7 +162,12 @@ const FIELD_LABELS: Record<string, string> = {
   driverName: '司机姓名', driverPhone: '联系电话', vehicleNo: '车牌号',
   contractName: '合同名称', contractType: '合同类型', signDate: '签订日期',
   vehicleType: '车型', loadWeight: '载重', salesBillNo: '销售出库单', prodType: '生产类型',
-  depotType: '仓库类型', principal: '负责人', isDefault: '默认', rate: '日利率'
+  depotType: '仓库类型', principal: '负责人', isDefault: '默认', rate: '日利率',
+  dailyRate: '日利率', effectiveDate: '生效日期', materialType: '物料类型',
+  modelNo: '型号', locked: '锁单', receiptFileUrl: '收货凭证', contractFileUrl: '合同附件',
+  linkBillType: '关联类型', linkBillNo: '关联单号', amount: '结算金额', invoiceAmount: '票款',
+  weightTon: '出库吨数', outTime: '出库时间', settleTime: '结算时间', loss: '损耗',
+  warehousing: '仓储费', truckage: '搬运费'
 }
 
 /** 不展示的字段 */
@@ -154,6 +196,16 @@ const headFields = computed(() => {
       value = raw === 'PURCHASE' ? '采购' : raw === 'SALES' ? '销售' : (raw as string)
     } else if (key === 'prodType') {
       value = raw === 'SALES' ? '关联销售' : raw === 'PRE' ? '预生产' : (raw as string)
+    } else if (key === 'lineType') {
+      value = raw === 'PICK' ? '领料' : raw === 'PRODUCT' ? '成品' : raw === 'RETURN' ? '退库' : (raw as string)
+    } else if (key === 'linkBillType') {
+      value = raw === 'PURCHASE' ? '采购入库' : raw === 'SALES' ? '销售出库' : raw === 'LOGISTICS' ? '物流费用' : (raw as string)
+    } else if (key === 'depotType') {
+      value = DEPOT_TYPE_TEXT[String(raw)] || (raw as string)
+    } else if (key === 'materialType') {
+      value = MATERIAL_TYPE_TEXT[String(raw)] || (raw as string)
+    } else if (key === 'locked') {
+      value = String(raw) === '1' ? '已锁单' : '未锁单'
     } else if (key === 'organId' && info.value.organName) {
       continue
     }
@@ -174,8 +226,14 @@ const itemRows = computed<any[]>(() => {
   return []
 })
 
+function itemWeight(item: any): string {
+  const w = item.recvWeight ?? item.shipWeight ?? item.weight ?? item.operNumber
+  if (w === undefined || w === null || w === '') return ''
+  return String(w)
+}
+
 function itemFields(item: any) {
-  const keys = ['barCode', 'materialUnit', 'depotName', 'unitPrice', 'allPrice', 'taxRate', 'taxMoney', 'furnaceNo', 'batchNo', 'contractNo', 'standard', 'shipWeight', 'recvWeight', 'weightDiff', 'freightUnitPrice', 'freightFee', 'remark']
+  const keys = ['barCode', 'materialUnit', 'depotName', 'anotherDepotName', 'unitPrice', 'allPrice', 'taxRate', 'taxMoney', 'furnaceNo', 'batchNo', 'orderNo', 'contractNo', 'standard', 'shipWeight', 'recvWeight', 'weight', 'weightDiff', 'freightUnitPrice', 'freightFee', 'lineType', 'remark']
   const rows: { label: string; value: string }[] = []
   keys.forEach((key) => {
     const raw = item[key]
@@ -183,6 +241,7 @@ function itemFields(item: any) {
     let value = String(raw)
     if (/price|money|fee/i.test(key)) value = formatMoney(raw)
     else if (key === 'taxRate') value = `${raw}%`
+    else if (key === 'lineType') value = raw === 'PICK' ? '领料' : raw === 'PRODUCT' ? '成品' : raw === 'RETURN' ? '退库' : String(raw)
     rows.push({ label: FIELD_LABELS[key] || key, value })
   })
   return rows
@@ -190,18 +249,19 @@ function itemFields(item: any) {
 
 const actionButtons = computed(() => {
   if (!module.value || !info.value) return []
-  // 基础资料:编辑/删除(schema 存在才可编辑)
+  const buttons: { action: string; text: string }[] = []
   if (module.value.shape === 'entity') {
-    const buttons: { action: string; text: string }[] = []
     if (getEntitySchema(module.value.key)) buttons.push({ action: 'edit', text: '编辑' })
     buttons.push({ action: 'delete', text: '删除' })
     return buttons
   }
+  if (canEditBill(module.value, info.value)) buttons.push({ action: 'edit', text: '编辑' })
   const status = Number(info.value.status ?? -1)
-  return availableActions(module.value, status).map((action) => ({
-    action,
-    text: ACTION_TEXT[action] || action
-  }))
+  availableActions(module.value, status, { locked: info.value.locked }).forEach((action) => {
+    buttons.push({ action, text: actionLabel(module.value!, action) })
+  })
+  if (canDeleteBill(module.value, info.value)) buttons.push({ action: 'delete', text: '删除' })
+  return buttons
 })
 
 watch(
@@ -268,18 +328,12 @@ function onActionButton(action: string) {
 async function execAction(action: CrudAction) {
   const m = module.value
   if (!m) return
-  // calc 为预览:拉取计息结果展示在页面内
   if (action === 'calc') {
-    try {
-      const api = getCrudApi(m.apiPrefix)
-      calcResult.value = await api.execAction('calc', props.recordId)
-    } catch (e) {
-      uni.showToast({ title: (e as Error).message || '计息预览失败', icon: 'none' })
-    }
+    await runInterestCalc()
     return
   }
 
-  const confirmText = ACTION_TEXT[action] || action
+  const confirmText = actionLabel(m, action)
   uni.showModal({
     title: '提示',
     content: `确定执行「${confirmText}」操作吗?`,
@@ -287,7 +341,11 @@ async function execAction(action: CrudAction) {
       if (!res.confirm) return
       try {
         const api = getCrudApi(m.apiPrefix)
-        await api.execAction(action, props.recordId)
+        if (action === 'confirm' && m.finalizeViaSave) {
+          await api.update(props.recordId, { ...info.value, status: '1' })
+        } else {
+          await api.execAction(action, props.recordId)
+        }
         uni.showToast({ title: `${confirmText}成功`, icon: 'success' })
         loadDetail()
       } catch (e) {
@@ -297,6 +355,33 @@ async function execAction(action: CrudAction) {
   })
 }
 
+function pickCalcBill() {
+  uni.navigateTo({
+    url: `${BILL_PICKER_ROUTE}?module=purchaseIn&status=1&event=${encodeURIComponent(calcEvent)}`
+  })
+}
+
+function onCalcBill(row: any) {
+  calcBillId.value = String(row.id)
+  calcBillNo.value = row.billNo
+}
+
+async function runInterestCalc() {
+  if (!calcBillId.value) {
+    uni.showToast({ title: '请选择已定稿采购入库', icon: 'none' })
+    return
+  }
+  try {
+    const res = await calcPurchaseInterest(calcBillId.value, calcAsOf.value)
+    calcResult.value = res.data || res
+  } catch (e) {
+    uni.showToast({ title: (e as Error).message || '计息试算失败', icon: 'none' })
+  }
+}
+
+uni.$on(calcEvent, onCalcBill)
+onUnmounted(() => uni.$off(calcEvent, onCalcBill))
+
 defineExpose({ reload: loadDetail })
 </script>
 
@@ -387,6 +472,25 @@ defineExpose({ reload: loadDetail })
   }
 }
 
+.calc-row {
+  display: flex;
+  justify-content: space-between;
+  padding: 16rpx 0;
+  font-size: 26rpx;
+
+  &__label {
+    color: #909399;
+  }
+
+  &__value {
+    color: #2979ff;
+  }
+}
+
+.link {
+  color: #2979ff;
+}
+
 .action-bar {
   position: fixed;
   left: 0;

+ 45 - 533
src/modules/bill/BillFormView.vue

@@ -1,6 +1,6 @@
 <template>
   <view class="bill-form">
-    <!-- ============ 基础资料 entity 表单 ============ -->
+    <!-- 基础资料 -->
     <template v-if="module?.shape === 'entity' && entityFields.length">
       <view class="section">
         <view v-for="field in entityFields" :key="field.key" class="field">
@@ -8,7 +8,6 @@
             {{ field.label }}
           </text>
           <view class="field__control">
-            <!-- 下拉:字典或静态选项 -->
             <picker
               v-if="field.type === 'select'"
               mode="selector"
@@ -17,13 +16,12 @@
               @change="onSelectChange(field, ($event as any).detail.value)"
             >
               <view class="field__picker">
-                <text :class="{ placeholder: !form[field.key] }">
+                <text :class="{ placeholder: !form[field.key] && form[field.key] !== 0 }">
                   {{ selectText(field) || `请选择${field.label}` }}
                 </text>
                 <text class="field__arrow">›</text>
               </view>
             </picker>
-            <!-- 日期 -->
             <picker
               v-else-if="field.type === 'date'"
               mode="date"
@@ -35,20 +33,17 @@
                 <text class="field__arrow">›</text>
               </view>
             </picker>
-            <!-- 开关 -->
             <switch
               v-else-if="field.type === 'switch'"
               :checked="form[field.key] === 1"
               @change="form[field.key] = ($event as any).detail.value ? 1 : 0"
             />
-            <!-- 多行 -->
             <textarea
               v-else-if="field.type === 'textarea'"
               v-model="form[field.key]"
               class="field__textarea"
               :placeholder="field.placeholder || `请输入${field.label}`"
             />
-            <!-- 数字/文本 -->
             <input
               v-else
               v-model="form[field.key]"
@@ -59,249 +54,45 @@
           </view>
         </view>
       </view>
-    </template>
-
-    <!-- ============ 单据 bill 表单(表头 + 明细行) ============ -->
-    <template v-else-if="module?.shape === 'bill'">
-      <view class="section">
-        <view class="section__title">表头信息</view>
-        <!-- 往来单位 -->
-        <view v-if="organDictKind" class="field">
-          <text class="field__label field__label--required">往来单位</text>
-          <view class="field__control">
-            <picker
-              mode="selector"
-              :range="dictStore.getItems(organDictKind)"
-              range-key="name"
-              @change="onOrganChange(($event as any).detail.value)"
-            >
-              <view class="field__picker">
-                <text :class="{ placeholder: !form.organName }">{{ form.organName || '请选择往来单位' }}</text>
-                <text class="field__arrow">›</text>
-              </view>
-            </picker>
-          </view>
-        </view>
-        <!-- 结算账户 -->
-        <view class="field">
-          <text class="field__label">结算账户</text>
-          <view class="field__control">
-            <picker
-              mode="selector"
-              :range="dictStore.getItems('account')"
-              range-key="name"
-              @change="onAccountChange(($event as any).detail.value)"
-            >
-              <view class="field__picker">
-                <text :class="{ placeholder: !form.accountName }">{{ form.accountName || '请选择结算账户' }}</text>
-                <text class="field__arrow">›</text>
-              </view>
-            </picker>
-          </view>
-        </view>
-        <!-- 单据日期 -->
-        <view class="field">
-          <text class="field__label field__label--required">单据日期</text>
-          <view class="field__control">
-            <picker mode="date" :value="form.operTime" @change="form.operTime = ($event as any).detail.value">
-              <view class="field__picker">
-                <text>{{ form.operTime }}</text>
-                <text class="field__arrow">›</text>
-              </view>
-            </picker>
-          </view>
-        </view>
-        <!-- 备注 -->
-        <view class="field">
-          <text class="field__label">备注</text>
-          <view class="field__control">
-            <textarea v-model="form.remark" class="field__textarea" placeholder="请输入备注" />
-          </view>
-        </view>
-        <!-- 模块差异化表头扩展字段(合同/回款/仓储/车辆等,缺省模块为空) -->
-        <view v-for="field in headExtFields" :key="field.key" class="field">
-          <text class="field__label">{{ field.label }}</text>
-          <view class="field__control">
-            <picker
-              v-if="field.type === 'select'"
-              mode="selector"
-              :range="headExtOptions(field)"
-              range-key="label"
-              @change="onHeadExtChange(field, ($event as any).detail.value)"
-            >
-              <view class="field__picker">
-                <text :class="{ placeholder: !form[field.key] }">{{ headExtText(field) || `请选择${field.label}` }}</text>
-                <text class="field__arrow">›</text>
-              </view>
-            </picker>
-            <picker
-              v-else-if="field.type === 'date'"
-              mode="date"
-              :value="form[field.key]"
-              @change="form[field.key] = ($event as any).detail.value"
-            >
-              <view class="field__picker">
-                <text :class="{ placeholder: !form[field.key] }">{{ form[field.key] || '请选择日期' }}</text>
-                <text class="field__arrow">›</text>
-              </view>
-            </picker>
-            <input
-              v-else
-              v-model="form[field.key]"
-              class="field__input"
-              :type="field.type === 'number' ? 'digit' : 'text'"
-              :placeholder="`请输入${field.label}`"
-            />
-          </view>
-        </view>
-      </view>
-
-      <!-- 明细行 -->
-      <view v-if="hasItems" class="section">
-        <view class="section__title">
-          明细({{ items.length }})
-          <view class="section__actions">
-            <text class="section__btn" @click="scanMaterial">扫码</text>
-            <text class="section__btn" @click="openMaterialPicker">+ 添加商品</text>
-          </view>
-        </view>
-        <view v-for="(item, idx) in items" :key="idx" class="item-card">
-          <view class="item-card__head">
-            <text class="item-card__name">{{ item.materialName || `行 ${idx + 1}` }}</text>
-            <text class="item-card__del" @click="items.splice(idx, 1)">删除</text>
-          </view>
-          <view class="item-card__grid">
-            <view class="item-card__cell">
-              <text class="item-card__label">数量</text>
-              <input v-model="item.operNumber" class="item-card__input" type="digit" placeholder="0" />
-            </view>
-            <view class="item-card__cell">
-              <text class="item-card__label">单价</text>
-              <input v-model="item.unitPrice" class="item-card__input" type="digit" placeholder="0.00" />
-            </view>
-            <view class="item-card__cell">
-              <text class="item-card__label">仓库</text>
-              <picker
-                mode="selector"
-                :range="dictStore.getItems('depot')"
-                range-key="name"
-                @change="onItemDepotChange(idx, ($event as any).detail.value)"
-              >
-                <text class="item-card__value">{{ item.depotName || '选择' }}</text>
-              </picker>
-            </view>
-          </view>
-          <view class="item-card__grid">
-            <view class="item-card__cell">
-              <text class="item-card__label">炉号</text>
-              <input v-model="item.furnaceNo" class="item-card__input" placeholder="选填" />
-            </view>
-            <view class="item-card__cell">
-              <text class="item-card__label">批号</text>
-              <input v-model="item.batchNo" class="item-card__input" placeholder="选填" />
-            </view>
-            <view class="item-card__cell">
-              <text class="item-card__label">合同号</text>
-              <input v-model="item.contractNo" class="item-card__input" placeholder="选填" />
-            </view>
-          </view>
-          <view class="item-card__grid">
-            <view class="item-card__cell">
-              <text class="item-card__label">规格</text>
-              <input v-model="item.standard" class="item-card__input" placeholder="选填" />
-            </view>
-            <view class="item-card__cell">
-              <text class="item-card__label">单位</text>
-              <picker mode="selector" :range="unitNames" @change="onItemUnitChange(idx, ($event as any).detail.value)">
-                <text class="item-card__value">{{ item.materialUnit || '选择' }}</text>
-              </picker>
-            </view>
-            <view class="item-card__cell">
-              <text class="item-card__label">发货重量</text>
-              <input v-model="item.shipWeight" class="item-card__input" type="digit" placeholder="选填" />
-            </view>
-          </view>
-          <view class="item-card__grid">
-            <view class="item-card__cell">
-              <text class="item-card__label">收货重量</text>
-              <input v-model="item.recvWeight" class="item-card__input" type="digit" placeholder="选填" />
-            </view>
-            <view class="item-card__cell">
-              <text class="item-card__label">运费单价</text>
-              <input v-model="item.freightUnitPrice" class="item-card__input" type="digit" placeholder="选填" />
-            </view>
-            <view class="item-card__cell">
-              <text class="item-card__label">运费金额</text>
-              <input v-model="item.freightFee" class="item-card__input" type="digit" placeholder="选填" />
-            </view>
-          </view>
-        </view>
-        <ErpEmpty v-if="!items.length" text="请添加商品明细" />
+      <view class="save-bar">
+        <button class="save-bar__btn" :disabled="saving" @click="handleEntitySave">
+          {{ saving ? '保存中...' : recordId ? '保存修改' : '提交' }}
+        </button>
       </view>
     </template>
 
+    <DepotBillForm v-else-if="formKind === 'depot'" :module-key="moduleKey" :record-id="recordId" />
+    <LogisticsFeeForm v-else-if="formKind === 'logistics'" :record-id="recordId" />
+    <MoneyBillForm v-else-if="formKind === 'money'" :module-key="moduleKey" :record-id="recordId" />
+    <SettlementForm v-else-if="formKind === 'settlement'" :record-id="recordId" />
+    <ProductionForm v-else-if="formKind === 'production'" :record-id="recordId" />
     <ErpEmpty v-else text="该模块暂不支持移动端编辑" />
-
-    <!-- 底部保存 -->
-    <view v-if="canSave" class="save-bar">
-      <button class="save-bar__btn" :disabled="saving" @click="handleSave">
-        <text>{{ saving ? '保存中...' : recordId ? '保存修改' : '提交' }}</text>
-      </button>
-    </view>
   </view>
 </template>
 
 <script setup lang="ts">
-/**
- * 通用表单模板(props 驱动,P2 写入能力)
- * - entity:formSchemas.ENTITY_SCHEMAS 字段清单渲染,create/update 提交
- * - bill:通用单据表头 + 明细行卡片(商品独立搜索页 + uni.scanCode 扫码快加,对齐 DepotBillSaveDTO)
- * - 写后失效对应字典缓存
- */
-import { ref, reactive, computed, watch, onUnmounted } from 'vue'
+import { ref, reactive, computed, watch } from 'vue'
 import { getModule, type ModuleConfig } from '@/modules/registry'
 import { getCrudApi } from '@/api/crud'
-import { getEntitySchema, getBillHeadExtFields, BILL_ITEM_MODULES, type FieldDef, type BillItemDraft } from './formSchemas'
+import { getEntitySchema, getBillFormKind, type FieldDef } from './formSchemas'
 import { useDictStore } from '@/store/modules/dict'
 import type { DictKind } from '@/api/modules/dict'
-import { today } from '@/utils/format'
 import ErpEmpty from '@/components/common/ErpEmpty.vue'
-import { MATERIAL_PICKER_EVENT, MATERIAL_PICKER_ROUTE } from '@/constants/routes'
+import DepotBillForm from './DepotBillForm.vue'
+import LogisticsFeeForm from './LogisticsFeeForm.vue'
+import MoneyBillForm from './MoneyBillForm.vue'
+import SettlementForm from './SettlementForm.vue'
+import ProductionForm from './ProductionForm.vue'
 
 const props = defineProps<{ moduleKey: string; recordId: string }>()
 
 const module = ref<ModuleConfig | null>(null)
 const entityFields = ref<FieldDef[]>([])
 const form = reactive<Record<string, any>>({})
-const items = ref<BillItemDraft[]>([])
 const saving = ref(false)
 const dictStore = useDictStore()
 
-/** 单据往来单位字典来源:采购=供应商,销售/零售=客户 */
-const organDictKind = computed<DictKind | null>(() => {
-  const prefix = module.value?.apiPrefix || ''
-  if (prefix.startsWith('/purchase')) return 'supplier'
-  if (prefix.startsWith('/sales') || prefix.startsWith('/retail')) return 'customer'
-  return null
-})
-
-const hasItems = computed(() => !!module.value && BILL_ITEM_MODULES.has(module.value.key))
-
-/** 模块差异化表头扩展字段 */
-const headExtFields = computed<FieldDef[]>(() => (module.value ? getBillHeadExtFields(module.value.key) : []))
-
-/** 数量单位选项(unit 字典 basicUnit 去重) */
-const unitNames = computed<string[]>(() => {
-  const rows = dictStore.getItems('unit')
-  return [...new Set(rows.map((u) => u.basicUnit || u.name).filter(Boolean))] as string[]
-})
-
-const canSave = computed(() => {
-  if (!module.value) return false
-  if (module.value.shape === 'entity') return entityFields.value.length > 0
-  if (module.value.shape === 'bill') return BILL_ITEM_MODULES.has(module.value.key)
-  return false
-})
+const formKind = computed(() => getBillFormKind(props.moduleKey))
 
 watch(
   () => [props.moduleKey, props.recordId],
@@ -310,60 +101,33 @@ watch(
     if (!m) return
     module.value = m
     uni.setNavigationBarTitle({ title: `${props.recordId ? '编辑' : '新增'}${m.title}` })
-    // 预热所需字典
-    const kinds = new Set<DictKind>(['depot', 'account', 'unit'])
-    if (organDictKind.value) kinds.add(organDictKind.value)
-    headExtFields.value.forEach((f) => {
+    if (m.shape !== 'entity') return
+    entityFields.value = getEntitySchema(m.key) || []
+    const kinds = new Set<DictKind>()
+    entityFields.value.forEach((f) => {
       if (f.dictKind) kinds.add(f.dictKind)
     })
-    if (m.shape === 'entity') {
-      entityFields.value = getEntitySchema(m.key) || []
-      entityFields.value.forEach((f) => {
-        if (f.dictKind) kinds.add(f.dictKind)
-      })
-    }
     await dictStore.warmup(Array.from(kinds)).catch(() => {})
-    // 初始化表单
     if (props.recordId) {
-      await loadForEdit()
+      try {
+        const info = await getCrudApi(m.apiPrefix).info(props.recordId)
+        Object.keys(form).forEach((k) => delete form[k])
+        entityFields.value.forEach((f) => {
+          form[f.key] = info[f.key] ?? (f.defaultValue !== undefined ? f.defaultValue : undefined)
+        })
+      } catch (e) {
+        uni.showToast({ title: (e as Error).message || '加载失败', icon: 'none' })
+      }
     } else {
-      initEmptyForm()
+      Object.keys(form).forEach((k) => delete form[k])
+      entityFields.value.forEach((f) => {
+        if (f.defaultValue !== undefined) form[f.key] = f.defaultValue
+      })
     }
   },
   { immediate: true }
 )
 
-function initEmptyForm() {
-  Object.keys(form).forEach((k) => delete form[k])
-  entityFields.value.forEach((f) => {
-    if (f.defaultValue !== undefined) form[f.key] = f.defaultValue
-  })
-  if (module.value?.shape === 'bill') {
-    form.operTime = today()
-  }
-  items.value = []
-}
-
-async function loadForEdit() {
-  if (!module.value) return
-  try {
-    const api = getCrudApi(module.value.apiPrefix)
-    const info = await api.info(props.recordId)
-    Object.keys(form).forEach((k) => delete form[k])
-    if (module.value.shape === 'entity') {
-      entityFields.value.forEach((f) => {
-        form[f.key] = info[f.key] ?? (f.defaultValue !== undefined ? f.defaultValue : undefined)
-      })
-    } else {
-      Object.assign(form, info)
-      items.value = (info.items || []).map((item: any) => ({ ...item }))
-    }
-  } catch (e) {
-    uni.showToast({ title: (e as Error).message || '加载失败', icon: 'none' })
-  }
-}
-
-// ---------- entity 表单辅助 ----------
 function selectOptions(field: FieldDef): { label: string; value: any }[] {
   if (field.options) return field.options
   if (field.dictKind) {
@@ -385,167 +149,20 @@ function onSelectChange(field: FieldDef, index: number) {
   if (hit) form[field.key] = hit.value
 }
 
-// ---------- bill 表单辅助 ----------
-function onOrganChange(index: number) {
-  if (!organDictKind.value) return
-  const hit = dictStore.getItems(organDictKind.value)[Number(index)]
-  if (hit) {
-    form.organId = hit.id
-    form.organName = hit.name
-  }
-}
-
-function onAccountChange(index: number) {
-  const hit = dictStore.getItems('account')[Number(index)]
-  if (hit) {
-    form.accountId = hit.id
-    form.accountName = hit.name
-  }
-}
-
-function onItemDepotChange(idx: number, index: number) {
-  const hit = dictStore.getItems('depot')[Number(index)]
-  if (hit) {
-    items.value[idx].depotId = String(hit.id)
-    items.value[idx].depotName = hit.name
-  }
-}
-
-function onItemUnitChange(idx: number, index: number) {
-  const hit = unitNames.value[Number(index)]
-  if (hit) items.value[idx].materialUnit = hit
-}
-
-// ---------- 表头扩展字段辅助 ----------
-/** 字典项显示名:合同=合同号(名称),车辆=车牌号,其余=name */
-function headExtLabel(kind: DictKind | undefined, d: any): string {
-  if (kind === 'contract') return d.contractNo ? `${d.contractNo}${d.contractName ? ' ' + d.contractName : ''}` : String(d.id)
-  if (kind === 'vehicle') return d.vehicleNo || String(d.id)
-  return d.name || String(d.id)
-}
-
-/** 字典项提交值:合同/车辆存字符串快照(兼容旧自由文本数据),其余存 id */
-function headExtValue(kind: DictKind | undefined, d: any): any {
-  if (kind === 'contract') return d.contractNo
-  if (kind === 'vehicle') return d.vehicleNo
-  return d.id
-}
-
-function headExtOptions(field: FieldDef): { label: string; value: any }[] {
-  if (!field.dictKind) return field.options || []
-  return dictStore.getItems(field.dictKind).map((d) => ({ label: headExtLabel(field.dictKind, d), value: headExtValue(field.dictKind, d) }))
-}
-
-function headExtText(field: FieldDef): string {
-  const value = form[field.key]
-  if (value === undefined || value === null || value === '') return ''
-  const hit = headExtOptions(field).find((o) => String(o.value) === String(value))
-  return hit?.label || String(value)
-}
-
-function onHeadExtChange(field: FieldDef, index: number) {
-  const hit = headExtOptions(field)[Number(index)]
-  if (!hit) return
-  form[field.key] = hit.value
-  // 车牌选中后带出司机信息(可手改)
-  if (field.dictKind === 'vehicle') {
-    const row = dictStore.getItems('vehicle')[Number(index)]
-    if (row) {
-      form.driverName = row.driverName || ''
-      form.driverPhone = row.driverPhone || ''
-    }
-  }
-}
-
-function openMaterialPicker() {
-  uni.navigateTo({ url: MATERIAL_PICKER_ROUTE })
-}
-
-function addMaterial(material: any) {
-  items.value.push({
-    materialId: String(material.id),
-    materialName: material.name,
-    barCode: material.barCode,
-    materialUnit: material.unit || material.unitName,
-    operNumber: 1,
-    unitPrice: material.purchasePrice ?? material.price
-  })
-}
-
-function scanMaterial() {
-  uni.scanCode({
-    success: async (res) => {
-      try {
-        const api = getCrudApi('/material/info/v1')
-        const page = await api.list({ pageNo: 1, pageSize: 1, barCode: res.result })
-        const hit = page.records?.[0] || page.list?.[0]
-        if (hit) addMaterial(hit)
-        else uni.showToast({ title: '未找到该条码商品', icon: 'none' })
-      } catch {
-        uni.showToast({ title: '扫码查询失败', icon: 'none' })
-      }
-    },
-    fail: () => uni.showToast({ title: '扫码取消', icon: 'none' })
-  })
-}
-
-// 商品选择器回传
-uni.$on(MATERIAL_PICKER_EVENT, addMaterial)
-onUnmounted(() => {
-  uni.$off(MATERIAL_PICKER_EVENT, addMaterial)
-})
-
-// ---------- 提交 ----------
-async function handleSave() {
+async function handleEntitySave() {
   const m = module.value
   if (!m || saving.value) return
-
-  // 必填校验
-  if (m.shape === 'entity') {
-    for (const field of entityFields.value) {
-      if (field.required && (form[field.key] === undefined || form[field.key] === '' || form[field.key] === null)) {
-        uni.showToast({ title: `请填写${field.label}`, icon: 'none' })
-        return
-      }
-    }
-  } else {
-    if (organDictKind.value && !form.organId) {
-      uni.showToast({ title: '请选择往来单位', icon: 'none' })
-      return
-    }
-    if (!form.operTime) {
-      uni.showToast({ title: '请选择单据日期', icon: 'none' })
-      return
-    }
-    if (hasItems.value && !items.value.length) {
-      uni.showToast({ title: '请添加商品明细', icon: 'none' })
+  for (const field of entityFields.value) {
+    if (field.required && (form[field.key] === undefined || form[field.key] === '' || form[field.key] === null)) {
+      uni.showToast({ title: `请填写${field.label}`, icon: 'none' })
       return
     }
   }
-
   saving.value = true
   try {
     const api = getCrudApi(m.apiPrefix)
-    const payload: Record<string, any> = { ...form }
-    if (m.shape === 'bill') {
-      payload.items = items.value.map((item) => ({
-        ...item,
-        operNumber: Number(item.operNumber) || 0,
-        unitPrice: Number(item.unitPrice) || 0,
-        allPrice: (Number(item.operNumber) || 0) * (Number(item.unitPrice) || 0),
-        shipWeight: item.shipWeight != null && item.shipWeight !== ('' as any) ? Number(item.shipWeight) : undefined,
-        recvWeight: item.recvWeight != null && item.recvWeight !== ('' as any) ? Number(item.recvWeight) : undefined,
-        freightUnitPrice: item.freightUnitPrice != null && item.freightUnitPrice !== ('' as any) ? Number(item.freightUnitPrice) : undefined,
-        freightFee: item.freightFee != null && item.freightFee !== ('' as any) ? Number(item.freightFee) : undefined
-      }))
-      payload.totalPrice = payload.items.reduce((sum: number, i: any) => sum + (i.allPrice || 0), 0)
-    }
-    if (props.recordId) {
-      await api.update(props.recordId, payload)
-    } else {
-      await api.create(payload)
-    }
-    // 写后失效字典
+    if (props.recordId) await api.update(props.recordId, { ...form })
+    else await api.create({ ...form })
     if (m.dictKind) dictStore.invalidate(m.dictKind)
     uni.showToast({ title: '保存成功', icon: 'success' })
     setTimeout(() => uni.navigateBack(), 600)
@@ -563,81 +180,45 @@ async function handleSave() {
   background: #f2f5fa;
   padding-bottom: 160rpx;
 }
-
 .section {
   margin: 20rpx 24rpx 0;
   background: #fff;
   border-radius: 16rpx;
   padding: 8rpx 24rpx 16rpx;
-
-  &__title {
-    display: flex;
-    align-items: center;
-    justify-content: space-between;
-    padding: 20rpx 0;
-    font-size: 28rpx;
-    font-weight: 600;
-    color: #303133;
-    border-bottom: 1rpx solid #f2f3f5;
-  }
-
-  &__actions {
-    display: flex;
-    gap: 24rpx;
-  }
-
-  &__btn {
-    font-size: 26rpx;
-    color: #2979ff;
-    font-weight: 400;
-  }
 }
-
 .field {
   display: flex;
   align-items: flex-start;
   padding: 20rpx 0;
   border-bottom: 1rpx solid #f7f8fa;
-
-  &:last-child {
-    border-bottom: none;
-  }
-
   &__label {
     width: 180rpx;
     flex-shrink: 0;
     font-size: 26rpx;
     color: #606266;
     line-height: 64rpx;
-
     &--required::before {
       content: '*';
       color: #fa3534;
       margin-right: 4rpx;
     }
   }
-
   &__control {
     flex: 1;
     min-height: 64rpx;
     display: flex;
     align-items: center;
   }
-
   &__input {
     flex: 1;
     height: 64rpx;
     font-size: 28rpx;
-    color: #303133;
   }
-
   &__textarea {
     flex: 1;
     min-height: 96rpx;
     font-size: 28rpx;
-    color: #303133;
   }
-
   &__picker {
     flex: 1;
     display: flex;
@@ -645,74 +226,13 @@ async function handleSave() {
     justify-content: space-between;
     height: 64rpx;
     font-size: 28rpx;
-    color: #303133;
   }
-
   &__arrow {
     color: #c0c4cc;
     font-size: 32rpx;
   }
 }
-
-.placeholder {
-  color: #999;
-}
-
-.item-card {
-  margin: 16rpx 0;
-  padding: 20rpx;
-  background: #f7f8fa;
-  border-radius: 12rpx;
-
-  &__head {
-    display: flex;
-    justify-content: space-between;
-    margin-bottom: 16rpx;
-  }
-
-  &__name {
-    font-size: 28rpx;
-    font-weight: 600;
-    color: #303133;
-  }
-
-  &__del {
-    font-size: 24rpx;
-    color: #fa3534;
-  }
-
-  &__grid {
-    display: flex;
-    gap: 16rpx;
-    margin-top: 12rpx;
-  }
-
-  &__cell {
-    flex: 1;
-    background: #fff;
-    border-radius: 8rpx;
-    padding: 12rpx 16rpx;
-  }
-
-  &__label {
-    display: block;
-    font-size: 22rpx;
-    color: #909399;
-    margin-bottom: 6rpx;
-  }
-
-  &__input {
-    font-size: 26rpx;
-    color: #303133;
-    height: 48rpx;
-  }
-
-  &__value {
-    font-size: 26rpx;
-    color: #2979ff;
-  }
-}
-
+.placeholder { color: #999; }
 .save-bar {
   position: fixed;
   left: 0;
@@ -720,21 +240,13 @@ async function handleSave() {
   bottom: 0;
   padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
   background: #fff;
-  box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.05);
-
   &__btn {
     background: #2979ff;
     color: #fff;
     border-radius: 12rpx;
     font-size: 30rpx;
-
-    &::after {
-      border: none;
-    }
-
-    &[disabled] {
-      opacity: 0.6;
-    }
+    &::after { border: none; }
+    &[disabled] { opacity: 0.6; }
   }
 }
 </style>

+ 49 - 12
src/modules/bill/BillListView.vue

@@ -19,6 +19,12 @@
       </view>
     </view>
 
+    <view v-if="moduleKey === 'stock'" class="extra-filter">
+      <input v-model="furnaceNo" class="extra-filter__input" placeholder="炉号" @confirm="applyStockFilter" />
+      <input v-model="contractNo" class="extra-filter__input" placeholder="合同号" @confirm="applyStockFilter" />
+      <text class="extra-filter__btn" @click="applyStockFilter">筛选</text>
+    </view>
+
     <!-- 列表 -->
     <ErpEmpty
       v-if="!state.loading && !state.records.length"
@@ -48,9 +54,10 @@
  * 页面壳通过 ref 调用 refresh()/loadMore() 挂接 onPullDownRefresh/onReachBottom
  */
 import { ref, reactive, computed, watch } from 'vue'
-import { getModule, BILL_STATUS_TEXT, BILL_STATUS_TYPE, type ModuleConfig } from '@/modules/registry'
+import { getModule, statusTextOf, statusOptionsOf, BILL_STATUS_TYPE, type ModuleConfig } from '@/modules/registry'
 import { usePagedList } from '@/composables/usePagedList'
 import { formatMoney, formatDateTime } from '@/utils/format'
+import { DEPOT_TYPE_TEXT, MATERIAL_TYPE_TEXT, REPORT_FIELD_LABELS } from './formSchemas'
 import ErpSearchBar from '@/components/common/ErpSearchBar.vue'
 import ErpEmpty from '@/components/common/ErpEmpty.vue'
 import ErpListItem from '@/components/common/ErpListItem.vue'
@@ -61,18 +68,15 @@ const props = defineProps<{ moduleKey: string; initialStatus?: string }>()
 
 const module = ref<ModuleConfig | null>(null)
 const keyword = ref('')
+const furnaceNo = ref('')
+const contractNo = ref('')
 const statusFilter = ref<number | null>(null)
 let paged: ReturnType<typeof usePagedList> | null = null
 
 // 模板渲染用响应式状态(initPaged 内与 usePagedList 同步)
 const state = reactive({ records: [] as any[], loading: false, finished: false, error: false })
 
-const statusOptions = [
-  { label: '全部', value: null },
-  { label: '未审核', value: 0 },
-  { label: '已审核', value: 1 },
-  { label: '已完成', value: 2 }
-] as { label: string; value: number | null }[]
+const statusOptions = computed(() => (module.value ? statusOptionsOf(module.value) : []))
 
 const moreStatus = computed(() => {
   if (state.error) return 'error'
@@ -143,10 +147,18 @@ function onStatusChange(value: number | null) {
   paged.setFixedQuery(value === null ? { status: undefined } : { status: value })
 }
 
+function applyStockFilter() {
+  if (!paged) return
+  paged.setFixedQuery({
+    furnaceNo: furnaceNo.value || undefined,
+    contractNo: contractNo.value || undefined
+  })
+}
+
 function rowTitle(row: any): string {
   const m = module.value!
   if (m.shape === 'bill') return row.billNo || `${m.title} #${row.id}`
-  if (m.shape === 'entity') return row.name || row.attributeName || row.nativeName || `${row.id}`
+  if (m.shape === 'entity') return row.name || row.attributeName || row.nativeName || row.vehicleNo || row.effectiveDate || `${row.id}`
   return row.materialName || row.billNo || row.name || String(row.id ?? m.title)
 }
 
@@ -174,7 +186,8 @@ function rowRows(row: any): { label: string; value?: string | number | null; str
     const rows: { label: string; value?: string | number | null }[] = []
     if (row.contacts || row.principal) rows.push({ label: '联系人', value: row.contacts || row.principal })
     if (row.phoneNum || row.telephone) rows.push({ label: '电话', value: row.phoneNum || row.telephone })
-    if (row.depotType) rows.push({ label: '类型', value: row.depotType })
+    if (row.depotType) rows.push({ label: '类型', value: DEPOT_TYPE_TEXT[row.depotType] || row.depotType })
+    if (row.materialType) rows.push({ label: '物料类型', value: MATERIAL_TYPE_TEXT[row.materialType] || row.materialType })
     if (row.address) rows.push({ label: '地址', value: row.address })
     if (row.remark) rows.push({ label: '备注', value: row.remark })
     if (!rows.length && row.enabled !== undefined) rows.push({ label: '状态', value: row.enabled === 1 ? '启用' : '停用' })
@@ -188,7 +201,7 @@ function rowRows(row: any): { label: string; value?: string | number | null; str
     if (value === null || value === undefined || typeof value === 'object') continue
     const strong = /amount|money|price/i.test(key)
     rows.push({
-      label: key,
+      label: REPORT_FIELD_LABELS[key] || key,
       value: typeof value === 'number' && strong ? formatMoney(value) : String(value),
       strong
     })
@@ -198,12 +211,14 @@ function rowRows(row: any): { label: string; value?: string | number | null; str
 
 function statusText(row: any): string {
   if (module.value?.shape !== 'bill' || row.status === undefined || row.status === null) return ''
-  return BILL_STATUS_TEXT[row.status] ?? ''
+  const text = statusTextOf(module.value, Number(row.status))
+  if (String(row.locked) === '1') return `${text}·已锁`
+  return text
 }
 
 function statusType(row: any): 'warning' | 'success' | 'primary' | undefined {
   if (module.value?.shape !== 'bill' || row.status === undefined || row.status === null) return undefined
-  return BILL_STATUS_TYPE[row.status]
+  return BILL_STATUS_TYPE[Number(row.status)]
 }
 
 function openDetail(row: any) {
@@ -222,6 +237,28 @@ defineExpose({ refresh, loadMore })
   padding-bottom: 40rpx;
 }
 
+.extra-filter {
+  display: flex;
+  gap: 12rpx;
+  padding: 12rpx 24rpx;
+  background: #fff;
+  align-items: center;
+
+  &__input {
+    flex: 1;
+    height: 56rpx;
+    background: #f4f5f7;
+    border-radius: 8rpx;
+    padding: 0 16rpx;
+    font-size: 24rpx;
+  }
+
+  &__btn {
+    font-size: 24rpx;
+    color: #2979ff;
+  }
+}
+
 .status-filter {
   display: flex;
   padding: 12rpx 24rpx;

+ 666 - 0
src/modules/bill/DepotBillForm.vue

@@ -0,0 +1,666 @@
+<template>
+  <view class="depot-form">
+    <view class="section">
+      <view class="section__title">表头信息</view>
+      <view v-if="organDictKind" class="field">
+        <text class="field__label field__label--required">{{ organLabel }}</text>
+        <view class="field__control">
+          <picker
+            mode="selector"
+            :range="dictStore.getItems(organDictKind)"
+            range-key="name"
+            @change="onOrganChange(($event as any).detail.value)"
+          >
+            <view class="field__picker">
+              <text :class="{ placeholder: !form.organName }">{{ form.organName || `请选择${organLabel}` }}</text>
+              <text class="field__arrow">›</text>
+            </view>
+          </picker>
+        </view>
+      </view>
+      <view v-if="moduleKey !== 'allocation'" class="field">
+        <text class="field__label">结算账户</text>
+        <view class="field__control">
+          <picker
+            mode="selector"
+            :range="dictStore.getItems('account')"
+            range-key="name"
+            @change="onAccountChange(($event as any).detail.value)"
+          >
+            <view class="field__picker">
+              <text :class="{ placeholder: !form.accountName }">{{ form.accountName || '请选择结算账户' }}</text>
+              <text class="field__arrow">›</text>
+            </view>
+          </picker>
+        </view>
+      </view>
+      <view class="field">
+        <text class="field__label field__label--required">单据日期</text>
+        <view class="field__control">
+          <picker mode="date" :value="form.operTime" @change="form.operTime = ($event as any).detail.value">
+            <view class="field__picker">
+              <text>{{ form.operTime }}</text>
+              <text class="field__arrow">›</text>
+            </view>
+          </picker>
+        </view>
+      </view>
+      <view v-for="field in headExtFields" :key="field.key" class="field">
+        <text class="field__label">{{ field.label }}</text>
+        <view class="field__control">
+          <picker
+            v-if="field.type === 'select'"
+            mode="selector"
+            :range="headExtOptions(field)"
+            range-key="label"
+            @change="onHeadExtChange(field, ($event as any).detail.value)"
+          >
+            <view class="field__picker">
+              <text :class="{ placeholder: !form[field.key] }">{{ headExtText(field) || `请选择${field.label}` }}</text>
+              <text class="field__arrow">›</text>
+            </view>
+          </picker>
+          <input
+            v-else
+            v-model="form[field.key]"
+            class="field__input"
+            :placeholder="`请输入${field.label}`"
+          />
+        </view>
+      </view>
+      <view v-if="moduleKey === 'purchaseIn'" class="field">
+        <text class="field__label">合同附件</text>
+        <view class="field__control">
+          <text class="link" @click="uploadContract">{{ form.contractFileUrl ? '已上传,点击重传' : '上传附件' }}</text>
+        </view>
+      </view>
+      <view v-if="moduleKey === 'salesOut'" class="field">
+        <text class="field__label">收货凭证</text>
+        <view class="field__control">
+          <text class="link" @click="uploadReceipt">{{ form.receiptFileUrl ? '已上传,点击重传' : '上传凭证' }}</text>
+        </view>
+      </view>
+      <view class="field">
+        <text class="field__label">备注</text>
+        <view class="field__control">
+          <textarea v-model="form.remark" class="field__textarea" placeholder="请输入备注" />
+        </view>
+      </view>
+    </view>
+
+    <view class="section">
+      <view class="section__title">
+        明细({{ items.length }})
+        <view class="section__actions">
+          <text class="section__btn" @click="scanMaterial">扫码</text>
+          <text class="section__btn" @click="openMaterialPicker">+ 添加物料</text>
+        </view>
+      </view>
+      <view v-for="(item, idx) in items" :key="idx" class="item-card">
+        <view class="item-card__head">
+          <text class="item-card__name">{{ item.materialName || `行 ${idx + 1}` }}</text>
+          <text class="item-card__del" @click="items.splice(idx, 1)">删除</text>
+        </view>
+        <view class="item-card__grid">
+          <view class="item-card__cell">
+            <text class="item-card__label">{{ weightLabel }}</text>
+            <input
+              v-model="item[weightKey]"
+              class="item-card__input"
+              type="digit"
+              placeholder="必填"
+            />
+          </view>
+          <view v-if="moduleKey !== 'allocation'" class="item-card__cell">
+            <text class="item-card__label">单价</text>
+            <input v-model="item.unitPrice" class="item-card__input" type="digit" placeholder="0.00" />
+          </view>
+          <view class="item-card__cell">
+            <text class="item-card__label">{{ moduleKey === 'allocation' ? '调出仓库' : '仓库' }}</text>
+            <picker
+              mode="selector"
+              :range="itemDepots"
+              range-key="name"
+              @change="onItemDepotChange(idx, ($event as any).detail.value)"
+            >
+              <text class="item-card__value">{{ item.depotName || '选择' }}</text>
+            </picker>
+          </view>
+        </view>
+        <view v-if="moduleKey === 'allocation'" class="item-card__grid">
+          <view class="item-card__cell">
+            <text class="item-card__label">调入仓库</text>
+            <picker
+              mode="selector"
+              :range="dictStore.getItems('depot')"
+              range-key="name"
+              @change="onItemToDepotChange(idx, ($event as any).detail.value)"
+            >
+              <text class="item-card__value">{{ item.anotherDepotName || '选择' }}</text>
+            </picker>
+          </view>
+        </view>
+        <view class="item-card__grid">
+          <view class="item-card__cell">
+            <text class="item-card__label">炉号</text>
+            <input v-model="item.furnaceNo" class="item-card__input" placeholder="选填" />
+          </view>
+          <view class="item-card__cell">
+            <text class="item-card__label">批号</text>
+            <input v-model="item.batchNo" class="item-card__input" placeholder="选填" />
+          </view>
+          <view class="item-card__cell">
+            <text class="item-card__label">合同号</text>
+            <input v-model="item.contractNo" class="item-card__input" placeholder="选填" />
+          </view>
+        </view>
+        <view v-if="moduleKey === 'purchaseIn'" class="item-card__grid">
+          <view class="item-card__cell">
+            <text class="item-card__label">令号</text>
+            <input v-model="item.orderNo" class="item-card__input" placeholder="选填" />
+          </view>
+        </view>
+        <view v-if="moduleKey === 'salesOut'" class="item-card__grid">
+          <view class="item-card__cell">
+            <text class="item-card__label">重量照片</text>
+            <text class="item-card__value" @click="uploadWeightPhoto(idx)">
+              {{ item.weightPhotoUrl ? '已上传' : '上传' }}
+            </text>
+          </view>
+        </view>
+      </view>
+      <ErpEmpty v-if="!items.length" text="请添加物料明细" />
+    </view>
+
+    <view class="save-bar">
+      <template v-if="draftConfirm">
+        <button class="save-bar__btn save-bar__btn--ghost" :disabled="saving" @click="handleSave('0')">
+          保存草稿
+        </button>
+        <button class="save-bar__btn" :disabled="saving" @click="handleSave('1')">定稿</button>
+      </template>
+      <button v-else class="save-bar__btn" :disabled="saving" @click="handleSave()">
+        {{ saving ? '保存中...' : recordId ? '保存修改' : '保存' }}
+      </button>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, computed, watch, onUnmounted } from 'vue'
+import { getModule } from '@/modules/registry'
+import { getCrudApi } from '@/api/crud'
+import { getBillHeadExtFields, type FieldDef, type BillItemDraft } from './formSchemas'
+import { useDictStore } from '@/store/modules/dict'
+import type { DictKind } from '@/api/modules/dict'
+import { today } from '@/utils/format'
+import { chooseAndUploadImage } from '@/utils/upload'
+import ErpEmpty from '@/components/common/ErpEmpty.vue'
+import { MATERIAL_PICKER_EVENT, MATERIAL_PICKER_ROUTE } from '@/constants/routes'
+
+const props = defineProps<{ moduleKey: string; recordId: string }>()
+
+const dictStore = useDictStore()
+const form = reactive<Record<string, any>>({})
+const items = ref<BillItemDraft[]>([])
+const saving = ref(false)
+
+const draftConfirm = computed(() => {
+  const m = getModule(props.moduleKey)
+  return m?.statusModel === 'draftConfirm'
+})
+
+const organDictKind = computed<DictKind | null>(() => {
+  if (props.moduleKey === 'purchaseIn' || props.moduleKey === 'otherIn') return 'supplier'
+  if (props.moduleKey === 'salesOut') return 'customer'
+  return null
+})
+
+const organLabel = computed(() => {
+  if (organDictKind.value === 'supplier') return '供应商'
+  if (organDictKind.value === 'customer') return '客户'
+  return '往来单位'
+})
+
+const weightKey = computed<'recvWeight' | 'shipWeight'>(() =>
+  props.moduleKey === 'salesOut' ? 'shipWeight' : 'recvWeight'
+)
+const weightLabel = computed(() => (props.moduleKey === 'salesOut' ? '发货重量' : '收货重量'))
+
+const itemDepots = computed(() => {
+  const all = dictStore.getItems('depot')
+  if (props.moduleKey === 'purchaseIn') return all.filter((d) => d.depotType === 'THIRD' || d.depotType === '三方库')
+  return all
+})
+
+const headExtFields = computed<FieldDef[]>(() => getBillHeadExtFields(props.moduleKey))
+
+watch(
+  () => [props.moduleKey, props.recordId],
+  async () => {
+    const m = getModule(props.moduleKey)
+    if (!m) return
+    const kinds = new Set<DictKind>(['depot', 'account', 'unit', 'contract', 'vehicle'])
+    if (organDictKind.value) kinds.add(organDictKind.value)
+    await dictStore.warmup(Array.from(kinds)).catch(() => {})
+    if (props.recordId) await loadForEdit()
+    else initEmptyForm()
+  },
+  { immediate: true }
+)
+
+function initEmptyForm() {
+  Object.keys(form).forEach((k) => delete form[k])
+  form.operTime = today()
+  items.value = []
+}
+
+async function loadForEdit() {
+  const m = getModule(props.moduleKey)
+  if (!m) return
+  try {
+    const info = await getCrudApi(m.apiPrefix).info(props.recordId)
+    Object.keys(form).forEach((k) => delete form[k])
+    Object.assign(form, info)
+    items.value = (info.items || []).map((item: any) => ({ ...item }))
+  } catch (e) {
+    uni.showToast({ title: (e as Error).message || '加载失败', icon: 'none' })
+  }
+}
+
+function onOrganChange(index: number) {
+  if (!organDictKind.value) return
+  const hit = dictStore.getItems(organDictKind.value)[Number(index)]
+  if (hit) {
+    form.organId = hit.id
+    form.organName = hit.name
+  }
+}
+
+function onAccountChange(index: number) {
+  const hit = dictStore.getItems('account')[Number(index)]
+  if (hit) {
+    form.accountId = hit.id
+    form.accountName = hit.name
+  }
+}
+
+function onItemDepotChange(idx: number, index: number) {
+  const hit = itemDepots.value[Number(index)]
+  if (hit) {
+    items.value[idx].depotId = String(hit.id)
+    items.value[idx].depotName = hit.name
+  }
+}
+
+function onItemToDepotChange(idx: number, index: number) {
+  const hit = dictStore.getItems('depot')[Number(index)]
+  if (hit) {
+    items.value[idx].anotherDepotId = String(hit.id)
+    items.value[idx].anotherDepotName = hit.name
+  }
+}
+
+function headExtLabel(kind: DictKind | undefined, d: any): string {
+  if (kind === 'contract') return d.contractNo ? `${d.contractNo}${d.contractName ? ' ' + d.contractName : ''}` : String(d.id)
+  if (kind === 'vehicle') return d.vehicleNo || String(d.id)
+  return d.name || String(d.id)
+}
+
+function headExtValue(kind: DictKind | undefined, d: any): any {
+  if (kind === 'contract') return d.contractNo
+  if (kind === 'vehicle') return d.vehicleNo
+  return d.id
+}
+
+function headExtOptions(field: FieldDef): { label: string; value: any }[] {
+  if (!field.dictKind) return field.options || []
+  return dictStore.getItems(field.dictKind).map((d) => ({
+    label: headExtLabel(field.dictKind, d),
+    value: headExtValue(field.dictKind, d)
+  }))
+}
+
+function headExtText(field: FieldDef): string {
+  const value = form[field.key]
+  if (value === undefined || value === null || value === '') return ''
+  const hit = headExtOptions(field).find((o) => String(o.value) === String(value))
+  return hit?.label || String(value)
+}
+
+function onHeadExtChange(field: FieldDef, index: number) {
+  const hit = headExtOptions(field)[Number(index)]
+  if (!hit) return
+  form[field.key] = hit.value
+  if (field.dictKind === 'vehicle') {
+    const row = dictStore.getItems('vehicle')[Number(index)]
+    if (row) {
+      form.driverName = row.driverName || ''
+      form.driverPhone = row.driverPhone || ''
+    }
+  }
+}
+
+function openMaterialPicker() {
+  uni.navigateTo({ url: MATERIAL_PICKER_ROUTE })
+}
+
+function addMaterial(material: any) {
+  items.value.push({
+    materialId: String(material.id),
+    materialName: material.name,
+    barCode: material.barCode,
+    materialUnit: material.unit || material.unitName,
+    unitPrice: material.purchasePrice ?? material.price
+  })
+}
+
+function scanMaterial() {
+  uni.scanCode({
+    success: async (res) => {
+      try {
+        const page = await getCrudApi('/material/info/v1').list({ pageNo: 1, pageSize: 1, barCode: res.result })
+        const hit = page.records?.[0] || page.list?.[0]
+        if (hit) addMaterial(hit)
+        else uni.showToast({ title: '未找到该条码商品', icon: 'none' })
+      } catch {
+        uni.showToast({ title: '扫码查询失败', icon: 'none' })
+      }
+    },
+    fail: () => uni.showToast({ title: '扫码取消', icon: 'none' })
+  })
+}
+
+uni.$on(MATERIAL_PICKER_EVENT, addMaterial)
+onUnmounted(() => uni.$off(MATERIAL_PICKER_EVENT, addMaterial))
+
+async function uploadContract() {
+  try {
+    form.contractFileUrl = await chooseAndUploadImage()
+  } catch (e) {
+    if ((e as Error).message !== '已取消') uni.showToast({ title: (e as Error).message, icon: 'none' })
+  }
+}
+
+async function uploadReceipt() {
+  try {
+    form.receiptFileUrl = await chooseAndUploadImage()
+  } catch (e) {
+    if ((e as Error).message !== '已取消') uni.showToast({ title: (e as Error).message, icon: 'none' })
+  }
+}
+
+async function uploadWeightPhoto(idx: number) {
+  try {
+    items.value[idx].weightPhotoUrl = await chooseAndUploadImage()
+  } catch (e) {
+    if ((e as Error).message !== '已取消') uni.showToast({ title: (e as Error).message, icon: 'none' })
+  }
+}
+
+function num(v: any): number | undefined {
+  if (v === undefined || v === null || v === '') return undefined
+  const n = Number(v)
+  return Number.isNaN(n) ? undefined : n
+}
+
+async function handleSave(status?: string) {
+  const m = getModule(props.moduleKey)
+  if (!m || saving.value) return
+  if (organDictKind.value && !form.organId) {
+    uni.showToast({ title: `请选择${organLabel.value}`, icon: 'none' })
+    return
+  }
+  if (!form.operTime) {
+    uni.showToast({ title: '请选择单据日期', icon: 'none' })
+    return
+  }
+  if (!items.value.length) {
+    uni.showToast({ title: '请添加物料明细', icon: 'none' })
+    return
+  }
+  for (let i = 0; i < items.value.length; i++) {
+    const item = items.value[i]
+    const weight = num(item[weightKey.value])
+    if (!weight || weight <= 0) {
+      uni.showToast({ title: `第 ${i + 1} 行请填写${weightLabel.value}`, icon: 'none' })
+      return
+    }
+    if (!item.depotId) {
+      uni.showToast({ title: `第 ${i + 1} 行请选择仓库`, icon: 'none' })
+      return
+    }
+    if (props.moduleKey === 'purchaseIn') {
+      const depot = itemDepots.value.find((d) => String(d.id) === String(item.depotId))
+      const type = depot?.depotType || ''
+      if (type && type !== 'THIRD' && type !== '三方库') {
+        uni.showToast({ title: '采购入库必须进三方库', icon: 'none' })
+        return
+      }
+    }
+    if (props.moduleKey === 'allocation' && !item.anotherDepotId) {
+      uni.showToast({ title: `第 ${i + 1} 行请选择调入仓库`, icon: 'none' })
+      return
+    }
+  }
+
+  saving.value = true
+  try {
+    const payload: Record<string, any> = { ...form }
+    payload.items = items.value.map((item) => {
+      const weight = num(item[weightKey.value]) || 0
+      const unitPrice = num(item.unitPrice) || 0
+      return {
+        ...item,
+        [weightKey.value]: weight,
+        operNumber: weight,
+        unitPrice,
+        allPrice: weight * unitPrice
+      }
+    })
+    payload.totalPrice = payload.items.reduce((sum: number, i: any) => sum + (i.allPrice || 0), 0)
+    if (status !== undefined) payload.status = status
+    const api = getCrudApi(m.apiPrefix)
+    if (props.recordId) await api.update(props.recordId, payload)
+    else await api.create(payload)
+    uni.showToast({ title: status === '1' ? '定稿成功' : '保存成功', icon: 'success' })
+    setTimeout(() => uni.navigateBack(), 600)
+  } catch (e) {
+    uni.showToast({ title: (e as Error).message || '保存失败', icon: 'none' })
+  } finally {
+    saving.value = false
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.depot-form {
+  min-height: 100vh;
+  background: #f2f5fa;
+  padding-bottom: 180rpx;
+}
+
+.section {
+  margin: 20rpx 24rpx 0;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 8rpx 24rpx 16rpx;
+
+  &__title {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 20rpx 0;
+    font-size: 28rpx;
+    font-weight: 600;
+    color: #303133;
+    border-bottom: 1rpx solid #f2f3f5;
+  }
+
+  &__actions {
+    display: flex;
+    gap: 24rpx;
+  }
+
+  &__btn {
+    font-size: 26rpx;
+    color: #2979ff;
+    font-weight: 400;
+  }
+}
+
+.field {
+  display: flex;
+  align-items: flex-start;
+  padding: 20rpx 0;
+  border-bottom: 1rpx solid #f7f8fa;
+
+  &__label {
+    width: 180rpx;
+    flex-shrink: 0;
+    font-size: 26rpx;
+    color: #606266;
+    line-height: 64rpx;
+
+    &--required::before {
+      content: '*';
+      color: #fa3534;
+      margin-right: 4rpx;
+    }
+  }
+
+  &__control {
+    flex: 1;
+    min-height: 64rpx;
+    display: flex;
+    align-items: center;
+  }
+
+  &__input,
+  &__textarea {
+    flex: 1;
+    font-size: 28rpx;
+    color: #303133;
+  }
+
+  &__input {
+    height: 64rpx;
+  }
+
+  &__textarea {
+    min-height: 96rpx;
+  }
+
+  &__picker {
+    flex: 1;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    height: 64rpx;
+    font-size: 28rpx;
+    color: #303133;
+  }
+
+  &__arrow {
+    color: #c0c4cc;
+    font-size: 32rpx;
+  }
+}
+
+.placeholder {
+  color: #999;
+}
+
+.link {
+  color: #2979ff;
+  font-size: 26rpx;
+}
+
+.item-card {
+  margin: 16rpx 0;
+  padding: 20rpx;
+  background: #f7f8fa;
+  border-radius: 12rpx;
+
+  &__head {
+    display: flex;
+    justify-content: space-between;
+    margin-bottom: 16rpx;
+  }
+
+  &__name {
+    font-size: 28rpx;
+    font-weight: 600;
+    color: #303133;
+  }
+
+  &__del {
+    font-size: 24rpx;
+    color: #fa3534;
+  }
+
+  &__grid {
+    display: flex;
+    gap: 16rpx;
+    margin-top: 12rpx;
+  }
+
+  &__cell {
+    flex: 1;
+    background: #fff;
+    border-radius: 8rpx;
+    padding: 12rpx 16rpx;
+  }
+
+  &__label {
+    display: block;
+    font-size: 22rpx;
+    color: #909399;
+    margin-bottom: 6rpx;
+  }
+
+  &__input {
+    font-size: 26rpx;
+    color: #303133;
+    height: 48rpx;
+  }
+
+  &__value {
+    font-size: 26rpx;
+    color: #2979ff;
+  }
+}
+
+.save-bar {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  gap: 16rpx;
+  padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
+  background: #fff;
+
+  &__btn {
+    flex: 1;
+    background: #2979ff;
+    color: #fff;
+    border-radius: 12rpx;
+    font-size: 30rpx;
+
+    &::after {
+      border: none;
+    }
+
+    &[disabled] {
+      opacity: 0.6;
+    }
+
+    &--ghost {
+      background: #fff;
+      color: #2979ff;
+      border: 1rpx solid #2979ff;
+    }
+  }
+}
+</style>

+ 273 - 0
src/modules/bill/LogisticsFeeForm.vue

@@ -0,0 +1,273 @@
+<template>
+  <view class="bill-form">
+    <view class="section">
+      <view class="section__title">物流费用</view>
+      <view class="field">
+        <text class="field__label field__label--required">销售出库单</text>
+        <view class="field__control">
+          <text class="link" @click="pickSales">{{ form.salesBillNo || '选择已定稿销售出库' }}</text>
+        </view>
+      </view>
+      <view class="field">
+        <text class="field__label field__label--required">三方库</text>
+        <view class="field__control">
+          <picker mode="selector" :range="thirdDepots" range-key="name" @change="onDepotChange(($event as any).detail.value)">
+            <view class="field__picker">
+              <text :class="{ placeholder: !form.depotName }">{{ form.depotName || '请选择三方库' }}</text>
+              <text class="field__arrow">›</text>
+            </view>
+          </picker>
+        </view>
+      </view>
+      <view class="field">
+        <text class="field__label">出库时间</text>
+        <view class="field__control">
+          <input v-model="form.outTime" class="field__input" placeholder="从销售单带出" />
+        </view>
+      </view>
+      <view class="field">
+        <text class="field__label field__label--required">出库吨数</text>
+        <view class="field__control">
+          <input v-model="form.weightTon" class="field__input" type="digit" placeholder="吨" />
+        </view>
+      </view>
+      <view class="field">
+        <text class="field__label">仓储费单价(元/吨)</text>
+        <view class="field__control">
+          <input v-model="form.storageUnitPrice" class="field__input" type="digit" />
+        </view>
+      </view>
+      <view class="field">
+        <text class="field__label">运费单价(元/吨)</text>
+        <view class="field__control">
+          <input v-model="form.freightUnitPrice" class="field__input" type="digit" />
+        </view>
+      </view>
+      <view class="field">
+        <text class="field__label">司机</text>
+        <view class="field__control">
+          <input v-model="form.driverName" class="field__input" placeholder="司机姓名" />
+        </view>
+      </view>
+      <view class="field">
+        <text class="field__label">电话</text>
+        <view class="field__control">
+          <input v-model="form.driverPhone" class="field__input" placeholder="联系电话" />
+        </view>
+      </view>
+      <view class="field">
+        <text class="field__label">车牌</text>
+        <view class="field__control">
+          <input v-model="form.vehicleNo" class="field__input" placeholder="车牌号" />
+        </view>
+      </view>
+      <view class="field">
+        <text class="field__label">备注</text>
+        <view class="field__control">
+          <textarea v-model="form.remark" class="field__textarea" />
+        </view>
+      </view>
+    </view>
+    <view class="save-bar">
+      <button class="save-bar__btn save-bar__btn--ghost" :disabled="saving" @click="handleSave('0')">保存草稿</button>
+      <button class="save-bar__btn" :disabled="saving" @click="handleSave('1')">定稿</button>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { reactive, computed, watch, onUnmounted, ref } from 'vue'
+import { getCrudApi } from '@/api/crud'
+import { fromSalesOut } from '@/api/modules/preview'
+import { useDictStore } from '@/store/modules/dict'
+import { BILL_PICKER_EVENT, BILL_PICKER_ROUTE } from '@/constants/routes'
+
+const props = defineProps<{ recordId: string }>()
+const dictStore = useDictStore()
+const form = reactive<Record<string, any>>({})
+const saving = ref(false)
+const eventName = BILL_PICKER_EVENT + ':logistics'
+
+const thirdDepots = computed(() =>
+  dictStore.getItems('depot').filter((d) => d.depotType === 'THIRD' || d.depotType === '三方库')
+)
+
+watch(
+  () => props.recordId,
+  async () => {
+    await dictStore.ensureDict('depot').catch(() => {})
+    if (props.recordId) {
+      try {
+        const info = await getCrudApi('/logistics/fee/v1').info(props.recordId)
+        Object.keys(form).forEach((k) => delete form[k])
+        Object.assign(form, info)
+      } catch (e) {
+        uni.showToast({ title: (e as Error).message || '加载失败', icon: 'none' })
+      }
+    } else {
+      Object.keys(form).forEach((k) => delete form[k])
+    }
+  },
+  { immediate: true }
+)
+
+function pickSales() {
+  uni.navigateTo({ url: `${BILL_PICKER_ROUTE}?module=salesOut&status=1&event=${encodeURIComponent(eventName)}` })
+}
+
+async function onSalesPicked(row: any) {
+  form.salesBillNo = row.billNo
+  try {
+    const res = await fromSalesOut(row.billNo)
+    const data = res.data || res
+    Object.assign(form, {
+      salesBillNo: data.salesBillNo || row.billNo,
+      outTime: data.outTime,
+      driverName: data.driverName,
+      driverPhone: data.driverPhone,
+      vehicleNo: data.vehicleNo,
+      weightTon: data.weightTon,
+      depotId: data.depotId,
+      depotName: data.depotName
+    })
+  } catch (e) {
+    uni.showToast({ title: (e as Error).message || '带出失败', icon: 'none' })
+  }
+}
+
+uni.$on(eventName, onSalesPicked)
+onUnmounted(() => uni.$off(eventName, onSalesPicked))
+
+function onDepotChange(index: number) {
+  const hit = thirdDepots.value[Number(index)]
+  if (hit) {
+    form.depotId = hit.id
+    form.depotName = hit.name
+  }
+}
+
+async function handleSave(status: string) {
+  if (!form.salesBillNo) {
+    uni.showToast({ title: '请选择已定稿销售出库', icon: 'none' })
+    return
+  }
+  if (!form.depotId) {
+    uni.showToast({ title: '请选择三方库', icon: 'none' })
+    return
+  }
+  if (!Number(form.weightTon)) {
+    uni.showToast({ title: '请填写出库吨数', icon: 'none' })
+    return
+  }
+  saving.value = true
+  try {
+    const payload = {
+      ...form,
+      weightTon: Number(form.weightTon),
+      storageUnitPrice: Number(form.storageUnitPrice) || 0,
+      freightUnitPrice: Number(form.freightUnitPrice) || 0,
+      status
+    }
+    const api = getCrudApi('/logistics/fee/v1')
+    if (props.recordId) await api.update(props.recordId, payload)
+    else await api.create(payload)
+    uni.showToast({ title: status === '1' ? '定稿成功' : '保存成功', icon: 'success' })
+    setTimeout(() => uni.navigateBack(), 600)
+  } catch (e) {
+    uni.showToast({ title: (e as Error).message || '保存失败', icon: 'none' })
+  } finally {
+    saving.value = false
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.bill-form {
+  min-height: 100vh;
+  background: #f2f5fa;
+  padding-bottom: 180rpx;
+}
+.section {
+  margin: 20rpx 24rpx 0;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 8rpx 24rpx 16rpx;
+  &__title {
+    padding: 20rpx 0;
+    font-size: 28rpx;
+    font-weight: 600;
+    border-bottom: 1rpx solid #f2f3f5;
+  }
+}
+.field {
+  display: flex;
+  align-items: flex-start;
+  padding: 20rpx 0;
+  border-bottom: 1rpx solid #f7f8fa;
+  &__label {
+    width: 220rpx;
+    flex-shrink: 0;
+    font-size: 26rpx;
+    color: #606266;
+    line-height: 64rpx;
+    &--required::before {
+      content: '*';
+      color: #fa3534;
+      margin-right: 4rpx;
+    }
+  }
+  &__control {
+    flex: 1;
+    min-height: 64rpx;
+    display: flex;
+    align-items: center;
+  }
+  &__input {
+    flex: 1;
+    height: 64rpx;
+    font-size: 28rpx;
+  }
+  &__textarea {
+    flex: 1;
+    min-height: 96rpx;
+    font-size: 28rpx;
+  }
+  &__picker {
+    flex: 1;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    height: 64rpx;
+    font-size: 28rpx;
+  }
+  &__arrow {
+    color: #c0c4cc;
+  }
+}
+.placeholder { color: #999; }
+.link { color: #2979ff; font-size: 26rpx; }
+.save-bar {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  gap: 16rpx;
+  padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
+  background: #fff;
+  &__btn {
+    flex: 1;
+    background: #2979ff;
+    color: #fff;
+    border-radius: 12rpx;
+    font-size: 30rpx;
+    &::after { border: none; }
+    &[disabled] { opacity: 0.6; }
+    &--ghost {
+      background: #fff;
+      color: #2979ff;
+      border: 1rpx solid #2979ff;
+    }
+  }
+}
+</style>

+ 245 - 0
src/modules/bill/MoneyBillForm.vue

@@ -0,0 +1,245 @@
+<template>
+  <view class="bill-form">
+    <view class="section">
+      <view class="section__title">{{ isPay ? '付款单' : '收款单' }}</view>
+      <view class="field">
+        <text class="field__label field__label--required">{{ isPay ? '供应商' : '客户' }}</text>
+        <view class="field__control">
+          <picker
+            mode="selector"
+            :range="dictStore.getItems(organKind)"
+            range-key="name"
+            @change="onOrganChange(($event as any).detail.value)"
+          >
+            <view class="field__picker">
+              <text :class="{ placeholder: !form.organName }">{{ form.organName || '请选择' }}</text>
+              <text class="field__arrow">›</text>
+            </view>
+          </picker>
+        </view>
+      </view>
+      <view class="field">
+        <text class="field__label field__label--required">结算账户</text>
+        <view class="field__control">
+          <picker
+            mode="selector"
+            :range="dictStore.getItems('account')"
+            range-key="name"
+            @change="onAccountChange(($event as any).detail.value)"
+          >
+            <view class="field__picker">
+              <text :class="{ placeholder: !form.accountName }">{{ form.accountName || '请选择' }}</text>
+              <text class="field__arrow">›</text>
+            </view>
+          </picker>
+        </view>
+      </view>
+      <view class="field">
+        <text class="field__label">优惠金额</text>
+        <view class="field__control">
+          <input v-model="form.discountMoney" class="field__input" type="digit" />
+        </view>
+      </view>
+      <view class="field">
+        <text class="field__label">备注</text>
+        <view class="field__control">
+          <textarea v-model="form.remark" class="field__textarea" />
+        </view>
+      </view>
+    </view>
+
+    <view class="section">
+      <view class="section__title">
+        核销明细({{ items.length }})
+        <text class="section__btn" @click="pickBill">+ 选择单据</text>
+      </view>
+      <view v-for="(item, idx) in items" :key="idx" class="item-card">
+        <view class="item-card__head">
+          <text class="item-card__name">{{ item.billNo }}</text>
+          <text class="item-card__del" @click="items.splice(idx, 1)">删除</text>
+        </view>
+        <view class="item-card__grid">
+          <view class="item-card__cell">
+            <text class="item-card__label">本次金额</text>
+            <input v-model="item.eachAmount" class="item-card__input" type="digit" />
+          </view>
+          <view v-if="isPay" class="item-card__cell">
+            <text class="item-card__label">付款比例(%)</text>
+            <input v-model="item.paymentRatio" class="item-card__input" type="digit" />
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="save-bar">
+      <button class="save-bar__btn" :disabled="saving" @click="handleSave">保存</button>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { reactive, computed, watch, onUnmounted, ref } from 'vue'
+import { getCrudApi } from '@/api/crud'
+import { useDictStore } from '@/store/modules/dict'
+import type { DictKind } from '@/api/modules/dict'
+import { BILL_PICKER_ROUTE } from '@/constants/routes'
+
+const props = defineProps<{ moduleKey: string; recordId: string }>()
+const dictStore = useDictStore()
+const form = reactive<Record<string, any>>({})
+const items = ref<any[]>([])
+const saving = ref(false)
+
+const isPay = computed(() => props.moduleKey === 'moneyOut')
+const organKind = computed<DictKind>(() => (isPay.value ? 'supplier' : 'customer'))
+const billModule = computed(() => (isPay.value ? 'purchaseIn' : 'salesOut'))
+const eventName = computed(() => `erp:bill-selected:${props.moduleKey}`)
+const apiPrefix = computed(() => (isPay.value ? '/finance/money-out/v1' : '/finance/money-in/v1'))
+
+watch(
+  () => [props.moduleKey, props.recordId],
+  async () => {
+    await dictStore.warmup([organKind.value, 'account']).catch(() => {})
+    if (props.recordId) {
+      try {
+        const info = await getCrudApi(apiPrefix.value).info(props.recordId)
+        Object.keys(form).forEach((k) => delete form[k])
+        Object.assign(form, info)
+        items.value = (info.items || []).map((i: any) => ({ ...i }))
+      } catch (e) {
+        uni.showToast({ title: (e as Error).message || '加载失败', icon: 'none' })
+      }
+    } else {
+      Object.keys(form).forEach((k) => delete form[k])
+      items.value = []
+    }
+  },
+  { immediate: true }
+)
+
+function onOrganChange(index: number) {
+  const hit = dictStore.getItems(organKind.value)[Number(index)]
+  if (hit) {
+    form.organId = hit.id
+    form.organName = hit.name
+  }
+}
+
+function onAccountChange(index: number) {
+  const hit = dictStore.getItems('account')[Number(index)]
+  if (hit) {
+    form.accountId = hit.id
+    form.accountName = hit.name
+  }
+}
+
+function pickBill() {
+  uni.navigateTo({
+    url: `${BILL_PICKER_ROUTE}?module=${billModule.value}&status=1&event=${encodeURIComponent(eventName.value)}`
+  })
+}
+
+function onBillPicked(row: any) {
+  if (items.value.some((i) => String(i.billId) === String(row.id))) {
+    uni.showToast({ title: '该单据已添加', icon: 'none' })
+    return
+  }
+  items.value.push({
+    billId: row.id,
+    billNo: row.billNo,
+    eachAmount: '',
+    paymentRatio: 100,
+    remark: ''
+  })
+}
+
+watch(eventName, (name, old) => {
+  if (old) uni.$off(old, onBillPicked)
+  uni.$on(name, onBillPicked)
+}, { immediate: true })
+
+onUnmounted(() => uni.$off(eventName.value, onBillPicked))
+
+async function handleSave() {
+  if (!form.organId) {
+    uni.showToast({ title: `请选择${isPay.value ? '供应商' : '客户'}`, icon: 'none' })
+    return
+  }
+  if (!form.accountId) {
+    uni.showToast({ title: '请选择结算账户', icon: 'none' })
+    return
+  }
+  if (!items.value.length) {
+    uni.showToast({ title: '请添加核销单据', icon: 'none' })
+    return
+  }
+  for (const item of items.value) {
+    if (!Number(item.eachAmount) || Number(item.eachAmount) <= 0) {
+      uni.showToast({ title: '核销金额须大于 0', icon: 'none' })
+      return
+    }
+  }
+  saving.value = true
+  try {
+    const payload: Record<string, any> = {
+      ...form,
+      discountMoney: Number(form.discountMoney) || 0,
+      items: items.value.map((item, idx) => ({
+        billId: item.billId,
+        billNo: item.billNo,
+        eachAmount: Number(item.eachAmount),
+        paymentRatio: isPay.value ? Number(item.paymentRatio) || 100 : undefined,
+        sort: idx,
+        remark: item.remark
+      }))
+    }
+    payload.changeAmount = payload.items.reduce((s: number, i: any) => s + (i.eachAmount || 0), 0)
+    payload.totalPrice = payload.changeAmount
+    const api = getCrudApi(apiPrefix.value)
+    if (props.recordId) await api.update(props.recordId, payload)
+    else await api.create(payload)
+    uni.showToast({ title: '保存成功', icon: 'success' })
+    setTimeout(() => uni.navigateBack(), 600)
+  } catch (e) {
+    uni.showToast({ title: (e as Error).message || '保存失败', icon: 'none' })
+  } finally {
+    saving.value = false
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.bill-form { min-height: 100vh; background: #f2f5fa; padding-bottom: 180rpx; }
+.section {
+  margin: 20rpx 24rpx 0; background: #fff; border-radius: 16rpx; padding: 8rpx 24rpx 16rpx;
+  &__title { display: flex; justify-content: space-between; padding: 20rpx 0; font-size: 28rpx; font-weight: 600; border-bottom: 1rpx solid #f2f3f5; }
+  &__btn { font-size: 26rpx; color: #2979ff; font-weight: 400; }
+}
+.field {
+  display: flex; padding: 20rpx 0; border-bottom: 1rpx solid #f7f8fa;
+  &__label { width: 180rpx; flex-shrink: 0; font-size: 26rpx; color: #606266; line-height: 64rpx;
+    &--required::before { content: '*'; color: #fa3534; margin-right: 4rpx; }
+  }
+  &__control { flex: 1; min-height: 64rpx; display: flex; align-items: center; }
+  &__input { flex: 1; height: 64rpx; font-size: 28rpx; }
+  &__textarea { flex: 1; min-height: 96rpx; font-size: 28rpx; }
+  &__picker { flex: 1; display: flex; justify-content: space-between; height: 64rpx; font-size: 28rpx; }
+  &__arrow { color: #c0c4cc; }
+}
+.placeholder { color: #999; }
+.item-card {
+  margin: 16rpx 0; padding: 20rpx; background: #f7f8fa; border-radius: 12rpx;
+  &__head { display: flex; justify-content: space-between; margin-bottom: 12rpx; }
+  &__name { font-size: 28rpx; font-weight: 600; }
+  &__del { font-size: 24rpx; color: #fa3534; }
+  &__grid { display: flex; gap: 16rpx; }
+  &__cell { flex: 1; background: #fff; border-radius: 8rpx; padding: 12rpx 16rpx; }
+  &__label { display: block; font-size: 22rpx; color: #909399; }
+  &__input { height: 48rpx; font-size: 26rpx; }
+}
+.save-bar {
+  position: fixed; left: 0; right: 0; bottom: 0;
+  padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom)); background: #fff;
+  &__btn { background: #2979ff; color: #fff; border-radius: 12rpx; font-size: 30rpx; &::after { border: none; } }
+}
+</style>

+ 316 - 0
src/modules/bill/ProductionForm.vue

@@ -0,0 +1,316 @@
+<template>
+  <view class="bill-form">
+    <view class="section">
+      <view class="section__title">生产单</view>
+      <view class="field">
+        <text class="field__label field__label--required">生产类型</text>
+        <view class="field__control">
+          <picker mode="selector" :range="prodTypes" range-key="label" @change="onProdType(($event as any).detail.value)">
+            <view class="field__picker">
+              <text :class="{ placeholder: !form.prodType }">{{ prodTypeLabel || '请选择' }}</text>
+              <text class="field__arrow">›</text>
+            </view>
+          </picker>
+        </view>
+      </view>
+      <view v-if="form.prodType === 'SALES'" class="field">
+        <text class="field__label field__label--required">销售出库单</text>
+        <view class="field__control">
+          <text class="link" @click="pickSales">{{ form.salesBillNo || '选择销售出库' }}</text>
+        </view>
+      </view>
+      <view class="field">
+        <text class="field__label">备注</text>
+        <view class="field__control">
+          <textarea v-model="form.remark" class="field__textarea" />
+        </view>
+      </view>
+    </view>
+
+    <view v-for="group in groups" :key="group.type" class="section">
+      <view class="section__title">
+        {{ group.title }}({{ linesOf(group.type).length }})
+        <text v-if="canEditGroup(group.type)" class="section__btn" @click="addLine(group.type)">+ 添加</text>
+      </view>
+      <view v-for="(item, idx) in linesOf(group.type)" :key="idx" class="item-card">
+        <view class="item-card__head">
+          <text class="item-card__name">{{ item.materialName || '未选物料' }}</text>
+          <text v-if="canEditGroup(group.type)" class="item-card__del" @click="removeLine(group.type, idx)">删除</text>
+        </view>
+        <view class="item-card__grid">
+          <view class="item-card__cell" @click="canEditGroup(group.type) && pickMaterial(group.type, idx)">
+            <text class="item-card__label">物料</text>
+            <text class="item-card__value">{{ item.materialName || '选择' }}</text>
+          </view>
+          <view class="item-card__cell">
+            <text class="item-card__label">仓库</text>
+            <picker
+              :disabled="!canEditGroup(group.type)"
+              mode="selector"
+              :range="depotsOf(group.type)"
+              range-key="name"
+              @change="onDepot(group.type, idx, ($event as any).detail.value)"
+            >
+              <text class="item-card__value">{{ item.depotName || '选择' }}</text>
+            </picker>
+          </view>
+          <view class="item-card__cell">
+            <text class="item-card__label">重量</text>
+            <input v-model="item.weight" class="item-card__input" type="digit" :disabled="!canEditGroup(group.type)" />
+          </view>
+        </view>
+        <view class="item-card__grid">
+          <view class="item-card__cell">
+            <text class="item-card__label">炉号</text>
+            <input v-model="item.furnaceNo" class="item-card__input" :disabled="!canEditGroup(group.type)" />
+          </view>
+          <view class="item-card__cell">
+            <text class="item-card__label">批号</text>
+            <input v-model="item.batchNo" class="item-card__input" :disabled="!canEditGroup(group.type)" />
+          </view>
+          <view class="item-card__cell">
+            <text class="item-card__label">合同号</text>
+            <input v-model="item.contractNo" class="item-card__input" :disabled="!canEditGroup(group.type)" />
+          </view>
+        </view>
+        <view v-if="group.type === 'PRODUCT'" class="item-card__grid">
+          <view class="item-card__cell">
+            <text class="item-card__label">块重照片</text>
+            <text class="item-card__value" @click="canEditGroup('PRODUCT') && uploadPhoto(idx)">
+              {{ item.weightPhotoUrl ? '已上传' : '上传' }}
+            </text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="save-bar">
+      <button class="save-bar__btn" :disabled="saving" @click="handleSave">保存</button>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { reactive, computed, watch, onUnmounted, ref } from 'vue'
+import { getCrudApi } from '@/api/crud'
+import { useDictStore } from '@/store/modules/dict'
+import { chooseAndUploadImage } from '@/utils/upload'
+import { BILL_PICKER_ROUTE, MATERIAL_PICKER_EVENT, MATERIAL_PICKER_ROUTE } from '@/constants/routes'
+import type { BillItemDraft } from './formSchemas'
+
+const props = defineProps<{ recordId: string }>()
+const dictStore = useDictStore()
+const form = reactive<Record<string, any>>({})
+const items = ref<BillItemDraft[]>([])
+const saving = ref(false)
+const billStatus = ref(0)
+const pendingLine = ref<{ type: string; idx: number } | null>(null)
+const salesEvent = 'erp:bill-selected:production'
+
+const prodTypes = [
+  { label: '关联销售', value: 'SALES' },
+  { label: '预生产', value: 'PRE' }
+]
+const groups = [
+  { type: 'PICK', title: '领料(自有库)' },
+  { type: 'PRODUCT', title: '成品' },
+  { type: 'RETURN', title: '退库' }
+]
+
+const prodTypeLabel = computed(() => prodTypes.find((t) => t.value === form.prodType)?.label || '')
+
+watch(
+  () => props.recordId,
+  async () => {
+    await dictStore.ensureDict('depot').catch(() => {})
+    if (props.recordId) {
+      try {
+        const info = await getCrudApi('/production/order/v1').info(props.recordId)
+        Object.keys(form).forEach((k) => delete form[k])
+        Object.assign(form, info)
+        billStatus.value = Number(info.status || 0)
+        items.value = (info.items || []).map((i: any) => ({ ...i }))
+      } catch (e) {
+        uni.showToast({ title: (e as Error).message || '加载失败', icon: 'none' })
+      }
+    } else {
+      Object.keys(form).forEach((k) => delete form[k])
+      items.value = []
+      billStatus.value = 0
+    }
+  },
+  { immediate: true }
+)
+
+function canEditGroup(type: string) {
+  if (billStatus.value >= 2) return false
+  if (type === 'PICK' && billStatus.value >= 1) return false
+  return true
+}
+
+function linesOf(type: string) {
+  return items.value.filter((i) => i.lineType === type)
+}
+
+function depotsOf(type: string) {
+  const all = dictStore.getItems('depot')
+  if (type === 'PICK') return all.filter((d) => d.depotType === 'OWN' || d.depotType === '自有库')
+  return all
+}
+
+function onProdType(index: number) {
+  const hit = prodTypes[Number(index)]
+  if (hit) {
+    form.prodType = hit.value
+    if (hit.value !== 'SALES') form.salesBillNo = ''
+  }
+}
+
+function pickSales() {
+  uni.navigateTo({ url: `${BILL_PICKER_ROUTE}?module=salesOut&status=1&event=${encodeURIComponent(salesEvent)}` })
+}
+
+function onSalesPicked(row: any) {
+  form.salesBillNo = row.billNo
+}
+
+function addLine(type: string) {
+  items.value.push({ lineType: type, weight: undefined })
+}
+
+function removeLine(type: string, idx: number) {
+  const line = linesOf(type)[idx]
+  const i = items.value.indexOf(line)
+  if (i >= 0) items.value.splice(i, 1)
+}
+
+function pickMaterial(type: string, idx: number) {
+  pendingLine.value = { type, idx }
+  uni.navigateTo({ url: MATERIAL_PICKER_ROUTE })
+}
+
+function onMaterial(material: any) {
+  if (!pendingLine.value) return
+  const line = linesOf(pendingLine.value.type)[pendingLine.value.idx]
+  if (line) {
+    line.materialId = String(material.id)
+    line.materialName = material.name
+  }
+  pendingLine.value = null
+}
+
+function onDepot(type: string, idx: number, index: number) {
+  const hit = depotsOf(type)[Number(index)]
+  const line = linesOf(type)[idx]
+  if (hit && line) {
+    line.depotId = String(hit.id)
+    line.depotName = hit.name
+  }
+}
+
+async function uploadPhoto(idx: number) {
+  const line = linesOf('PRODUCT')[idx]
+  if (!line) return
+  try {
+    line.weightPhotoUrl = await chooseAndUploadImage()
+  } catch (e) {
+    if ((e as Error).message !== '已取消') uni.showToast({ title: (e as Error).message, icon: 'none' })
+  }
+}
+
+uni.$on(MATERIAL_PICKER_EVENT, onMaterial)
+uni.$on(salesEvent, onSalesPicked)
+onUnmounted(() => {
+  uni.$off(MATERIAL_PICKER_EVENT, onMaterial)
+  uni.$off(salesEvent, onSalesPicked)
+})
+
+async function handleSave() {
+  if (!form.prodType) {
+    uni.showToast({ title: '请选择生产类型', icon: 'none' })
+    return
+  }
+  if (form.prodType === 'SALES' && !form.salesBillNo) {
+    uni.showToast({ title: '请选择销售出库单', icon: 'none' })
+    return
+  }
+  if (!items.value.length) {
+    uni.showToast({ title: '请添加明细', icon: 'none' })
+    return
+  }
+  for (const item of items.value) {
+    if (!item.materialId || !item.depotId || !Number(item.weight)) {
+      uni.showToast({ title: '明细须填写物料、仓库和重量', icon: 'none' })
+      return
+    }
+    if (item.lineType === 'PICK') {
+      const depot = dictStore.getItems('depot').find((d) => String(d.id) === String(item.depotId))
+      const type = depot?.depotType || ''
+      if (type && type !== 'OWN' && type !== '自有库') {
+        uni.showToast({ title: '领料必须使用自有库', icon: 'none' })
+        return
+      }
+    }
+  }
+  saving.value = true
+  try {
+    const payload = {
+      prodType: form.prodType,
+      salesBillNo: form.salesBillNo,
+      remark: form.remark,
+      items: items.value.map((item, idx) => ({
+        ...item,
+        weight: Number(item.weight),
+        operNumber: Number(item.weight),
+        sort: idx
+      }))
+    }
+    const api = getCrudApi('/production/order/v1')
+    if (props.recordId) await api.update(props.recordId, payload)
+    else await api.create(payload)
+    uni.showToast({ title: '保存成功', icon: 'success' })
+    setTimeout(() => uni.navigateBack(), 600)
+  } catch (e) {
+    uni.showToast({ title: (e as Error).message || '保存失败', icon: 'none' })
+  } finally {
+    saving.value = false
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.bill-form { min-height: 100vh; background: #f2f5fa; padding-bottom: 180rpx; }
+.section {
+  margin: 20rpx 24rpx 0; background: #fff; border-radius: 16rpx; padding: 8rpx 24rpx 16rpx;
+  &__title { display: flex; justify-content: space-between; padding: 20rpx 0; font-size: 28rpx; font-weight: 600; border-bottom: 1rpx solid #f2f3f5; }
+  &__btn { font-size: 26rpx; color: #2979ff; font-weight: 400; }
+}
+.field {
+  display: flex; padding: 20rpx 0; border-bottom: 1rpx solid #f7f8fa;
+  &__label { width: 200rpx; flex-shrink: 0; font-size: 26rpx; color: #606266; line-height: 64rpx;
+    &--required::before { content: '*'; color: #fa3534; margin-right: 4rpx; }
+  }
+  &__control { flex: 1; min-height: 64rpx; display: flex; align-items: center; }
+  &__textarea { flex: 1; min-height: 80rpx; }
+  &__picker { flex: 1; display: flex; justify-content: space-between; height: 64rpx; }
+  &__arrow { color: #c0c4cc; }
+}
+.placeholder { color: #999; }
+.link { color: #2979ff; }
+.item-card {
+  margin: 16rpx 0; padding: 20rpx; background: #f7f8fa; border-radius: 12rpx;
+  &__head { display: flex; justify-content: space-between; margin-bottom: 12rpx; }
+  &__name { font-weight: 600; font-size: 28rpx; }
+  &__del { color: #fa3534; font-size: 24rpx; }
+  &__grid { display: flex; gap: 12rpx; margin-top: 12rpx; }
+  &__cell { flex: 1; background: #fff; border-radius: 8rpx; padding: 12rpx; }
+  &__label { display: block; font-size: 22rpx; color: #909399; }
+  &__input { height: 48rpx; font-size: 26rpx; }
+  &__value { font-size: 26rpx; color: #2979ff; }
+}
+.save-bar {
+  position: fixed; left: 0; right: 0; bottom: 0;
+  padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom)); background: #fff;
+  &__btn { background: #2979ff; color: #fff; border-radius: 12rpx; &::after { border: none; } }
+}
+</style>

+ 218 - 0
src/modules/bill/SettlementForm.vue

@@ -0,0 +1,218 @@
+<template>
+  <view class="bill-form">
+    <view class="section">
+      <view class="section__title">结算单</view>
+      <view class="field">
+        <text class="field__label field__label--required">关联类型</text>
+        <view class="field__control">
+          <picker mode="selector" :range="typeOptions" range-key="label" @change="onTypeChange(($event as any).detail.value)">
+            <view class="field__picker">
+              <text :class="{ placeholder: !form.linkBillType }">{{ typeLabel || '请选择' }}</text>
+              <text class="field__arrow">›</text>
+            </view>
+          </picker>
+        </view>
+      </view>
+      <view class="field">
+        <text class="field__label field__label--required">关联业务单</text>
+        <view class="field__control">
+          <text class="link" @click="pickBill">{{ form.linkBillNo || '选择已定稿单据' }}</text>
+        </view>
+      </view>
+      <view v-if="preview.organName" class="field">
+        <text class="field__label">往来单位</text>
+        <view class="field__control"><text>{{ preview.organName }}</text></view>
+      </view>
+      <view v-if="preview.billAmount != null" class="field">
+        <text class="field__label">单据金额</text>
+        <view class="field__control"><text>{{ preview.billAmount }}</text></view>
+      </view>
+      <view v-if="preview.paidAmount != null" class="field">
+        <text class="field__label">已收/已付</text>
+        <view class="field__control"><text>{{ preview.paidAmount }}</text></view>
+      </view>
+      <view v-if="preview.invoiceDebt != null" class="field">
+        <text class="field__label">欠票</text>
+        <view class="field__control"><text>{{ preview.invoiceDebt }}</text></view>
+      </view>
+      <view class="field">
+        <text class="field__label field__label--required">结算金额</text>
+        <view class="field__control">
+          <input v-model="form.amount" class="field__input" type="digit" />
+        </view>
+      </view>
+      <view class="field">
+        <text class="field__label">票款</text>
+        <view class="field__control">
+          <input v-model="form.invoiceAmount" class="field__input" type="digit" />
+        </view>
+      </view>
+      <view class="field">
+        <text class="field__label">结算时间</text>
+        <view class="field__control">
+          <picker mode="date" :value="form.settleTime" @change="form.settleTime = ($event as any).detail.value">
+            <view class="field__picker">
+              <text>{{ form.settleTime || '请选择' }}</text>
+              <text class="field__arrow">›</text>
+            </view>
+          </picker>
+        </view>
+      </view>
+      <view class="field">
+        <text class="field__label">备注</text>
+        <view class="field__control">
+          <textarea v-model="form.remark" class="field__textarea" />
+        </view>
+      </view>
+    </view>
+    <view class="save-bar">
+      <button class="save-bar__btn save-bar__btn--ghost" :disabled="saving" @click="handleSave('0')">保存草稿</button>
+      <button class="save-bar__btn" :disabled="saving" @click="handleSave('1')">定稿</button>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { reactive, computed, watch, onUnmounted, ref } from 'vue'
+import { getCrudApi } from '@/api/crud'
+import { fromSettlementBill } from '@/api/modules/preview'
+import { BILL_PICKER_ROUTE } from '@/constants/routes'
+import { today } from '@/utils/format'
+
+const props = defineProps<{ recordId: string }>()
+const form = reactive<Record<string, any>>({})
+const preview = reactive<Record<string, any>>({})
+const saving = ref(false)
+const eventName = 'erp:bill-selected:settlement'
+
+const typeOptions = [
+  { label: '采购入库', value: 'PURCHASE', module: 'purchaseIn' },
+  { label: '销售出库', value: 'SALES', module: 'salesOut' },
+  { label: '物流费用', value: 'LOGISTICS', module: 'logisticsFee' }
+]
+
+const typeLabel = computed(() => typeOptions.find((t) => t.value === form.linkBillType)?.label || '')
+
+watch(
+  () => props.recordId,
+  async () => {
+    if (props.recordId) {
+      try {
+        const info = await getCrudApi('/finance/settlement/v1').info(props.recordId)
+        Object.keys(form).forEach((k) => delete form[k])
+        Object.assign(form, info)
+        if (form.linkBillType && form.linkBillNo) await loadPreview()
+      } catch (e) {
+        uni.showToast({ title: (e as Error).message || '加载失败', icon: 'none' })
+      }
+    } else {
+      Object.keys(form).forEach((k) => delete form[k])
+      Object.keys(preview).forEach((k) => delete preview[k])
+      form.settleTime = today()
+    }
+  },
+  { immediate: true }
+)
+
+function onTypeChange(index: number) {
+  const hit = typeOptions[Number(index)]
+  if (!hit) return
+  form.linkBillType = hit.value
+  form.linkBillNo = ''
+  Object.keys(preview).forEach((k) => delete preview[k])
+}
+
+function pickBill() {
+  const hit = typeOptions.find((t) => t.value === form.linkBillType)
+  if (!hit) {
+    uni.showToast({ title: '请先选择关联类型', icon: 'none' })
+    return
+  }
+  uni.navigateTo({
+    url: `${BILL_PICKER_ROUTE}?module=${hit.module}&status=1&event=${encodeURIComponent(eventName)}`
+  })
+}
+
+async function loadPreview() {
+  try {
+    const res = await fromSettlementBill(form.linkBillType, form.linkBillNo)
+    const data = res.data || res
+    Object.keys(preview).forEach((k) => delete preview[k])
+    Object.assign(preview, data)
+    if (form.amount == null || form.amount === '') form.amount = data.amount ?? data.debtAmount
+    if (form.invoiceAmount == null || form.invoiceAmount === '') form.invoiceAmount = data.invoiceAmount ?? data.invoiceDebt
+  } catch (e) {
+    uni.showToast({ title: (e as Error).message || '带出失败', icon: 'none' })
+  }
+}
+
+async function onBillPicked(row: any) {
+  form.linkBillNo = row.billNo
+  await loadPreview()
+}
+
+uni.$on(eventName, onBillPicked)
+onUnmounted(() => uni.$off(eventName, onBillPicked))
+
+async function handleSave(status: string) {
+  if (!form.linkBillType || !form.linkBillNo) {
+    uni.showToast({ title: '请选择已定稿业务单', icon: 'none' })
+    return
+  }
+  if (!Number(form.amount) || Number(form.amount) <= 0) {
+    uni.showToast({ title: '请填写结算金额', icon: 'none' })
+    return
+  }
+  saving.value = true
+  try {
+    const payload = {
+      linkBillType: form.linkBillType,
+      linkBillNo: form.linkBillNo,
+      amount: Number(form.amount),
+      invoiceAmount: Number(form.invoiceAmount) || 0,
+      settleTime: form.settleTime,
+      remark: form.remark,
+      status
+    }
+    const api = getCrudApi('/finance/settlement/v1')
+    if (props.recordId) await api.update(props.recordId, payload)
+    else await api.create(payload)
+    uni.showToast({ title: status === '1' ? '定稿成功' : '保存成功', icon: 'success' })
+    setTimeout(() => uni.navigateBack(), 600)
+  } catch (e) {
+    uni.showToast({ title: (e as Error).message || '保存失败', icon: 'none' })
+  } finally {
+    saving.value = false
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.bill-form { min-height: 100vh; background: #f2f5fa; padding-bottom: 180rpx; }
+.section {
+  margin: 20rpx 24rpx 0; background: #fff; border-radius: 16rpx; padding: 8rpx 24rpx 16rpx;
+  &__title { padding: 20rpx 0; font-size: 28rpx; font-weight: 600; border-bottom: 1rpx solid #f2f3f5; }
+}
+.field {
+  display: flex; padding: 20rpx 0; border-bottom: 1rpx solid #f7f8fa;
+  &__label { width: 200rpx; flex-shrink: 0; font-size: 26rpx; color: #606266; line-height: 64rpx;
+    &--required::before { content: '*'; color: #fa3534; margin-right: 4rpx; }
+  }
+  &__control { flex: 1; min-height: 64rpx; display: flex; align-items: center; font-size: 28rpx; }
+  &__input { flex: 1; height: 64rpx; font-size: 28rpx; }
+  &__textarea { flex: 1; min-height: 96rpx; }
+  &__picker { flex: 1; display: flex; justify-content: space-between; height: 64rpx; }
+  &__arrow { color: #c0c4cc; }
+}
+.placeholder { color: #999; }
+.link { color: #2979ff; font-size: 26rpx; }
+.save-bar {
+  position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 16rpx;
+  padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom)); background: #fff;
+  &__btn {
+    flex: 1; background: #2979ff; color: #fff; border-radius: 12rpx; font-size: 30rpx;
+    &::after { border: none; }
+    &--ghost { background: #fff; color: #2979ff; border: 1rpx solid #2979ff; }
+  }
+}
+</style>

+ 106 - 23
src/modules/bill/formSchemas.ts

@@ -1,9 +1,9 @@
 /**
- * 表单 Schema 注册(P2 写入能力)
+ * 表单 Schema 注册
  * - entity 形态模块:字段清单驱动通用表单
- * - bill 形态模块:通用单据表单(表头 + 明细行,对齐 DepotBillSaveDTO)
- * 未登记 schema 的模块隐藏新增入口,仅保留列表/详情只读能力。
+ * - bill 形态模块:主路径开单由专用表单处理;未登记 writable 的模块仅列表/详情
  */
+import type { ModuleConfig } from '@/modules/registry'
 import type { DictKind } from '@/api/modules/dict'
 
 export type FieldType = 'input' | 'number' | 'select' | 'date' | 'textarea' | 'switch'
@@ -58,14 +58,14 @@ export const ENTITY_SCHEMAS: Record<string, FieldDef[]> = {
     {
       key: 'depotType', label: '仓库类型', type: 'select', required: true,
       options: [
-        { label: '自有库', value: '自有库' },
-        { label: '三方库', value: '三方库' }
+        { label: '三方库', value: 'THIRD' },
+        { label: '自有库', value: 'OWN' }
       ]
     },
     { key: 'address', label: '地址', type: 'input' },
     { key: 'principal', label: '负责人', type: 'input' },
-    { key: 'warehousing', label: '入库费', type: 'number' },
-    { key: 'truckage', label: '运费', type: 'number' },
+    { key: 'warehousing', label: '仓储费', type: 'number' },
+    { key: 'truckage', label: '运费', type: 'number' },
     { key: 'isDefault', label: '默认仓库', type: 'switch', defaultValue: 0 },
     { key: 'enabled', label: '启用', type: 'switch', defaultValue: 1 }
   ],
@@ -93,11 +93,20 @@ export const ENTITY_SCHEMAS: Record<string, FieldDef[]> = {
     { key: 'remark', label: '备注', type: 'textarea' }
   ],
   material: [
-    { key: 'name', label: '商品名称', type: 'input', required: true },
+    { key: 'name', label: '物料名称', type: 'input', required: true },
+    {
+      key: 'materialType', label: '物料类型', type: 'select', required: true,
+      options: [
+        { label: '原料', value: 'RAW' },
+        { label: '半成品', value: 'SEMI' },
+        { label: '成品', value: 'FINISHED' }
+      ]
+    },
     { key: 'barCode', label: '条码', type: 'input' },
     { key: 'categoryId', label: '商品类别', type: 'select', dictKind: 'category' },
-    { key: 'unitId', label: '单位', type: 'select', dictKind: 'unit' },
     { key: 'standard', label: '规格', type: 'input' },
+    { key: 'unitId', label: '单位', type: 'select', dictKind: 'unit' },
+    { key: 'model', label: '型号', type: 'input' },
     { key: 'remark', label: '备注', type: 'textarea' },
     { key: 'enabled', label: '启用', type: 'switch', defaultValue: 1 }
   ],
@@ -107,8 +116,8 @@ export const ENTITY_SCHEMAS: Record<string, FieldDef[]> = {
     { key: 'enabled', label: '启用', type: 'switch', defaultValue: 1 }
   ],
   interestRate: [
-    { key: 'name', label: '名称', type: 'input', required: true },
-    { key: 'rate', label: '日利率(%)', type: 'number', required: true },
+    { key: 'effectiveDate', label: '生效日期', type: 'date', required: true },
+    { key: 'dailyRate', label: '日利率(%)', type: 'number', required: true },
     { key: 'remark', label: '备注', type: 'textarea' }
   ],
   contract: [
@@ -156,21 +165,27 @@ export interface BillItemDraft {
   materialUnit?: string
   depotId?: string
   depotName?: string
+  anotherDepotId?: string
+  anotherDepotName?: string
   operNumber?: number
   unitPrice?: number
   allPrice?: number
   furnaceNo?: string
   batchNo?: string
+  orderNo?: string
   contractNo?: string
   standard?: string
   shipWeight?: number
   recvWeight?: number
+  weight?: number
+  weightPhotoUrl?: string
+  lineType?: string
   freightUnitPrice?: number
   freightFee?: number
   remark?: string
 }
 
-/** 需要明细行的单据模块(DepotBill 系) */
+/** 进出库明细单据(DepotBill 系,含只读模块) */
 export const BILL_ITEM_MODULES = new Set([
   'purchaseApply', 'purchaseOrder', 'purchaseIn', 'purchaseBack',
   'salesOrder', 'salesOut', 'salesBack',
@@ -180,10 +195,53 @@ export const BILL_ITEM_MODULES = new Set([
   'productionOrder'
 ])
 
+/** 主路径允许在小程序新增/编辑的单据 */
+export const WRITABLE_BILL_KEYS = new Set([
+  'purchaseIn',
+  'otherIn',
+  'allocation',
+  'salesOut',
+  'moneyIn',
+  'moneyOut',
+  'settlement',
+  'logisticsFee',
+  'productionOrder'
+])
+
+export type BillFormKind = 'depot' | 'logistics' | 'money' | 'settlement' | 'production' | 'none'
+
+export function getBillFormKind(moduleKey: string): BillFormKind {
+  if (['purchaseIn', 'otherIn', 'allocation', 'salesOut'].includes(moduleKey)) return 'depot'
+  if (moduleKey === 'logisticsFee') return 'logistics'
+  if (moduleKey === 'moneyIn' || moduleKey === 'moneyOut') return 'money'
+  if (moduleKey === 'settlement') return 'settlement'
+  if (moduleKey === 'productionOrder') return 'production'
+  return 'none'
+}
+
 export function getEntitySchema(moduleKey: string): FieldDef[] | undefined {
   return ENTITY_SCHEMAS[moduleKey]
 }
 
+export function canCreateModule(module: ModuleConfig | undefined | null): boolean {
+  if (!module || module.shape === 'report') return false
+  if (module.shape === 'entity') return !!getEntitySchema(module.key)
+  return WRITABLE_BILL_KEYS.has(module.key)
+}
+
+export function canEditBill(module: ModuleConfig, info: any): boolean {
+  if (!WRITABLE_BILL_KEYS.has(module.key)) return false
+  if (String(info?.locked ?? '0') === '1') return false
+  if (module.statusModel === 'production' && Number(info?.status) === 2) return false
+  return true
+}
+
+export function canDeleteBill(module: ModuleConfig, info: any): boolean {
+  if (String(info?.locked ?? '0') === '1') return false
+  if (module.statusModel === 'production' && Number(info?.status) !== 0) return false
+  return true
+}
+
 /** 单据表头差异化扩展字段(在通用 BILL_HEAD_FIELDS 后追加;缺省模块回退空数组) */
 export const BILL_HEAD_EXT_FIELDS: Record<string, FieldDef[]> = {
   purchaseOrder: [
@@ -192,20 +250,10 @@ export const BILL_HEAD_EXT_FIELDS: Record<string, FieldDef[]> = {
   ],
   purchaseIn: [
     { key: 'contractNo', label: '合同号', type: 'select', dictKind: 'contract' },
-    { key: 'contractOrderNo', label: '合同订单号', type: 'input' },
-    { key: 'paybackDays', label: '回款天数', type: 'number' },
-    { key: 'interest', label: '利息', type: 'number' },
-    { key: 'storageUnitPrice', label: '仓储单价', type: 'number' },
-    { key: 'storageFee', label: '仓储费', type: 'number' },
-    { key: 'vehicleNo', label: '车牌号', type: 'select', dictKind: 'vehicle' },
-    { key: 'driverName', label: '司机姓名', type: 'input' },
-    { key: 'driverPhone', label: '联系电话', type: 'input' }
+    { key: 'modelNo', label: '型号', type: 'input' }
   ],
   salesOut: [
     { key: 'contractNo', label: '合同号', type: 'select', dictKind: 'contract' },
-    { key: 'invoiceNo', label: '发票', type: 'input' },
-    { key: 'paybackDate', label: '回款日期', type: 'date' },
-    { key: 'paybackMoney', label: '回款金额', type: 'number' },
     { key: 'vehicleNo', label: '车牌号', type: 'select', dictKind: 'vehicle' },
     { key: 'driverName', label: '司机姓名', type: 'input' },
     { key: 'driverPhone', label: '联系电话', type: 'input' }
@@ -215,3 +263,38 @@ export const BILL_HEAD_EXT_FIELDS: Record<string, FieldDef[]> = {
 export function getBillHeadExtFields(moduleKey: string): FieldDef[] {
   return BILL_HEAD_EXT_FIELDS[moduleKey] || []
 }
+
+export const DEPOT_TYPE_TEXT: Record<string, string> = {
+  THIRD: '三方库',
+  OWN: '自有库',
+  三方库: '三方库',
+  自有库: '自有库'
+}
+
+export const MATERIAL_TYPE_TEXT: Record<string, string> = {
+  RAW: '原料',
+  SEMI: '半成品',
+  FINISHED: '成品'
+}
+
+export const REPORT_FIELD_LABELS: Record<string, string> = {
+  materialName: '商品名称',
+  barCode: '条码',
+  standard: '规格',
+  model: '型号',
+  unit: '单位',
+  depotName: '仓库',
+  furnaceNo: '炉号',
+  batchNo: '批号',
+  contractNo: '合同号',
+  currentNumber: '当前库存',
+  bizTypeName: '类型',
+  billNo: '单据号',
+  organName: '往来单位',
+  billAmount: '单据金额',
+  paidAmount: '已收/已付',
+  debtAmount: '欠款',
+  invoiceAmount: '已开/收票',
+  invoiceDebt: '欠票',
+  billTime: '单据日期'
+}

+ 97 - 22
src/modules/registry.ts

@@ -8,6 +8,9 @@ import type { DictKind } from '@/api/modules/dict'
 /** 页面形态:bill=单据(头+明细行) / entity=基础资料 / report=只读报表 */
 export type ModuleShape = 'bill' | 'entity' | 'report'
 
+/** 单据状态机:审核流 / 草稿定稿 / 生产单三态 */
+export type StatusModel = 'audit' | 'draftConfirm' | 'production'
+
 export interface ModuleConfig {
   /** 唯一 key(页面 ?module= 参数) */
   key: string
@@ -30,6 +33,10 @@ export interface ModuleConfig {
   hasItems?: boolean
   /** 仅列表无详情(报表默认 true;serialNumber 等按模块声明) */
   listOnly?: boolean
+  /** 单据状态机;缺省按审核流 */
+  statusModel?: StatusModel
+  /** 定稿通过保存 status=1(采购入库/销售出库),否则走 POST confirm/{id} */
+  finalizeViaSave?: boolean
 }
 
 export const MODULES: ModuleConfig[] = [
@@ -44,7 +51,7 @@ export const MODULES: ModuleConfig[] = [
 
   // ================= 商品资料 packageMaterial(7) =================
   { key: 'category', title: '商品类别', apiPrefix: '/material/category/v1', pkg: 'packageMaterial', shape: 'entity', permission: 'material:category:list', searchKey: 'name', dictKind: 'category' },
-  { key: 'material', title: '商品', apiPrefix: '/material/info/v1', pkg: 'packageMaterial', shape: 'entity', permission: 'material:info:list', searchKey: 'name' },
+  { key: 'material', title: '商品信息', apiPrefix: '/material/info/v1', pkg: 'packageMaterial', shape: 'entity', permission: 'material:info:list', searchKey: 'name' },
   { key: 'unit', title: '单位', apiPrefix: '/material/unit/v1', pkg: 'packageMaterial', shape: 'entity', permission: 'material:unit:list', searchKey: 'name', dictKind: 'unit' },
   { key: 'attribute', title: '多属性', apiPrefix: '/material/attribute/v1', pkg: 'packageMaterial', shape: 'entity', permission: 'material:attribute:list', searchKey: 'attributeName' },
   { key: 'property', title: '多单位', apiPrefix: '/material/property/v1', pkg: 'packageMaterial', shape: 'entity', permission: 'material:property:list', searchKey: 'nativeName' },
@@ -55,12 +62,12 @@ export const MODULES: ModuleConfig[] = [
   { key: 'contract', title: '合同管理', apiPrefix: '/purchase/contract/v1', pkg: 'packagePurchase', shape: 'entity', permission: 'purchase:contract:list', searchKey: 'contractNo', dictKind: 'contract' },
   { key: 'purchaseApply', title: '请购单', apiPrefix: '/purchase/apply/v1', pkg: 'packagePurchase', shape: 'bill', permission: 'purchase:apply:list', searchKey: 'billNo', actions: ['audit', 'unaudit'], hasItems: true },
   { key: 'purchaseOrder', title: '采购订单', apiPrefix: '/purchase/order/v1', pkg: 'packagePurchase', shape: 'bill', permission: 'purchase:order:list', searchKey: 'billNo', actions: ['audit', 'unaudit', 'convert'], hasItems: true },
-  { key: 'purchaseIn', title: '采购入库', apiPrefix: '/purchase/in/v1', pkg: 'packagePurchase', shape: 'bill', permission: 'purchase:in:list', searchKey: 'billNo', actions: ['audit', 'unaudit'], hasItems: true },
+  { key: 'purchaseIn', title: '采购入库', apiPrefix: '/purchase/in/v1', pkg: 'packagePurchase', shape: 'bill', permission: 'purchase:in:list', searchKey: 'billNo', actions: ['confirm'], hasItems: true, statusModel: 'draftConfirm', finalizeViaSave: true },
   { key: 'purchaseBack', title: '采购退货', apiPrefix: '/purchase/back/v1', pkg: 'packagePurchase', shape: 'bill', permission: 'purchase:back:list', searchKey: 'billNo', actions: ['audit', 'unaudit'], hasItems: true },
 
   // ================= 销售 packageSales(3) =================
   { key: 'salesOrder', title: '销售订单', apiPrefix: '/sales/order/v1', pkg: 'packageSales', shape: 'bill', permission: 'sales:order:list', searchKey: 'billNo', actions: ['audit', 'unaudit', 'convert'], hasItems: true },
-  { key: 'salesOut', title: '销售出库', apiPrefix: '/sales/out/v1', pkg: 'packageSales', shape: 'bill', permission: 'sales:out:list', searchKey: 'billNo', actions: ['audit', 'unaudit', 'lock', 'unlock', 'finish'], hasItems: true },
+  { key: 'salesOut', title: '销售出库', apiPrefix: '/sales/out/v1', pkg: 'packageSales', shape: 'bill', permission: 'sales:out:list', searchKey: 'billNo', actions: ['confirm', 'lock', 'unlock'], hasItems: true, statusModel: 'draftConfirm', finalizeViaSave: true },
   { key: 'salesBack', title: '销售退货', apiPrefix: '/sales/back/v1', pkg: 'packageSales', shape: 'bill', permission: 'sales:back:list', searchKey: 'billNo', actions: ['audit', 'unaudit'], hasItems: true },
 
   // ================= 零售 packageRetail(2) =================
@@ -68,9 +75,9 @@ export const MODULES: ModuleConfig[] = [
   { key: 'retailBack', title: '零售退货', apiPrefix: '/retail/back/v1', pkg: 'packageRetail', shape: 'bill', permission: 'retail:back:list', searchKey: 'billNo', actions: ['audit', 'unaudit'], hasItems: true },
 
   // ================= 仓库 packageWarehouse(8) =================
-  { key: 'otherIn', title: '其入库', apiPrefix: '/warehouse/other-in/v1', pkg: 'packageWarehouse', shape: 'bill', permission: 'warehouse:otherIn:list', searchKey: 'billNo', actions: ['audit', 'unaudit'], hasItems: true },
-  { key: 'otherOut', title: '其出库', apiPrefix: '/warehouse/other-out/v1', pkg: 'packageWarehouse', shape: 'bill', permission: 'warehouse:otherOut:list', searchKey: 'billNo', actions: ['audit', 'unaudit'], hasItems: true },
-  { key: 'allocation', title: '调拨', apiPrefix: '/warehouse/allocation/v1', pkg: 'packageWarehouse', shape: 'bill', permission: 'warehouse:allocation:list', searchKey: 'billNo', actions: ['audit', 'unaudit'], hasItems: true },
+  { key: 'otherIn', title: '其入库', apiPrefix: '/warehouse/other-in/v1', pkg: 'packageWarehouse', shape: 'bill', permission: 'warehouse:otherIn:list', searchKey: 'billNo', actions: ['audit', 'unaudit'], hasItems: true, statusModel: 'audit' },
+  { key: 'otherOut', title: '其出库', apiPrefix: '/warehouse/other-out/v1', pkg: 'packageWarehouse', shape: 'bill', permission: 'warehouse:otherOut:list', searchKey: 'billNo', actions: ['audit', 'unaudit'], hasItems: true },
+  { key: 'allocation', title: '调拨出库', apiPrefix: '/warehouse/allocation/v1', pkg: 'packageWarehouse', shape: 'bill', permission: 'warehouse:allocation:list', searchKey: 'billNo', actions: ['audit', 'unaudit'], hasItems: true, statusModel: 'audit' },
   { key: 'assemble', title: '组装单', apiPrefix: '/warehouse/assemble/v1', pkg: 'packageWarehouse', shape: 'bill', permission: 'warehouse:assemble:list', searchKey: 'billNo', actions: ['audit', 'unaudit'], hasItems: true },
   { key: 'disassemble', title: '拆卸单', apiPrefix: '/warehouse/disassemble/v1', pkg: 'packageWarehouse', shape: 'bill', permission: 'warehouse:disassemble:list', searchKey: 'billNo', actions: ['audit', 'unaudit'], hasItems: true },
   { key: 'produceIn', title: '生产入库', apiPrefix: '/warehouse/produce-in/v1', pkg: 'packageWarehouse', shape: 'bill', permission: 'warehouse:produceIn:list', searchKey: 'billNo', actions: ['audit', 'unaudit'], hasItems: true },
@@ -80,24 +87,24 @@ export const MODULES: ModuleConfig[] = [
   // ================= 财务 packageFinance(8) =================
   { key: 'itemIn', title: '收入单', apiPrefix: '/finance/item-in/v1', pkg: 'packageFinance', shape: 'bill', permission: 'finance:itemIn:list', searchKey: 'billNo', actions: ['audit', 'unaudit'] },
   { key: 'itemOut', title: '支出单', apiPrefix: '/finance/item-out/v1', pkg: 'packageFinance', shape: 'bill', permission: 'finance:itemOut:list', searchKey: 'billNo', actions: ['audit', 'unaudit'] },
-  { key: 'moneyIn', title: '收款单', apiPrefix: '/finance/money-in/v1', pkg: 'packageFinance', shape: 'bill', permission: 'finance:moneyIn:list', searchKey: 'billNo', actions: ['audit', 'unaudit'] },
-  { key: 'moneyOut', title: '付款单', apiPrefix: '/finance/money-out/v1', pkg: 'packageFinance', shape: 'bill', permission: 'finance:moneyOut:list', searchKey: 'billNo', actions: ['audit', 'unaudit'] },
+  { key: 'moneyIn', title: '收款单', apiPrefix: '/finance/money-in/v1', pkg: 'packageFinance', shape: 'bill', permission: 'finance:moneyIn:list', searchKey: 'billNo', actions: ['audit', 'unaudit'], statusModel: 'audit' },
+  { key: 'moneyOut', title: '付款单', apiPrefix: '/finance/money-out/v1', pkg: 'packageFinance', shape: 'bill', permission: 'finance:moneyOut:list', searchKey: 'billNo', actions: ['audit', 'unaudit'], statusModel: 'audit' },
   { key: 'giro', title: '转账单', apiPrefix: '/finance/giro/v1', pkg: 'packageFinance', shape: 'bill', permission: 'finance:giro:list', searchKey: 'billNo', actions: ['audit', 'unaudit'] },
   { key: 'advanceIn', title: '收预付款', apiPrefix: '/finance/advance-in/v1', pkg: 'packageFinance', shape: 'bill', permission: 'finance:advanceIn:list', searchKey: 'billNo', actions: ['audit', 'unaudit'] },
-  { key: 'interestRate', title: '采购日利率', apiPrefix: '/finance/interest-rate/v1', pkg: 'packageFinance', shape: 'bill', permission: 'finance:interestRate:list', actions: ['calc'] },
-  { key: 'settlement', title: '结算单', apiPrefix: '/finance/settlement/v1', pkg: 'packageFinance', shape: 'bill', permission: 'finance:settlement:list', searchKey: 'billNo', actions: ['audit', 'unaudit', 'fromBill', 'confirm'] },
+  { key: 'interestRate', title: '采购日利率', apiPrefix: '/finance/interest-rate/v1', pkg: 'packageFinance', shape: 'entity', permission: 'finance:interestRate:list', actions: ['calc'] },
+  { key: 'settlement', title: '结算单', apiPrefix: '/finance/settlement/v1', pkg: 'packageFinance', shape: 'bill', permission: 'finance:settlement:list', searchKey: 'billNo', actions: ['confirm'], statusModel: 'draftConfirm' },
 
   // ================= 生产与物流 packageProductionLogistics(3) =================
-  { key: 'productionOrder', title: '生产单', apiPrefix: '/production/order/v1', pkg: 'packageProductionLogistics', shape: 'bill', permission: 'production:order:list', searchKey: 'billNo', actions: ['audit', 'unaudit', 'confirm', 'finish'], hasItems: true },
+  { key: 'productionOrder', title: '生产单', apiPrefix: '/production/order/v1', pkg: 'packageProductionLogistics', shape: 'bill', permission: 'production:order:list', searchKey: 'billNo', actions: ['confirm', 'finish'], hasItems: true, statusModel: 'production' },
   { key: 'vehicle', title: '车辆管理', apiPrefix: '/logistics/vehicle/v1', pkg: 'packageProductionLogistics', shape: 'entity', permission: 'logistics:vehicle:list', searchKey: 'vehicleNo', dictKind: 'vehicle' },
-  { key: 'logisticsFee', title: '物流费用', apiPrefix: '/logistics/fee/v1', pkg: 'packageProductionLogistics', shape: 'bill', permission: 'logistics:fee:list', searchKey: 'billNo', actions: ['audit', 'unaudit', 'fromSales', 'confirm'] },
+  { key: 'logisticsFee', title: '物流费用', apiPrefix: '/logistics/fee/v1', pkg: 'packageProductionLogistics', shape: 'bill', permission: 'logistics:fee:list', searchKey: 'billNo', actions: ['confirm'], statusModel: 'draftConfirm' },
 
   // ================= 报表 packageReport(14,只读) =================
   { key: 'stock', title: '库存报表', apiPrefix: '/report/stock/v1', pkg: 'packageReport', shape: 'report', permission: 'report:stock:list', searchKey: 'materialName' },
   { key: 'inOutStock', title: '出入库汇总', apiPrefix: '/report/in-out-stock/v1', pkg: 'packageReport', shape: 'report', permission: 'report:inOutStock:list', searchKey: 'materialName' },
   { key: 'accountReport', title: '账户报表', apiPrefix: '/report/account/v1', pkg: 'packageReport', shape: 'report', permission: 'report:account:list' },
   { key: 'statement', title: '往来对账', apiPrefix: '/report/statement/v1', pkg: 'packageReport', shape: 'report', permission: 'report:statement:list' },
-  { key: 'debtInvoice', title: '欠款欠票', apiPrefix: '/report/debt-invoice/v1', pkg: 'packageReport', shape: 'report', permission: 'report:debtInvoice:list' },
+  { key: 'debtInvoice', title: '欠款欠票', apiPrefix: '/report/debt-invoice/v1', pkg: 'packageReport', shape: 'report', permission: 'report:debtInvoice:list', searchKey: 'billNo' },
   { key: 'tradeBuy', title: '采购统计', apiPrefix: '/report/trade/v1/buy', pkg: 'packageReport', shape: 'report', permission: 'report:trade:list', searchKey: 'materialName' },
   { key: 'tradeSale', title: '销售统计', apiPrefix: '/report/trade/v1/sale', pkg: 'packageReport', shape: 'report', permission: 'report:trade:list', searchKey: 'materialName' },
   { key: 'tradeRetail', title: '零售统计', apiPrefix: '/report/trade/v1/retail', pkg: 'packageReport', shape: 'report', permission: 'report:trade:list', searchKey: 'materialName' },
@@ -142,19 +149,65 @@ export const DOMAIN_GROUPS: DomainGroup[] = [
   { title: '报表中心', pkg: 'packageReport', icon: '📊', modules: getModulesByPkg('packageReport') }
 ]
 
-/** 单据状态映射(0 未审核 / 1 已审核 / 2 已完成) */
+export function getStatusModel(module: ModuleConfig): StatusModel {
+  return module.statusModel || 'audit'
+}
+
+/** 单据状态映射(审核流默认) */
 export const BILL_STATUS_TEXT: Record<number, string> = {
   0: '未审核',
   1: '已审核',
   2: '已完成'
 }
 
+const DRAFT_STATUS_TEXT: Record<number, string> = {
+  0: '草稿',
+  1: '定稿'
+}
+
+const PRODUCTION_STATUS_TEXT: Record<number, string> = {
+  0: '草稿',
+  1: '进行中',
+  2: '完成'
+}
+
 export const BILL_STATUS_TYPE: Record<number, 'warning' | 'success' | 'primary'> = {
   0: 'warning',
   1: 'success',
   2: 'primary'
 }
 
+export function statusTextOf(module: ModuleConfig, status: number): string {
+  const model = getStatusModel(module)
+  if (model === 'draftConfirm') return DRAFT_STATUS_TEXT[status] ?? ''
+  if (model === 'production') return PRODUCTION_STATUS_TEXT[status] ?? ''
+  return BILL_STATUS_TEXT[status] ?? ''
+}
+
+export function statusOptionsOf(module: ModuleConfig): { label: string; value: number | null }[] {
+  const model = getStatusModel(module)
+  if (model === 'draftConfirm') {
+    return [
+      { label: '全部', value: null },
+      { label: '草稿', value: 0 },
+      { label: '定稿', value: 1 }
+    ]
+  }
+  if (model === 'production') {
+    return [
+      { label: '全部', value: null },
+      { label: '草稿', value: 0 },
+      { label: '进行中', value: 1 },
+      { label: '完成', value: 2 }
+    ]
+  }
+  return [
+    { label: '全部', value: null },
+    { label: '未审核', value: 0 },
+    { label: '已审核', value: 1 }
+  ]
+}
+
 /** 状态机动作文案 */
 export const ACTION_TEXT: Record<string, string> = {
   audit: '审核',
@@ -166,30 +219,52 @@ export const ACTION_TEXT: Record<string, string> = {
   unlock: '解锁',
   fromBill: '从单据生成',
   fromSales: '从销售出库生成',
-  calc: '计息预览'
+  calc: '计息试算'
+}
+
+export function actionLabel(module: ModuleConfig, action: string): string {
+  if (getStatusModel(module) === 'production') {
+    if (action === 'confirm') return '开始加工'
+    if (action === 'finish') return '完工'
+  }
+  return ACTION_TEXT[action] || action
+}
+
+export interface ActionContext {
+  locked?: string | number
+}
+
+function isLocked(ctx?: ActionContext): boolean {
+  return String(ctx?.locked ?? '0') === '1'
 }
 
 /** 按单据 status 判定可执行动作(模板页渲染操作条的依据) */
-export function availableActions(module: ModuleConfig, status: number): CrudAction[] {
+export function availableActions(
+  module: ModuleConfig,
+  status: number,
+  ctx?: ActionContext
+): CrudAction[] {
   const all = module.actions || []
+  const model = getStatusModel(module)
   return all.filter((action) => {
     switch (action) {
       case 'audit':
-        return status === 0
+        return model === 'audit' && status === 0
       case 'unaudit':
-        return status === 1
+        return model === 'audit' && status === 1
       case 'convert':
         return status === 1
       case 'finish':
-        return status === 1
+        return model === 'production' ? status === 1 : status === 1
       case 'confirm':
-        return status < 2
+        return (model === 'draftConfirm' || model === 'production') && status === 0
       case 'lock':
-        return status === 0 || status === 1
+        return status === 1 && !isLocked(ctx)
       case 'unlock':
-        return status === 0 || status === 1
+        return isLocked(ctx)
       case 'fromBill':
       case 'fromSales':
+        return false
       case 'calc':
         return true
       default:

+ 2 - 7
src/packageBasic/pages/list.vue

@@ -16,19 +16,14 @@ import { ref, computed } from 'vue'
 import { onLoad, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
 import BillListView from '@/modules/bill/BillListView.vue'
 import { getModule } from '@/modules/registry'
-import { getEntitySchema, BILL_ITEM_MODULES } from '@/modules/bill/formSchemas'
+import { canCreateModule } from '@/modules/bill/formSchemas'
 import { moduleEditPage } from '@/constants/routes'
 
 const moduleKey = ref('')
 const initialStatus = ref('')
 const viewRef = ref<InstanceType<typeof BillListView> | null>(null)
 
-const canCreate = computed(() => {
-  const m = getModule(moduleKey.value)
-  if (!m || m.shape === 'report') return false
-  if (m.shape === 'entity') return !!getEntitySchema(m.key)
-  return BILL_ITEM_MODULES.has(m.key)
-})
+const canCreate = computed(() => canCreateModule(getModule(moduleKey.value)))
 
 onLoad((options) => {
   moduleKey.value = options?.module || ''

+ 2 - 7
src/packageFinance/pages/list.vue

@@ -16,19 +16,14 @@ import { ref, computed } from 'vue'
 import { onLoad, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
 import BillListView from '@/modules/bill/BillListView.vue'
 import { getModule } from '@/modules/registry'
-import { getEntitySchema, BILL_ITEM_MODULES } from '@/modules/bill/formSchemas'
+import { canCreateModule } from '@/modules/bill/formSchemas'
 import { moduleEditPage } from '@/constants/routes'
 
 const moduleKey = ref('')
 const initialStatus = ref('')
 const viewRef = ref<InstanceType<typeof BillListView> | null>(null)
 
-const canCreate = computed(() => {
-  const m = getModule(moduleKey.value)
-  if (!m || m.shape === 'report') return false
-  if (m.shape === 'entity') return !!getEntitySchema(m.key)
-  return BILL_ITEM_MODULES.has(m.key)
-})
+const canCreate = computed(() => canCreateModule(getModule(moduleKey.value)))
 
 onLoad((options) => {
   moduleKey.value = options?.module || ''

+ 2 - 7
src/packageMaterial/pages/list.vue

@@ -16,19 +16,14 @@ import { ref, computed } from 'vue'
 import { onLoad, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
 import BillListView from '@/modules/bill/BillListView.vue'
 import { getModule } from '@/modules/registry'
-import { getEntitySchema, BILL_ITEM_MODULES } from '@/modules/bill/formSchemas'
+import { canCreateModule } from '@/modules/bill/formSchemas'
 import { moduleEditPage } from '@/constants/routes'
 
 const moduleKey = ref('')
 const initialStatus = ref('')
 const viewRef = ref<InstanceType<typeof BillListView> | null>(null)
 
-const canCreate = computed(() => {
-  const m = getModule(moduleKey.value)
-  if (!m || m.shape === 'report') return false
-  if (m.shape === 'entity') return !!getEntitySchema(m.key)
-  return BILL_ITEM_MODULES.has(m.key)
-})
+const canCreate = computed(() => canCreateModule(getModule(moduleKey.value)))
 
 onLoad((options) => {
   moduleKey.value = options?.module || ''

+ 2 - 7
src/packageProductionLogistics/pages/list.vue

@@ -16,19 +16,14 @@ import { ref, computed } from 'vue'
 import { onLoad, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
 import BillListView from '@/modules/bill/BillListView.vue'
 import { getModule } from '@/modules/registry'
-import { getEntitySchema, BILL_ITEM_MODULES } from '@/modules/bill/formSchemas'
+import { canCreateModule } from '@/modules/bill/formSchemas'
 import { moduleEditPage } from '@/constants/routes'
 
 const moduleKey = ref('')
 const initialStatus = ref('')
 const viewRef = ref<InstanceType<typeof BillListView> | null>(null)
 
-const canCreate = computed(() => {
-  const m = getModule(moduleKey.value)
-  if (!m || m.shape === 'report') return false
-  if (m.shape === 'entity') return !!getEntitySchema(m.key)
-  return BILL_ITEM_MODULES.has(m.key)
-})
+const canCreate = computed(() => canCreateModule(getModule(moduleKey.value)))
 
 onLoad((options) => {
   moduleKey.value = options?.module || ''

+ 2 - 7
src/packagePurchase/pages/list.vue

@@ -16,19 +16,14 @@ import { ref, computed } from 'vue'
 import { onLoad, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
 import BillListView from '@/modules/bill/BillListView.vue'
 import { getModule } from '@/modules/registry'
-import { getEntitySchema, BILL_ITEM_MODULES } from '@/modules/bill/formSchemas'
+import { canCreateModule } from '@/modules/bill/formSchemas'
 import { moduleEditPage } from '@/constants/routes'
 
 const moduleKey = ref('')
 const initialStatus = ref('')
 const viewRef = ref<InstanceType<typeof BillListView> | null>(null)
 
-const canCreate = computed(() => {
-  const m = getModule(moduleKey.value)
-  if (!m || m.shape === 'report') return false
-  if (m.shape === 'entity') return !!getEntitySchema(m.key)
-  return BILL_ITEM_MODULES.has(m.key)
-})
+const canCreate = computed(() => canCreateModule(getModule(moduleKey.value)))
 
 onLoad((options) => {
   moduleKey.value = options?.module || ''

+ 2 - 7
src/packageRetail/pages/list.vue

@@ -16,19 +16,14 @@ import { ref, computed } from 'vue'
 import { onLoad, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
 import BillListView from '@/modules/bill/BillListView.vue'
 import { getModule } from '@/modules/registry'
-import { getEntitySchema, BILL_ITEM_MODULES } from '@/modules/bill/formSchemas'
+import { canCreateModule } from '@/modules/bill/formSchemas'
 import { moduleEditPage } from '@/constants/routes'
 
 const moduleKey = ref('')
 const initialStatus = ref('')
 const viewRef = ref<InstanceType<typeof BillListView> | null>(null)
 
-const canCreate = computed(() => {
-  const m = getModule(moduleKey.value)
-  if (!m || m.shape === 'report') return false
-  if (m.shape === 'entity') return !!getEntitySchema(m.key)
-  return BILL_ITEM_MODULES.has(m.key)
-})
+const canCreate = computed(() => canCreateModule(getModule(moduleKey.value)))
 
 onLoad((options) => {
   moduleKey.value = options?.module || ''

+ 2 - 7
src/packageSales/pages/list.vue

@@ -16,19 +16,14 @@ import { ref, computed } from 'vue'
 import { onLoad, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
 import BillListView from '@/modules/bill/BillListView.vue'
 import { getModule } from '@/modules/registry'
-import { getEntitySchema, BILL_ITEM_MODULES } from '@/modules/bill/formSchemas'
+import { canCreateModule } from '@/modules/bill/formSchemas'
 import { moduleEditPage } from '@/constants/routes'
 
 const moduleKey = ref('')
 const initialStatus = ref('')
 const viewRef = ref<InstanceType<typeof BillListView> | null>(null)
 
-const canCreate = computed(() => {
-  const m = getModule(moduleKey.value)
-  if (!m || m.shape === 'report') return false
-  if (m.shape === 'entity') return !!getEntitySchema(m.key)
-  return BILL_ITEM_MODULES.has(m.key)
-})
+const canCreate = computed(() => canCreateModule(getModule(moduleKey.value)))
 
 onLoad((options) => {
   moduleKey.value = options?.module || ''

+ 2 - 7
src/packageWarehouse/pages/list.vue

@@ -16,19 +16,14 @@ import { ref, computed } from 'vue'
 import { onLoad, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
 import BillListView from '@/modules/bill/BillListView.vue'
 import { getModule } from '@/modules/registry'
-import { getEntitySchema, BILL_ITEM_MODULES } from '@/modules/bill/formSchemas'
+import { canCreateModule } from '@/modules/bill/formSchemas'
 import { moduleEditPage } from '@/constants/routes'
 
 const moduleKey = ref('')
 const initialStatus = ref('')
 const viewRef = ref<InstanceType<typeof BillListView> | null>(null)
 
-const canCreate = computed(() => {
-  const m = getModule(moduleKey.value)
-  if (!m || m.shape === 'report') return false
-  if (m.shape === 'entity') return !!getEntitySchema(m.key)
-  return BILL_ITEM_MODULES.has(m.key)
-})
+const canCreate = computed(() => canCreateModule(getModule(moduleKey.value)))
 
 onLoad((options) => {
   moduleKey.value = options?.module || ''

+ 6 - 0
src/pages.json

@@ -40,6 +40,12 @@
         "navigationBarTitleText": "通知消息",
         "enablePullDownRefresh": true
       }
+    },
+    {
+      "path": "pages/picker/bill",
+      "style": {
+        "navigationBarTitleText": "选择单据"
+      }
     }
   ],
   "globalStyle": {

+ 137 - 27
src/pages/login/index.vue

@@ -1,28 +1,39 @@
 <template>
   <view class="login-page">
-    <!-- Logo 与品牌 -->
     <view class="logo-section">
       <view class="logo-icon">🏗️</view>
       <text class="logo-text">泰昌 ERP</text>
       <text class="logo-sub">钢材进销存 · 移动办公</text>
     </view>
 
-    <!-- 登录表单卡片 -->
     <view class="form-container">
+      <!-- #ifdef MP-WEIXIN -->
+      <text class="hint">用微信手机号匹配员工档案,匹配不到不会自动开户。</text>
+      <button
+        class="login-btn"
+        open-type="getPhoneNumber"
+        :disabled="submitting"
+        @getphonenumber="onGetPhoneNumber"
+      >
+        <text class="btn-text">{{ submitting ? '登录中...' : '微信授权手机号登录' }}</text>
+      </button>
+      <text class="hint hint--muted">已绑定过的账号会自动登录,无需再次授权。</text>
+      <!-- #endif -->
+
+      <!-- #ifdef H5 -->
       <view class="form-item">
         <text class="form-label">登录账号</text>
         <view class="input-wrapper">
           <input
             v-model="form.username"
             type="text"
-            placeholder="请输入用户名"
+            placeholder="H5 调试用账密,小程序请用微信授权"
             class="input"
             maxlength="64"
             placeholder-style="color: #999999;"
           />
         </view>
       </view>
-
       <view class="form-item">
         <text class="form-label">密码</text>
         <view class="input-wrapper">
@@ -34,15 +45,13 @@
             placeholder="请输入密码"
             class="input"
             placeholder-style="color: #999999;"
-            @confirm="handleLogin"
+            @confirm="handlePasswordLogin"
           />
           <text class="password-toggle" @click="showPassword = !showPassword">
             {{ showPassword ? '🙈' : '👁️' }}
           </text>
         </view>
       </view>
-
-      <!-- 图形验证码(后端 enabled=true 时展示) -->
       <view v-if="captcha.enabled" class="form-item">
         <text class="form-label">验证码</text>
         <view class="captcha-row">
@@ -54,7 +63,7 @@
               class="input"
               maxlength="8"
               placeholder-style="color: #999999;"
-              @confirm="handleLogin"
+              @confirm="handlePasswordLogin"
             />
           </view>
           <image
@@ -69,23 +78,34 @@
           </view>
         </view>
       </view>
-
-      <button class="login-btn" :disabled="submitting" @click="handleLogin">
-        <text class="btn-text">{{ submitting ? '登录中...' : '立即登录' }}</text>
+      <button class="login-btn" :disabled="submitting" @click="handlePasswordLogin">
+        <text class="btn-text">{{ submitting ? '登录中...' : '账密登录(H5 调试)' }}</text>
       </button>
+      <!-- #endif -->
     </view>
   </view>
 </template>
 
 <script setup lang="ts">
 /**
- * 账号密码登录(jeesharp 管理端契约:RSA 双字段加密 + 可选图形验证码)
+ * 小程序:微信授权手机号绑定已有 ERP 用户(不自动开户)。
+ * H5:保留账密,方便浏览器调试。
  */
 import { ref, computed } from 'vue'
 import { onLoad } from '@dcloudio/uni-app'
-import { login, getLoginCaptcha, getCurrentUserInfo, type LoginCaptchaVO } from '@/api/modules/auth'
+import {
+  login,
+  getLoginCaptcha,
+  getCurrentUserInfo,
+  wxMiniLogin,
+  wxMiniSilent,
+  fetchMiniSession,
+  type LoginCaptchaVO,
+  type LoginResultVO
+} from '@/api/modules/auth'
 import { useUserStore } from '@/store/modules/user'
-import { navigateAfterLogin } from '@/utils/auth'
+import { navigateAfterLogin, getToken } from '@/utils/auth'
+import { getWxLoginCode } from '@/utils/wxLogin'
 
 const userStore = useUserStore()
 
@@ -93,7 +113,6 @@ const form = ref({ username: '', password: '', code: '' })
 const showPassword = ref(false)
 const submitting = ref(false)
 const captcha = ref<LoginCaptchaVO>({ enabled: false })
-/** 登录成功后回跳地址(tabBar 页) */
 const redirect = ref('')
 
 const captchaImgSrc = computed(() => {
@@ -106,9 +125,95 @@ onLoad((options) => {
   if (options?.redirect) {
     redirect.value = decodeURIComponent(options.redirect)
   }
+  // #ifdef H5
   loadCaptcha()
+  // #endif
+  bootstrap()
 })
 
+async function bootstrap() {
+  if (getToken()) {
+    try {
+      await finishWithUserInfo()
+      return
+    } catch {
+      userStore.clearUser()
+    }
+  }
+  // #ifdef MP-WEIXIN
+  await trySilent()
+  // #endif
+}
+
+async function trySilent() {
+  submitting.value = true
+  uni.showLoading({ title: '登录中...', mask: true })
+  try {
+    const jsCode = await getWxLoginCode()
+    const result = await wxMiniSilent(jsCode)
+    await finishLogin(result)
+  } catch {
+    uni.hideLoading()
+  } finally {
+    submitting.value = false
+  }
+}
+
+async function onGetPhoneNumber(e: any) {
+  const errMsg = String(e?.detail?.errMsg || '')
+  if (errMsg && !errMsg.includes('ok')) {
+    uni.showToast({ title: '需要授权手机号才能登录', icon: 'none' })
+    return
+  }
+  const phoneCode = e?.detail?.code
+  if (!phoneCode) {
+    uni.showToast({ title: '未拿到手机号授权,请升级微信后重试', icon: 'none' })
+    return
+  }
+  if (submitting.value) return
+  submitting.value = true
+  uni.showLoading({ title: '登录中...', mask: true })
+  try {
+    const jsCode = await getWxLoginCode()
+    const result = await wxMiniLogin(jsCode, phoneCode)
+    await finishLogin(result)
+  } catch (err) {
+    uni.hideLoading()
+    uni.showToast({ title: (err as Error).message || '登录失败', icon: 'none' })
+  } finally {
+    submitting.value = false
+  }
+}
+
+async function finishLogin(result: LoginResultVO) {
+  userStore.setMiniSession(result)
+  await finishWithUserInfo(result.accessToken, result.ssoToken ?? null)
+}
+
+async function finishWithUserInfo(accessToken?: string, ssoToken?: string | null) {
+  try {
+    const userInfo = await getCurrentUserInfo('login')
+    if (accessToken) {
+      userStore.setLoginState(accessToken, ssoToken ?? null, userInfo)
+    } else {
+      userStore.setUser(userInfo)
+    }
+  } catch {
+    if (accessToken) {
+      userStore.setLoginState(accessToken, ssoToken ?? null)
+    }
+  }
+  try {
+    const session = await fetchMiniSession()
+    userStore.setMiniSession(session)
+  } catch {
+    // 会话补齐失败不阻断进入
+  }
+  uni.hideLoading()
+  uni.showToast({ title: '登录成功', icon: 'success' })
+  setTimeout(() => navigateAfterLogin(redirect.value || undefined), 400)
+}
+
 async function loadCaptcha() {
   try {
     captcha.value = await getLoginCaptcha()
@@ -117,7 +222,7 @@ async function loadCaptcha() {
   }
 }
 
-async function handleLogin() {
+async function handlePasswordLogin() {
   const username = form.value.username.trim()
   if (!username) {
     uni.showToast({ title: '请输入登录账号', icon: 'none' })
@@ -141,20 +246,10 @@ async function handleLogin() {
       captcha.value.enabled ? form.value.code.trim() : undefined,
       captcha.value.enabled ? captcha.value.uuid : undefined
     )
-    // 拉取用户信息(失败不阻断登录,工作台会重试);登录后首次需传 requestFlag='login'
-    try {
-      const userInfo = await getCurrentUserInfo('login')
-      userStore.setLoginState(result.accessToken, result.ssoToken ?? null, userInfo)
-    } catch {
-      userStore.setLoginState(result.accessToken, result.ssoToken ?? null)
-    }
-    uni.hideLoading()
-    uni.showToast({ title: '登录成功', icon: 'success' })
-    setTimeout(() => navigateAfterLogin(redirect.value || undefined), 500)
+    await finishLogin(result)
   } catch (e) {
     uni.hideLoading()
     uni.showToast({ title: (e as Error).message || '登录失败', icon: 'none' })
-    // 登录失败刷新验证码
     if (captcha.value.enabled) {
       form.value.code = ''
       loadCaptcha()
@@ -216,6 +311,21 @@ $primary: #2979ff;
   box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.06);
 }
 
+.hint {
+  display: block;
+  font-size: 26rpx;
+  line-height: 1.6;
+  color: #606266;
+  margin-bottom: 28rpx;
+
+  &--muted {
+    margin-top: 24rpx;
+    margin-bottom: 0;
+    color: #909399;
+    font-size: 24rpx;
+  }
+}
+
 .form-item {
   margin-bottom: 32rpx;
 }

+ 151 - 0
src/pages/picker/bill.vue

@@ -0,0 +1,151 @@
+<template>
+  <view class="picker-page">
+    <ErpSearchBar v-model="keyword" :placeholder="placeholder" @search="onSearch" />
+    <ErpEmpty v-if="!loading && !records.length" :show-retry="error" @retry="refresh" />
+    <view v-for="row in records" :key="row.id" class="bill-item" @click="select(row)">
+      <view class="bill-item__main">
+        <text class="bill-item__no">{{ row.billNo || row.id }}</text>
+        <text class="bill-item__sub">{{ row.organName || row.memberName || row.depotName || '' }}</text>
+      </view>
+      <text class="bill-item__time">{{ row.operTime || row.billTime || '' }}</text>
+    </view>
+    <ErpLoadMore v-if="records.length" :status="moreStatus" @retry="loadMore" />
+  </view>
+</template>
+
+<script setup lang="ts">
+/**
+ * 业务单据选择器:?module=purchaseIn&status=1,选中后 emit 并返回
+ */
+import { ref, computed, watch } from 'vue'
+import { onLoad, onReachBottom } from '@dcloudio/uni-app'
+import { getCrudApi, parsePage } from '@/api/crud'
+import { getModule } from '@/modules/registry'
+import ErpSearchBar from '@/components/common/ErpSearchBar.vue'
+import ErpEmpty from '@/components/common/ErpEmpty.vue'
+import ErpLoadMore from '@/components/common/ErpLoadMore.vue'
+import { BILL_PICKER_EVENT } from '@/constants/routes'
+
+const moduleKey = ref('')
+const status = ref<string>('')
+const eventName = ref(BILL_PICKER_EVENT)
+const records = ref<any[]>([])
+const keyword = ref('')
+const loading = ref(false)
+const finished = ref(false)
+const error = ref(false)
+let pageNo = 1
+let timer: ReturnType<typeof setTimeout> | null = null
+
+const placeholder = computed(() => {
+  const m = getModule(moduleKey.value)
+  return `搜索${m?.title || '单据'}`
+})
+
+const moreStatus = computed(() => {
+  if (error.value) return 'error'
+  if (loading.value) return 'loading'
+  if (finished.value) return 'nomore'
+  return 'more'
+})
+
+onLoad((options) => {
+  moduleKey.value = options?.module || ''
+  status.value = options?.status ?? ''
+  eventName.value = options?.event || BILL_PICKER_EVENT
+  const m = getModule(moduleKey.value)
+  if (m) uni.setNavigationBarTitle({ title: `选择${m.title}` })
+  refresh()
+})
+
+onReachBottom(() => loadMore())
+
+async function load(page: number, append: boolean) {
+  const m = getModule(moduleKey.value)
+  if (!m) return
+  loading.value = true
+  error.value = false
+  try {
+    const api = getCrudApi(m.apiPrefix)
+    const query: Record<string, any> = {
+      pageNo: page,
+      pageSize: 20
+    }
+    if (status.value !== '') query.status = Number(status.value)
+    if (keyword.value && m.searchKey) query[m.searchKey] = keyword.value
+    const resp = await api.list(query)
+    const { records: rows, total } = parsePage(resp)
+    records.value = append ? [...records.value, ...rows] : rows
+    pageNo = page
+    finished.value = records.value.length >= total || rows.length === 0
+  } catch {
+    error.value = true
+  } finally {
+    loading.value = false
+  }
+}
+
+function refresh() {
+  finished.value = false
+  load(1, false)
+}
+
+function loadMore() {
+  if (loading.value || finished.value || error.value) return
+  load(pageNo + 1, true)
+}
+
+function onSearch() {
+  refresh()
+}
+
+watch(keyword, () => {
+  if (timer) clearTimeout(timer)
+  timer = setTimeout(() => refresh(), 300)
+})
+
+function select(row: any) {
+  uni.$emit(eventName.value, row)
+  uni.navigateBack()
+}
+</script>
+
+<style lang="scss" scoped>
+.picker-page {
+  min-height: 100vh;
+  background: #f2f5fa;
+  padding-bottom: 40rpx;
+}
+
+.bill-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin: 16rpx 24rpx 0;
+  padding: 24rpx;
+  background: #fff;
+  border-radius: 12rpx;
+
+  &__main {
+    display: flex;
+    flex-direction: column;
+    gap: 8rpx;
+  }
+
+  &__no {
+    font-size: 28rpx;
+    font-weight: 600;
+    color: #303133;
+  }
+
+  &__sub {
+    font-size: 24rpx;
+    color: #909399;
+  }
+
+  &__time {
+    font-size: 22rpx;
+    color: #c0c4cc;
+  }
+}
+</style>

+ 98 - 5
src/pages/profile/index.vue

@@ -1,6 +1,5 @@
 <template>
   <view class="profile-page">
-    <!-- 用户信息卡 -->
     <view class="user-card">
       <view class="user-card__avatar">
         <image v-if="avatar" :src="avatar" mode="aspectFill" class="user-card__img" />
@@ -9,10 +8,25 @@
       <view class="user-card__info">
         <text class="user-card__name">{{ userStore.displayName }}</text>
         <text class="user-card__sub">{{ userStore.userInfo?.username || userStore.userInfo?.loginName || '—' }}</text>
+        <text v-if="userStore.currentRoleName" class="user-card__role">当前岗位:{{ userStore.currentRoleName }}</text>
+      </view>
+    </view>
+
+    <view v-if="userStore.miniRoles.length" class="role-box">
+      <text class="role-box__title">切换岗位</text>
+      <text class="role-box__hint">小程序按当前岗位展示菜单,不把多个岗位权限并在一起。</text>
+      <view
+        v-for="role in userStore.miniRoles"
+        :key="role.id"
+        class="role-item"
+        :class="{ 'role-item--active': role.id === userStore.currentRoleId }"
+        @click="onSwitchRole(role.id)"
+      >
+        <text class="role-item__name">{{ role.name }}</text>
+        <text v-if="role.id === userStore.currentRoleId" class="role-item__mark">当前</text>
       </view>
     </view>
 
-    <!-- 功能列表 -->
     <view class="menu">
       <view class="menu-item" @click="refreshUserInfo">
         <text class="menu-item__text">刷新用户信息</text>
@@ -34,29 +48,42 @@
 
 <script setup lang="ts">
 /**
- * 我的:当前用户信息展示 + 退出登录
+ * 我的:当前用户信息、岗位切换、退出登录
  */
 import { computed } from 'vue'
 import { onShow } from '@dcloudio/uni-app'
 import { useUserStore } from '@/store/modules/user'
-import { getCurrentUserInfo } from '@/api/modules/auth'
+import { useMenuStore } from '@/store/modules/menu'
+import { getCurrentUserInfo, fetchMiniSession, switchMiniRole } from '@/api/modules/auth'
 import { logout } from '@/utils/auth'
 import { requireLogin } from '@/utils/auth'
 
 const userStore = useUserStore()
+const menuStore = useMenuStore()
 
 const avatar = computed(() => userStore.userInfo?.avatar || '')
 
 onShow(() => {
   if (!requireLogin()) return
   userStore.initUserInfo()
+  syncSession()
 })
 
+async function syncSession() {
+  try {
+    const session = await fetchMiniSession()
+    userStore.setMiniSession(session)
+  } catch {
+    // 未绑微信时忽略
+  }
+}
+
 async function refreshUserInfo() {
   uni.showLoading({ title: '加载中...' })
   try {
     const info = await getCurrentUserInfo()
     userStore.setUser(info)
+    await syncSession()
     uni.hideLoading()
     uni.showToast({ title: '已更新', icon: 'success' })
   } catch (e) {
@@ -65,10 +92,25 @@ async function refreshUserInfo() {
   }
 }
 
+async function onSwitchRole(roleId: string) {
+  if (roleId === userStore.currentRoleId) return
+  uni.showLoading({ title: '切换中...', mask: true })
+  try {
+    const session = await switchMiniRole(roleId)
+    userStore.setMiniSession(session)
+    await menuStore.ensureMenus(true)
+    uni.hideLoading()
+    uni.showToast({ title: '已切换岗位', icon: 'success' })
+  } catch (e) {
+    uni.hideLoading()
+    uni.showToast({ title: (e as Error).message || '切换失败', icon: 'none' })
+  }
+}
+
 function showAbout() {
   uni.showModal({
     title: '泰昌 ERP',
-    content: '钢材进销存管理小程序,复用管理端全部接口。',
+    content: '钢材进销存管理小程序。登录用微信手机号匹配员工档案,不会自动开户。',
     showCancel: false
   })
 }
@@ -136,6 +178,57 @@ function handleLogout() {
     font-size: 26rpx;
     color: rgba(255, 255, 255, 0.8);
   }
+
+  &__role {
+    margin-top: 8rpx;
+    font-size: 24rpx;
+    color: rgba(255, 255, 255, 0.9);
+  }
+}
+
+.role-box {
+  margin: 24rpx;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx 28rpx 8rpx;
+}
+
+.role-box__title {
+  display: block;
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #303133;
+}
+
+.role-box__hint {
+  display: block;
+  margin: 8rpx 0 16rpx;
+  font-size: 22rpx;
+  color: #909399;
+  line-height: 1.5;
+}
+
+.role-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 22rpx 0;
+  border-top: 1rpx solid #f2f3f5;
+
+  &--active .role-item__name {
+    color: #2979ff;
+    font-weight: 600;
+  }
+}
+
+.role-item__name {
+  font-size: 28rpx;
+  color: #303133;
+}
+
+.role-item__mark {
+  font-size: 22rpx;
+  color: #2979ff;
 }
 
 .menu {

+ 35 - 5
src/store/modules/menu.ts

@@ -11,7 +11,7 @@ import { ref, computed } from 'vue'
 import { findMenuResources, getTenantApplications } from '@/api/modules/menu'
 import type { TenantApplicationItem } from '@/api/modules/menu'
 import { collectMappedKeys, filterDomainGroups, ALWAYS_VISIBLE_KEYS } from '@/modules/menuMapping'
-import { DOMAIN_GROUPS, type DomainGroup } from '@/modules/registry'
+import { DOMAIN_GROUPS, MODULES, type DomainGroup } from '@/modules/registry'
 import { getStorage, setStorage } from '@/utils/storage'
 import { useUserStore } from './user'
 
@@ -111,10 +111,11 @@ export const useMenuStore = defineStore('menu', () => {
     const info = userStore.userInfo as Record<string, any> | null
     const userId = info?.id
     if (userId == null || userId === '') return
+    const roleId = userStore.currentRoleId || 'all'
 
     // 快路径:上次解析成功提示 + MENU 缓存仍鲜 → 零请求
     const hint = getStorage<string>(SCOPE_HINT_PREFIX + userId)
-    const hintScope = hint ? `${userId}_${hint}` : ''
+    const hintScope = hint ? `${userId}_${hint}_${roleId}` : ''
     if (hintScope && !force) {
       restore(hintScope)
       if (loadedScope.value === hintScope && Date.now() - loadedAt.value < MENU_TTL) return
@@ -126,7 +127,8 @@ export const useMenuStore = defineStore('menu', () => {
         const subsystemId = await resolveBusinessSubsystemId(info!)
         if (!subsystemId) return // 保持 null 降级全量
 
-        const scope = `${userId}_${subsystemId}`
+        const roleId = userStore.currentRoleId || 'all'
+        const scope = `${userId}_${subsystemId}_${roleId}`
         restore(scope)
         if (!force && loadedScope.value === scope && Date.now() - loadedAt.value < MENU_TTL) {
           setStorage(SCOPE_HINT_PREFIX + userId, subsystemId)
@@ -145,10 +147,11 @@ export const useMenuStore = defineStore('menu', () => {
           return
         }
         ALWAYS_VISIBLE_KEYS.forEach((k) => mapped.add(k))
-        allowedKeys.value = mapped
+        const filtered = filterKeysByCurrentRole(mapped, userStore)
+        allowedKeys.value = filtered
         loadedScope.value = scope
         loadedAt.value = Date.now()
-        setStorage(storageKey(scope), { keys: Array.from(mapped), loadedAt: loadedAt.value })
+        setStorage(storageKey(scope), { keys: Array.from(filtered), loadedAt: loadedAt.value })
         setStorage(SCOPE_HINT_PREFIX + userId, subsystemId)
       } catch {
         // 拉取失败保留旧缓存(如有),无缓存保持 null 降级全量
@@ -174,3 +177,30 @@ export const useMenuStore = defineStore('menu', () => {
 
   return { allowedKeys, ensureMenus, visibleGroups, visibleBillGroups, isModuleAllowed }
 })
+
+/** 小程序按当前角色裁菜单;读不到角色权限码时保持平台并集,避免工作台空白 */
+function filterKeysByCurrentRole(
+  mapped: Set<string>,
+  userStore: ReturnType<typeof useUserStore>
+): Set<string> {
+  const role = userStore.miniRoles.find((r) => r.id === userStore.currentRoleId)
+  const perms = role?.permissions || []
+  if (!perms.length) return mapped
+  const next = new Set<string>()
+  for (const key of mapped) {
+    if (ALWAYS_VISIBLE_KEYS.includes(key)) {
+      next.add(key)
+      continue
+    }
+    const mod = MODULES.find((m) => m.key === key)
+    if (!mod) {
+      next.add(key)
+      continue
+    }
+    const prefix = mod.permission.split(':').slice(0, 2).join(':')
+    if (perms.some((p) => p === mod.permission || p === prefix || p.startsWith(`${prefix}:`))) {
+      next.add(key)
+    }
+  }
+  return next.size ? next : mapped
+}

+ 6 - 2
src/store/modules/todo.ts

@@ -17,8 +17,12 @@ const TODO_TTL = 5 * 60 * 1000
 const TODO_HARD_MAX = 60 * 60 * 1000
 const STORAGE_PREFIX = 'TODO_'
 
-/** 可审核单据模块(registry 派生,唯一真源) */
-const AUDIT_MODULES = MODULES.filter((m) => m.shape === 'bill' && m.actions?.includes('audit'))
+/** 待处理单据:审核流未审核、草稿定稿的草稿、生产单草稿 */
+const AUDIT_MODULES = MODULES.filter(
+  (m) =>
+    m.shape === 'bill' &&
+    (m.actions?.includes('audit') || m.statusModel === 'draftConfirm' || m.statusModel === 'production')
+)
 
 interface TodoCacheEntry {
   counts: Record<string, number>

+ 54 - 2
src/store/modules/user.ts

@@ -12,6 +12,10 @@ import {
   getSatoken,
   removeSatoken
 } from '@/utils/auth'
+import { getStorage, setStorage, removeStorage } from '@/utils/storage'
+import type { LoginResultVO, MiniRoleVO } from '@/api/modules/auth'
+
+const MINI_SESSION_KEY = 'MINI_SESSION'
 
 export interface UserInfo {
   /** 管理端用户 id 多为字符串 */
@@ -26,6 +30,11 @@ export interface UserInfo {
   [key: string]: any
 }
 
+interface MiniSession {
+  currentRoleId?: string | null
+  roles?: MiniRoleVO[]
+}
+
 export const useUserStore = defineStore('user', () => {
   // 用户信息
   const userInfo = ref<UserInfo | null>(null)
@@ -40,6 +49,10 @@ export const useUserStore = defineStore('user', () => {
   // 权限码集合(currentUserInfo 中解析,供按钮级显隐)
   const permissions = ref<string[]>([])
 
+  /** 小程序当前角色(不走 Web 权限并集) */
+  const currentRoleId = ref<string | null>(null)
+  const miniRoles = ref<MiniRoleVO[]>([])
+
   // jeesharp currentUserInfo 返回字段为 name/realName/loginName,nickname/username 仅作兜底
   const displayName = computed(
     () =>
@@ -51,6 +64,18 @@ export const useUserStore = defineStore('user', () => {
       '未登录'
   )
 
+  const currentRoleName = computed(() => {
+    const hit = miniRoles.value.find((r) => r.id === currentRoleId.value)
+    return hit?.name || ''
+  })
+
+  /** 当前岗位权限:有角色权限码时用角色,否则回退用户并集 */
+  const activePermissions = computed(() => {
+    const role = miniRoles.value.find((r) => r.id === currentRoleId.value)
+    if (role?.permissions?.length) return role.permissions
+    return permissions.value
+  })
+
   // 初始化用户信息(冷启动从 storage 恢复)
   function initUserInfo() {
     const info = getUserInfo<UserInfo>()
@@ -59,6 +84,11 @@ export const useUserStore = defineStore('user', () => {
       isLoggedIn.value = !!getToken()
       permissions.value = parsePermissions(info)
     }
+    const session = getStorage<MiniSession>(MINI_SESSION_KEY)
+    if (session) {
+      currentRoleId.value = session.currentRoleId || null
+      miniRoles.value = Array.isArray(session.roles) ? session.roles : []
+    }
   }
 
   // 从 currentUserInfo 宽松解析权限码(perms/permissionList/roles[].perm 等字段兜底)
@@ -84,6 +114,19 @@ export const useUserStore = defineStore('user', () => {
     return Array.from(set)
   }
 
+  function persistMiniSession() {
+    setStorage(MINI_SESSION_KEY, {
+      currentRoleId: currentRoleId.value,
+      roles: miniRoles.value
+    })
+  }
+
+  function setMiniSession(session: Pick<LoginResultVO, 'currentRoleId' | 'roles'>) {
+    currentRoleId.value = session.currentRoleId || null
+    miniRoles.value = Array.isArray(session.roles) ? session.roles : []
+    persistMiniSession()
+  }
+
   /** 设置登录态:accessToken + ssoToken + 用户信息 */
   function setLoginState(accessToken: string, ssoToken: string | null, user?: UserInfo | null) {
     token.value = accessToken
@@ -106,15 +149,19 @@ export const useUserStore = defineStore('user', () => {
 
   /** 是否拥有指定权限码(未解析到权限时放行,交由后端 205 兜底) */
   function hasPermission(code: string): boolean {
-    if (!permissions.value.length) return true
-    return permissions.value.includes(code)
+    const list = activePermissions.value
+    if (!list.length) return true
+    return list.includes(code)
   }
 
   // 清除用户信息(配合 tokenStorage.clearAuthState 使用)
   function clearUser() {
     userInfo.value = null
     permissions.value = []
+    currentRoleId.value = null
+    miniRoles.value = []
     removeUserInfo()
+    removeStorage(MINI_SESSION_KEY)
     removeToken()
     removeSatoken()
     isLoggedIn.value = false
@@ -134,10 +181,15 @@ export const useUserStore = defineStore('user', () => {
     token,
     satoken,
     permissions,
+    currentRoleId,
+    miniRoles,
     displayName,
+    currentRoleName,
+    activePermissions,
     initUserInfo,
     setLoginState,
     setUser,
+    setMiniSession,
     hasPermission,
     clearUser,
     setToken

+ 1 - 0
src/utils/tokenStorage.ts

@@ -47,6 +47,7 @@ export function clearAuthState(): void {
   removeSsoToken()
   try {
     uni.removeStorageSync('APP_' + USER_INFO_KEY)
+    uni.removeStorageSync('APP_MINI_SESSION')
   } catch (e) {
     console.warn('清理本地用户信息失败', e)
   }

+ 63 - 0
src/utils/upload.ts

@@ -0,0 +1,63 @@
+/**
+ * 文件上传:对齐 Web POST /admin/common/upload,字段名 file,响应 data 为 URL
+ */
+import { apiConfig } from '@/config/api'
+import { getAccessToken, getSsoToken } from './tokenStorage'
+import { getMobileClientHeaders } from './device'
+
+export function uploadFile(filePath: string): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const token = getAccessToken()
+    const satoken = getSsoToken()
+    uni.uploadFile({
+      url: `${apiConfig.baseURL}/common/upload`,
+      filePath,
+      name: 'file',
+      header: {
+        ...getMobileClientHeaders(),
+        ...(token ? { access_token: token, Authorization: `Bearer ${token}` } : {}),
+        ...(satoken ? { satoken } : {})
+      },
+      success: (res) => {
+        try {
+          const body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
+          if (body?.code !== 200 || !body.data) {
+            reject(new Error(body?.msg || '上传失败'))
+            return
+          }
+          resolve(String(body.data))
+        } catch {
+          reject(new Error('上传响应解析失败'))
+        }
+      },
+      fail: (err) => reject(new Error(err.errMsg || '上传失败'))
+    })
+  })
+}
+
+export function chooseAndUploadImage(): Promise<string> {
+  return new Promise((resolve, reject) => {
+    uni.chooseImage({
+      count: 1,
+      sizeType: ['compressed'],
+      sourceType: ['album', 'camera'],
+      success: async (res) => {
+        const path = res.tempFilePaths?.[0]
+        if (!path) {
+          reject(new Error('未选择图片'))
+          return
+        }
+        uni.showLoading({ title: '上传中...', mask: true })
+        try {
+          const url = await uploadFile(path)
+          resolve(url)
+        } catch (e) {
+          reject(e)
+        } finally {
+          uni.hideLoading()
+        }
+      },
+      fail: () => reject(new Error('已取消'))
+    })
+  })
+}

+ 20 - 0
src/utils/wxLogin.ts

@@ -0,0 +1,20 @@
+/**
+ * 微信登录凭证(仅微信小程序有效)
+ */
+export function getWxLoginCode(): Promise<string> {
+  return new Promise((resolve, reject) => {
+    uni.login({
+      provider: 'weixin',
+      success: (res) => {
+        if (res.code) {
+          resolve(res.code)
+          return
+        }
+        reject(new Error('未获取到微信登录凭证'))
+      },
+      fail: (err) => {
+        reject(new Error(err.errMsg || '微信登录失败'))
+      }
+    })
+  })
+}