feat(backend): 주문완료 화면의 구매 때 유입 수집과 비회원 구매 업체 귀속

아임웹 주문 API 에는 손님이 어디서 왔는지가 없어 구매는 가입 경로로만 볼 수 있었고,
비회원 구매는 회원 코드가 없어 업체 링크 실적으로 잡을 수 없었다.

- POST /api/order-inflows: 주문완료 화면이 주문번호·유입·업체 클릭 ID 를 보낸다
  (공개 · 1분 10회 · 항상 204 · 같은 주문번호는 처음 값만)
- 주문 동기화가 같은 주문번호의 구매에 유입을 채운다 (비어 있는 구매에만)
- 업체 귀속 후보 = 회원 클릭 연결 + 주문완료 클릭 ID → 비회원 구매도 마지막 클릭에 귀속
  (partner_conversions.member_code nullable, 실적 상세에 주문자 이름)
- 90일 동안 주문과 이어지지 않은 값은 동기화가 끝날 때 지운다
- 관리자 조회: 유입 기준 apply 를 신청·구매 때 유입으로 넓힌다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 13:14:33 +09:00
parent 8d4adfd7f1
commit 1f71ce048f
21 changed files with 803 additions and 75 deletions
@@ -0,0 +1,57 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddOrderInflows1791258525922 implements MigrationInterface {
name = 'AddOrderInflows1791258525922';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "order_inflows" ("order_no" character varying(30) NOT NULL, "inflow_raw" text, "inflow_source" character varying(100), "inflow_medium" character varying(100), "inflow_campaign" character varying(100), "inflow_content" character varying(100), "partner_refs" character varying(16) array NOT NULL DEFAULT '{}', "received_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_5611d45b9e9708e854d3131aeda" PRIMARY KEY ("order_no"))`,
);
await queryRunner.query(
`CREATE INDEX "idx_order_inflows_received" ON "order_inflows" ("received_at") `,
);
await queryRunner.query(`ALTER TABLE "purchases" ADD "inflow_raw" text`);
await queryRunner.query(
`ALTER TABLE "purchases" ADD "inflow_source" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "purchases" ADD "inflow_medium" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "purchases" ADD "inflow_campaign" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "purchases" ADD "inflow_content" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "partner_conversions" ALTER COLUMN "member_code" DROP NOT NULL`,
);
await queryRunner.query(
`CREATE INDEX "IDX_7fc2cb346462240f6f38ea03c1" ON "purchases" ("inflow_source") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_7fc2cb346462240f6f38ea03c1"`,
);
await queryRunner.query(
`ALTER TABLE "partner_conversions" ALTER COLUMN "member_code" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "purchases" DROP COLUMN "inflow_content"`,
);
await queryRunner.query(
`ALTER TABLE "purchases" DROP COLUMN "inflow_campaign"`,
);
await queryRunner.query(
`ALTER TABLE "purchases" DROP COLUMN "inflow_medium"`,
);
await queryRunner.query(
`ALTER TABLE "purchases" DROP COLUMN "inflow_source"`,
);
await queryRunner.query(`ALTER TABLE "purchases" DROP COLUMN "inflow_raw"`);
await queryRunner.query(`DROP INDEX "public"."idx_order_inflows_received"`);
await queryRunner.query(`DROP TABLE "order_inflows"`);
}
}
@@ -60,7 +60,7 @@ export class AdminActivitiesController {
@Get('inflow-stats')
@ApiOperation({
summary:
'유입 집계 — 신청 때 유입(무료 신청만) 또는 가입 경로(신청·구매) 기준',
'유입 집계 — 신청·구매 때 유입 또는 가입 경로 기준 (신청·구매 모두)',
})
@ApiStandardResponse(ActivityInflowStatsResponseDto, {
description: '집계 성공',
@@ -198,10 +198,10 @@ export class ActivitiesService {
[`${PUR}.member_uid`, 'member_uid'],
[`${PUR_CONVERSION}.partner_id`, 'partner_id'],
['CAST(NULL AS timestamptz)', 'kakao_clicked_at'],
['CAST(NULL AS varchar)', 'inflow_source'],
['CAST(NULL AS varchar)', 'inflow_medium'],
['CAST(NULL AS varchar)', 'inflow_campaign'],
['CAST(NULL AS varchar)', 'inflow_content'],
[`${PUR}.inflow_source`, 'inflow_source'],
[`${PUR}.inflow_medium`, 'inflow_medium'],
[`${PUR}.inflow_campaign`, 'inflow_campaign'],
[`${PUR}.inflow_content`, 'inflow_content'],
[`${PUR}.order_no`, 'order_no'],
[`${PUR}.is_valid`, 'is_valid'],
[`${PUR}.orderer_name`, 'orderer_name'],
@@ -272,14 +272,12 @@ export class ActivitiesService {
}
: null,
kakaoClickedAt: row.kakao_clicked_at?.toISOString() ?? null,
inflow: isApplication
? {
source: row.inflow_source,
medium: row.inflow_medium,
campaign: row.inflow_campaign,
content: row.inflow_content,
}
: null,
inflow: {
source: row.inflow_source,
medium: row.inflow_medium,
campaign: row.inflow_campaign,
content: row.inflow_content,
},
orderNo: row.order_no,
isCancelled: isApplication ? null : row.is_valid === false,
orderer: isApplication
@@ -388,9 +386,10 @@ export class ActivitiesService {
}
/**
* 유입 집계.
* - 신청 때 유입: 무료 신청만 센다 (구매 시점의 유입은 아임웹 주문에 남지 않는다)
* - 가입 경로: 신청·구매(환불·취소 제외) 모두 회원의 가입 경로로 센다. 비회원 구매는 값 없음
* 유입 집계 — 신청·구매(환불·취소 제외) 모두 센다.
* - 신청·구매 때 유입(apply): 신청은 신청 완료 화면, 구매는 주문완료 화면이 보낸 값.
* 주문완료 화면이 보내지 못한 구매(도입 전 주문 등)는 값 없음
* - 가입 경로(join): 회원의 가입 경로. 비회원 구매는 값 없음
*/
async inflowStats(
query: ActivityInflowStatsQueryDto,
@@ -412,13 +411,18 @@ export class ActivitiesService {
]),
);
}
const pur = isJoin ? this.purchasePart(query) : null;
const pur = this.purchasePart(query);
if (pur) {
pur.andWhere(`${PUR}.is_valid = true`);
parts.push(
selectColumns(pur, [
[`'purchase'`, 'type'],
[joinInflow(PUR_MEMBER, query.groupBy), 'key'],
[
isJoin
? joinInflow(PUR_MEMBER, query.groupBy)
: `${PUR}.inflow_${query.groupBy}`,
'key',
],
]),
);
}
@@ -466,16 +470,11 @@ export class ActivitiesService {
return filter.type === 'purchase' ? null : this.applicationQuery(filter);
}
/** 유료 구매 쪽 — 종류 필터로 빠지거나, 신청 때 유입으로 거르면(구매엔 없는 기록) null */
/** 유료 구매 쪽 — 종류 필터로 빠지면 null */
private purchasePart(
filter: ListFilter,
): SelectQueryBuilder<ObjectLiteral> | null {
if (filter.type === 'application') return null;
const filtersByApplyInflow =
filter.inflowBasis === 'apply' &&
!!filter.inflowField &&
!!filter.inflowValue;
return filtersByApplyInflow ? null : this.purchaseQuery(filter);
return filter.type === 'application' ? null : this.purchaseQuery(filter);
}
/** 무료 신청 — 필터만 걸고 열은 부르는 쪽이 고른다 */
@@ -521,7 +520,7 @@ export class ActivitiesService {
return qb;
}
/** 유료 구매 — 필터만 걸고 열은 부르는 쪽이 고른다. 유입 필터는 가입 경로일 때만 건다 */
/** 유료 구매 — 필터만 걸고 열은 부르는 쪽이 고른다 */
private purchaseQuery(filter: ListFilter): SelectQueryBuilder<ObjectLiteral> {
const qb = this.dataSource
.createQueryBuilder()
@@ -552,17 +551,14 @@ export class ActivitiesService {
`${PUR}.orderer_call`,
],
});
if (
filter.inflowBasis === 'join' &&
filter.inflowField &&
filter.inflowValue
) {
qb.andWhere(
`${joinInflow(PUR_MEMBER, filter.inflowField)} = :inflowValue`,
{
inflowValue: filter.inflowValue,
},
);
if (filter.inflowBasis && filter.inflowField && filter.inflowValue) {
const expression =
filter.inflowBasis === 'join'
? joinInflow(PUR_MEMBER, filter.inflowField)
: `${PUR}.inflow_${filter.inflowField}`;
qb.andWhere(`${expression} = :inflowValue`, {
inflowValue: filter.inflowValue,
});
}
return qb;
}
@@ -26,7 +26,8 @@ const PARTNER_FILTER_PATTERN =
/**
* 유입을 어느 기록으로 볼 것인가.
* - apply : 신청 때 유입 — 신청 시점 브라우저에 남아 있던 경로 (2일 보관). 구매에는 이 기록이 없다
* - apply : 신청·구매 때 유입 — 신청 완료·주문완료 화면이 보낸 그 순간의 경로 (UTM 스크립트 보관 2일).
* 주문완료 화면이 보내지 못한 구매는 값 없음
* - join : 가입 경로 — 신청·구매한 회원이 가입할 때 기록된 값만 (가입 후 채워진 값·비회원은 값 없음)
*/
export const ACTIVITY_INFLOW_BASIS = ['apply', 'join'] as const;
@@ -100,7 +101,7 @@ export class ActivityListQueryDto extends IntersectionType(
) {
@ApiPropertyOptional({
description:
'유입 필터 기준 — apply(신청 때 유입, 구매는 빠진다) · join(가입 경로). inflowField·inflowValue 와 함께 쓴다',
'유입 필터 기준 — apply(신청·구매 때 유입) · join(가입 경로). inflowField·inflowValue 와 함께 쓴다',
enum: ACTIVITY_INFLOW_BASIS,
})
@IsOptional()
@@ -139,7 +140,7 @@ export class ActivityListQueryDto extends IntersectionType(
export class ActivityInflowStatsQueryDto extends ActivityFilterDto {
@ApiPropertyOptional({
description:
'집계 기준 — apply(신청 때 유입, 무료 신청만 · 기본) · join(가입 경로, 신청·구매 모두)',
'집계 기준 — apply(신청·구매 때 유입 · 기본) · join(가입 경로)',
enum: ACTIVITY_INFLOW_BASIS,
default: 'apply',
})
@@ -106,11 +106,10 @@ export class ActivityResponseDto {
@ApiProperty({
description:
'[신청] 신청 때 유입 (신청 시점 브라우저에 남아 있던 경로) — 구매면 null',
'신청·구매 때 유입 — 신청 완료·주문완료 화면이 보낸 그 순간의 경로. 받지 못했으면 항목이 모두 null',
type: ActivityInflowDto,
nullable: true,
})
inflow!: ActivityInflowDto | null;
inflow!: ActivityInflowDto;
@ApiProperty({
description: '[구매] 아임웹 주문번호 — 신청이면 null',
@@ -212,7 +211,7 @@ export class ActivityInflowStatsResponseDto {
applicationTotal!: number;
@ApiProperty({
description: '집계한 유료 구매 수 (가입 경로 기준일 때만, 환불·취소 제외)',
description: '집계한 유료 구매 수 (환불·취소 제외)',
example: 10,
})
purchaseTotal!: number;
@@ -53,15 +53,25 @@ export class ConversionResponseDto {
})
isValid!: boolean;
@ApiProperty({ description: '아임웹 회원 코드' })
memberCode!: string;
@ApiProperty({
description: '아임웹 회원 코드 — 비회원 구매면 null',
nullable: true,
})
memberCode!: string | null;
@ApiProperty({
description: '회원 정보 (동기화된 회원 기준) — 탈퇴 등으로 없으면 null',
description: '회원 정보 (동기화된 회원 기준) — 탈퇴·비회원이면 null',
type: MemberResponseDto,
nullable: true,
})
member!: MemberResponseDto | null;
@ApiProperty({
description: '[비회원 구매] 주문서의 주문자 이름 — 회원이거나 모르면 null',
nullable: true,
example: '홍길동',
})
ordererName!: string | null;
}
/** 주문 실적 새로고침 상태 (서버 메모리 기준 — 재시작하면 비워진다) */
@@ -18,7 +18,7 @@ export enum ConversionType {
}
/**
* 업체 실적 — 업체 링크로 들어온 회원의 신청·구매 한 건.
* 업체 실적 — 업체 링크로 들어온 손님의 신청·구매 한 건 (구매는 비회원 포함).
*
* 신청은 신청 기록당 하나, 구매는 주문·상품당 하나다.
* 구매가 나중에 전액 환불·취소되면 지우지 않고 isValid=false 로 바꿔 집계에서 뺀다.
@@ -56,8 +56,9 @@ export class PartnerConversion {
@Column({ name: 'prod_no', type: 'int' })
prodNo!: number;
@Column({ name: 'member_code', type: 'varchar', length: 40 })
memberCode!: string;
/** 아임웹 회원 코드 — 비회원 구매(주문완료 화면이 보낸 클릭 ID 로 이은 것)면 null */
@Column({ name: 'member_code', type: 'varchar', length: 40, nullable: true })
memberCode!: string | null;
/** 신청 기록 ID (구매면 null) */
@Column({ name: 'application_id', type: 'uuid', nullable: true })
@@ -277,7 +277,7 @@ describe('PartnerAttributionService.attributeOrder', () => {
expect(conversionStore[0].isValid).toBe(false);
});
it('결제 전 주문·비회원 주문·링크 없는 상품은 잡지 않는다', async () => {
it('결제 전 주문·클릭 ID 없는 비회원 주문·링크 없는 상품은 잡지 않는다', async () => {
const { service, conversionStore, touch } = setup();
touch(LINK_A, new Date(Date.now() - DAY));
@@ -294,6 +294,90 @@ describe('PartnerAttributionService.attributeOrder', () => {
expect(conversionStore).toHaveLength(0);
});
it('비회원 주문도 주문완료 화면이 보낸 클릭 ID 로 그 상품 링크에 귀속한다', async () => {
const wtime = new Date();
const clickedAt = new Date(wtime.getTime() - DAY);
const { service, conversionStore } = setup({
clicks: [
{
ref: 'AAAAAAAAAAAAAAAA',
linkId: LINK_A.id,
clickedAt,
link: { ...LINK_A, isActive: true } as PartnerLink,
},
],
});
const result = await service.attributeOrder(
order({ isMember: 'N', memberCode: null, wtime: wtime.toISOString() }),
linked,
['AAAAAAAAAAAAAAAA'],
);
expect(result).toEqual({ created: 1, updated: 0 });
expect(conversionStore[0]).toMatchObject({
type: ConversionType.PURCHASE,
linkId: LINK_A.id,
memberCode: null,
orderNo: '202610050001',
touchedAt: clickedAt,
});
});
it('다른 상품 링크·중지된 링크의 클릭 ID 는 귀속하지 않는다', async () => {
const clickedAt = new Date(Date.now() - DAY);
const { service, conversionStore } = setup({
clicks: [
{
ref: 'AAAAAAAAAAAAAAAA',
linkId: 'link-other',
clickedAt,
link: { id: 'link-other', prodNo: 60, isActive: true } as PartnerLink,
},
{
ref: 'BBBBBBBBBBBBBBBB',
linkId: LINK_B.id,
clickedAt,
link: { ...LINK_B, isActive: false } as PartnerLink,
},
],
});
await service.attributeOrder(
order({ isMember: 'N', memberCode: null }),
linked,
['AAAAAAAAAAAAAAAA', 'BBBBBBBBBBBBBBBB'],
);
expect(conversionStore).toHaveLength(0);
});
it('회원 주문은 회원 클릭과 주문완료 클릭 ID 중 마지막 클릭에 귀속한다', async () => {
const wtime = new Date();
const { service, conversionStore, touch } = setup({
clicks: [
{
ref: 'BBBBBBBBBBBBBBBB',
linkId: LINK_B.id,
clickedAt: new Date(wtime.getTime() - DAY),
link: { ...LINK_B, isActive: true } as PartnerLink,
},
],
});
touch(LINK_A, new Date(wtime.getTime() - 3 * DAY));
await service.attributeOrder(
order({ wtime: wtime.toISOString() }),
linked,
['BBBBBBBBBBBBBBBB'],
);
expect(conversionStore[0]).toMatchObject({
linkId: LINK_B.id,
memberCode: MEMBER_CODE,
});
});
it('클릭보다 먼저 한 주문은 그 링크의 실적이 아니다', async () => {
const { service, conversionStore, touch } = setup();
const wtime = new Date(Date.now() - 3 * DAY);
@@ -10,6 +10,7 @@ import {
ATTRIBUTION_WINDOW_MS,
evaluateOrder,
pickLastTouch,
TouchCandidate,
} from '@/shared/utils/partner-attribution';
import { CLICK_REF_PATTERN } from '@/shared/utils/partner-link';
import {
@@ -159,18 +160,24 @@ export class PartnerAttributionService {
* 아임웹 주문 한 건을 반영한다.
* - 이미 잡힌 구매는 환불·취소 여부만 갱신한다.
* - 새로 결제완료된 상품은 마지막 클릭 링크가 있으면 구매로 잡는다.
* 클릭 후보는 회원의 클릭 연결(partner_touches)과 주문완료 화면이 보낸 클릭 ID 를 합친 것이다.
* 주문완료 화면의 클릭 ID 로는 비회원 구매도 잡힌다.
*
* @param linkedProdNos 업체 링크가 있는 상품번호 — 그 밖의 상품은 볼 필요가 없다
* @param partnerRefs 주문완료 화면이 보낸 이 주문의 업체 링크 클릭 ID (없으면 빈 배열)
*/
async attributeOrder(
order: ImwebOrderData,
linkedProdNos: ReadonlySet<number>,
partnerRefs: readonly string[] = [],
): Promise<OrderAttributionResult> {
const result: OrderAttributionResult = { created: 0, updated: 0 };
const orderNo = String(order.orderNo);
const orderedAt = new Date(order.wtime);
if (Number.isNaN(orderedAt.getTime())) return result;
const memberCode =
order.isMember === 'Y' && order.memberCode ? order.memberCode : null;
const { products } = evaluateOrder(order);
for (const [prodNo, counts] of products) {
if (!linkedProdNos.has(prodNo)) continue;
@@ -186,12 +193,17 @@ export class PartnerAttributionService {
continue;
}
// 결제 전이면 다음 동기화에서 다시 본다. 비회원 주문은 회원과 이을 수 없다.
if (!counts || order.isMember !== 'Y' || !order.memberCode) continue;
// 결제 전이면 다음 동기화에서 다시 본다.
// 회원도 아니고 주문완료 화면이 클릭 ID 를 보내지도 않았으면 이을 방법이 없다.
if (!counts || (!memberCode && partnerRefs.length === 0)) continue;
const touch = await this.findLastTouch(
order.memberCode,
prodNo,
const touch = pickLastTouch(
[
...(memberCode
? await this.memberTouches(memberCode, prodNo, orderedAt)
: []),
...(await this.refTouches(partnerRefs, prodNo, orderedAt)),
],
orderedAt,
);
if (!touch) continue;
@@ -199,7 +211,7 @@ export class PartnerAttributionService {
await this.insertConversion({
type: ConversionType.PURCHASE,
touch,
memberCode: order.memberCode,
memberCode,
prodNo,
applicationId: null,
orderNo,
@@ -207,7 +219,7 @@ export class PartnerAttributionService {
});
result.created += 1;
this.logger.log(
`업체 실적(구매): link=${touch.linkId} member=${order.memberCode} order=${orderNo} product=${prodNo}`,
`업체 실적(구매): link=${touch.linkId} member=${memberCode ?? 'guest'} order=${orderNo} product=${prodNo}`,
);
}
return result;
@@ -227,7 +239,19 @@ export class PartnerAttributionService {
prodNo: number,
convertedAt: Date,
): Promise<PartnerTouch | null> {
const candidates = await this.touches.find({
return pickLastTouch(
await this.memberTouches(memberCode, prodNo, convertedAt),
convertedAt,
);
}
/** 회원의 그 상품 링크 클릭 연결 — 전환 전 30일 안 (전환 뒤 클릭은 pickLastTouch 가 거른다) */
private memberTouches(
memberCode: string,
prodNo: number,
convertedAt: Date,
): Promise<PartnerTouch[]> {
return this.touches.find({
where: {
memberCode,
prodNo,
@@ -236,13 +260,40 @@ export class PartnerAttributionService {
),
},
});
return pickLastTouch(candidates, convertedAt);
}
/**
* 주문완료 화면이 보낸 클릭 ID 중 그 상품의 링크 클릭 — 전환 전 30일 안.
* 회원 연결(recordTouches)과 같이 실제로 있는 클릭만, 중지된 링크의 클릭은 뺀다.
*/
private async refTouches(
refs: readonly string[],
prodNo: number,
convertedAt: Date,
): Promise<TouchCandidate[]> {
const valid = [...new Set(refs)]
.filter((ref) => CLICK_REF_PATTERN.test(ref))
.slice(0, PARTNER_REFS_MAX);
if (valid.length === 0) return [];
const found = await this.clicks.find({
where: {
ref: In(valid),
clickedAt: MoreThanOrEqual(
new Date(convertedAt.getTime() - ATTRIBUTION_WINDOW_MS),
),
},
relations: { link: true },
});
return found
.filter((click) => click.link?.isActive && click.link.prodNo === prodNo)
.map((click) => ({ linkId: click.linkId, clickedAt: click.clickedAt }));
}
private async insertConversion(input: {
type: ConversionType;
touch: PartnerTouch;
memberCode: string;
touch: TouchCandidate;
memberCode: string | null;
prodNo: number;
applicationId: string | null;
orderNo: string | null;
@@ -14,6 +14,7 @@ import { ImwebProduct } from '@/modules/imweb/entities/imweb-product.entity';
import { ImwebConfig } from '@/modules/imweb/imweb.config';
import { MemberResponseDto } from '@/modules/members/dto/member-response.dto';
import { MembersService } from '@/modules/members/members.service';
import { PurchasesService } from '@/modules/purchases/purchases.service';
import {
CLICK_REF_LENGTH,
LINK_CODE_LENGTH,
@@ -82,6 +83,7 @@ export class PartnerLinksService {
private readonly membersService: MembersService,
private readonly imwebConfig: ImwebConfig,
private readonly config: ConfigService,
private readonly purchases: PurchasesService,
) {}
/** 링크 목록 — 최근 발급순, 기간 내 실적 포함 */
@@ -240,18 +242,31 @@ export class PartnerLinksService {
const items = rows.slice(0, query.limit);
const last = items.at(-1);
const codes = [...new Set(items.map((row) => row.memberCode))];
const [memberRows, groupTitles] = await Promise.all([
const codes = [
...new Set(
items
.map((row) => row.memberCode)
.filter((code): code is string => !!code),
),
];
// 비회원 구매는 회원 정보가 없어 주문자 이름으로 보여준다
const guestOrderNos = items
.filter((row) => !row.memberCode && row.orderNo)
.map((row) => row.orderNo as string);
const [memberRows, groupTitles, ordererNames] = await Promise.all([
codes.length > 0
? this.members.find({ where: { memberCode: In(codes) } })
: Promise.resolve([]),
this.membersService.groupTitleMap(),
this.purchases.ordererNames(guestOrderNos),
]);
const membersByCode = new Map(memberRows.map((m) => [m.memberCode, m]));
return toPaginatedResult(
items.map((row) => {
const member = membersByCode.get(row.memberCode);
const member = row.memberCode
? membersByCode.get(row.memberCode)
: undefined;
return {
id: row.id,
type: row.type,
@@ -261,6 +276,10 @@ export class PartnerLinksService {
isValid: row.isValid,
memberCode: row.memberCode,
member: member ? MemberResponseDto.from(member, groupTitles) : null,
ordererName:
!row.memberCode && row.orderNo
? (ordererNames.get(row.orderNo) ?? null)
: null,
};
}),
{
@@ -3,6 +3,7 @@ import type { SchedulerRegistry } from '@nestjs/schedule';
import type { ImwebConfig } from '@/modules/imweb/imweb.config';
import type { ImwebAuthService } from '@/modules/imweb/imweb-auth.service';
import type { ImwebOrderData } from '@/modules/imweb/imweb.types';
import type { OrderInflowsService } from '@/modules/purchases/order-inflows.service';
import type { PurchasesService } from '@/modules/purchases/purchases.service';
import type { PartnerAttributionService } from './partner-attribution.service';
import { PartnerOrderSyncService } from './partner-order-sync.service';
@@ -11,7 +12,10 @@ const DAY_MS = 86_400_000;
const NOW = new Date('2026-10-06T00:00:00.000Z');
const ORDER = { orderNo: 1, wtime: '2026-10-05T00:00:00Z' } as ImwebOrderData;
function setup(linked: number[] = []) {
function setup(
linked: number[] = [],
refsByOrder = new Map<string, string[]>(),
) {
const get = jest.fn(() =>
Promise.resolve({ data: { list: [ORDER], totalPage: 1 } }),
);
@@ -24,9 +28,14 @@ function setup(linked: number[] = []) {
attributeOrder: jest.fn(() => Promise.resolve({ created: 0, updated: 0 })),
};
const purchases = { recordOrders: jest.fn(() => Promise.resolve(1)) };
const orderInflows = {
partnerRefsByOrder: jest.fn(() => Promise.resolve(refsByOrder)),
prune: jest.fn(() => Promise.resolve(0)),
};
const service = new PartnerOrderSyncService(
attribution as unknown as PartnerAttributionService,
purchases as unknown as PurchasesService,
orderInflows as unknown as OrderInflowsService,
auth as unknown as ImwebAuthService,
{} as ImwebConfig,
{} as SchedulerRegistry,
@@ -38,7 +47,7 @@ function setup(linked: number[] = []) {
};
return new Date(params.startWtime).getTime();
};
return { service, get, attribution, purchases, sinceOf };
return { service, get, attribution, purchases, orderInflows, sinceOf };
}
async function runOnce(service: PartnerOrderSyncService): Promise<void> {
@@ -64,9 +73,34 @@ describe('PartnerOrderSyncService', () => {
expect(attribution.attributeOrder).toHaveBeenCalledWith(
ORDER,
new Set([52]),
[],
);
});
it('주문완료 화면이 보낸 클릭 ID 를 그 주문의 업체 실적 판정에 넘긴다 (비회원 구매 귀속)', async () => {
const refs = ['AAAAAAAAAAAAAAAA'];
const { service, attribution, orderInflows } = setup(
[52],
new Map([['1', refs]]),
);
await runOnce(service);
expect(orderInflows.partnerRefsByOrder).toHaveBeenCalledWith(['1']);
expect(attribution.attributeOrder).toHaveBeenCalledWith(
ORDER,
new Set([52]),
refs,
);
});
it('끝까지 성공했을 때만 오래된 구매 때 유입을 정리한다', async () => {
const { service, get, orderInflows } = setup();
get.mockImplementationOnce(() => Promise.reject(new Error('down')));
await runOnce(service);
expect(orderInflows.prune).not.toHaveBeenCalled();
await runOnce(service);
expect(orderInflows.prune).toHaveBeenCalledTimes(1);
});
it('첫 실행은 1년치, 성공한 뒤부터는 60일치만 본다', async () => {
const { service, sinceOf } = setup();
await runOnce(service);
@@ -10,6 +10,7 @@ import { BusinessException } from '@/common/errors/business.exception';
import { IMWEB_ORDERS_PATH, ImwebConfig } from '@/modules/imweb/imweb.config';
import { ImwebAuthService } from '@/modules/imweb/imweb-auth.service';
import { ImwebOrderPageData } from '@/modules/imweb/imweb.types';
import { OrderInflowsService } from '@/modules/purchases/order-inflows.service';
import { PurchasesService } from '@/modules/purchases/purchases.service';
import { OrderSyncStatusDto } from './dto/partner-conversion.dto';
import { PartnerAttributionService } from './partner-attribution.service';
@@ -44,7 +45,7 @@ function toImwebTime(date: Date): string {
/**
* 주문 동기화 — 아임웹 주문을 주기적으로 훑어
* (1) 결제된 주문을 구매 내역(purchases)에 남기고 (회원·비회원 모두)
* (1) 결제된 주문을 구매 내역(purchases)에 남기고 (회원·비회원 모두, 구매 때 유입도 함께 채운다)
* (2) 업체 링크가 있는 상품이면 업체 실적(구매)으로 잡는다. 둘 다 환불·취소를 반영한다.
*
* 아임웹은 주문 완료를 알려주지 않으므로(웹훅 없음) 회원 동기화와 같은 주기로 최근 주문을 다시 본다.
@@ -70,6 +71,7 @@ export class PartnerOrderSyncService
constructor(
private readonly attribution: PartnerAttributionService,
private readonly purchases: PurchasesService,
private readonly orderInflows: OrderInflowsService,
private readonly auth: ImwebAuthService,
private readonly config: ImwebConfig,
private readonly scheduler: SchedulerRegistry,
@@ -155,12 +157,17 @@ export class PartnerOrderSyncService
},
);
purchases += await this.purchases.recordOrders(data.list);
// 업체 실적은 업체 링크가 있는 상품만 (비회원 주문은 그 안에서 걸러진다)
// 업체 실적은 업체 링크가 있는 상품만.
// 비회원 주문은 주문완료 화면이 보낸 클릭 ID 가 있을 때만 잡힌다
if (linkedProdNos.size > 0) {
const refsByOrder = await this.orderInflows.partnerRefsByOrder(
data.list.map((order) => String(order.orderNo)),
);
for (const order of data.list) {
const result = await this.attribution.attributeOrder(
order,
linkedProdNos,
refsByOrder.get(String(order.orderNo)) ?? [],
);
created += result.created;
updated += result.updated;
@@ -170,6 +177,8 @@ export class PartnerOrderSyncService
if (data.list.length === 0 || page >= data.totalPage) break;
}
this.isBackfilled = true;
// 주문과 이어지지 않은 채 오래된 구매 때 유입을 지운다 (동기화가 끝까지 성공했을 때만)
await this.orderInflows.prune();
this.status = {
...this.status,
@@ -0,0 +1,54 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMaxSize,
IsArray,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { PARTNER_REFS_MAX } from '@/modules/partners/dto/partner-touch.dto';
import { CLICK_REF_PATTERN } from '@/shared/utils/partner-link';
/** 아임웹 주문번호 — 숫자만 (예: 202610064563182). 저장 칸(30자)을 넘지 않게 */
export const ORDER_NO_PATTERN = /^\d{1,30}$/;
/** imweb-header-top.html 이 만드는 값은 200자 남짓 — 넉넉히 두되 무한정 받지 않는다 */
const INFLOW_RAW_MAX_LENGTH = 1000;
/** 주문완료 화면(imweb-header-top.html)이 보내는 구매 때 유입 */
export class CreateOrderInflowDto {
@ApiProperty({
description: '아임웹 주문번호 (주문완료 주소의 order_no)',
example: '202610064563182',
})
@IsString()
@Matches(ORDER_NO_PATTERN, { message: '주문번호 형식이 올바르지 않습니다.' })
orderNo!: string;
@ApiPropertyOptional({
description:
'주문 순간의 유입정보 (imweb-header-top.html 의 MG_UTM.peek().value, 파이프 형식)',
example: 'src=facebook|med=paid|cmp=fall|ts=2026-10-05',
})
@IsOptional()
@IsString()
@MaxLength(INFLOW_RAW_MAX_LENGTH)
inflow?: string;
@ApiPropertyOptional({
description:
'브라우저가 보관한 업체 링크 클릭 ID 목록 (imweb-header-top.html 의 MG_PARTNER.refs())',
example: ['AbCd1234EfGh5678'],
type: [String],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(PARTNER_REFS_MAX)
@IsString({ each: true })
@Matches(CLICK_REF_PATTERN, {
each: true,
message: '클릭 정보 형식이 올바르지 않습니다.',
})
partnerRefs?: string[];
}
@@ -0,0 +1,68 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
/**
* 주문완료 화면이 보낸 구매 때 유입 — 주문번호당 한 줄 (먼저 온 값만 남긴다).
*
* 아임웹 주문 API 에는 손님이 어디서 왔는지가 없어서, 주문완료 화면(imweb-header-top.html)이
* 그 순간 브라우저에 남아 있던 유입과 업체 링크 클릭 ID 를 보낸다.
* 공개 페이지에서 오는 값이라 받을 때는 확인하지 않고, 주문 동기화가 아임웹에 실제로 있는
* 주문번호와 이을 때만 쓴다 (없는 주문번호는 쓰이지 않고 보관 기간이 지나면 지워진다).
*/
@Entity({ name: 'order_inflows' })
@Index('idx_order_inflows_received', ['receivedAt'])
export class OrderInflow {
/** 아임웹 주문번호 (주문완료 주소의 order_no) */
@PrimaryColumn({ name: 'order_no', type: 'varchar', length: 30 })
orderNo!: string;
/** 유입정보 원본 (파이프 형식) — 형식이 아니면 null */
@Column({ name: 'inflow_raw', type: 'text', nullable: true })
inflowRaw!: string | null;
@Column({
name: 'inflow_source',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowSource!: string | null;
@Column({
name: 'inflow_medium',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowMedium!: string | null;
@Column({
name: 'inflow_campaign',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowCampaign!: string | null;
@Column({
name: 'inflow_content',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowContent!: string | null;
/** 브라우저가 보관한 업체 링크 클릭 ID (MG_PARTNER.refs()) — 비회원 구매도 업체 실적으로 잇는다 */
@Column({
name: 'partner_refs',
type: 'varchar',
length: 16,
array: true,
default: () => "'{}'",
})
partnerRefs!: string[];
@Column({ name: 'received_at', type: 'timestamptz' })
receivedAt!: Date;
}
@@ -0,0 +1,31 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { ApiErrorResponse } from '@/common/decorators/api-standard-response.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { CreateOrderInflowDto } from './dto/create-order-inflow.dto';
import { OrderInflowsService } from './order-inflows.service';
/**
* 구매 때 유입 — 아임웹 주문완료 화면(공개 페이지)의 Header Code 가 부른다.
* 로그인 없이 열려 있으므로 요청 수를 엄격히 제한한다 (신청 기록과 같다).
*/
@ApiTags('Order Inflows')
@Controller('order-inflows')
export class OrderInflowsController {
constructor(private readonly orderInflows: OrderInflowsService) {}
@Post()
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform()
@Throttle({ default: { ttl: 60_000, limit: 10 } })
@ApiOperation({
summary: '구매 때 유입 기록 — 항상 204 (같은 주문번호는 처음 값만 남는다)',
})
@ApiErrorResponse('VAL_001', '입력값 오류')
@ApiErrorResponse('REQ_001', '요청 수 초과 (1분 10회)')
async create(@Body() dto: CreateOrderInflowDto): Promise<void> {
await this.orderInflows.record(dto);
}
}
@@ -0,0 +1,108 @@
import type { Repository } from 'typeorm';
import type { ImwebConfig } from '@/modules/imweb/imweb.config';
import type { OrderInflow } from './order-inflow.entity';
import {
ORDER_INFLOW_RETENTION_DAYS,
OrderInflowsService,
} from './order-inflows.service';
const DAY_MS = 86_400_000;
/** insert 쿼리 빌더 흉내 — 메서드가 자기 자신을 돌려준다 */
interface InsertQueryMock {
insert: jest.Mock<InsertQueryMock, []>;
values: jest.Mock<InsertQueryMock, [OrderInflow]>;
orIgnore: jest.Mock<InsertQueryMock, []>;
execute: jest.Mock<Promise<void>, []>;
}
function setup() {
const inserted: OrderInflow[] = [];
const builder: InsertQueryMock = {
insert: jest.fn(() => builder),
values: jest.fn((row: OrderInflow) => {
inserted.push(row);
return builder;
}),
orIgnore: jest.fn(() => builder),
execute: jest.fn(() => Promise.resolve()),
};
const repo = {
create: jest.fn((row: OrderInflow) => row),
createQueryBuilder: jest.fn(() => builder),
find: jest.fn(() => Promise.resolve([] as Partial<OrderInflow>[])),
delete: jest.fn(() => Promise.resolve({ affected: 0 })),
};
const service = new OrderInflowsService(
repo as unknown as Repository<OrderInflow>,
{ trackingHost: 'utm.moneyguider.co.kr' } as ImwebConfig,
);
return { service, repo, builder, inserted };
}
describe('OrderInflowsService.record', () => {
it('유입정보를 풀어 주문번호와 함께 남긴다 (같은 주문번호는 처음 값만)', async () => {
const { service, builder, inserted } = setup();
await service.record({
orderNo: '202610064563182',
inflow: 'src=업체A|med=partner|cmp=CODE1234|lp=/free-shop/',
partnerRefs: ['AAAAAAAAAAAAAAAA', 'AAAAAAAAAAAAAAAA'],
});
expect(builder.orIgnore).toHaveBeenCalled();
expect(inserted[0]).toMatchObject({
orderNo: '202610064563182',
inflowRaw: 'src=업체A|med=partner|cmp=CODE1234|lp=/free-shop/',
inflowSource: '업체A',
inflowMedium: 'partner',
inflowCampaign: 'CODE1234',
partnerRefs: ['AAAAAAAAAAAAAAAA'],
});
});
it('유입정보가 없거나 형식이 아니면 유입 칸은 비우고 클릭 ID 만 남긴다', async () => {
const { service, inserted } = setup();
await service.record({
orderNo: '202610064563182',
inflow: 'hello',
partnerRefs: ['AAAAAAAAAAAAAAAA'],
});
expect(inserted[0]).toMatchObject({
inflowRaw: null,
inflowSource: null,
partnerRefs: ['AAAAAAAAAAAAAAAA'],
});
});
});
describe('OrderInflowsService.partnerRefsByOrder', () => {
it('클릭 ID 가 있는 주문만 돌려준다', async () => {
const { service, repo } = setup();
repo.find.mockResolvedValueOnce([
{ orderNo: '1', partnerRefs: ['AAAAAAAAAAAAAAAA'] },
{ orderNo: '2', partnerRefs: [] },
]);
const refs = await service.partnerRefsByOrder(['1', '2', '1']);
expect(refs).toEqual(new Map([['1', ['AAAAAAAAAAAAAAAA']]]));
});
it('주문번호가 없으면 DB 를 보지 않는다', async () => {
const { service, repo } = setup();
await expect(service.partnerRefsByOrder([])).resolves.toEqual(new Map());
expect(repo.find).not.toHaveBeenCalled();
});
});
describe('OrderInflowsService.prune', () => {
it('보관 기간이 지난 값만 지운다', async () => {
const { service, repo } = setup();
const now = new Date('2026-10-06T00:00:00.000Z');
await service.prune(now);
const [where] = repo.delete.mock.calls[0] as unknown as [
{ receivedAt: { value: Date } },
];
expect(now.getTime() - where.receivedAt.value.getTime()).toBe(
ORDER_INFLOW_RETENTION_DAYS * DAY_MS,
);
});
});
@@ -0,0 +1,89 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, LessThan, Repository } from 'typeorm';
import { ImwebConfig } from '@/modules/imweb/imweb.config';
import { parseInflow } from '@/shared/utils/inflow';
import { CreateOrderInflowDto } from './dto/create-order-inflow.dto';
import { OrderInflow } from './order-inflow.entity';
/**
* 이만큼 지나도록 아임웹 주문과 이어지지 않으면 지운다 — 없는 주문번호이거나 끝내 결제되지 않은 주문이다.
* 무통장입금은 입금 확인 후에야 구매로 잡히므로 입금 기한보다 넉넉히 둔다.
*/
export const ORDER_INFLOW_RETENTION_DAYS = 90;
const DAY_MS = 86_400_000;
/**
* 구매 때 유입 수집 — 주문완료 화면이 보낸 값을 주문번호당 한 줄로 받아 둔다.
*
* 공개 페이지에서 오는 값이라 정말 그 주문인지는 확인할 수 없다. 그래서
* (1) 먼저 온 값만 남기고 (2) 주문 동기화가 아임웹에 실제로 있는 주문과 이을 때만 쓰며
* (3) 이어지지 않은 값은 보관 기간이 지나면 지운다.
*/
@Injectable()
export class OrderInflowsService {
private readonly logger = new Logger(OrderInflowsService.name);
constructor(
@InjectRepository(OrderInflow)
private readonly inflows: Repository<OrderInflow>,
private readonly config: ImwebConfig,
) {}
/** 같은 주문번호가 다시 오면 처음 값을 그대로 둔다 (새로고침·중복 전송에도 안전) */
async record(dto: CreateOrderInflowDto): Promise<void> {
const receivedAt = new Date();
const inflow = parseInflow(
dto.inflow,
receivedAt.toISOString(),
this.config.trackingHost,
);
await this.inflows
.createQueryBuilder()
.insert()
.values(
this.inflows.create({
orderNo: dto.orderNo,
inflowRaw: inflow.source ? (dto.inflow?.trim() ?? null) : null,
inflowSource: inflow.source,
inflowMedium: inflow.medium,
inflowCampaign: inflow.campaign,
inflowContent: inflow.content,
partnerRefs: [...new Set(dto.partnerRefs ?? [])],
receivedAt,
}),
)
.orIgnore()
.execute();
}
/** 주문번호 → 주문완료 화면이 보낸 업체 링크 클릭 ID (보낸 적 없거나 비었으면 빠진다) */
async partnerRefsByOrder(
orderNos: readonly string[],
): Promise<Map<string, string[]>> {
const unique = [...new Set(orderNos)];
if (unique.length === 0) return new Map();
const rows = await this.inflows.find({
select: { orderNo: true, partnerRefs: true },
where: { orderNo: In(unique) },
});
return new Map(
rows
.filter((row) => row.partnerRefs.length > 0)
.map((row) => [row.orderNo, row.partnerRefs]),
);
}
/** 보관 기간이 지난 값을 지운다 — 주문 동기화가 끝날 때 부른다 */
async prune(now: Date = new Date()): Promise<number> {
const result = await this.inflows.delete({
receivedAt: LessThan(
new Date(now.getTime() - ORDER_INFLOW_RETENTION_DAYS * DAY_MS),
),
});
const deleted = result.affected ?? 0;
if (deleted > 0) this.logger.log(`구매 때 유입 정리: ${deleted}건`);
return deleted;
}
}
@@ -6,6 +6,8 @@ import {
PrimaryColumn,
} from 'typeorm';
import { INFLOW_VALUE_MAX_LENGTH } from '@/shared/utils/inflow';
/**
* 유료 구매 — 아임웹 주문을 주기적으로 훑어 결제된 주문의 상품을 남긴다 (주문·상품당 한 줄).
*
@@ -78,6 +80,48 @@ export class Purchase {
@Column({ name: 'is_valid', type: 'boolean', default: true })
isValid!: boolean;
/*
* 구매 때 유입 — 주문완료 화면이 보낸 유입(order_inflows)을 주문 동기화가 주문번호로 이어 채운다.
* 주문완료 화면이 보내지 못한 주문(다른 기기·스크립트 차단·도입 전 주문)은 null 이다.
*/
/** 유입정보 원본 (imweb-header-top.html 의 MG_UTM.peek().value, 파이프 형식) */
@Column({ name: 'inflow_raw', type: 'text', nullable: true })
inflowRaw!: string | null;
@Index()
@Column({
name: 'inflow_source',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowSource!: string | null;
@Column({
name: 'inflow_medium',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowMedium!: string | null;
@Column({
name: 'inflow_campaign',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowCampaign!: string | null;
@Column({
name: 'inflow_content',
type: 'varchar',
length: INFLOW_VALUE_MAX_LENGTH,
nullable: true,
})
inflowContent!: string | null;
/** 처음 잡힌 시각 */
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@@ -1,16 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ImwebModule } from '@/modules/imweb/imweb.module';
import { OrderInflow } from './order-inflow.entity';
import { OrderInflowsController } from './order-inflows.controller';
import { OrderInflowsService } from './order-inflows.service';
import { Purchase } from './purchase.entity';
import { PurchasesService } from './purchases.service';
/**
* 유료 구매 기록 — 아임웹 주문 동기화(PartnersModule 의 PartnerOrderSyncService)가 채우고,
* 관리자 신청·구매 내역(ActivitiesModule)이 읽는다.
* 구매 때 유입은 주문완료 화면이 보낸 값(order_inflows)을 동기화가 주문번호로 이어 채운다.
*/
@Module({
imports: [TypeOrmModule.forFeature([Purchase])],
providers: [PurchasesService],
exports: [PurchasesService],
imports: [TypeOrmModule.forFeature([Purchase, OrderInflow]), ImwebModule],
controllers: [OrderInflowsController],
providers: [PurchasesService, OrderInflowsService],
exports: [PurchasesService, OrderInflowsService],
})
export class PurchasesModule {}
@@ -46,9 +46,11 @@ function setup() {
const repo = {
create: jest.fn((row: Purchase) => row),
createQueryBuilder: jest.fn(() => query),
// 구매 때 유입 채우기 (UPDATE ... FROM order_inflows)
query: jest.fn(() => Promise.resolve()),
};
const service = new PurchasesService(repo as unknown as Repository<Purchase>);
return { service, inserted, query };
return { service, inserted, query, repo };
}
describe('PurchasesService.recordOrders', () => {
@@ -80,13 +82,34 @@ describe('PurchasesService.recordOrders', () => {
});
it('결제 전 주문만 있으면 DB 를 부르지 않는다', async () => {
const { service, query } = setup();
const { service, query, repo } = setup();
await expect(
service.recordOrders([
order({ payments: [{ paymentStatus: 'PAYMENT_PREPARATION' }] }),
]),
).resolves.toBe(0);
expect(query.execute).not.toHaveBeenCalled();
expect(repo.query).not.toHaveBeenCalled();
});
it('구매를 남긴 뒤 같은 주문번호의 구매 때 유입을 아직 비어 있는 구매에만 채운다', async () => {
const { service, repo } = setup();
await service.recordOrders([order({}), order({ orderNo: 202610050002 })]);
expect(repo.query).toHaveBeenCalledTimes(1);
const [sql, params] = repo.query.mock.calls[0] as unknown as [
string,
unknown[],
];
expect(sql).toContain('"order_inflows"');
expect(sql).toContain('p."inflow_source" IS NULL');
expect(params).toEqual([['202610050001', '202610050002']]);
});
it('구매 때 유입은 다시 볼 때 덮어쓰지 않는다 (갱신 목록에 없다)', async () => {
const { service, query } = setup();
await service.recordOrders([order({})]);
const [columns] = query.orUpdate.mock.calls[0] as [string[]];
expect(columns.some((column) => column.startsWith('inflow_'))).toBe(false);
});
it('같은 주문이 두 번 와도 한 줄로 모은다 (ON CONFLICT 이중 갱신 방지)', async () => {
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { ImwebOrderData } from '@/modules/imweb/imweb.types';
import { purchaseLines } from '@/shared/utils/order-purchase';
@@ -33,6 +33,7 @@ const CONFLICT_COLUMNS = ['order_no', 'prod_no'];
/**
* 유료 구매 기록 — 주문 동기화(PartnerOrderSyncService)가 아임웹 주문 한 페이지씩 넘겨준다.
* 같은 주문·상품은 한 줄만 남고, 다시 보면 환불·취소 여부를 갱신한다 (여러 번 불러도 안전).
* 구매 때 유입은 주문완료 화면이 보낸 값을 주문번호로 이어 채운다 (OrderInflowsService).
*/
@Injectable()
export class PurchasesService {
@@ -81,6 +82,49 @@ export class PurchasesService {
skipUpdateIfNoValuesChanged: true,
})
.execute();
await this.fillInflows([
...new Set([...rows.values()].map((row) => row.orderNo)),
]);
return rows.size;
}
/** 주문번호 → 주문자 이름 — 업체 실적의 비회원 구매를 누군지 보여줄 때 쓴다 */
async ordererNames(
orderNos: readonly string[],
): Promise<Map<string, string>> {
const unique = [...new Set(orderNos)];
if (unique.length === 0) return new Map();
const rows = await this.purchases.find({
select: { orderNo: true, ordererName: true },
where: { orderNo: In(unique) },
});
const names = new Map<string, string>();
for (const row of rows) {
if (row.ordererName) names.set(row.orderNo, row.ordererName);
}
return names;
}
/**
* 주문완료 화면이 보낸 구매 때 유입(order_inflows)을 같은 주문번호의 구매에 채운다.
* 아직 유입이 없는 구매에만 채운다 — 동기화가 같은 주문을 다시 봐도 값이 바뀌지 않는다.
* 유입이 구매보다 늦게 들어와도 다음 동기화(최근 60일)에서 채워진다.
*/
private async fillInflows(orderNos: string[]): Promise<void> {
if (orderNos.length === 0) return;
await this.purchases.query(
`UPDATE "purchases" AS p
SET "inflow_raw" = o."inflow_raw",
"inflow_source" = o."inflow_source",
"inflow_medium" = o."inflow_medium",
"inflow_campaign" = o."inflow_campaign",
"inflow_content" = o."inflow_content"
FROM "order_inflows" AS o
WHERE p."order_no" = o."order_no"
AND p."order_no" = ANY($1)
AND p."inflow_source" IS NULL
AND o."inflow_source" IS NOT NULL`,
[orderNos],
);
}
}