feat(app): 销售订单/供应商准入按流程key判断免审批,仓储单据补提交与生成入口

- 新增 isAutoApproveByKey 查询 /bpm/process-auto-approve/ui-policy
- 销售订单提交命中免审批时直接提交生效并提示已自动审核通过
- 供应商准入命中免审批时不再弹审批流程选择
- 发货通知/销售出库列表草稿单增加行内提交按钮
- 发货通知改为从已审核销售订单生成(订单选择弹层),隐藏手工新建
已修改4个文件
179 ■■■■■ 文件已修改
src/api/bpm.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sale/order/index.vue 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/supplier/access/index.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/wls/components/WlsFlowDocList.vue 122 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/bpm.ts
@@ -3,6 +3,18 @@
const header = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
/**
 * 查询某个流程是否免审批。
 * 后端判定:全局总开关开启,或该流程 Key 单独配置了免审批,都算免审批。
 * 接口异常时退回 false(走正常审批流程选择),避免误放行。
 */
export function isAutoApproveByKey(accessToken: string, processDefinitionKey: string) {
  return request<boolean>(
    `/bpm/process-auto-approve/ui-policy${query({ key: processDefinitionKey })}`,
    { header: header(accessToken) },
  ).catch(() => false);
}
type QueryValue = string | number | boolean | string[] | number[] | undefined;
function query(params: Record<string, QueryValue>) {
src/pages/sale/order/index.vue
@@ -11,6 +11,7 @@
    submitSaleOrder,
    type SaleOrder,
  } from "../../../api/sale";
  import { isAutoApproveByKey } from "../../../api/bpm";
  import { useAuth } from "../../../stores/auth";
  import { statusToneClass } from "../../../utils/status";
