首页修改卡片展示

main
kangwenjing 2026-04-15 18:11:16 +08:00
parent db41fe2a91
commit 14087b3a5a
12 changed files with 185 additions and 56 deletions

View File

@ -32,7 +32,11 @@ public class OpportunitySchemaInitializer implements ApplicationRunner {
try (Statement statement = connection.createStatement()) { try (Statement statement = connection.createStatement()) {
statement.execute("alter table crm_opportunity add column if not exists pre_sales_id bigint"); statement.execute("alter table crm_opportunity add column if not exists pre_sales_id bigint");
statement.execute("alter table crm_opportunity add column if not exists pre_sales_name varchar(100)"); statement.execute("alter table crm_opportunity add column if not exists pre_sales_name varchar(100)");
statement.execute("alter table crm_opportunity add column if not exists archived_at timestamptz");
statement.execute("create index if not exists idx_crm_opportunity_archived_at on crm_opportunity(archived_at)");
statement.execute("comment on column crm_opportunity.archived_at is '归档时间'");
} }
ensureArchivedAtStorage(connection);
ensureConfidenceGradeStorage(connection); ensureConfidenceGradeStorage(connection);
migrateLegacyOmsProjectCode(connection); migrateLegacyOmsProjectCode(connection);
log.info("Ensured compatibility columns exist for crm_opportunity"); log.info("Ensured compatibility columns exist for crm_opportunity");
@ -41,6 +45,22 @@ public class OpportunitySchemaInitializer implements ApplicationRunner {
} }
} }
private void ensureArchivedAtStorage(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement()) {
statement.execute("""
update crm_opportunity
set archived_at = case
when coalesce(archived, false) then coalesce(archived_at, updated_at, created_at, now())
else null
end
where archived_at is distinct from case
when coalesce(archived, false) then coalesce(archived_at, updated_at, created_at, now())
else null
end
""");
}
}
private void ensureConfidenceGradeStorage(Connection connection) throws SQLException { private void ensureConfidenceGradeStorage(Connection connection) throws SQLException {
String dataType = findColumnDataType(connection, "crm_opportunity", "confidence_pct"); String dataType = findColumnDataType(connection, "crm_opportunity", "confidence_pct");
if (dataType == null) { if (dataType == null) {

View File

@ -1,9 +1,11 @@
package com.unis.crm.dto.dashboard; package com.unis.crm.dto.dashboard;
import java.math.BigDecimal;
public class DashboardStatDTO { public class DashboardStatDTO {
private String name; private String name;
private Long value; private BigDecimal value;
private String metricKey; private String metricKey;
public String getName() { public String getName() {
@ -14,11 +16,11 @@ public class DashboardStatDTO {
this.name = name; this.name = name;
} }
public Long getValue() { public BigDecimal getValue() {
return value; return value;
} }
public void setValue(Long value) { public void setValue(BigDecimal value) {
this.value = value; this.value = value;
} }

View File

@ -55,6 +55,8 @@ public class UpdateOpportunityIntegrationRequest {
private Boolean archived; private Boolean archived;
private OffsetDateTime archivedAt;
private Boolean pushedToOms; private Boolean pushedToOms;
private OffsetDateTime omsPushTime; private OffsetDateTime omsPushTime;
@ -89,6 +91,7 @@ public class UpdateOpportunityIntegrationRequest {
|| preSalesName != null || preSalesName != null
|| competitorName != null || competitorName != null
|| archived != null || archived != null
|| archivedAt != null
|| pushedToOms != null || pushedToOms != null
|| omsPushTime != null || omsPushTime != null
|| status != null || status != null
@ -231,6 +234,14 @@ public class UpdateOpportunityIntegrationRequest {
this.archived = archived; this.archived = archived;
} }
public OffsetDateTime getArchivedAt() {
return archivedAt;
}
public void setArchivedAt(OffsetDateTime archivedAt) {
this.archivedAt = archivedAt;
}
public Boolean getPushedToOms() { public Boolean getPushedToOms() {
return pushedToOms; return pushedToOms;
} }

View File

@ -4,6 +4,7 @@ import com.unis.crm.dto.dashboard.DashboardActivityDTO;
import com.unis.crm.dto.dashboard.DashboardStatDTO; import com.unis.crm.dto.dashboard.DashboardStatDTO;
import com.unis.crm.dto.dashboard.DashboardTodoDTO; import com.unis.crm.dto.dashboard.DashboardTodoDTO;
import com.unis.crm.dto.dashboard.UserWelcomeDTO; import com.unis.crm.dto.dashboard.UserWelcomeDTO;
import com.unisbase.annotation.DataScope;
import java.util.List; import java.util.List;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
@ -15,7 +16,11 @@ public interface DashboardMapper {
UserWelcomeDTO selectUserWelcome(@Param("userId") Long userId); UserWelcomeDTO selectUserWelcome(@Param("userId") Long userId);
List<DashboardStatDTO> selectDashboardStats(@Param("userId") Long userId); @DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
List<DashboardStatDTO> selectOpportunityStats(@Param("userId") Long userId);
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
DashboardStatDTO selectMonthlyChannelStat(@Param("userId") Long userId);
List<DashboardTodoDTO> selectTodos(@Param("userId") Long userId); List<DashboardTodoDTO> selectTodos(@Param("userId") Long userId);

View File

@ -12,6 +12,7 @@ import java.time.LocalDate;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -35,7 +36,11 @@ public class DashboardServiceImpl implements DashboardService {
throw new BusinessException("未找到当前用户对应数据"); throw new BusinessException("未找到当前用户对应数据");
} }
List<DashboardStatDTO> stats = dashboardMapper.selectDashboardStats(userId); List<DashboardStatDTO> stats = new ArrayList<>(dashboardMapper.selectOpportunityStats(userId));
DashboardStatDTO monthlyChannelStat = dashboardMapper.selectMonthlyChannelStat(userId);
if (monthlyChannelStat != null) {
stats.add(monthlyChannelStat);
}
List<DashboardTodoDTO> todos = dashboardMapper.selectTodos(userId); List<DashboardTodoDTO> todos = dashboardMapper.selectTodos(userId);
List<DashboardActivityDTO> activities = dashboardMapper.selectLatestActivities(userId); List<DashboardActivityDTO> activities = dashboardMapper.selectLatestActivities(userId);
enrichActivityTimeText(activities); enrichActivityTimeText(activities);

View File

@ -629,11 +629,21 @@ public class OpportunityServiceImpl implements OpportunityService {
if (request.getDescription() != null) { if (request.getDescription() != null) {
request.setDescription(trimToEmpty(request.getDescription())); request.setDescription(trimToEmpty(request.getDescription()));
} }
normalizeArchivedTime(request);
request.setStatus(resolveIntegrationStatus(request.getStatus(), request.getStage())); request.setStatus(resolveIntegrationStatus(request.getStatus(), request.getStage()));
autoFillOmsPushTime(request); autoFillOmsPushTime(request);
validateIntegrationOperatorRelations(request, target); validateIntegrationOperatorRelations(request, target);
} }
private void normalizeArchivedTime(UpdateOpportunityIntegrationRequest request) {
if (request.getArchivedAt() != null && request.getArchived() == null) {
request.setArchived(Boolean.TRUE);
}
if (Boolean.FALSE.equals(request.getArchived())) {
request.setArchivedAt(null);
}
}
private void autoFillOmsPushTime(UpdateOpportunityIntegrationRequest request) { private void autoFillOmsPushTime(UpdateOpportunityIntegrationRequest request) {
if (Boolean.TRUE.equals(request.getPushedToOms()) && request.getOmsPushTime() == null) { if (Boolean.TRUE.equals(request.getPushedToOms()) && request.getOmsPushTime() == null) {
request.setOmsPushTime(java.time.OffsetDateTime.now()); request.setOmsPushTime(java.time.OffsetDateTime.now());

View File

@ -43,40 +43,37 @@
limit 1 limit 1
</select> </select>
<select id="selectDashboardStats" resultType="com.unis.crm.dto.dashboard.DashboardStatDTO"> <select id="selectOpportunityStats" resultType="com.unis.crm.dto.dashboard.DashboardStatDTO">
select '本月新增商机' as name, select '本月新增商机' as name,
count(1)::bigint as value, coalesce(round(sum(o.amount) / 10000.0, 2), 0)::numeric as value,
'monthlyOpportunities' as metricKey 'monthlyOpportunities' as metricKey
from crm_opportunity from crm_opportunity o
where owner_user_id = #{userId} where date_trunc('month', o.created_at) = date_trunc('month', now())
and date_trunc('month', created_at) = date_trunc('month', now())
union all union all
select '已推送OMS项目' as name, select '已推送OMS项目' as name,
count(1)::bigint as value, coalesce(round(sum(o.amount) / 10000.0, 2), 0)::numeric as value,
'pushedOmsProjects' as metricKey 'pushedOmsProjects' as metricKey
from crm_opportunity from crm_opportunity o
where owner_user_id = #{userId} where coalesce(o.pushed_to_oms, false) = true
and coalesce(pushed_to_oms, false) = true
union all union all
select '本月已签单商机金额' as name,
coalesce(round(sum(o.amount) / 10000.0, 2), 0)::numeric as value,
'monthlyWonOpportunities' as metricKey
from crm_opportunity o
where o.archived_at is not null
and date_trunc('month', o.archived_at) = date_trunc('month', now())
</select>
<select id="selectMonthlyChannelStat" resultType="com.unis.crm.dto.dashboard.DashboardStatDTO">
select '本月新增渠道' as name, select '本月新增渠道' as name,
count(1)::bigint as value, count(1)::numeric as value,
'monthlyChannels' as metricKey 'monthlyChannels' as metricKey
from crm_channel_expansion from crm_channel_expansion c
where owner_user_id = #{userId} where date_trunc('month', c.created_at) = date_trunc('month', now())
and date_trunc('month', created_at) = date_trunc('month', now())
union all
select '本月打卡次数' as name,
count(1)::bigint as value,
'monthlyCheckins' as metricKey
from work_checkin
where user_id = #{userId}
and date_trunc('month', checkin_date::timestamp) = date_trunc('month', now())
</select> </select>
<select id="selectTodos" resultType="com.unis.crm.dto.dashboard.DashboardTodoDTO"> <select id="selectTodos" resultType="com.unis.crm.dto.dashboard.DashboardTodoDTO">

View File

@ -458,6 +458,15 @@
<if test="request.archived != null"> <if test="request.archived != null">
archived = #{request.archived}, archived = #{request.archived},
</if> </if>
<if test="request.archivedAt != null">
archived_at = #{request.archivedAt},
</if>
<if test="request.archivedAt == null and request.archived != null">
archived_at = case
when #{request.archived} then coalesce(o.archived_at, now())
else null
end,
</if>
<if test="request.pushedToOms != null"> <if test="request.pushedToOms != null">
pushed_to_oms = #{request.pushedToOms}, pushed_to_oms = #{request.pushedToOms},
</if> </if>

View File

@ -10,19 +10,47 @@ const DASHBOARD_PREVIEW_COUNT = 5;
const DASHBOARD_HISTORY_PREVIEW_COUNT = 3; const DASHBOARD_HISTORY_PREVIEW_COUNT = 3;
const baseStats = [ const baseStats = [
{ name: "本月新增商机", metricKey: "monthlyOpportunities", icon: TrendingUp, color: "text-emerald-600 dark:text-emerald-400", bg: "bg-emerald-100 dark:bg-emerald-500/20" }, { name: "本月新增商机", metricKey: "monthlyOpportunities", icon: TrendingUp, color: "text-emerald-600 dark:text-emerald-400", bg: "bg-emerald-100 dark:bg-emerald-500/20", mobileVariant: "hero" },
{ name: "已推送OMS项目", metricKey: "pushedOmsProjects", icon: Users, color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-100 dark:bg-blue-500/20" }, { name: "本月已签单商机金额", metricKey: "monthlyWonOpportunities", icon: BarChart3, color: "text-amber-600 dark:text-amber-400", bg: "bg-amber-100 dark:bg-amber-500/20", mobileVariant: "hero" },
{ name: "本月新增渠道", metricKey: "monthlyChannels", icon: Building2, color: "text-violet-600 dark:text-violet-400", bg: "bg-violet-100 dark:bg-violet-500/20" }, { name: "已推送OMS项目", metricKey: "pushedOmsProjects", icon: Users, color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-100 dark:bg-blue-500/20", mobileVariant: "compact" },
{ name: "本月打卡次数", metricKey: "monthlyCheckins", icon: BarChart3, color: "text-amber-600 dark:text-amber-400", bg: "bg-amber-100 dark:bg-amber-500/20" }, { name: "本月新增渠道", metricKey: "monthlyChannels", icon: Building2, color: "text-violet-600 dark:text-violet-400", bg: "bg-violet-100 dark:bg-violet-500/20", mobileVariant: "compact" },
] as const; ] as const;
const statRoutes: Record<(typeof baseStats)[number]["metricKey"], { pathname: string; state?: { tab: "sales" | "channel" } }> = { const statRoutes: Record<(typeof baseStats)[number]["metricKey"], { pathname: string; state?: { tab: "sales" | "channel" } }> = {
monthlyOpportunities: { pathname: "/opportunities" }, monthlyOpportunities: { pathname: "/opportunities" },
pushedOmsProjects: { pathname: "/opportunities" }, pushedOmsProjects: { pathname: "/opportunities" },
monthlyChannels: { pathname: "/expansion", state: { tab: "channel" } }, monthlyChannels: { pathname: "/expansion", state: { tab: "channel" } },
monthlyCheckins: { pathname: "/work/checkin" }, monthlyWonOpportunities: { pathname: "/opportunities" },
}; };
const amountMetricKeys = new Set<(typeof baseStats)[number]["metricKey"]>([
"monthlyOpportunities",
"pushedOmsProjects",
"monthlyWonOpportunities",
]);
function formatStatDisplay(metricKey: (typeof baseStats)[number]["metricKey"], value?: number) {
const numericValue = value ?? 0;
if (!amountMetricKeys.has(metricKey)) {
return {
value: String(numericValue),
unit: "个",
};
}
return {
value: numericValue.toFixed(2).replace(/\.?0+$/, ""),
unit: "万元",
};
}
function getMobileStatCardClass(mobileVariant: (typeof baseStats)[number]["mobileVariant"]) {
if (mobileVariant === "hero") {
return "col-span-2 min-h-[116px]";
}
return "col-span-1 min-h-[104px]";
}
export default function Dashboard() { export default function Dashboard() {
const navigate = useNavigate(); const navigate = useNavigate();
const isMobileViewport = useIsMobileViewport(); const isMobileViewport = useIsMobileViewport();
@ -68,7 +96,7 @@ export default function Dashboard() {
setHistoryExpanded(false); setHistoryExpanded(false);
}, [home.activities, home.todos]); }, [home.activities, home.todos]);
const statMap = new Map((home.stats ?? []).map((item: DashboardStat) => [item.metricKey, item.value])); const statMap = new Map<string, number | undefined>((home.stats ?? []).map((item: DashboardStat) => [item.metricKey, item.value]));
const stats = baseStats.map((stat) => ({ const stats = baseStats.map((stat) => ({
...stat, ...stat,
value: statMap.get(stat.metricKey), value: statMap.get(stat.metricKey),
@ -146,30 +174,50 @@ export default function Dashboard() {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ duration: disableMobileMotion ? 0 : 0.24, ease: "easeOut" }} transition={{ duration: disableMobileMotion ? 0 : 0.24, ease: "easeOut" }}
> >
<div className="grid grid-cols-2 gap-x-3 gap-y-4 sm:gap-4 xl:grid-cols-4"> <div className="grid grid-cols-2 gap-x-3 gap-y-4 min-[520px]:grid-cols-2 sm:gap-4 xl:grid-cols-4">
{stats.map((stat, i) => ( {stats.map((stat, i) => {
<motion.button const display = formatStatDisplay(stat.metricKey, stat.value);
key={stat.name} const isHeroCard = isMobileViewport && stat.mobileVariant === "hero";
type="button" const mobileClass = isMobileViewport ? getMobileStatCardClass(stat.mobileVariant) : "";
onClick={() => handleStatCardClick(stat.metricKey)}
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }} return (
animate={{ opacity: 1, y: 0 }} <motion.button
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.1 }} key={stat.name}
className="crm-card min-h-[88px] rounded-xl p-3 text-left transition-shadow transition-colors hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 dark:hover:bg-slate-900 sm:min-h-[104px] sm:rounded-2xl sm:p-5" type="button"
whileTap={disableMobileMotion ? undefined : { scale: 0.98 }} onClick={() => handleStatCardClick(stat.metricKey)}
aria-label={`查看${stat.name}`} initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
> animate={{ opacity: 1, y: 0 }}
<div className="flex items-center gap-2.5 sm:gap-4"> transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.1 }}
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${stat.bg} sm:h-12 sm:w-12 sm:rounded-xl`}> className={`crm-card group overflow-hidden rounded-xl border border-slate-200/80 bg-white/95 p-3 text-left shadow-[0_14px_40px_-28px_rgba(15,23,42,0.35)] transition-all duration-200 hover:-translate-y-0.5 hover:border-violet-200 hover:shadow-[0_18px_44px_-24px_rgba(109,40,217,0.22)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 dark:hover:bg-slate-900 min-[520px]:min-h-[128px] sm:rounded-2xl sm:p-5 ${mobileClass} ${!isMobileViewport ? "min-h-[104px]" : ""}`}
<stat.icon className={`h-4.5 w-4.5 ${stat.color} sm:h-6 sm:w-6`} /> whileTap={disableMobileMotion ? undefined : { scale: 0.98 }}
aria-label={`查看${stat.name}`}
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-transparent via-violet-200/70 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
<div className={`flex h-full flex-col justify-between ${isHeroCard ? "gap-4" : "gap-3"} min-[520px]:gap-4`}>
<div className="flex items-start gap-3 sm:gap-4">
<div className={`flex shrink-0 items-center justify-center rounded-xl ${stat.bg} shadow-inner shadow-white/60 ${isHeroCard ? "h-11 w-11" : "h-10 w-10"} min-[520px]:h-12 min-[520px]:w-12`}>
<stat.icon className={`${isHeroCard ? "h-5.5 w-5.5" : "h-5 w-5"} ${stat.color} min-[520px]:h-6 min-[520px]:w-6`} />
</div>
<div className="min-w-0 pt-0.5">
<p className={`overflow-hidden font-semibold leading-5 text-slate-500 dark:text-slate-400 ${isHeroCard ? "text-[13px]" : "text-xs"} min-[520px]:max-h-10 min-[520px]:text-sm`}>
{stat.name}
</p>
</div>
</div>
<div className="flex items-end justify-between gap-2 min-[520px]:gap-3">
<div className="min-w-0 flex-1 overflow-hidden">
<p className={`overflow-hidden text-ellipsis whitespace-nowrap font-bold leading-none tracking-[-0.04em] text-slate-900 dark:text-white ${isHeroCard ? "text-[30px]" : "text-[22px]"} min-[400px]:text-[26px] min-[520px]:text-[34px]`}>
{display.value}
</p>
</div>
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-[10px] font-medium tracking-wide text-slate-500 dark:bg-slate-800 dark:text-slate-300 min-[520px]:px-2.5 min-[520px]:text-xs">
{display.unit}
</span>
</div>
</div> </div>
<div className="min-w-0 flex-1"> </motion.button>
<p className="truncate text-[11px] font-medium leading-4 text-slate-500 dark:text-slate-400 sm:text-sm sm:leading-5">{stat.name}</p> );
<p className="mt-1 text-lg font-bold leading-none text-slate-900 dark:text-white sm:text-2xl">{stat.value ?? 0}</p> })}
</div>
</div>
</motion.button>
))}
</div> </div>
<div className="mt-5 grid gap-5 md:mt-6 md:grid-cols-2 md:gap-6"> <div className="mt-5 grid gap-5 md:mt-6 md:grid-cols-2 md:gap-6">

View File

@ -0,0 +1,17 @@
alter table crm_opportunity
add column if not exists archived_at timestamptz;
create index if not exists idx_crm_opportunity_archived_at
on crm_opportunity(archived_at);
comment on column crm_opportunity.archived_at is '归档时间';
update crm_opportunity
set archived_at = case
when coalesce(archived, false) then coalesce(archived_at, updated_at, created_at, now())
else null
end
where archived_at is distinct from case
when coalesce(archived, false) then coalesce(archived_at, updated_at, created_at, now())
else null
end;

View File

@ -100,6 +100,7 @@ create table if not exists crm_opportunity (
source varchar(50), source varchar(50),
competitor_name varchar(200), competitor_name varchar(200),
archived boolean not null default false, archived boolean not null default false,
archived_at timestamptz,
pushed_to_oms boolean not null default false, pushed_to_oms boolean not null default false,
oms_push_time timestamptz, oms_push_time timestamptz,
description text, description text,
@ -310,6 +311,7 @@ create index if not exists idx_crm_opportunity_sales_expansion on crm_opportunit
create index if not exists idx_crm_opportunity_stage on crm_opportunity(stage); create index if not exists idx_crm_opportunity_stage on crm_opportunity(stage);
create index if not exists idx_crm_opportunity_expected_close on crm_opportunity(expected_close_date); create index if not exists idx_crm_opportunity_expected_close on crm_opportunity(expected_close_date);
create index if not exists idx_crm_opportunity_archived on crm_opportunity(archived); create index if not exists idx_crm_opportunity_archived on crm_opportunity(archived);
create index if not exists idx_crm_opportunity_archived_at on crm_opportunity(archived_at);
create index if not exists idx_crm_opportunity_followup_opportunity_time create index if not exists idx_crm_opportunity_followup_opportunity_time
on crm_opportunity_followup(opportunity_id, followup_time desc); on crm_opportunity_followup(opportunity_id, followup_time desc);
create index if not exists idx_crm_opportunity_followup_user on crm_opportunity_followup(followup_user_id); create index if not exists idx_crm_opportunity_followup_user on crm_opportunity_followup(followup_user_id);

View File

@ -138,6 +138,7 @@ create table if not exists crm_opportunity (
source varchar(50), source varchar(50),
competitor_name varchar(200), competitor_name varchar(200),
archived boolean not null default false, archived boolean not null default false,
archived_at timestamptz,
pushed_to_oms boolean not null default false, pushed_to_oms boolean not null default false,
oms_push_time timestamptz, oms_push_time timestamptz,
description text, description text,
@ -366,6 +367,7 @@ create index if not exists idx_crm_opportunity_sales_expansion on crm_opportunit
create index if not exists idx_crm_opportunity_stage on crm_opportunity(stage); create index if not exists idx_crm_opportunity_stage on crm_opportunity(stage);
create index if not exists idx_crm_opportunity_expected_close on crm_opportunity(expected_close_date); create index if not exists idx_crm_opportunity_expected_close on crm_opportunity(expected_close_date);
create index if not exists idx_crm_opportunity_archived on crm_opportunity(archived); create index if not exists idx_crm_opportunity_archived on crm_opportunity(archived);
create index if not exists idx_crm_opportunity_archived_at on crm_opportunity(archived_at);
create index if not exists idx_crm_opportunity_followup_opportunity_time create index if not exists idx_crm_opportunity_followup_opportunity_time
on crm_opportunity_followup(opportunity_id, followup_time desc); on crm_opportunity_followup(opportunity_id, followup_time desc);
create index if not exists idx_crm_opportunity_followup_user on crm_opportunity_followup(followup_user_id); create index if not exists idx_crm_opportunity_followup_user on crm_opportunity_followup(followup_user_id);
@ -786,6 +788,7 @@ WITH column_comments(table_name, column_name, comment_text) AS (
('crm_opportunity', 'source', '商机来源'), ('crm_opportunity', 'source', '商机来源'),
('crm_opportunity', 'competitor_name', '竞品名称'), ('crm_opportunity', 'competitor_name', '竞品名称'),
('crm_opportunity', 'archived', '是否归档'), ('crm_opportunity', 'archived', '是否归档'),
('crm_opportunity', 'archived_at', '归档时间'),
('crm_opportunity', 'pushed_to_oms', '是否已推送OMS'), ('crm_opportunity', 'pushed_to_oms', '是否已推送OMS'),
('crm_opportunity', 'oms_push_time', '推送OMS时间'), ('crm_opportunity', 'oms_push_time', '推送OMS时间'),
('crm_opportunity', 'description', '商机说明/备注'), ('crm_opportunity', 'description', '商机说明/备注'),