批量更新功能
This commit is contained in:
1 parent
6f106e43af
commit
a26c98b92b
79 files changed
+4529
-724
No files matched your search
@@ -88,6 +88,11 @@
|
||||
</el-select>
|
||||
<el-input v-model="party.signer_phone" placeholder="联系电话" maxlength="20" />
|
||||
</div>
|
||||
|
||||
<!-- 该企业还没有联系人:签约人姓名与电话会在保存合同时写入企业通讯录 -->
|
||||
<div v-if="showContactTip" class="contact-tip">
|
||||
该企业暂无联系人,填写姓名与电话即可,保存合同后会自动写入通讯录,可稍后补全其他资料
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -143,6 +148,15 @@ const contactPlaceholder = computed(() => {
|
||||
return party.value?.ref_id ? "选择签约人(也可输入)" : "请先选择客户 / 供应商";
|
||||
});
|
||||
|
||||
/** 已选企业但通讯录里还没有联系人:提示签约人信息会在保存合同后自动写入通讯录 */
|
||||
const showContactTip = computed(
|
||||
() =>
|
||||
!isOwnCompany.value &&
|
||||
!!party.value?.ref_id &&
|
||||
!contactLoading.value &&
|
||||
contacts.value.length === 0
|
||||
);
|
||||
|
||||
/** 拉取本公司人员(在职员工),映射为与联系人一致的字段结构,便于复用下拉渲染 */
|
||||
async function fetchOwnEmployees() {
|
||||
ownLoading.value = true;
|
||||
@@ -338,5 +352,13 @@ const handleRefTypeChange = () => {
|
||||
.select-footer {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 暂无联系人提示:签约人信息保存后自动写入通讯录 */
|
||||
.contact-tip {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,15 @@
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="研发方式" width="120">
|
||||
<template #default="{ row }">
|
||||
<!-- 软件开发分两种:自行研发 / 外协研发(别人帮我们研发,成本按外包总额计) -->
|
||||
<el-select v-if="row.line_type === 'dev'" v-model="row.dev_mode" style="width: 100%">
|
||||
<el-option v-for="i in DEV_MODE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="产品类别" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
@@ -74,7 +83,24 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="单位" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.unit" :placeholder="row.line_type === 'dev' ? '项' : '套'" maxlength="10" />
|
||||
<!-- 单位下拉取自「基本设置 - 单位设置」(启用中的单位),支持搜索与直接输入 -->
|
||||
<el-select
|
||||
v-model="row.unit"
|
||||
filterable
|
||||
allow-create
|
||||
clearable
|
||||
:placeholder="row.line_type === 'dev' ? '项' : '套'"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="u in unitOptions" :key="u" :label="u" :value="u" />
|
||||
<!-- 历史数据中的单位不在单位设置里时兜底展示,避免已填单位看不到 -->
|
||||
<el-option
|
||||
v-if="row.unit && !unitOptions.includes(row.unit)"
|
||||
:key="row.unit"
|
||||
:label="row.unit"
|
||||
:value="row.unit"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" width="110">
|
||||
@@ -198,13 +224,14 @@
|
||||
<span class="summary-label">产品总成本</span>
|
||||
<span class="summary-value">¥{{ formatMoney(summary.total_cost) }}</span>
|
||||
</div>
|
||||
<!-- 我方为甲方(付款方)时显示「合同支出」(负数),否则显示「合同总利润」 -->
|
||||
<div class="summary-item profit">
|
||||
<span class="summary-label">合同总利润</span>
|
||||
<span class="summary-label">{{ contractProfitLabel(ourRole) }}</span>
|
||||
<span
|
||||
class="summary-value"
|
||||
:class="{ loss: summary.total_profit < 0 }"
|
||||
:class="{ loss: !isPayerContract(ourRole) && summary.total_profit < 0 }"
|
||||
>
|
||||
¥{{ formatMoney(summary.total_profit) }}
|
||||
{{ contractProfitText(ourRole, summary) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,20 +253,31 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import { formatMoney } from "../../dict";
|
||||
import {
|
||||
formatMoney,
|
||||
PRODUCT_UNIT_OPTIONS,
|
||||
DEV_MODE_OPTIONS,
|
||||
contractProfitLabel,
|
||||
contractProfitText,
|
||||
isPayerContract,
|
||||
} from "../../dict";
|
||||
import { buildSummary, lineAmount, devCost, treeSellTotal } from "./utils";
|
||||
import { getProductList } from "@/api/crmProduct";
|
||||
import { getProductCategoryList } from "@/api/crmProductCategory";
|
||||
import { getUnitList } from "@/api/crmUnit";
|
||||
import DevModuleTree from "./DevModuleTree.vue";
|
||||
|
||||
const props = defineProps({
|
||||
/** 产品行数组(父组件持有) */
|
||||
products: { type: Array, required: true },
|
||||
/** 我方角色:1=甲方(付款方,金额汇总显示「合同支出」,不显示利润) */
|
||||
ourRole: { type: [Number, String], default: 2 },
|
||||
});
|
||||
|
||||
const summary = computed(() => buildSummary(props.products));
|
||||
|
||||
const categoryOptions = ref([]);
|
||||
const unitOptions = ref([]);
|
||||
const productOptions = ref([]);
|
||||
const activeRow = ref(null); // 当前正在操作的产品行(用于按分类过滤搜索)
|
||||
|
||||
@@ -259,6 +297,7 @@ const addRow = () => {
|
||||
tax_rate: 0,
|
||||
dev_tree: [],
|
||||
dev_unit_price: 0,
|
||||
dev_mode: "self", // 软件开发研发方式:self=自行研发(默认)/ outsource=外协研发
|
||||
dev_total_price: 0, // 模块报价带出的开发总成本(= 本行成本单价)
|
||||
dev_auto_total: 0, // 最近一次按人天自动汇总的值,用于判断是否需要跟随更新
|
||||
remark: "",
|
||||
@@ -269,11 +308,12 @@ const removeRow = (index) => {
|
||||
props.products.splice(index, 1);
|
||||
};
|
||||
|
||||
/** 切换行类型:软件开发行确保有模块树容器,并补上类别/单位默认值 */
|
||||
/** 切换行类型:软件开发行确保有模块树容器与研发方式,并补上类别/单位默认值 */
|
||||
function onLineTypeChange(row) {
|
||||
if (row.line_type === "dev") {
|
||||
if (!Array.isArray(row.dev_tree)) row.dev_tree = [];
|
||||
if (row.dev_unit_price == null) row.dev_unit_price = 0;
|
||||
if (!row.dev_mode) row.dev_mode = "self";
|
||||
// 开发行不进产品管理建档,类别与单位需在此兜底,否则详情展示为空
|
||||
if (!row.category) row.category = "软件开发";
|
||||
if (!row.unit) row.unit = "项";
|
||||
@@ -333,6 +373,21 @@ async function fetchCategories() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载计量单位下拉:基本设置「单位设置」中启用中的单位 + 内置默认单位(去重) */
|
||||
async function fetchUnits() {
|
||||
let names = [];
|
||||
try {
|
||||
const res = await getUnitList({ page: 1, pageSize: 200, status: 1 });
|
||||
names = (res?.data?.list || []).map((i) => i.name).filter(Boolean);
|
||||
} catch (e) {
|
||||
names = [];
|
||||
}
|
||||
PRODUCT_UNIT_OPTIONS.forEach((i) => {
|
||||
if (!names.includes(i.value)) names.push(i.value);
|
||||
});
|
||||
unitOptions.value = names;
|
||||
}
|
||||
|
||||
/** 切换分类:清空已选产品及带出的字段,强制「分类优先」,并重载该分类下的产品选项 */
|
||||
function onCategoryChange(row) {
|
||||
row.name = "";
|
||||
@@ -393,6 +448,8 @@ function onProductPick(row, val) {
|
||||
if (hit.line_type === "dev") {
|
||||
row.line_type = "dev";
|
||||
row.dev_unit_price = Number(hit.dev_unit_price) || 0;
|
||||
// 研发方式随产品档案带出(自行研发 / 外协研发),可在本行调整
|
||||
row.dev_mode = hit.dev_mode === "outsource" ? "outsource" : "self";
|
||||
row.dev_tree = Array.isArray(hit.dev_tree)
|
||||
? JSON.parse(JSON.stringify(hit.dev_tree))
|
||||
: safeParseTree(hit.dev_tree);
|
||||
@@ -442,6 +499,7 @@ function safeParseTree(v) {
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories();
|
||||
fetchUnits();
|
||||
// 为已加载的合同产品预置下拉选项,保证名称回显
|
||||
props.products.forEach((r) => {
|
||||
// 兼容历史数据:开发行补全「开发总成本 / 成本单价 / 销售单价」,避免显示为 0
|
||||
@@ -454,6 +512,8 @@ onMounted(() => {
|
||||
if (!Number(r.price)) r.price = total;
|
||||
if (!r.category) r.category = "软件开发";
|
||||
if (!r.unit) r.unit = "项";
|
||||
// 历史数据补默认研发方式(自行研发)
|
||||
if (!r.dev_mode) r.dev_mode = "self";
|
||||
}
|
||||
if (r && r.name && !productOptions.value.some((o) => o.product_name === r.name)) {
|
||||
productOptions.value.push({
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<el-alert
|
||||
:title="`创建成功后将自动关联到合同参与方`"
|
||||
:title="`创建成功后将自动关联到${associateTip}`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
@@ -32,7 +32,7 @@
|
||||
<el-input v-model="form.contact_person" placeholder="请输入联系人(选填)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系电话" prop="contact_phone">
|
||||
<el-input v-model="form.contact_phone" placeholder="请输入联系电话(选填)" />
|
||||
<el-input v-model="form.contact_phone" placeholder="请输入联系电话" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -48,6 +48,7 @@
|
||||
import { ref, reactive, computed, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { createCrmCustomer, createCrmSupplier } from "@/api/crm";
|
||||
import { createContact } from "@/api/contact";
|
||||
import { CUSTOMER_TYPE_OPTIONS, SUPPLIER_TYPE_OPTIONS } from "../../dict";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -56,6 +57,8 @@ const props = defineProps({
|
||||
refType: { type: [Number, String], default: 1 },
|
||||
/** 从搜索词带入名称 */
|
||||
presetName: { type: String, default: "" },
|
||||
/** 创建成功后自动关联的目标(提示文案,如:合同参与方 / 项目客户) */
|
||||
associateTip: { type: String, default: "合同参与方" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:visible", "created"]);
|
||||
@@ -79,6 +82,8 @@ const form = reactive(defaultForm());
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: "名称不能为空", trigger: "blur" }],
|
||||
// 后端正式保存要求联系电话非空,这里提前提示,避免提交后才报错
|
||||
contact_phone: [{ required: true, message: "联系人电话不能为空", trigger: "blur" }],
|
||||
};
|
||||
|
||||
watch(
|
||||
@@ -99,6 +104,33 @@ function handleClosed() {
|
||||
Object.assign(form, defaultForm());
|
||||
}
|
||||
|
||||
/** 是否手机号(11 位、1 开头);非手机号按座机提交,保证联系人资料分列正确 */
|
||||
function isMobilePhone(phone) {
|
||||
return /^1\d{10}$/.test(String(phone || "").replace(/[\s-]/g, ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建企业时填写的联系人一并写入企业通讯录(仅姓名 + 电话,其余资料可后续在联系人中补全);
|
||||
* 写入失败不阻断企业创建结果。
|
||||
*/
|
||||
async function syncContactToBook(companyID) {
|
||||
const name = String(form.contact_person || "").trim();
|
||||
if (!companyID || !name) return;
|
||||
const phone = String(form.contact_phone || "").trim();
|
||||
try {
|
||||
await createContact({
|
||||
related_type: Number(props.refType) === 2 ? 2 : 1,
|
||||
related_id: companyID,
|
||||
contact_name: name,
|
||||
mobile: phone && isMobilePhone(phone) ? phone : "",
|
||||
phone: phone && !isMobilePhone(phone) ? phone : "",
|
||||
is_primary: 1, // 新企业还没有联系人,首个联系人默认设为主联系人
|
||||
});
|
||||
} catch (e) {
|
||||
// 通讯录写入失败不影响企业创建,用户可稍后在联系人中手动补录
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return;
|
||||
try {
|
||||
@@ -127,11 +159,17 @@ async function handleSubmit() {
|
||||
const res = await createCrmCustomer(payload);
|
||||
row = res?.data || { id: null, customer_name: form.name };
|
||||
}
|
||||
const createdID = Number(row?.id ?? row?.customer_id ?? row?.supplier_id ?? 0);
|
||||
// 新建企业时填写的联系人写入企业通讯录(先写入再回调,调用方刷新联系人时即可选到)
|
||||
await syncContactToBook(createdID);
|
||||
ElMessage.success(`${refTypeText.value}创建成功`);
|
||||
emit("created", {
|
||||
...row,
|
||||
id: row?.id ?? row?.customer_id ?? row?.supplier_id ?? null,
|
||||
id: createdID || null,
|
||||
label: form.name,
|
||||
// 一并回传联系人信息,供调用方自动带出对接人 / 电话
|
||||
contact_person: form.contact_person,
|
||||
contact_phone: form.contact_phone,
|
||||
});
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
|
||||
@@ -45,12 +45,30 @@
|
||||
<div class="config-block">
|
||||
<span class="config-label">我方角色</span>
|
||||
<el-radio-group v-model="form.our_role">
|
||||
<el-radio-button :value="0">无</el-radio-button>
|
||||
<el-radio-button v-for="i in roleOptionsForCount" :key="i.value" :value="i.value">
|
||||
{{ i.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 我方不参与:如上中游项目中上游与中游主体之间签订的合同,甲乙双方均非本公司 -->
|
||||
<el-alert
|
||||
v-if="Number(form.our_role) === 0"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="我方不参与本合同签约:甲乙双方均从客户库 / 供应商库中选择,本公司不作为签约主体。"
|
||||
style="margin: 0 16px 16px"
|
||||
/>
|
||||
<el-alert
|
||||
v-else-if="selectedProjectType === 3"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="该项目为上中游项目:若登记的是上游公司与中游主体之间签订的合同,我方不参与签约,请将「我方角色」切换为「无」;若是我方与中游主体之间的合同,保持当前角色即可。"
|
||||
style="margin: 0 16px 16px"
|
||||
/>
|
||||
<div class="parties-grid">
|
||||
<div v-for="role in activeRoles" :key="role.key">
|
||||
<PartySelect
|
||||
@@ -188,7 +206,7 @@
|
||||
|
||||
<!-- 步骤 2:产品清单 -->
|
||||
<div v-show="activeStep === 1">
|
||||
<ProductList :products="products" />
|
||||
<ProductList :products="products" :our-role="form.our_role" />
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
@@ -321,8 +339,12 @@ const initParties = () => {
|
||||
parties.value = PARTY_ROLES.slice(0, count).map((r) => emptyParty(r.key));
|
||||
};
|
||||
|
||||
/** 当前租户在合同中的角色:由「我方角色」决定(1甲方/2乙方/3丙方/4丁方) */
|
||||
/**
|
||||
* 当前租户在合同中的角色:由「我方角色」决定(1甲方/2乙方/3丙方/4丁方)。
|
||||
* 0=我方不参与(如上中游项目的链条合同):不锁定任何一方,全部参与方从客户/供应商库选择。
|
||||
*/
|
||||
const ownPartyRole = computed(() => {
|
||||
if (Number(form.our_role) === 0) return "";
|
||||
const idx = Math.min(Math.max(Number(form.our_role) || 2, 1), 4);
|
||||
return PARTY_ROLES[idx - 1].key;
|
||||
});
|
||||
@@ -399,6 +421,16 @@ const handleProjectChange = (id) => {
|
||||
form.project_name = hit?.project_name || "";
|
||||
};
|
||||
|
||||
/**
|
||||
* 当前选中项目的类型:1=单一 2=上下游 3=上中游。
|
||||
* 上中游项目存在「上游与中游主体之间」的合同,我方不参与签约,需提示切换角色。
|
||||
*/
|
||||
const selectedProjectType = computed(() => {
|
||||
if (!form.project_id) return 0;
|
||||
const hit = projectOptions.value.find((i) => String(i.id) === String(form.project_id));
|
||||
return Number(hit?.project_type) || 0;
|
||||
});
|
||||
|
||||
/* ------------------------------ 联动 ------------------------------ */
|
||||
|
||||
/** 我方角色切换:原我方还原为待选方(保留签约人),新我方写入当前租户,其余各方不受影响 */
|
||||
@@ -406,7 +438,9 @@ watch(
|
||||
() => form.our_role,
|
||||
(nv, ov) => {
|
||||
if (!props.visible) return;
|
||||
const roleKeyOf = (v) => PARTY_ROLES[Math.min(Math.max(Number(v) || 2, 1), 4) - 1].key;
|
||||
// 0=我方不参与:没有对应的参与方
|
||||
const roleKeyOf = (v) =>
|
||||
Number(v) === 0 ? "" : PARTY_ROLES[Math.min(Math.max(Number(v) || 2, 1), 4) - 1].key;
|
||||
const prevKey = roleKeyOf(ov);
|
||||
const nextKey = roleKeyOf(nv);
|
||||
if (prevKey === nextKey) return;
|
||||
@@ -460,8 +494,9 @@ const setPartyCount = (v) => {
|
||||
applyOwnParty();
|
||||
};
|
||||
|
||||
/** 把当前租户(组织架构中的企业单位)写入本公司方 */
|
||||
/** 把当前租户(组织架构中的企业单位)写入本公司方;我方不参与时不写入任何一方 */
|
||||
const applyOwnParty = () => {
|
||||
if (!ownPartyRole.value) return;
|
||||
const own = parties.value.find((p) => p.role === ownPartyRole.value);
|
||||
if (own) {
|
||||
own.ref_type = 0;
|
||||
@@ -482,8 +517,9 @@ watch(
|
||||
isEdit.value = true;
|
||||
internalId.value = props.editData.id;
|
||||
Object.assign(form, emptyForm(), props.editData);
|
||||
// 类型归一:后端返回数字,统一转为对应类型确保回显匹配
|
||||
form.our_role = Number(props.editData.our_role) || 2;
|
||||
// 类型归一:后端返回数字,统一转为对应类型确保回显匹配(0=我方不参与是合法值)
|
||||
form.our_role =
|
||||
props.editData.our_role == null ? 2 : Number(props.editData.our_role);
|
||||
form.contract_category =
|
||||
props.editData.contract_category == null ? "" : String(props.editData.contract_category);
|
||||
form.party_count = Number(props.editData.party_count) || 2;
|
||||
@@ -516,6 +552,7 @@ watch(
|
||||
{
|
||||
id: props.presetProject.id,
|
||||
project_name: form.project_name || `项目 #${props.presetProject.id}`,
|
||||
project_type: props.presetProject.project_type,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -583,7 +620,7 @@ const buildPayload = (step, status) => {
|
||||
sign_date: normalizeDate(form.sign_date),
|
||||
effective_date: normalizeDate(form.effective_date),
|
||||
expire_date: normalizeDate(form.expire_date),
|
||||
our_role: Number(form.our_role) || 2,
|
||||
our_role: Number(form.our_role) === 0 ? 0 : Number(form.our_role) || 2,
|
||||
party_count: Number(form.party_count) || 2,
|
||||
parties: parties.value.map((p) => ({ ...p })),
|
||||
products: products.value.map(({ __key, ...rest }) => ({
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{{ contractStatusText(detail.status) }}
|
||||
</el-tag> -->
|
||||
<el-tag v-if="contractOverdue(detail).overdue" type="danger" size="small" effect="dark">
|
||||
{{ overdueText(contractOverdue(detail).days) }}
|
||||
{{ contractOverdue(detail).text }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<!-- <div class="detail-sub">
|
||||
@@ -25,7 +25,11 @@
|
||||
<el-descriptions-item label="履约状态">
|
||||
<el-tag :type="contractStatusTag(detail.status)" size="small">{{ contractStatusText(detail.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="我方角色">{{ ourRoleText(detail.our_role) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="我方角色">
|
||||
<el-tag :type="ourRoleTag(detail.our_role)" size="small" effect="plain">
|
||||
{{ ourRoleText(detail.our_role) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="所属项目" :span="2">
|
||||
<span v-if="detail.project_id">{{ detail.project_name }}</span>
|
||||
<span v-else>-</span>
|
||||
@@ -66,7 +70,10 @@
|
||||
<el-table-column label="产品名称" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.name || (isDevRow(row) ? "软件开发" : "-") }}</span>
|
||||
<!-- <el-tag v-if="isDevRow(row)" size="small" effect="plain" type="danger" class="dev-flag">开发</el-tag> -->
|
||||
<!-- 软件开发行区分研发方式:自研 / 外协(别人帮我们研发) -->
|
||||
<el-tag v-if="isDevRow(row)" size="small" effect="plain" :type="devModeTag(row.dev_mode)" class="dev-flag">
|
||||
{{ devModeShortText(row.dev_mode) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类别" width="100" align="center">
|
||||
@@ -103,9 +110,12 @@
|
||||
<span class="label">产品总成本</span>
|
||||
<span class="value">¥{{ formatMoney(summary.total_cost) }}</span>
|
||||
</div>
|
||||
<!-- 我方为甲方(付款方)时显示「合同支出」(负数),否则显示「合同总利润」 -->
|
||||
<div class="summary-card">
|
||||
<span class="label">合同总利润</span>
|
||||
<span class="value success">¥{{ formatMoney(summary.total_profit) }}</span>
|
||||
<span class="label">{{ contractProfitLabel(detail.our_role) }}</span>
|
||||
<span class="value success">
|
||||
{{ contractProfitText(detail.our_role, summary) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="label">硬件部分金额</span>
|
||||
@@ -130,13 +140,17 @@ import {
|
||||
formatMoney,
|
||||
formatDateOnly,
|
||||
contractCategoryText,
|
||||
devModeShortText,
|
||||
devModeTag,
|
||||
ourRoleText,
|
||||
ourRoleTag,
|
||||
contractStatusText,
|
||||
contractStatusTag,
|
||||
contractOverdue,
|
||||
overdueText,
|
||||
productCategoryText,
|
||||
productCategoryTag,
|
||||
contractProfitLabel,
|
||||
contractProfitText,
|
||||
} from "../../dict";
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -11,37 +11,68 @@
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border size="small">
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<!-- 树形:计划为总条目(父行),其明细节点(进度 / 分期)以子条目挂在下方 -->
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="treeRows"
|
||||
border
|
||||
size="small"
|
||||
row-key="id"
|
||||
:tree-props="{ children: '__itemRows' }"
|
||||
default-expand-all
|
||||
>
|
||||
<el-table-column label="名称" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="paybackStatusTag(row.status)" size="small">{{ paybackStatusText(row.status) }}</el-tag>
|
||||
<span v-if="!row.__isItem" class="plan-name">{{ paybackCycleText(row.plan_type) }}回款计划</span>
|
||||
<span v-else class="item-name">{{ row.name || `节点${row.seq}` }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回款周期" width="90" align="center">
|
||||
<el-table-column label="状态" width="85" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ paybackCycleText(row.plan_type) }}</el-tag>
|
||||
<el-tag v-if="!row.__isItem" :type="paybackStatusTag(row.status)" size="small">
|
||||
{{ paybackStatusText(row.status) }}
|
||||
</el-tag>
|
||||
<el-tag v-else :type="paybackItemStatusTag(row)" size="small">
|
||||
{{ paybackItemStatusText(row) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回款进度" min-width="140" align="center">
|
||||
<el-table-column label="周期 / 进度" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="paybackPercent(row)" :stroke-width="10" />
|
||||
<el-tag v-if="!row.__isItem" size="small" effect="plain">{{ paybackCycleText(row.plan_type) }}</el-tag>
|
||||
<span v-else class="item-percent">{{ Number(row.percent) > 0 ? `${Number(row.percent)}%` : "-" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="计划回款总额" width="130" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.total_amount) }}</template>
|
||||
<el-table-column label="回款进度" min-width="125" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-progress v-if="!row.__isItem" :percentage="paybackPercent(row)" :stroke-width="10" />
|
||||
<el-progress
|
||||
v-else
|
||||
:percentage="paybackItemPercent(row)"
|
||||
:stroke-width="8"
|
||||
:status="itemStatusOf(row) === 2 ? 'success' : undefined"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已回款" width="120" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.received_amount) }}</template>
|
||||
<el-table-column label="计划回款总额" width="115" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.__isItem ? row.amount : row.total_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回款方式" width="100" align="center">
|
||||
<template #default="{ row }">{{ paybackMethodText(row.pay_method) }}</template>
|
||||
<el-table-column label="已回款" width="105" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="!row.__isItem">{{ formatMoney(row.received_amount) }}</span>
|
||||
<span v-else class="amount">{{ formatMoney(itemReceivedOf(row)) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="负责人" width="90">
|
||||
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
|
||||
<el-table-column label="计划回款日期" width="105" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.__isItem">{{ row.plan_date || "-" }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||
<!-- 仅顶级(计划)有详情按钮;回款登记统一走「回款进度」区块的新建回款 -->
|
||||
<el-button v-if="!row.__isItem" link type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
@@ -59,7 +90,7 @@
|
||||
</div>
|
||||
<el-tooltip :disabled="list.length > 0" content="回款进度以回款计划为先导条件,请先新建回款计划" placement="top">
|
||||
<span class="btn-wrap">
|
||||
<el-button type="primary" size="small" :icon="Plus" :disabled="!list.length" @click="openReceive">
|
||||
<el-button type="primary" size="small" :icon="Plus" :disabled="!list.length" @click="openReceive()">
|
||||
新建回款
|
||||
</el-button>
|
||||
</span>
|
||||
@@ -71,7 +102,10 @@
|
||||
<template #default="{ row }">{{ row.received_date || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="对应回款计划" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ planText(planOf(row.payback_id)) }}</template>
|
||||
<template #default="{ row }">
|
||||
{{ planText(planOf(row.payback_id)) }}
|
||||
<span v-if="row.item_name" class="item-tag">· {{ row.item_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="本次回款金额" width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
@@ -149,6 +183,22 @@
|
||||
<el-option v-for="p in list" :key="p.id" :label="planText(p)" :value="p.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="进度节点">
|
||||
<!-- 按现有进度选择性付款:不指定时按整单登记 -->
|
||||
<el-select
|
||||
v-model="receiveForm.item_seq"
|
||||
clearable
|
||||
placeholder="不指定(按整单登记)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="it in planItems(receiveForm.payback_id)"
|
||||
:key="it.seq"
|
||||
:label="`${it.name || '节点' + it.seq}(¥${formatMoney(it.amount)})`"
|
||||
:value="it.seq"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="本次回款" required>
|
||||
<el-input-number
|
||||
v-model="receiveForm.amount"
|
||||
@@ -296,6 +346,68 @@ function planText(p) {
|
||||
)}`;
|
||||
}
|
||||
|
||||
/** 金额保留两位小数 */
|
||||
const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
|
||||
|
||||
/* ---------------------- 树形结构:计划(总条目) → 明细节点(子条目) ---------------------- */
|
||||
|
||||
/**
|
||||
* 树形行数据:计划为父行,其 items 明细(进度节点 / 分期)为子行;
|
||||
* 子行 row-key 全局唯一(`{计划ID}-item-{序号}`);父行的回款记录(children)仅用于节点已回款汇总,
|
||||
* 不参与树形展示(tree-props 显式指向 __itemRows)。
|
||||
*/
|
||||
const treeRows = computed(() =>
|
||||
list.value.map((p) => ({
|
||||
...p,
|
||||
__isItem: false,
|
||||
__itemRows: (Array.isArray(p.items) ? p.items : []).map((it) => ({
|
||||
id: `${p.id}-item-${it.seq}`,
|
||||
__isItem: true,
|
||||
__plan: p,
|
||||
...it,
|
||||
})),
|
||||
}))
|
||||
);
|
||||
|
||||
/** 指定计划的明细节点列表(供新建回款选择进度节点) */
|
||||
function planItems(paybackId) {
|
||||
const p = list.value.find((i) => Number(i.id) === Number(paybackId));
|
||||
return Array.isArray(p?.items) ? p.items : [];
|
||||
}
|
||||
|
||||
/** 节点已回款:按「标记到该节点的回款记录」汇总,兼容历史上按明细直接登记(received_amount)的值 */
|
||||
function itemReceivedOf(row) {
|
||||
const plan = row.__plan;
|
||||
const fromRecords = (Array.isArray(plan?.children) ? plan.children : [])
|
||||
.filter((r) => Number(r.item_seq) === Number(row.seq))
|
||||
.reduce((s, r) => s + (Number(r.amount) || 0), 0);
|
||||
const legacy = Number(row.received_amount) || 0;
|
||||
return Math.max(round2(fromRecords), legacy);
|
||||
}
|
||||
|
||||
/** 节点回款进度百分比 */
|
||||
function paybackItemPercent(row) {
|
||||
const total = Number(row.amount) || 0;
|
||||
if (!total) return 0;
|
||||
return Math.min(100, Math.round((itemReceivedOf(row) / total) * 100));
|
||||
}
|
||||
|
||||
/** 节点状态:0未回款 1部分回款 2已收齐 */
|
||||
function itemStatusOf(row) {
|
||||
const total = Number(row.amount) || 0;
|
||||
const received = itemReceivedOf(row);
|
||||
if (total > 0 && received >= total) return 2;
|
||||
return received > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
function paybackItemStatusText(row) {
|
||||
return { 0: "未回款", 1: "部分回款", 2: "已收齐" }[itemStatusOf(row)] || "-";
|
||||
}
|
||||
|
||||
function paybackItemStatusTag(row) {
|
||||
return { 0: "info", 1: "warning", 2: "success" }[itemStatusOf(row)] || "info";
|
||||
}
|
||||
|
||||
/** 回款计划是否已全部收齐(总进度达到 100%),收齐后禁止再新建回款计划 */
|
||||
const paidOff = computed(() => {
|
||||
const planned = Number(progressSummary.value.total_planned) || 0;
|
||||
@@ -386,6 +498,7 @@ const receiveVisible = ref(false);
|
||||
const receiveSaving = ref(false);
|
||||
const receiveForm = reactive({
|
||||
payback_id: null,
|
||||
item_seq: 0, // 进度节点序号(1-based),0=不指定(按整单登记)
|
||||
amount: 0,
|
||||
received_date: "",
|
||||
pay_method: 1,
|
||||
@@ -394,7 +507,10 @@ const receiveForm = reactive({
|
||||
remark: "",
|
||||
});
|
||||
|
||||
/** 新建回款:以回款计划为先导条件(无计划时不允许登记) */
|
||||
/**
|
||||
* 新建回款:以回款计划为先导条件(无计划时不允许登记)。
|
||||
* 计划与进度节点(按进度选择性付款)都在弹窗内选择。
|
||||
*/
|
||||
function openReceive() {
|
||||
if (!list.value.length) {
|
||||
ElMessage.warning("请先新建回款计划,回款进度以计划为先导条件");
|
||||
@@ -403,6 +519,7 @@ function openReceive() {
|
||||
const first = list.value[0];
|
||||
Object.assign(receiveForm, {
|
||||
payback_id: first.id,
|
||||
item_seq: 0,
|
||||
amount: 0,
|
||||
received_date: todayStr(),
|
||||
pay_method: first.pay_method || 1,
|
||||
@@ -413,10 +530,11 @@ function openReceive() {
|
||||
receiveVisible.value = true;
|
||||
}
|
||||
|
||||
/** 切换回款计划:同步该计划的回款方式作为默认值 */
|
||||
/** 切换回款计划:同步该计划的回款方式作为默认值,并清空进度节点选择 */
|
||||
function onPlanChange(pid) {
|
||||
const p = planOf(pid);
|
||||
if (p) receiveForm.pay_method = p.pay_method || 1;
|
||||
receiveForm.item_seq = 0;
|
||||
}
|
||||
|
||||
const uploading = ref(false);
|
||||
@@ -515,6 +633,7 @@ async function submitReceive() {
|
||||
try {
|
||||
await createPaybackRecord({
|
||||
payback_id: receiveForm.payback_id,
|
||||
item_seq: Number(receiveForm.item_seq) || 0,
|
||||
amount: Number(receiveForm.amount) || 0,
|
||||
received_date: receiveForm.received_date || "",
|
||||
pay_method: receiveForm.pay_method,
|
||||
@@ -602,6 +721,29 @@ defineExpose({ reload });
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
/* 树形父行:计划名称 */
|
||||
.plan-name {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
/* 树形子条目:进度节点名称 + 百分比 */
|
||||
.item-name {
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.item-percent {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.item-tag {
|
||||
margin-left: 2px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.receipt-block {
|
||||
width: 100%;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<!-- 统计周期 -->
|
||||
<div class="stat-period-bar">
|
||||
<el-radio-group v-model="statsPeriod" @change="onPeriodChange">
|
||||
<el-radio-button label="all">全部</el-radio-button>
|
||||
<el-radio-button label="month">本月</el-radio-button>
|
||||
<el-radio-button label="quarter">本季</el-radio-button>
|
||||
<el-radio-button label="year">本年</el-radio-button>
|
||||
@@ -30,10 +31,12 @@
|
||||
:clearable="false"
|
||||
@change="onRangeChange"
|
||||
/>
|
||||
<!-- 当前统计范围:切换周期后卡片按该区间重新统计(区间相同则数字自然相同) -->
|
||||
<span class="stat-scope">统计范围:{{ statsScopeText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stat-cards">
|
||||
<div class="stat-cards" v-loading="statsLoading">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">合同总数</span>
|
||||
<span class="stat-value">{{ stats.total ?? 0 }}</span>
|
||||
@@ -47,7 +50,12 @@
|
||||
<span class="stat-value">{{ formatMoney(stats.total_cost) }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">合同总利润</span>
|
||||
<el-tooltip
|
||||
content="仅统计非甲方(收款方)合同的利润;我方为甲方的合同属于支出,不计入利润"
|
||||
placement="top"
|
||||
>
|
||||
<span class="stat-label">合同总利润</span>
|
||||
</el-tooltip>
|
||||
<span class="stat-value" :style="profitStyle(stats.total_profit)">{{ profitText(stats.total_profit) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,8 +73,8 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="我方角色">
|
||||
<el-select v-model="filters.our_role" clearable placeholder="全部" style="width: 110px">
|
||||
<el-option v-for="i in OUR_ROLE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
<el-select v-model="filters.our_role" clearable placeholder="全部" style="width: 130px">
|
||||
<el-option v-for="i in ourRoleFilterOptions" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="合同分类">
|
||||
@@ -101,8 +109,13 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="contract_no" label="合同编号" width="150" show-overflow-tooltip />
|
||||
<el-table-column label="合同名称" min-width="190" show-overflow-tooltip fixed>
|
||||
<el-table-column label="我方角色" width="90" align="center" fixed>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="ourRoleTag(row.our_role)" size="small" effect="plain">{{ ourRoleText(row.our_role) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- <el-table-column prop="contract_no" label="合同编号" width="150" show-overflow-tooltip /> -->
|
||||
<el-table-column label="合同名称" min-width="190" show-overflow-tooltip >
|
||||
<template #default="{ row }">
|
||||
<span class="name-link" @click="openRow(row)">
|
||||
{{ row.contract_name }}
|
||||
@@ -110,11 +123,6 @@
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="我方角色" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="primary" size="small" effect="plain">{{ ourRoleText(row.our_role) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" width="100" align="center">
|
||||
<template #default="{ row }">{{ contractCategoryText(row.contract_category) }}</template>
|
||||
</el-table-column>
|
||||
@@ -128,20 +136,28 @@
|
||||
{{ formatMoney(row.summary?.total_amount ?? row.total_amount) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="合同总利润" width="130" align="right">
|
||||
<el-table-column label="合同利润/支出" width="140" align="right">
|
||||
<template #header>
|
||||
<el-tooltip
|
||||
content="我方为甲方(付款方):按负数(绿色)显示合同支出 = 合同总金额;其余角色:显示合同利润 = 总金额 − 产品总成本"
|
||||
placement="top"
|
||||
>
|
||||
<span>合同利润/支出</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<span :style="profitStyle(row.summary?.total_profit ?? row.total_profit)">
|
||||
{{ profitText(row.summary?.total_profit ?? row.total_profit) }}
|
||||
<span :style="profitStyle(contractProfitOrExpense(row))">
|
||||
{{ profitText(contractProfitOrExpense(row)) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="签订日期" width="110" align="center">
|
||||
<template #default="{ row }">{{ formatDateOnly(row.sign_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="逾期" width="100" align="center">
|
||||
<el-table-column label="逾期" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="contractOverdue(row).overdue" type="danger" size="small" effect="dark">
|
||||
{{ overdueText(contractOverdue(row).days) }}
|
||||
{{ contractOverdue(row).text }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
@@ -184,7 +200,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, nextTick, watch } from "vue";
|
||||
import { ref, reactive, computed, onMounted, nextTick, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
|
||||
@@ -197,16 +213,17 @@ import ContractCreate from "./components/create.vue";
|
||||
import ContractDetail from "./components/detail.vue";
|
||||
import {
|
||||
CONTRACT_CATEGORY_OPTIONS,
|
||||
OUR_ROLE_OPTIONS,
|
||||
ourRoleFilterOptions,
|
||||
CONTRACT_STATUS_OPTIONS,
|
||||
ourRoleText,
|
||||
ourRoleTag,
|
||||
contractCategoryText,
|
||||
contractStatusText,
|
||||
contractStatusTag,
|
||||
contractOverdue,
|
||||
overdueText,
|
||||
formatDateOnly,
|
||||
formatMoney,
|
||||
isPayerContract,
|
||||
} from "../dict";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -228,15 +245,46 @@ const filters = reactive({
|
||||
});
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
|
||||
/** 统计周期:month/quarter/year/custom(自定义选区间),默认本年 */
|
||||
const statsPeriod = ref("year");
|
||||
/** 统计周期:all(全部)/ month / quarter / year / custom(自定义选区间),默认全部 */
|
||||
const statsPeriod = ref("all");
|
||||
const statsRange = ref([]);
|
||||
|
||||
/** 列表页统计:后端按周期汇总(排除已作废),周期参数透传给 Stats */
|
||||
const stats = ref({ total: 0, total_amount: 0, total_cost: 0, total_profit: 0 });
|
||||
/** 卡片加载态:切换周期时给出可见的刷新反馈 */
|
||||
const statsLoading = ref(false);
|
||||
|
||||
/** 周期参数:统计与列表共用,保证两者口径一致 */
|
||||
/** 当前统计范围文案:与后端口径一致(合同有效期与所选周期重叠),便于对照卡片数字 */
|
||||
const statsScopeText = computed(() => {
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
const fmt = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = now.getMonth();
|
||||
switch (statsPeriod.value) {
|
||||
case "month":
|
||||
return `${fmt(new Date(y, m, 1))} ~ ${fmt(new Date(y, m + 1, 0))}`;
|
||||
case "quarter": {
|
||||
const q = Math.floor(m / 3);
|
||||
return `${fmt(new Date(y, q * 3, 1))} ~ ${fmt(new Date(y, q * 3 + 3, 0))}`;
|
||||
}
|
||||
case "year":
|
||||
return `${y}-01-01 ~ ${y}-12-31`;
|
||||
case "custom":
|
||||
return statsRange.value?.length === 2
|
||||
? `${statsRange.value[0]} ~ ${statsRange.value[1]}`
|
||||
: "自定义区间";
|
||||
default:
|
||||
return "全部时间(不限有效期)";
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 周期参数:统计与列表共用,保证两者口径一致。
|
||||
* 「全部」不传周期参数——列表不按有效期过滤,避免有效期不在所选周期内的合同(如历史合同)被隐藏。
|
||||
*/
|
||||
function periodParams() {
|
||||
if (statsPeriod.value === "all") return {};
|
||||
const params = { period: statsPeriod.value };
|
||||
if (statsPeriod.value === "custom" && statsRange.value && statsRange.value.length === 2) {
|
||||
params.start_date = statsRange.value[0];
|
||||
@@ -246,11 +294,14 @@ function periodParams() {
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
statsLoading.value = true;
|
||||
try {
|
||||
const res = await getContractStats(periodParams());
|
||||
stats.value = res?.data || stats.value;
|
||||
} catch {
|
||||
/* 统计失败不影响列表 */
|
||||
} finally {
|
||||
statsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +424,18 @@ function profitStyle(val) {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表展示值:我方为甲方(付款方)的合同是「支出」,取负值 → 显示为 -¥X(绿色);
|
||||
* 其余角色的合同是「利润」(总金额 − 产品总成本)。表头已注明口径,单元格内不写"支出"文字。
|
||||
*/
|
||||
function contractProfitOrExpense(row) {
|
||||
if (!isPayerContract(row.our_role)) {
|
||||
return Number(row.summary?.total_profit ?? row.total_profit) || 0;
|
||||
}
|
||||
const paid = Number(row.summary?.total_amount ?? row.total_amount) || 0;
|
||||
return -paid;
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
currentRow.value = null;
|
||||
initStep.value = 1;
|
||||
@@ -434,6 +497,13 @@ async function handleDelete(row) {
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
/* 当前统计范围:右对齐说明,切换周期后随卡片一起刷新 */
|
||||
.stat-scope {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
|
||||
Reference in new issue
Block a user