@@ -97,20 +98,27 @@
  }
  async function submit(item: SaleOrder) {
    if (!state.accessToken || !item.id) return;
    if (
      !(await confirm(
        "确认提交审批",
        `确认提交${item.no || "该销售订单"}的审批吗?提交后审批将在协同办公中完成`
      ))
    )
      return;
    try {
      const list = await getSaleOrderApproveProcessList(state.accessToken);
      const process = list[0];
      if (!process?.key)
        return uni.showToast({ title: "销售订单审批流程未配置", icon: "none" });
      await submitSaleOrder(state.accessToken, item.id, process.key);
      uni.showToast({ title: "提交审批成功", icon: "success" });
      // 按流程 Key 判断免审批:全局开关开启或该流程单独配置免审批都算
      const autoApprove = await isAutoApproveByKey(
        state.accessToken,
        "sale_order_approve"
      );
      const confirmContent = autoApprove
        ? `确认提交${item.no || "该销售订单"}吗?提交后将自动审核通过`
        : `确认提交${item.no || "该销售订单"}的审批吗?提交后审批将在协同办公中完成`;
      if (!(await confirm("确认提交", confirmContent))) return;
      if (autoApprove) {
        await submitSaleOrder(state.accessToken, item.id, "sale_order_approve");
        uni.showToast({ title: "提交成功,已自动审核通过", icon: "none" });
      } else {
        const list = await getSaleOrderApproveProcessList(state.accessToken);
        const process = list[0];
        if (!process?.key)
          return uni.showToast({ title: "销售订单审批流程未配置", icon: "none" });
        await submitSaleOrder(state.accessToken, item.id, process.key);
        uni.showToast({ title: "提交审批成功,请到协同办公中处理", icon: "none" });
      }
      load(true);
    } catch (e) {
      uni.showToast({ title: message(e), icon: "none" });
src/pages/supplier/access/index.vue
@@ -16,6 +16,7 @@
    type SupplierApplyItem,
    type SupplierItem,
  } from "../../../api/supplier";
  import { isAutoApproveByKey } from "../../../api/bpm";
  import { useAuth } from "../../../stores/auth";
  import { statusToneClass } from "../../../utils/status";
  const { hasPermission, isLoggedIn, state } = useAuth();
@@ -169,7 +170,17 @@
  }
  async function submit(item: SupplierApplyItem) {
    if (!state.accessToken || !item.id) return;
    // 供应商准入审批流程 Key(免审批配置也按此 Key 记录)
    const PROCESS_KEY = "supplier_apply_approve";
    try {
      // 命中免审批(全局开关或该流程单独开启)时直接提交生效,不再拉取审批流程
      const autoApprove = await isAutoApproveByKey(state.accessToken, PROCESS_KEY);
      if (autoApprove) {
        await submitApply(state.accessToken, item.id, PROCESS_KEY);
        uni.showToast({ title: "已提交生效", icon: "success" });
        load(true);
        return;
      }
      const list = await getApplyProcessList(state.accessToken);
      const process = list[0];
      const processKey = process?.processDefinitionKey || process?.key || process?.id;
src/pages/wls/components/WlsFlowDocList.vue
@@ -31,8 +31,11 @@
  stockProductSales,
  stockReturnIssue,
  stockReturnSales,
  submitProductSales,
  submitSalesNotice,
  type WlsDocBase,
} from "../../../api/wls";
import { generateSalesNoticeFromSaleOrder, getSaleOrderPage, type SaleOrder } from "../../../api/sale";
import { useAuth } from "../../../stores/auth";
import { dateText, dayText, dictLabel, documentStatusClass } from "./shared";
@@ -151,6 +154,71 @@
    uni.navigateTo({ url: `/pages/wls/product-sales/form?id=${id}` });
  });
}
/** 列表行内提交:发货通知 草稿(0)→待出库(3);销售出库 草稿(0)→待拣货(2) */
function submitDoc(row: WlsDocBase) {
  const token = state.accessToken;
  if (!token || !row.id) return;
  const label = props.kind === "sales-notice" ? "发货通知单" : "销售出库单";
  confirmAction("确认提交", `确认提交${label} ${row.code || ""} 吗?提交后才能继续后续流程`, async () => {
    if (props.kind === "sales-notice") return submitSalesNotice(token, row.id!);
    return submitProductSales(token, row.id!);
  });
}
// 发货通知「从销售订单生成」
const orderPickerOpen = ref(false);
const orderRows = ref<SaleOrder[]>([]);
const orderKeyword = ref("");
const orderPageNo = ref(1);
const orderTotal = ref(0);
const orderLoading = ref(false);
const generatingId = ref<number>();
const orderHasMore = computed(() => orderRows.value.length < orderTotal.value);
async function loadOrders(reset = false) {
  if (!state.accessToken || orderLoading.value) return;
  if (reset) { orderPageNo.value = 1; orderRows.value = []; orderTotal.value = 0; }
  orderLoading.value = true;
  try {
    const data = await getSaleOrderPage(state.accessToken, {
      pageNo: orderPageNo.value,
      pageSize: 15,
      no: orderKeyword.value.trim() || undefined,
      status: 20,
    });
    const list = (data.list || []).filter(item => item.canCreateSalesNotice !== false);
    orderRows.value = reset ? list : [...orderRows.value, ...list];
    orderTotal.value = Number(data.total || 0);
    orderPageNo.value += 1;
  } catch (error) { uni.showToast({ title: errorText(error), icon: "none" }); }
  finally { orderLoading.value = false; }
}
function openOrderPicker() {
  orderPickerOpen.value = true;
  loadOrders(true);
}
function closeOrderPicker() {
  if (generatingId.value) return;
  orderPickerOpen.value = false;
}
async function pickOrder(order: SaleOrder) {
  if (!state.accessToken || !order.id || generatingId.value) return;
  const ok = await new Promise<boolean>(resolve => uni.showModal({
    title: "确认生成",
    content: `确认根据销售订单 ${order.no || order.id} 生成发货通知单?`,
    success: r => resolve(r.confirm),
  }));
  if (!ok) return;
  generatingId.value = order.id;
  try {
    await generateSalesNoticeFromSaleOrder(state.accessToken, order.id);
    uni.showToast({ title: "生成成功", icon: "success" });
    orderPickerOpen.value = false;
    load(true);
  } catch (error) {
    uni.showToast({ title: errorText(error), icon: "none" });
  } finally {
    generatingId.value = undefined;
  }
}
function qcTip() { uni.showToast({ title: "请前往质量管理执行质检", icon: "none" }); }
function stockText() {
  if (props.kind === "product-receipt" || props.kind === "return-issue" || props.kind === "return-sales") return "执行上架";
@@ -180,6 +248,7 @@
  <view class="page">
    <template v-if="isLoggedIn">
      <view v-if="canQuery" class="search"><input v-model="keyword" :placeholder="`搜索${config.code}`" confirm-type="search" @confirm="load(true)" /><text v-if="keyword" @click="keyword='';load(true)">清除</text></view>
      <view v-if="canQuery && kind === 'sales-notice' && canCreate" class="top-actions"><button @click="openOrderPicker">从销售订单生成</button></view>
      <scroll-view v-if="canQuery" class="content" scroll-y lower-threshold="80" @scrolltolower="hasMore && load()">
        <view v-if="loadError" class="state error" @click="load(true)">{{ loadError }},点击重试</view>
        <view v-else-if="rows.length">
@@ -201,6 +270,7 @@
            <view class="actions" @click.stop>
              <button v-if="canQuery" @click="go(row,'detail')">详情</button>
              <button v-if="canUpdate && isDraft(row)" @click="go(row)">编辑</button>
              <button v-if="canUpdate && isDraft(row) && (kind === 'sales-notice' || kind === 'product-sales')" @click="submitDoc(row)">提交</button>
              <button v-if="canDelete && isDraft(row)" class="danger" @click="remove(row)">删除</button>
              <button v-if="(kind === 'return-issue' || kind === 'return-sales') && isConfirmed(row)" @click="qcTip">执行质检</button>
              <button v-if="kind === 'sales-notice' && canGenerateSales && isApproved(row)" @click="generateSales(row)">生成出库单</button>
@@ -217,10 +287,60 @@
      <view v-else class="state">暂无{{ config.title }}查看权限</view>
    </template>
    <view v-else class="state">登录后查看{{ config.title }}</view>
    <button v-if="canCreate" class="fab" @click="go()"><view /></button>
    <!-- 发货通知只能从销售订单生成,不提供手工新建入口 -->
    <button v-if="canCreate && kind !== 'sales-notice'" class="fab" @click="go()"><view /></button>
    <!-- 从销售订单生成发货通知 -->
    <view v-if="orderPickerOpen" class="picker-mask" @click="closeOrderPicker">
      <view class="picker-panel" @click.stop>
        <view class="picker-head">
          <text class="picker-title">选择销售订单生成发货通知</text>
          <text class="picker-close" @click="closeOrderPicker">×</text>
        </view>
        <view class="picker-search">
          <input v-model="orderKeyword" placeholder="搜索订单号" confirm-type="search" @confirm="loadOrders(true)" />
          <text @click="loadOrders(true)">搜索</text>
        </view>
        <scroll-view class="picker-list" scroll-y lower-threshold="80" @scrolltolower="orderHasMore && loadOrders()">
          <view v-if="orderRows.length">
            <view v-for="order in orderRows" :key="order.id" class="order-card" @click="pickOrder(order)">
              <view class="order-head">
                <text class="order-no">{{ order.no || '--' }}</text>
                <text class="order-pick">{{ generatingId === order.id ? '生成中...' : '选择' }}</text>
              </view>
              <text class="order-customer">{{ order.customerName || '--' }}</text>
              <view class="order-grid">
                <text>产品:{{ order.productNames || '—' }}</text>
                <text>总数量:{{ order.totalCount ?? '—' }}</text>
                <text>订单时间:{{ order.orderTime?.replace('T', ' ').slice(0, 16) || '—' }}</text>
              </view>
            </view>
            <text class="more">{{ orderLoading ? '加载中...' : orderHasMore ? '上拉加载更多' : '没有更多了' }}</text>
          </view>
          <view v-else class="state">{{ orderLoading ? '加载中...' : '暂无可生成发货通知的已审核订单' }}</view>
        </scroll-view>
      </view>
    </view>
  </view>
</template>
<style lang="scss" scoped>
.page{height:100vh;display:flex;flex-direction:column;padding:0 30rpx;box-sizing:border-box;overflow:hidden;background:#f3f6fb}.search{display:flex;align-items:center;height:80rpx;margin:20rpx 0 16rpx;padding:0 20rpx;background:#fff;border-radius:16rpx;box-shadow:0 8rpx 20rpx rgba(43,69,107,.06)}.search input{flex:1;font-size:27rpx}.search text{color:var(--color-primary, #1769d8);font-size:24rpx}.content{height:0;flex:1;padding-bottom: 132rpx;box-sizing:border-box}.row{padding:24rpx 22rpx 20rpx;margin-bottom:18rpx;background:#fff;border-radius:16rpx;box-shadow:0 10rpx 24rpx rgba(43,69,107,.08)}.head{display:flex;align-items:flex-start;justify-content:space-between;gap:16rpx}.head>view:first-child{display:flex;min-width:0;flex:1;flex-direction:column;gap:8rpx}.head>view:first-child text:last-child{color:#78879b;font-size:23rpx}.name{overflow:hidden;color:#20334c;font-size:30rpx;font-weight:600;text-overflow:ellipsis;white-space:nowrap}.status-wrap{display:flex;align-items:flex-start;justify-content:center;flex:none;min-height:44rpx}.status{display:inline-flex;align-items:center;justify-content:center;height:44rpx;padding:0 18rpx;font-size:23rpx;line-height:44rpx;border-radius:999rpx;white-space:nowrap}.tag-draft{color:#6f7e92;background:#f3f6fa;border:1rpx solid #dce4ee}.tag-progress{color:#b57800;background:#fff7dd;border:1rpx solid #f3dc98}.tag-approved{color:var(--color-primary, #1769d8);background:var(--color-primary-soft, #edf5ff);border:1rpx solid var(--color-primary-border, #bfdbff)}.tag-finished{color:#17865a;background:#eafaf2;border:1rpx solid #b8efd5}.tag-cancelled{color:#c65050;background:#fff7f7;border:1rpx solid #e3a0a0}.tag-info{color:#66778c;background:#f6f8fb;border:1rpx solid #dce4ee}.grid{display:grid;gap:10rpx;padding:16rpx 0;margin-top:16rpx;color:#66778c;font-size:23rpx;border-top:1rpx solid #edf1f6;border-bottom:1rpx solid #edf1f6}.grid text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:12rpx;padding-top:16rpx}.actions button{min-height:50rpx;padding:0 16rpx;margin:0;color:var(--color-primary, #1769d8);font-size:22rpx;line-height:48rpx;background:var(--color-primary-soft, #f5f8ff);border:1rpx solid var(--color-primary-border, #8ab3ee);border-radius:8rpx}.actions .danger{color:#c65050;background:#fff7f7;border-color:#e3a0a0}.actions button::after,.fab::after{border:0}.more,.state{display:block;padding:35rpx;color:#8795a8;font-size:26rpx;text-align:center}.error{color:#c3654d}.fab{position:fixed;right:30rpx;bottom:calc(32rpx + env(safe-area-inset-bottom));display:flex;align-items:center;justify-content:center;width: 104rpx; height: 104rpx; min-height: 104rpx;padding:0;margin:0;background:var(--color-primary, #1769d8);border-radius:50%;box-shadow:0 12rpx 24rpx rgba(23,105,216,.3)}.fab view{width:38rpx;height:38rpx;background:linear-gradient(#fff,#fff) center/38rpx 8rpx no-repeat,linear-gradient(#fff,#fff) center/8rpx 38rpx no-repeat}.fab view::before,.fab view::after{display:none;content:none}
.top-actions{display:flex;justify-content:flex-end;flex:none;margin-bottom:14rpx}
.top-actions button{min-height:56rpx;padding:0 24rpx;margin:0;color:#fff;font-size:24rpx;line-height:54rpx;background:var(--color-primary, #1769d8);border-radius:8rpx}
.top-actions button::after{border:0}
.picker-mask{position:fixed;top:0;right:0;bottom:0;left:0;z-index:999;background:rgba(0,0,0,.45)}
.picker-panel{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;flex-direction:column;background:#f3f6fb}
.picker-head{display:flex;align-items:center;justify-content:space-between;flex:none;padding:28rpx 30rpx;background:#fff}
.picker-title{font-size:30rpx;font-weight:600;color:#20334c}
.picker-close{color:#8a97a8;font-size:48rpx;line-height:1}
.picker-search{display:flex;align-items:center;flex:none;height:80rpx;margin:20rpx 30rpx 12rpx;padding:0 20rpx;background:#fff;border-radius:16rpx}
.picker-search input{flex:1;font-size:27rpx}
.picker-search text{color:var(--color-primary, #1769d8);font-size:24rpx}
.picker-list{flex:1;height:0;padding:0 30rpx 30rpx;box-sizing:border-box}
.order-card{padding:22rpx;margin-bottom:16rpx;background:#fff;border-radius:16rpx;box-shadow:0 8rpx 20rpx rgba(43,69,107,.06)}
.order-head{display:flex;align-items:center;justify-content:space-between;gap:16rpx}
.order-no{overflow:hidden;font-size:28rpx;font-weight:600;color:#20334c;text-overflow:ellipsis;white-space:nowrap}
.order-pick{flex:none;padding:6rpx 20rpx;color:#fff;font-size:22rpx;background:var(--color-primary, #1769d8);border-radius:999rpx}
.order-customer{display:block;margin-top:10rpx;color:#20334c;font-size:25rpx}
.order-grid{display:grid;grid-template-columns:1fr;gap:8rpx;padding-top:12rpx;margin-top:12rpx;color:#78879b;font-size:22rpx;border-top:1rpx solid #edf1f6}
</style>