新{{ d.insertCount }}
@@ -52,14 +55,14 @@
{{ activeDate || "-" }}
|
- 当日合计 {{ dayStats.total }}
+ 当日合计 {{ dayStats.total }} 道
|
早餐 {{ dayStats.breakfast }}
午餐 {{ dayStats.lunch }}
晚餐 {{ dayStats.dinner }}
其他 {{ dayStats.other }}
|
- 已勾选 {{ daySelectedCount }}
+ 已勾选 {{ daySelectedMenuCount }} 道
餐段
@@ -168,13 +171,23 @@
{{ row.id || "-" }}
+
+
+ {{ row.spuId || "-" }}
+
+
+
+
+ {{ row.childSpuId || "-" }}
+
+
取消
- 确认导入(全部已勾选 {{ selectedCount }} 行)
+ 确认导入(全部已勾选 {{ selectedMenuCount }} 道)
@@ -203,6 +216,38 @@ const ALL_MEALS = ["早餐", "午餐", "晚餐"] as const
const selectedCount = computed(() => rows.value.filter((r) => r.selected && !r.locked).length)
+/** 同一套餐多行共用 groupKey,按「一道排菜」去重计数 */
+const scheduleGroupKey = (r: any) => {
+ if (r?.groupKey) return String(r.groupKey)
+ const date = normalizeBizDate(r?.bizDate)
+ const meals = parseMealNames(r?.mealNames?.length ? r.mealNames : r?.mealName).join(",")
+ const id = String(r?.id || "").trim()
+ if (id) return `id:${id}|${date}|${meals}`
+ const combo = String(r?.comboName || "").trim()
+ if (combo && !isSingleComboMarker(combo)) return `new:${date}|${meals}|${combo}`
+ return `new:${date}|${meals}|${String(r?.dishName || "").trim()}`
+}
+
+const isSingleComboMarker = (combo: string) => {
+ const c = String(combo || "").trim()
+ if (!c) return false
+ if (["无", "-", "无套餐", "不套餐"].includes(c)) return true
+ return c.startsWith("单品") || c.startsWith("单点")
+}
+
+const countUniqueGroups = (list: any[], predicate?: (r: any) => boolean) => {
+ const keys = new Set()
+ for (const r of list) {
+ if (predicate && !predicate(r)) continue
+ keys.add(scheduleGroupKey(r))
+ }
+ return keys.size
+}
+
+const selectedMenuCount = computed(() =>
+ countUniqueGroups(rows.value, (r) => r.selected && !r.locked),
+)
+
/** 把后端/Excel 各种日期写法统一成 yyyy-MM-dd,避免同一天拆成两个 Tab */
const normalizeBizDate = (raw?: string) => {
const s = String(raw || "").trim()
@@ -221,10 +266,12 @@ const dayTabs = computed(() => {
bizDate: string
locked: boolean
rowCount: number
+ excelRowCount: number
insertCount: number
updateCount: number
skipLockedCount: number
}>()
+ const seenGroup = new Set()
for (const r of rows.value) {
const date = normalizeBizDate(r.bizDate)
if (!date) continue
@@ -232,26 +279,40 @@ const dayTabs = computed(() => {
bizDate: date,
locked: false,
rowCount: 0,
+ excelRowCount: 0,
insertCount: 0,
updateCount: 0,
skipLockedCount: 0,
}
- tab.rowCount++
- if (r.locked || r.action === "SKIP_LOCKED") {
+ tab.excelRowCount++
+ const gk = `${date}::${scheduleGroupKey(r)}`
+ if (!seenGroup.has(gk)) {
+ seenGroup.add(gk)
+ tab.rowCount++
+ if (r.locked || r.action === "SKIP_LOCKED") {
+ tab.locked = true
+ tab.skipLockedCount++
+ } else if (r.action === "UPDATE") {
+ tab.updateCount++
+ } else {
+ tab.insertCount++
+ }
+ } else if (r.locked || r.action === "SKIP_LOCKED") {
tab.locked = true
- tab.skipLockedCount++
- } else if (r.action === "UPDATE") {
- tab.updateCount++
- } else {
- tab.insertCount++
}
map.set(date, tab)
}
- // 优先用后端 daySummaries 的锁定标记补齐
+ // 后端 daySummaries 已按排菜组统计,优先覆盖
for (const d of preview.value?.daySummaries || []) {
const key = normalizeBizDate(d.bizDate)
const exist = map.get(key)
- if (exist && d.locked) exist.locked = true
+ if (!exist) continue
+ if (d.locked) exist.locked = true
+ if (typeof d.rowCount === "number") exist.rowCount = d.rowCount
+ if (typeof d.excelRowCount === "number") exist.excelRowCount = d.excelRowCount
+ if (typeof d.insertCount === "number") exist.insertCount = d.insertCount
+ if (typeof d.updateCount === "number") exist.updateCount = d.updateCount
+ if (typeof d.skipLockedCount === "number") exist.skipLockedCount = d.skipLockedCount
}
return [...map.values()].sort((a, b) => a.bizDate.localeCompare(b.bizDate))
})
@@ -307,14 +368,17 @@ const dayRows = computed(() =>
const dayStats = computed(() => {
const stats = { total: 0, breakfast: 0, lunch: 0, dinner: 0, other: 0 }
+ const seen = new Set()
for (const r of dayRows.value) {
+ const gk = scheduleGroupKey(r)
+ if (seen.has(gk)) continue
+ seen.add(gk)
stats.total++
const meals = parseMealNames(r.mealNames?.length ? r.mealNames : r.mealName)
if (!meals.length) {
stats.other++
continue
}
- // 一行多餐时,各餐段计数都 +1(方便排查)
let hit = false
for (const meal of meals) {
if (meal === "早餐") {
@@ -337,6 +401,10 @@ const daySelectedCount = computed(
() => dayRows.value.filter((r) => r.selected && !r.locked).length,
)
+const daySelectedMenuCount = computed(() =>
+ countUniqueGroups(dayRows.value, (r) => r.selected && !r.locked),
+)
+
const filteredRows = computed(() => {
let list = dayRows.value
const selectedMeals = mealFilters.value || []
@@ -451,6 +519,7 @@ const submitConfirm = async () => {
ingredients: r.ingredients,
id: r.id,
spuId: r.spuId,
+ childSpuId: r.childSpuId,
}
}),
}
diff --git a/src/views/canteen/canteenDailyMenu/index.vue b/src/views/canteen/canteenDailyMenu/index.vue
index 8f1de29d..e0fcb8cf 100644
--- a/src/views/canteen/canteenDailyMenu/index.vue
+++ b/src/views/canteen/canteenDailyMenu/index.vue
@@ -180,6 +180,15 @@
+ 中餐
+ 晚餐
刷新
+
+ 按订单重算销量
+
@@ -353,6 +362,7 @@ import {
previewDailyMenuImport,
listDayLocks,
setDayLock,
+ syncChannelSold,
} from "/@/api/canteen/canteenDailyMenu"
import { getAllSimple as getCanteenCategory } from "/@/api/canteen/canteenCategory"
import { useTable } from "/@/hooks/table"
@@ -725,6 +735,7 @@ const delteData = async (tid: number) => {
const dayLocked = ref(false)
const dayLockLoading = ref(false)
+const syncSoldLoading = ref(false)
const weekLockedDates = ref>(new Set())
const normalizeLockDate = (v: any) => {
@@ -798,6 +809,36 @@ const onDayLockChange = async (val: string | number | boolean) => {
}
}
+/** 按当日订单净销量覆盖 online/offline sold,刷新剩余展示 */
+const syncSoldFromOrders = async () => {
+ if (!merchantId.value || !timedata) {
+ message.warning("请先选择商户和日期")
+ return
+ }
+ try {
+ await ElMessageBox.confirm(
+ `将按订单重算 ${timedata} 的线上/线下销量(配额不变;无订单的菜品销量置 0)。是否继续?`,
+ "按订单重算销量",
+ { type: "warning", confirmButtonText: "重算", cancelButtonText: "取消" }
+ )
+ } catch {
+ return
+ }
+ syncSoldLoading.value = true
+ try {
+ const res: any = await syncChannelSold({
+ merchantId: merchantId.value,
+ mealDate: timedata,
+ })
+ message.success(res.msg || "重算完成")
+ await loadAdminList()
+ } catch (err: any) {
+ message.error(err?.msg || "重算失败")
+ } finally {
+ syncSoldLoading.value = false
+ }
+}
+
const importVisible = ref(false)
const importLoading = ref(false)
const importCategoryId = ref()
diff --git a/src/views/canteen/canteenDailyMenu/rowDialog.vue b/src/views/canteen/canteenDailyMenu/rowDialog.vue
index 2c8c035f..e373ddc5 100644
--- a/src/views/canteen/canteenDailyMenu/rowDialog.vue
+++ b/src/views/canteen/canteenDailyMenu/rowDialog.vue
@@ -4,35 +4,81 @@
v-model="visible"
:close-on-click-modal="false"
draggable
- width="640px"
+ :width="mode === 'view' ? '720px' : '640px'"
destroy-on-close
+ class="daily-menu-row-dialog"
>
-
- {{ detail.name || "-" }}
-
- 套餐
- 单品
-
- {{ detail.categoryName || "-" }}
- {{ hourTypeLabel(detail.hourType) }}
- {{ formatDate(detail.startTime) }}
- {{ detail.sort ?? "-" }}
- {{ detail.onlineStock ?? "-" }}
- {{ detail.remainOnlineStock ?? "-" }}
- {{ detail.offlineStock ?? "-" }}
- {{ detail.remainOfflineStock ?? "-" }}
-
- {{ displayPrice(detail.price) }}
-
-
- {{ formatComboItems(detail.comboItems) || "-" }}
-
-
+
+
+
+
+
+
+
+
+ 套餐组成
+ 共 {{ comboItemList.length }} 项
+
+
+ -
+ {{ idx + 1 }}
+
+
{{ item.name }}
+
{{ item.sub }}
+
+ {{ item.priceText }}
+
+
+
暂无组成菜
+
+
+
+
@@ -109,8 +155,46 @@ const rules = {
const hourTypeLabel = (hourType?: number) => HOUR_TYPE_LABEL[hourType || 0] || "-"
const formatDate = (value?: string) => (value ? String(value).substring(0, 10) : "-")
-const formatComboItems = (comboItems: any[] = []) =>
- comboItems.map((c) => c.name || c.childSpuName).filter(Boolean).join("、")
+
+/** 套餐组成:拆成列表项,长中英混排名尽量分行展示 */
+const comboItemList = computed(() => {
+ const items = Array.isArray(detail.comboItems) ? detail.comboItems : []
+ return items
+ .map((c: any) => {
+ const raw = String(c?.name || c?.childSpuName || "").trim()
+ if (!raw) return null
+ const parts = splitDishName(raw)
+ const priceNum = c?.price
+ const priceText =
+ priceNum === null || priceNum === undefined || priceNum === ""
+ ? ""
+ : displayPrice(priceNum)
+ return {
+ name: parts.primary,
+ sub: parts.secondary,
+ priceText,
+ id: c?.childSpuId || c?.id,
+ }
+ })
+ .filter(Boolean) as { name: string; sub: string; priceText: string; id?: string | number }[]
+})
+
+const itemKey = (item: { id?: string | number; name: string }, idx: number) =>
+ item.id != null ? String(item.id) : `${idx}-${item.name}`
+
+/** 尽量把英文/日期后缀放到副行,主行保留中文菜名 */
+const splitDishName = (raw: string) => {
+ const oneLine = raw.replace(/\s+/g, " ").trim()
+ const m = oneLine.match(/^(.+?)([A-Za-z].*)$/)
+ if (m && m[1].trim().length >= 2) {
+ return { primary: m[1].trim(), secondary: m[2].trim() }
+ }
+ if (oneLine.includes("\n")) {
+ const [first, ...rest] = oneLine.split("\n").map((s) => s.trim()).filter(Boolean)
+ return { primary: first || oneLine, secondary: rest.join(" ") }
+ }
+ return { primary: oneLine, secondary: "" }
+}
const resetDetail = () => {
Object.keys(detail).forEach((k) => delete detail[k])
@@ -125,6 +209,7 @@ const openDialog = async (row: any, openMode: "view" | "edit" = "view") => {
...row,
ingredients: row.ingredients || "",
categoryName: row.categoryName || "-",
+ comboItems: row.comboItems || [],
})
form.menuId = row.id
form.spuId = row.spuId
@@ -143,6 +228,9 @@ const openDialog = async (row: any, openMode: "view" | "edit" = "view") => {
const spu = res.data || {}
detail.ingredients = spu.description || row.ingredients || ""
detail.categoryName = row.categoryName || detail.categoryName
+ if (Array.isArray(spu.comboItems) && spu.comboItems.length) {
+ detail.comboItems = spu.comboItems
+ }
if (openMode === "edit") {
form.sort = spu.sort ?? form.sort
form.priceYuan = Number(displayPrice(spu.price, "")) || form.priceYuan
@@ -194,13 +282,190 @@ defineExpose({ openDialog })
diff --git a/src/views/canteen/canteenReportFaceImport/index.vue b/src/views/canteen/canteenReportFaceImport/index.vue
index 7d8eda7b..0479efe7 100644
--- a/src/views/canteen/canteenReportFaceImport/index.vue
+++ b/src/views/canteen/canteenReportFaceImport/index.vue
@@ -217,7 +217,7 @@
+ title="按整月已入库人脸明细写入刷卡。排除厂区:这些厂区不写入,并清除其当月 FACE,看板不再显示该厂区。" />
@@ -245,7 +245,7 @@
- 清空当月 FACE 再写入(已排除厂区会保留)
+ 清空当月 FACE 再写入(排除厂区一并清除、不再展示)
@@ -631,7 +631,7 @@ const submitAggregate = async () => {
const excludeTip = aggForm.excludeParks.length ? `,排除厂区:${aggForm.excludeParks.join("、")}` : ""
try {
await useMessageBox().confirm(
- `确认将 ${aggForm.bizMonth} 全部已入库人脸明细汇总写入刷卡?匹配方式:${matchTip}(月份对月份)${excludeTip}。将覆盖该月 FACE 刷卡(排除厂区保留)。`
+ `确认将 ${aggForm.bizMonth} 全部已入库人脸明细汇总写入刷卡?匹配方式:${matchTip}(月份对月份)${excludeTip}。将覆盖该月 FACE 刷卡(排除厂区会清除并不再展示)。`
)
} catch {
return